╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────╮ │src/ui/User.zig│ ╰───────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 98 repos_next_start = if (end < count) end else null; │ │ 99 } │ │100 } │ │101 + │ │102 + var forks: std.ArrayList(ForkItem) = .empty; │ │103 + var forks_next_start: ?usize = null; │ │104 + if (try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Fork.user_id_to_fork_id_set_key))) |by_user_cursor| { │ │105 + const by_user = try DB.HashMap(.read_only).init(by_user_cursor); │ │106 + if (try by_user.getCursor(hash.hashInt(hash_kind, user_id))) |user_forks_cursor| { │ │107 + const user_forks = try DB.SortedSet(.read_only).init(user_forks_cursor); │ │108 + const repos_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Repo.record_map_key)) orelse return error.NotFound; │ │109 + const repo_records = try DB.HashMap(.read_only).init(repos_cursor); │ │110 + const count = try user_forks.count(); │ │111 + const end = @min(forks_start + page_size, count); │ │112 + var iter = try user_forks.iteratorFromIndex(forks_start); │ │113 + var i = forks_start; │ │114 + while (i < end) : (i += 1) { │ │115 + const cursor = (try iter.next()) orelse break; │ │116 + var kv_cursor = cursor; │ │117 + const kv = try kv_cursor.readKeyValuePair(); │ │118 + var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; │ │119 + _ = try kv.key_cursor.readBytes(&order_key); │ │120 + const fork_id = order_key[@sizeOf(u64)..]; │ │121 + const record = (try evt.Fork.readById(DB, hash_kind, haxy_moment, arena, fork_id)) orelse continue; │ │122 │ │123 + const repo_cursor = try repo_records.getCursor(hash.hashInt(hash_kind, record.event.repo_id)) orelse continue; │ │124 + const target_repo = try evt.read(evt.Repo.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(repo_cursor)); │ │125 + const owner = (try evt.User.readById(DB, hash_kind, haxy_moment, arena, target_repo.event.user_id)) orelse continue; │ │126 + const target = try std.fmt.allocPrint(arena.allocator(), "{s}/{s}", .{ owner.event.name, target_repo.event.name }); │ │127 + │ │128 + var title: []const u8 = "(unavailable)"; │ │129 + if (session.io) |io| if (session.repos_dir) |repos_dir| { │ │130 + const id_hex = std.fmt.bytesToHex(fork_id.*, .lower); │ │131 + const path = try fork.forkPath(arena.allocator(), repos_dir, &id_hex); │ │132 + if (rp.Repo(.xit, .{}).open(io, arena.child_allocator, .{ .path = path, .require_repo_root = true })) |opened| { │ │133 + var fork_repo = opened; │ │134 + defer fork_repo.deinit(io, arena.child_allocator); │ │135 + if (evt.currentMoment(.{}, &fork_repo)) |moment| { │ │136 + if (try evt.Patch.readById(evt.EventDB(.sha1), .sha1, moment, arena, fork_id)) |patch| title = patch.event.title;│ │137 + } else |_| {} │ │138 + } else |_| {} │ │139 + }; │ │140 + try forks.append(arena.allocator(), .{ .target = target, .title = title }); │ │141 + } │ │142 + forks_next_start = if (end < count) end else null; │ │143 + } │ │144 + } │ │145 + │ │146 return .{ │ │147 .header = try Header.init(arena, user.event.name), │ │148 .user = evt.project(evt.User.Public, user.event), │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────╮ │149 .repos = repos.items, │ │ 94 - .repos_start = start, │ │150 + .repos_start = repos_start, │ │151 .repos_next_start = repos_next_start,│ │152 + .forks = forks.items, │ │153 + .forks_start = forks_start, │ │154 + .forks_next_start = forks_next_start,│ │155 .settings = Settings.init(), │ │156 .auth = Auth.init(), │ │157 .quit = Quit.init(), │ ╰──────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │212 try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); │ │213 } │ │214 │ │215 + // forks list │ │216 + { │ │217 + var list = try ui.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); │ │218 + errdefer list.deinit(allocator); │ │219 + │ │220 + var item_arena = std.heap.ArenaAllocator.init(allocator); │ │221 + defer item_arena.deinit(); │ │222 + const aa = item_arena.allocator(); │ │223 + var items: std.ArrayList(ui.FlowBox.Item) = .empty; │ │224 + if (data.forks_start > 0) │ │225 + try items.append(aa, .{ .text = "← previous", .link = try std.fmt.allocPrint(aa, "a:/user/{s}/forks/start:{d}", .{ data.user.name, data.forks_start -| page_size }) });│ │226 + for (data.forks) |fork_item| │ │227 + try items.append(aa, .{ .text = try std.fmt.allocPrint(aa, "{s}\n{s}", .{ fork_item.target, fork_item.title }), .link = "" }); │ │228 + if (data.forks_next_start) |next_start| │ │229 + try items.append(aa, .{ .text = "next →", .link = try std.fmt.allocPrint(aa, "a:/user/{s}/forks/start:{d}", .{ data.user.name, next_start }) }); │ │230 + try list.setItems(allocator, items.items); │ │231 + try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); │ │232 + } │ │233 + │ │234 // the header has no settings tab without a login, so keep the │ │235 // stack's children 1:1 with the tabs by skipping the view too │ │236 if (session.data.user_id != null) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │275 if (header.getSelectedIndex()) |index| { │ │276 stack.getFocus().child_id = stack.children.keys()[index]; │ │277 const name = self.data.route_name; │ │200 - switch (std.meta.activeTag(stack.children.values()[index])) { │ │201 - .home_settings => self.session.data.current_page = .{ .user_settings = name }, │ │202 - .home_auth => self.session.data.current_page = .{ .user_auth = name }, │ │203 - // the quit tab is tty-only and not a route, so leave current_page │ │204 - // alone (nothing to mirror into the url). │ │205 - .quit => {}, │ │206 - // the repos list (the default tab) │ │207 - else => self.session.data.current_page = .{ .user_repos = .{ .name = name, .start = self.data.repos_start } },│ │278 + switch (index) { │ │279 + 0 => self.session.data.current_page = .{ .user_repos = .{ .name = name, .start = self.data.repos_start } }, │ │280 + 1 => self.session.data.current_page = .{ .user_forks = .{ .name = name, .start = self.data.forks_start } }, │ │281 + else => switch (std.meta.activeTag(stack.children.values()[index])) { │ │282 + .home_settings => self.session.data.current_page = .{ .user_settings = name }, │ │283 + .home_auth => self.session.data.current_page = .{ .user_auth = name }, │ │284 + // the quit tab is tty-only and not a route, so leave current_page │ │285 + // alone (nothing to mirror into the url). │ │286 + .quit => {}, │ │287 + else => {}, │ │288 + }, │ │289 } │ │290 } │ │291 try self.box.build(allocator, constraint, root_focus); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────╮ │src/ui/User/Header.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │72 // kinds that borrow them live as long as this page's widget tree. │ │73 const aa = session.page_arena.allocator(); │ │74 const repos_link = try std.fmt.allocPrint(aa, "ai:/user/{s}", .{data.name}); │ │75 + const forks_link = try std.fmt.allocPrint(aa, "ai:/user/{s}/forks", .{data.name}); │ │76 const settings_link = try std.fmt.allocPrint(aa, "ai:/user/{s}/settings", .{data.name});│ │77 const auth_link = try std.fmt.allocPrint(aa, "ai:/user/{s}/auth", .{data.name}); │ │78 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │81 const current_link: []const u8 = switch (session.data.current_page) {│ │82 .user_settings => settings_link, │ │83 .user_auth => auth_link, │ │84 + .user_forks => forks_link, │ │85 else => repos_link, │ │86 }; │ │87 var selected_tab: ?usize = null; │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │101 }); │ │102 } │ │103 │ │104 + // forks tab │ │105 + { │ │106 + var text_box = try wgt.TextBox(ui.Widget).init(allocator, "forks", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none });│ │107 + errdefer text_box.deinit(allocator); │ │108 + text_box.getFocus().focusable = true; │ │109 + text_box.getFocus().kind = .{ .custom = forks_link }; │ │110 + try tab_ids.put(allocator, text_box.getFocus().id, {}); │ │111 + if (std.mem.eql(u8, forks_link, current_link)) selected_tab = text_box.getFocus().id; │ │112 + try box.children.put(allocator, text_box.getFocus().id, .{ │ │113 + .widget = .{ .text_box = text_box }, │ │114 + .rect = null, │ │115 + .min_size = .{ .width = "forks".len + 2, .height = null }, │ │116 + }); │ │117 + } │ │118 + │ │119 // spacer pushes settings + auth to the right │ │120 { │ │121 var spacer = try ui.Spacer.init(allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────╮ │ 6 const evt = @import("./event.zig"); │ │ 7 const serve_common = @import("./serve_common.zig");│ │ 8 const ui = @import("./ui.zig"); │ │ 9 +const fork = @import("./fork.zig"); │ │10 +const pch = @import("./patch.zig"); │ │11 const xitui = xit.xitui; │ │12 const wgt = xitui.widget; │ │13 const Focus = xitui.focus.Focus; │ ╰───────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │122 host: Host, │ │123 ) !void { │ │124 const method = request.head.method; │ │123 - const uri = try std.Uri.parseAfterScheme("", request.head.target); │ │125 + const owned_target = if (method == .POST) try allocator.dupe(u8, request.head.target) else null;│ │126 + defer if (owned_target) |target| allocator.free(target); │ │127 + const uri = try std.Uri.parseAfterScheme("", owned_target orelse request.head.target); │ │128 const path = uri.path.percent_encoded; │ │129 │ │130 // POST routes can be scoped by any page, so they can redirect using │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │133 if (method == .POST) { │ │134 switch (host) { │ │135 .remote => |remote| { │ │132 - const PostRoute = enum { login, logout, ansi, new, edit, remove, open, close, resolve, attach }; │ │136 + const PostRoute = enum { login, logout, ansi, new, edit, remove, open, close, resolve, post, attach };│ │137 inline for (@typeInfo(PostRoute).@"enum".fields) |field| { │ │138 const suffix = "/" ++ field.name; │ │139 if (std.mem.endsWith(u8, path, suffix)) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯