╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2554 }, │ │2555 .mouse => |mouse| { │ │2556 if (mouse.action == .press and mouse.action.press == .left) { │ │2557 + if (cellPageLink(root, mouse.x, mouse.y, session.data)) |route| return session.navigate(route);│ │2558 if (root_focus.hitTest(mouse.x, mouse.y)) |hit| { │ │2559 const focus_id = hit.id; │ │2560 if (hit.kind == .background) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2659 // links stay in-page. │ │2660 if (route.parent() != current.parent() or RoutablePage.repoPageChanged(route, current) or RoutablePage.forkPageChanged(route, current) or RoutablePage.homePageChanged(route, current) or RoutablePage.userPageChanged(route, current)) return route;│ │2661 return null; │ │2662 +} │ │2663 + │ │2664 +// the page the clicked cell links to, from a link inside a text box's content │ │2665 +fn cellPageLink(root: *Widget, x: usize, y: usize, data: Session.Data) ?RoutablePage { │ │2666 + const grid = root.getGrid() orelse return null; │ │2667 + const cell = grid.cell(x, y) catch return null; │ │2668 + const link = cell.link orelse return null; │ │2669 + if (link.len == 0 or link[0] != '/') return null; │ │2670 + return if (data.host_kind == .local) RoutablePage.fromUrlLocal(link) else RoutablePage.fromUrl(link); │ │2671 } │ │2672 │ │2673 // whether the `a:` focus target links to the page already shown, which a click │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/ui/Repo/Files.zig│ ╰─────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │507 // it's borderless — the border lives on the surrounding scroll frame, which │ │508 // doubles when this content is focused. a non-empty `link` makes activating │ │509 // the box follow it, like addRow. │ │510 - fn addContentBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), text: []const u8, link: []const u8) !void { │ │510 + fn addContentBox(self: *View, allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), spans: []const wgt.Span, link: []const u8) !void {│ │511 _ = self; │ │512 - var tb = try wgt.TextBox.init(allocator, text, .{ .border = null, .wrap_kind = .none }); │ │512 + var tb = try wgt.TextBox.initSpans(allocator, spans, .{ .border = null, .wrap_kind = .none }); │ │513 errdefer tb.deinit(allocator); │ │514 tb.getFocus().mode = .all; │ │515 if (link.len != 0) tb.getFocus().kind = .{ .custom = link }; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │677 var markdown = false; │ │678 if (self.selectedEntry()) |entry| { │ │679 if (!entry.is_dir) { │ │680 + const path = try childDir(self.session.page_arena.allocator(), self.data.dir, entry.name); │ │681 // the window links sit above the scroll, so they stay put │ │682 // while the content scrolls. │ │683 if (entry.window_start > 0) try self.addNavLink(allocator, nav_box, "scroll to top", entry, 0);│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │685 if (!entry.loaded) { │ │686 // the placeholder carries the file's "a:" link, so enter │ │687 // (or a click) here loads the file like on its list row. │ │687 - const path = try childDir(self.session.page_arena.allocator(), self.data.dir, entry.name); │ │688 const link = try fileLink(self.session.page_arena, self.data, path, false, 0); │ │689 - try self.addContentBox(allocator, inner, "(press enter to load this file)", link); │ │689 + try self.addContentBox(allocator, inner, &.{.{ .text = "(press enter to load this file)" }}, link); │ │690 } else if (entry.is_binary) { │ │691 - try self.addContentBox(allocator, inner, "(binary file)", ""); │ │691 + try self.addContentBox(allocator, inner, &.{.{ .text = "(binary file)" }}, ""); │ │692 } else if (isMarkdown(entry.name) and entry.window_start == 0 and !entry.has_more and entry.lines.len <= max_markdown_lines) {│ │693 const page_arena = self.session.page_arena; │ │694 const doc = try md.parse(page_arena.allocator(), entry.lines); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │695 - const path = try childDir(page_arena.allocator(), self.data.dir, entry.name); │ │695 var view = try Markdown.View.init(allocator, doc, self.data, path, page_arena); │ │696 errdefer view.deinit(allocator); │ │697 try inner.children.put(allocator, view.getFocus().id, .{ .widget = .{ .markdown = view }, .rect = null, .min_size = null });│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │698 markdown = true; │ │699 } else { │ │701 - const text = try numberedContent(self.session.page_arena.allocator(), entry.lines, entry.window_start); │ │702 - try self.addContentBox(allocator, inner, text, ""); │ │700 + try self.addContentBox(allocator, inner, try numberedContent(self.session.page_arena, self.data, path, entry.lines, entry.window_start), "");│ │701 } │ │702 } │ │703 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │957 } │ │958 }; │ │959 │ │962 -// join `lines` into one string, each prefixed with its 1-based file line number │ │960 +// `lines` as spans, each prefixed with its 1-based file line number │ │961 // (right-aligned in a column wide enough for the last number, then a space), │ │964 -// like the diffs view. `start` is the window's 0-based first line, so numbers │ │965 -// continue across paginated windows. │ │966 -fn numberedContent(arena: std.mem.Allocator, lines: []const []const u8, start: usize) ![]const u8 { │ │967 - if (lines.len == 0) return ""; │ │962 +// like the diffs view. each number links to the window starting at its line. │ │963 +// `start` is the window's 0-based first line, so numbers continue across │ │964 +// paginated windows. │ │965 +fn numberedContent(page_arena: *std.heap.ArenaAllocator, data: *const Self, path: []const u8, lines: []const []const u8, start: usize) ![]const wgt.Span {│ │966 + const arena = page_arena.allocator(); │ │967 const width = std.fmt.count("{d}", .{start + lines.len}); │ │969 - const indent = try arena.alloc(u8, width); │ │970 - @memset(indent, ' '); │ │971 - │ │972 - var out: std.ArrayList(u8) = .empty; │ │968 + var spans: std.ArrayList(wgt.Span) = .empty; │ │969 for (lines, 0..) |line, i| { │ │974 - if (i != 0) try out.append(arena, '\n'); │ │975 - const num_str = try arena.print("{d}", .{start + i + 1}); │ │976 - try out.appendSlice(arena, indent[0 .. width - num_str.len]); │ │977 - try out.appendSlice(arena, num_str); │ │978 - try out.append(arena, ' '); │ │979 - try out.appendSlice(arena, line); │ │970 + const number = start + i + 1; │ │971 + const route = data.filesRoute(path, number) orelse return error.RouteTooLong; │ │972 + try spans.append(arena, .{ .text = try arena.print("{d: >[1]}", .{ number, width }), .link = try route.toUrl(page_arena) }); │ │973 + try spans.append(arena, .{ .text = try arena.print(" {s}{s}", .{ line, if (i + 1 < lines.len) "\n" else "" }) }); │ │974 } │ │981 - return out.toOwnedSlice(arena); │ │975 + return spans.toOwnedSlice(arena); │ │976 } │ │977 │ │978 // the index of the "README"/"README.md" file entry (case-insensitive), or null │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2212 if (!sameLink(link, open_link)) { │ │2213 if (open_link != null) try output.appendSlice(allocator, "</a>"); │ │2214 if (link) |url| { │ │2215 - try output.appendSlice(allocator, "<a class=\"cell-link\" tabindex=\"-1\" target=\"_blank\" rel=\"noopener noreferrer\" href=\"");│ │2215 + // a page path opens in this tab, any other url in a new one │ │2216 + try output.appendSlice(allocator, if (url[0] == '/') │ │2217 + "<a class=\"cell-link\" tabindex=\"-1\" href=\"" │ │2218 + else │ │2219 + "<a class=\"cell-link\" tabindex=\"-1\" target=\"_blank\" rel=\"noopener noreferrer\" href=\""); │ │2220 try appendEscapedHtml(allocator, output, url); │ │2221 try output.appendSlice(allocator, "\">"); │ │2222 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │2512 return i + s.len; │ │2513 } │ │2514 │ │2511 -// a cell's link as an href, when it's a web or mail url │ │2515 +// a cell's link as an href, when it's a page path or a web or mail url│ │2516 fn webLink(link: ?[]const u8) ?[]const u8 { │ │2517 const url = link orelse return null; │ │2518 + // a page path, not a protocol-relative url │ │2519 + if (url.len > 1 and url[0] == '/' and url[1] != '/') return url; │ │2520 for ([_][]const u8{ "http://", "https://", "mailto:" }) |scheme| { │ │2521 if (std.ascii.startsWithIgnoreCase(url, scheme)) return url; │ │2522 } │ ╰─────────────────────────────────────────────────────────────────────────────╯