╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │155 _navigate(url.ptr, @intCast(url.len)); │ │156 return; │ │157 } │ │158 + // the grid suppresses the anchor's own navigation, so follow it here│ │159 + if (ui.rawLink(root_ptr.getFocus(), focus_id)) |url| { │ │160 + _navigate(url.ptr, @intCast(url.len)); │ │161 + return; │ │162 + } │ │163 root_ptr.getFocus().setFocus(focus_id); │ │164 } │ │165 │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │1317 if (crossPageLink(root_focus, gid, session.data)) |route| { │ │1318 return session.navigate(route); │ │1319 } │ │1320 + if (rawLink(root_focus, gid)) |url| return requestRawLink(session, url);│ │1321 } │ │1322 try root.input(allocator, key, root_focus); │ │1323 }, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────╮ │1341 // follow a cross-page link │ │1342 if (crossPageLink(root_focus, focus_id, session.data)) |route| {│ │1343 return session.navigate(route); │ │1344 + } │ │1345 + if (rawLink(root_focus, focus_id)) |url| { │ │1346 + if (!mouse.ctrl) try requestRawLink(session, url); │ │1347 + return; │ │1348 } │ │1349 root_focus.setFocus(focus_id); │ │1350 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1360 } │ │1361 } │ │1362 │ │1363 +// a link to bytes the server serves directly rather than to a page route, so │ │1364 +// it never resolves to a RoutablePage │ │1365 +pub const raw_link_prefix = "ax:"; │ │1366 + │ │1367 +// a button the web overlay covers with a file picker that posts the chosen │ │1368 +// file to the url after the prefix │ │1369 +pub const file_input_prefix = "file:"; │ │1370 + │ │1371 +// the url an `ax:` link points at, or null for anything else. a host that │ │1372 +// suppresses the anchor's own navigation needs this to follow the link. │ │1373 +pub fn rawLink(root_focus: *Focus, focus_id: usize) ?[]const u8 { │ │1374 + const child = root_focus.children.get(focus_id) orelse return null; │ │1375 + const custom = switch (child.focus.kind) { │ │1376 + .custom => |c| c, │ │1377 + else => return null, │ │1378 + }; │ │1379 + if (!std.mem.startsWith(u8, custom, raw_link_prefix)) return null; │ │1380 + return custom[raw_link_prefix.len..]; │ │1381 +} │ │1382 + │ │1383 +fn requestRawLink(session: *Session, url: []const u8) !void { │ │1384 + session.host_request = .{ .show_copyable_text = try terminalWebUrl(session.page_arena.allocator(), session, url) };│ │1385 +} │ │1386 + │ │1387 +fn terminalWebUrl(allocator: std.mem.Allocator, session: *const Session, path: []const u8) ![]const u8 { │ │1388 + return if (session.web_port) |port| │ │1389 + try std.fmt.allocPrint(allocator, "http://localhost:{d}{s}", .{ port, path }) │ │1390 + else │ │1391 + path; │ │1392 +} │ │1393 + │ │1394 // if the focus target at focus_id is an `a:` link that should navigate (a │ │1395 // different parent page, or a different files directory within the repo page), │ │1396 // return its route; otherwise null. lets a host turn a click / enter on such a │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │2293 self.url_width = 0; │ │2294 const aa = self.arena.allocator(); │ │2295 const path = self.session.data.current_page.toUrl(&self.arena) catch return; │ │2260 - const text = if (self.session.web_port) |port| │ │2261 - std.fmt.allocPrint(aa, "http://localhost:{d}{s}", .{ port, path }) catch return│ │2262 - else │ │2263 - path; │ │2296 + const text = terminalWebUrl(aa, self.session, path) catch return; │ │2297 self.url = text; │ │2298 │ │2299 var grid = try Grid.init(allocator, .{ .width = width, .height = 1 }); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Events.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │171 .repo => (try readRecord(evt.Repo, hash_kind, arena, haxy_moment, id) orelse return error.NotFound).created_ts, │ │172 .issue => (try readRecord(evt.Issue, hash_kind, arena, haxy_moment, id) orelse return error.NotFound).created_ts, │ │173 .comment => (try readRecord(evt.Comment, hash_kind, arena, haxy_moment, id) orelse return error.NotFound).created_ts, │ │174 + .attach => (try readRecord(evt.Attachment, hash_kind, arena, haxy_moment, id) orelse return error.NotFound).created_ts,│ │175 }; │ │176 } │ │177 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │221 const thread_kind = (try readKind(hash_kind, kind_map, &thread_id)) orelse return item; │ │222 const route = switch (thread_kind) { │ │223 .issue => ui.RoutablePage.repoCommentsRoute(identity, &record.event.thread_id, item.id, 0), │ │224 + else => null, │ │225 + } orelse return item; │ │226 + item.view_url = try route.toUrl(arena); │ │227 + }, │ │228 + .attach => { │ │229 + const record = (try readRecord(evt.Attachment, hash_kind, arena, haxy_moment, id)) orelse return null;│ │230 + item.deleted = record.deleted; │ │231 + item.author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email); │ │232 + var parent_id: [evt.event_id_size]u8 = undefined; │ │233 + _ = std.fmt.hexToBytes(&parent_id, &record.event.parent_id) catch return error.InvalidEventId; │ │234 + const parent_kind = (try readKind(hash_kind, kind_map, &parent_id)) orelse return item; │ │235 + const route = switch (parent_kind) { │ │236 + .issue => ui.RoutablePage.repoIssueCommentsRoute(identity, &record.event.parent_id, 0), │ │237 else => null, │ │238 } orelse return item; │ │239 item.view_url = try route.toUrl(arena); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────╮ │14 const inp = @import("../input.zig"); │ │15 const diff3 = @import("../../diff3.zig"); │ │16 const Comment = @import("Comment.zig"); │ │17 +const Attachment = @import("Attachment.zig"); │ │18 │ │19 const wasm = builtin.target.cpu.arch == .wasm32;│ │20 │ ╰────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────╮ │38 // whether the issue has an unresolved merge conflict │ │39 conflicted: bool = false, │ │40 comments: Comment.Window = .empty, │ │41 + attachments: []const Attachment.WithId = &.{}, │ │42 }; │ │43 │ │44 // one side of a conflicted field: its value and who set it│ ╰───────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │445 &id_hex, │ │446 if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, │ │447 ), │ │448 + .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex),│ │449 }); │ │450 } │ │451 │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯