╭ Discussions.zig ──────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const evt = @import("../../event.zig"); 4 const ui = @import("../../ui.zig"); 5 const xit = @import("xit"); 6 const rp = xit.repo; 7 const hash = xit.hash; 8 const xitui = xit.xitui; 9 const wgt = xitui.widget; 10 const layout = xitui.layout; 11 const Key = xitui.input.Key; 12 const Grid = xitui.grid.Grid; 13 const Focus = xitui.focus.Focus; 14 const inp = @import("../input.zig"); 15 const Comment = @import("Comment.zig"); 16 const Attachment = @import("Attachment.zig"); 17 const Threads = @import("Threads.zig"); 18 19 const wasm = builtin.target.cpu.arch == .wasm32; 20 21 // how much of a description the detail pane shows before linking to the 22 // /description page, the same limit commit messages use. 23 pub const max_description_size = 2 * 1024; 24 25 // how many discussions one window shows before a "next" link appears. 26 pub const page_size = 20; 27 28 // how many tags the tags view shows at most. 29 pub const max_tags = 1000; 30 31 // one discussion from the repo's consumed event database, with its hex event id 32 // (the id lives in the event envelope, not the payload). 33 pub const DiscussionWithId = struct { 34 id: []const u8, 35 record: evt.Discussion.Record, 36 author: ui.Author = .unknown, 37 comments: Comment.Window = .empty, 38 attachments: []const Attachment.WithId = &.{}, 39 }; 40 41 pub const Entry = DiscussionWithId; 42 43 pub const Window = struct { 44 items: []const DiscussionWithId, 45 prev_id: ?[]const u8, 46 next_id: ?[]const u8, 47 count: usize, 48 49 pub const empty: Window = .{ .items = &.{}, .prev_id = null, .next_id = null, .count = 0 }; 50 }; 51 52 identity: []const u8, 53 tag: []const u8, 54 selected_id: []const u8, 55 comment_id: []const u8, 56 comments_start: usize, 57 comment_page: ?Comment.Permalink = null, 58 recent: Window, 59 view: ui.RoutablePage.DiscussionsView, 60 description_page: bool = false, 61 tags: []const []const u8, 62 repo_source: ?ui.RepoSource = null, 63 64 const Self = @This(); 65 66 pub const Event = evt.Discussion; 67 pub const Status = enum { recent }; 68 pub const ViewKind = ui.RoutablePage.DiscussionsView; 69 70 pub fn selectedThread(self: *const Self) ?*const DiscussionWithId { 71 if (self.selected_id.len == 0) return null; 72 for (self.recent.items) |*entry| { 73 if (std.mem.eql(u8, entry.id, self.selected_id)) return entry; 74 } 75 return null; 76 } 77 78 pub fn window(self: *const Self, _: Status) *const Window { 79 return &self.recent; 80 } 81 82 pub fn emptyResult( 83 aa: std.mem.Allocator, 84 identity: []const u8, 85 tag: []const u8, 86 selected_id: []const u8, 87 comment_id: []const u8, 88 comments_start: usize, 89 view: ui.RoutablePage.DiscussionsView, 90 ) !Self { 91 return .{ 92 .identity = try aa.dupe(u8, identity), 93 .tag = try aa.dupe(u8, tag), 94 .selected_id = try aa.dupe(u8, selected_id), 95 .comment_id = try aa.dupe(u8, comment_id), 96 .comments_start = comments_start, 97 .recent = .empty, 98 .view = if (view == .description) .recent else view, 99 .description_page = view == .description, 100 .tags = &.{}, 101 }; 102 } 103 104 pub fn init( 105 comptime repo_kind: rp.RepoKind, 106 comptime repo_opts: rp.RepoOpts(repo_kind), 107 arena: *std.heap.ArenaAllocator, 108 repo: *rp.Repo(repo_kind, repo_opts), 109 io: std.Io, 110 admin_moment: ?evt.AdminDB.HashMap(.read_only), 111 identity: []const u8, 112 tag: []const u8, 113 selected_id: []const u8, 114 comment_id: []const u8, 115 comments_start: usize, 116 view: ui.RoutablePage.DiscussionsView, 117 ) !Self { 118 const empty = try emptyResult(arena.allocator(), identity, tag, selected_id, comment_id, comments_start, view); 119 const aa = arena.allocator(); 120 const DB = evt.EventDB(repo_opts.hash); 121 const rooted = empty.selected_id.len != 0; 122 const tagged = empty.tag.len != 0; 123 const strict = rooted or tagged; 124 125 const gpa = arena.child_allocator; 126 var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, gpa, repo.core.repo_dir) else null; 127 defer if (event_db_maybe) |*event_db| event_db.deinit(io, gpa); 128 const haxy_moment = (if (event_db_maybe) |*event_db| 129 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 130 else if (repo_kind == .git) 131 return empty 132 else 133 evt.currentMoment(repo_opts, repo)) catch { 134 if (strict) return error.NotFound; 135 return empty; 136 }; 137 138 const set_maybe: ?DB.SortedSet(.read_only) = if (tagged) blk: { 139 const tags_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.tag_to_id_set_key)) orelse return error.NotFound; 140 const tag_sets = try DB.SortedMap(.read_only).init(tags_cursor); 141 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.tag)); 142 const cursor = try tag_sets.getCursor(decoded) orelse return error.NotFound; 143 break :blk try DB.SortedSet(.read_only).init(cursor); 144 } else if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.active_id_set_key))) |cursor| 145 try DB.SortedSet(.read_only).init(cursor) 146 else 147 null; 148 149 const records_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.record_map_key)) orelse { 150 if (strict) return error.NotFound; 151 return empty; 152 }; 153 const records = try DB.HashMap(.read_only).init(records_cursor); 154 155 var root_key: ?[]const u8 = null; 156 if (rooted) { 157 if (empty.selected_id.len != evt.event_id_size * 2) return error.NotFound; 158 var id: [evt.event_id_size]u8 = undefined; 159 _ = std.fmt.hexToBytes(&id, empty.selected_id) catch return error.NotFound; 160 const record_cursor = try records.getCursor(hash.hashInt(repo_opts.hash, &id)) orelse return error.NotFound; 161 const record = try evt.read(evt.Discussion.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(record_cursor)); 162 if (record.removed) return error.NotFound; 163 const activity_order = try evt.Discussion.activityOrder(DB, repo_opts.hash, haxy_moment, &id); 164 const order_key = try aa.dupe(u8, &evt.orderKeyDesc(activity_order, &id)); 165 const set = set_maybe orelse return error.NotFound; 166 if (!try set.contains(order_key)) return error.NotFound; 167 root_key = order_key; 168 } 169 170 const discussion_comments_start = if (empty.comment_id.len == 0) comments_start else 0; 171 const loaded_window = try loadWindow(repo_opts.hash, arena, admin_moment, haxy_moment, records, set_maybe, root_key, empty.selected_id, discussion_comments_start); 172 const comment_page = if (empty.comment_id.len == 0) 173 null 174 else 175 try Comment.init(repo_opts.hash, arena, admin_moment, haxy_moment, empty.selected_id, empty.comment_id, comments_start); 176 177 var tag_names: std.ArrayList([]const u8) = .empty; 178 if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.tag_to_id_set_key))) |tags_cursor| { 179 const tag_sets = try DB.SortedMap(.read_only).init(tags_cursor); 180 var tag_iter = try tag_sets.iterator(); 181 while (try tag_iter.next()) |entry_cursor| { 182 if (tag_names.items.len == max_tags) break; 183 var entry = entry_cursor; 184 const pair = try entry.readKeyValuePair(); 185 try tag_names.append(aa, try pair.key_cursor.readBytesAlloc(aa, null)); 186 } 187 } 188 189 return .{ 190 .identity = empty.identity, 191 .tag = empty.tag, 192 .selected_id = empty.selected_id, 193 .comment_id = empty.comment_id, 194 .comments_start = comments_start, 195 .comment_page = comment_page, 196 .recent = loaded_window, 197 .view = empty.view, 198 .description_page = empty.description_page, 199 .tags = tag_names.items, 200 }; 201 } 202 203 fn loadWindow( 204 comptime hash_kind: hash.HashKind, 205 arena: *std.heap.ArenaAllocator, 206 admin_moment: ?evt.AdminDB.HashMap(.read_only), 207 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 208 records: evt.EventDB(hash_kind).HashMap(.read_only), 209 set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), 210 root_key: ?[]const u8, 211 selected_id: []const u8, 212 comments_start: usize, 213 ) !Window { 214 const set = set_maybe orelse return .empty; 215 const DB = evt.EventDB(hash_kind); 216 const aa = arena.allocator(); 217 218 var prev_id: ?[]const u8 = null; 219 var iter = if (root_key) |key| blk: { 220 const rank = try set.rank(key); 221 if (rank > 0 and rank <= page_size) { 222 prev_id = ""; 223 } else if (rank > page_size) { 224 const pair = try set.getIndexKeyValuePair(@intCast(rank - page_size)) orelse return error.NotFound; 225 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 226 _ = try pair.key_cursor.readBytes(&order_key); 227 const hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 228 prev_id = try aa.dupe(u8, &hex); 229 } 230 break :blk try set.iteratorFrom(key); 231 } else try set.iteratorFromIndex(0); 232 233 var discussions: std.ArrayList(DiscussionWithId) = .empty; 234 var next_id: ?[]const u8 = null; 235 while (try iter.next()) |cursor| { 236 const id = try evt.readOrderKeyId(DB, cursor); 237 const id_hex = std.fmt.bytesToHex(id, .lower); 238 if (discussions.items.len == page_size) { 239 next_id = try aa.dupe(u8, &id_hex); 240 break; 241 } 242 const record_cursor = try records.getCursor(hash.hashInt(hash_kind, &id)) orelse continue; 243 const record = try evt.read(evt.Discussion.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); 244 try discussions.append(aa, .{ 245 .id = try aa.dupe(u8, &id_hex), 246 .record = record, 247 .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), 248 .comments = try Comment.loadWindow( 249 hash_kind, 250 arena, 251 admin_moment, 252 haxy_moment, 253 evt.Comment.thread_id_to_comment_id_set_key, 254 &id_hex, 255 if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, 256 ), 257 .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), 258 }); 259 } 260 261 return .{ 262 .items = discussions.items, 263 .prev_id = prev_id, 264 .next_id = next_id, 265 .count = @intCast(try set.count()), 266 }; 267 } 268 pub const View = Threads.View(.discuss, Self); 269 270 // tabs switching between the discussions page's views. 271 pub const Header = struct { 272 box: wgt.Box(ui.Widget), 273 tab_ids: std.AutoArrayHashMapUnmanaged(usize, void), 274 275 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 276 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 277 errdefer box.deinit(allocator); 278 279 var tab_ids: std.AutoArrayHashMapUnmanaged(usize, void) = .empty; 280 errdefer tab_ids.deinit(allocator); 281 282 const aa = session.page_arena.allocator(); 283 284 // recent discussions 285 { 286 const route = ui.RoutablePage.repoDiscussionsRoute(data.identity, data.tag, "") orelse return error.RouteTooLong; 287 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 288 var label_buf: [64]u8 = undefined; 289 const label = try std.fmt.bufPrint(&label_buf, "recent ({d})", .{data.recent.count}); 290 try addTab(allocator, &box, &tab_ids, label, link); 291 } 292 293 // tags tab, labeled with the active tag filter 294 { 295 const tags_route = ui.RoutablePage.repoThreadTagsRoute(.discuss, data.identity, data.tag) orelse return error.RouteTooLong; 296 const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); 297 const label = if (data.tag.len == 0) "tags" else blk: { 298 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); 299 break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); 300 }; 301 try addTab(allocator, &box, &tab_ids, label, tags_link); 302 } 303 304 // new-discussion tab; an edit or comment url shows its tab in this place 305 { 306 const route = switch (data.view) { 307 .edit => ui.RoutablePage.repoThreadEditRoute(.discuss, data.identity, data.selected_id) orelse return error.RouteTooLong, 308 .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 309 .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 310 .remove => ui.RoutablePage.repoThreadRemoveRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 311 else => ui.RoutablePage.repoThreadNewRoute(.discuss, data.identity) orelse return error.RouteTooLong, 312 }; 313 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 314 const label: []const u8 = switch (data.view) { 315 .edit => "edit", 316 .new_comment => "reply", 317 .edit_comment => "edit", 318 .remove => "remove", 319 else => "new", 320 }; 321 try addTab(allocator, &box, &tab_ids, label, link); 322 } 323 324 var self = Header{ .box = box, .tab_ids = tab_ids }; 325 // the tab matching the page's view is selected initially. 326 self.getFocus().child_id = self.tab_ids.keys()[View.viewIndex(data.view)]; 327 return self; 328 } 329 330 fn addTab(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), tab_ids: *std.AutoArrayHashMapUnmanaged(usize, void), label: []const u8, link: []const u8) !void { 331 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 332 errdefer text_box.deinit(allocator); 333 text_box.getFocus().focusable = true; 334 text_box.getFocus().kind = .{ .custom = link }; 335 try tab_ids.put(allocator, text_box.getFocus().id, {}); 336 try box.children.put(allocator, text_box.getFocus().id, .{ 337 .widget = .{ .text_box = text_box }, 338 .rect = null, 339 .min_size = .{ .width = label.len + 2, .height = null }, 340 }); 341 } 342 343 pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { 344 self.box.deinit(allocator); 345 self.tab_ids.deinit(allocator); 346 } 347 348 pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 349 self.clearGrid(); 350 // only the selected tab shows its border 351 for (self.box.children.keys(), self.box.children.values()) |id, *child| { 352 switch (child.widget) { 353 .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, 354 else => {}, 355 } 356 } 357 try self.box.build(allocator, constraint, root_focus); 358 } 359 360 pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 361 _ = allocator; 362 const current_tab = self.currentTabIndex() orelse return; 363 if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { 364 root_focus.setFocus(self.tab_ids.keys()[new_tab]); 365 } 366 } 367 368 pub fn clearGrid(self: *Header) void { 369 self.box.clearGrid(); 370 } 371 372 pub fn getGrid(self: Header) ?Grid { 373 return self.box.getGrid(); 374 } 375 376 pub fn getFocus(self: *Header) *Focus { 377 return self.box.getFocus(); 378 } 379 380 pub fn getSelectedIndex(self: Header) ?usize { 381 return self.currentTabIndex(); 382 } 383 384 fn currentTabIndex(self: Header) ?usize { 385 const child_id = self.box.focus.child_id orelse return null; 386 return self.tab_ids.getIndex(child_id); 387 } 388 }; 389