╭ 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 Comment = @import("Comment.zig"); 9 const Attachment = @import("Attachment.zig"); 10 const thread = ui.widget.thread; 11 12 const wasm = builtin.target.cpu.arch == .wasm32; 13 14 // how much of a description the detail pane shows before linking to the 15 // /description page, the same limit commit messages use. 16 pub const max_description_size = 2 * 1024; 17 18 // how many discussions one window shows before a "next" link appears. 19 pub const page_size = 20; 20 21 // how many tags the tags view shows at most. 22 pub const max_tags = 1000; 23 24 // one discussion from the repo's consumed event database, with its hex event id 25 // (the id lives in the event envelope, not the payload). 26 pub const DiscussionWithId = struct { 27 id: []const u8, 28 record: evt.Discussion.Record, 29 author: ui.Author = .unknown, 30 comments: Comment.Window = .empty, 31 attachments: []const Attachment.WithId = &.{}, 32 }; 33 34 pub const Entry = DiscussionWithId; 35 36 pub const Window = struct { 37 items: []const DiscussionWithId, 38 prev_id: ?[]const u8, 39 next_id: ?[]const u8, 40 count: usize, 41 42 pub const empty: Window = .{ .items = &.{}, .prev_id = null, .next_id = null, .count = 0 }; 43 }; 44 45 identity: []const u8, 46 tag: []const u8, 47 selected_id: []const u8, 48 comment_id: []const u8, 49 comments_start: usize, 50 comment_page: ?Comment.Permalink = null, 51 recent: Window, 52 view: ui.RoutablePage.DiscussionsView, 53 description_page: bool = false, 54 tags: []const []const u8, 55 repo_source: ?ui.RepoSource = null, 56 57 const Self = @This(); 58 59 pub const Event = evt.Discussion; 60 pub const Status = enum { recent }; 61 pub const ViewKind = ui.RoutablePage.DiscussionsView; 62 pub const thread_name = "discussion"; 63 pub const header_widget_name = "repo_discussions_header"; 64 65 pub fn listRoute(identity: []const u8, _: Status, tag: []const u8, selected: []const u8) ?ui.RoutablePage { 66 return ui.RoutablePage.repoDiscussionsRoute(identity, tag, selected); 67 } 68 69 pub fn selectedThread(self: *const Self) ?*const DiscussionWithId { 70 if (self.selected_id.len == 0) return null; 71 for (self.recent.items) |*entry| { 72 if (std.mem.eql(u8, entry.id, self.selected_id)) return entry; 73 } 74 return null; 75 } 76 77 pub fn window(self: *const Self, _: Status) *const Window { 78 return &self.recent; 79 } 80 81 pub fn emptyResult( 82 aa: std.mem.Allocator, 83 identity: []const u8, 84 tag: []const u8, 85 selected_id: []const u8, 86 comment_id: []const u8, 87 comments_start: usize, 88 view: ui.RoutablePage.DiscussionsView, 89 ) !Self { 90 return .{ 91 .identity = try aa.dupe(u8, identity), 92 .tag = try aa.dupe(u8, tag), 93 .selected_id = try aa.dupe(u8, selected_id), 94 .comment_id = try aa.dupe(u8, comment_id), 95 .comments_start = comments_start, 96 .recent = .empty, 97 .view = if (view == .description) .recent else view, 98 .description_page = view == .description, 99 .tags = &.{}, 100 }; 101 } 102 103 pub fn init( 104 comptime repo_kind: rp.RepoKind, 105 comptime repo_opts: rp.RepoOpts(repo_kind), 106 arena: *std.heap.ArenaAllocator, 107 repo: *rp.Repo(repo_kind, repo_opts), 108 io: std.Io, 109 admin_moment: ?evt.AdminDB.HashMap(.read_only), 110 identity: []const u8, 111 tag: []const u8, 112 selected_id: []const u8, 113 comment_id: []const u8, 114 comments_start: usize, 115 view: ui.RoutablePage.DiscussionsView, 116 ) !Self { 117 const empty = try emptyResult(arena.allocator(), identity, tag, selected_id, comment_id, comments_start, view); 118 const aa = arena.allocator(); 119 const DB = evt.EventDB(repo_opts.hash); 120 const rooted = empty.selected_id.len != 0; 121 const tagged = empty.tag.len != 0; 122 const strict = rooted or tagged; 123 124 const gpa = arena.child_allocator; 125 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; 126 defer if (event_db_maybe) |*event_db| event_db.deinit(io, gpa); 127 const haxy_moment = (if (event_db_maybe) |*event_db| 128 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 129 else if (repo_kind == .git) 130 return empty 131 else 132 evt.currentMoment(repo_opts, repo)) catch { 133 if (strict) return error.NotFound; 134 return empty; 135 }; 136 137 const set_maybe: ?DB.SortedSet(.read_only) = if (tagged) blk: { 138 const tags_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.tag_to_id_set_key)) orelse return error.NotFound; 139 const tag_sets = try DB.SortedMap(.read_only).init(tags_cursor); 140 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.tag)); 141 const cursor = try tag_sets.getCursor(decoded) orelse return error.NotFound; 142 break :blk try DB.SortedSet(.read_only).init(cursor); 143 } else if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.active_id_set_key))) |cursor| 144 try DB.SortedSet(.read_only).init(cursor) 145 else 146 null; 147 148 const records_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.record_map_key)) orelse { 149 if (strict) return error.NotFound; 150 return empty; 151 }; 152 const records = try DB.HashMap(.read_only).init(records_cursor); 153 154 var root_key: ?[]const u8 = null; 155 if (rooted) { 156 const id = evt.parseEventId(empty.selected_id) catch return error.NotFound; 157 const record_cursor = try records.getCursor(hash.hashInt(repo_opts.hash, &id)) orelse return error.NotFound; 158 const record = try evt.read(evt.Discussion.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(record_cursor)); 159 if (record.removed) return error.NotFound; 160 const activity_order = try evt.Discussion.activityOrder(DB, repo_opts.hash, haxy_moment, &id); 161 const order_key = try aa.dupe(u8, &evt.orderKeyDesc(activity_order, &id)); 162 const set = set_maybe orelse return error.NotFound; 163 if (!try set.contains(order_key)) return error.NotFound; 164 root_key = order_key; 165 } 166 167 const discussion_comments_start = if (empty.comment_id.len == 0) comments_start else 0; 168 const loaded_window = try loadWindow(repo_opts.hash, arena, admin_moment, haxy_moment, records, set_maybe, root_key, empty.selected_id, discussion_comments_start); 169 const comment_page = if (empty.comment_id.len == 0) 170 null 171 else 172 try Comment.init(repo_opts.hash, arena, admin_moment, haxy_moment, empty.selected_id, empty.comment_id, comments_start); 173 174 var tag_names: std.ArrayList([]const u8) = .empty; 175 if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.tag_to_id_set_key))) |tags_cursor| { 176 const tag_sets = try DB.SortedMap(.read_only).init(tags_cursor); 177 var tag_iter = try tag_sets.iterator(); 178 while (try tag_iter.next()) |entry_cursor| { 179 if (tag_names.items.len == max_tags) break; 180 var entry = entry_cursor; 181 const pair = try entry.readKeyValuePair(); 182 try tag_names.append(aa, try pair.key_cursor.readBytesAlloc(aa, null)); 183 } 184 } 185 186 return .{ 187 .identity = empty.identity, 188 .tag = empty.tag, 189 .selected_id = empty.selected_id, 190 .comment_id = empty.comment_id, 191 .comments_start = comments_start, 192 .comment_page = comment_page, 193 .recent = loaded_window, 194 .view = empty.view, 195 .description_page = empty.description_page, 196 .tags = tag_names.items, 197 }; 198 } 199 200 fn loadWindow( 201 comptime hash_kind: hash.HashKind, 202 arena: *std.heap.ArenaAllocator, 203 admin_moment: ?evt.AdminDB.HashMap(.read_only), 204 haxy_moment: evt.EventDB(hash_kind).HashMap(.read_only), 205 records: evt.EventDB(hash_kind).HashMap(.read_only), 206 set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), 207 root_key: ?[]const u8, 208 selected_id: []const u8, 209 comments_start: usize, 210 ) !Window { 211 const set = set_maybe orelse return .empty; 212 const DB = evt.EventDB(hash_kind); 213 const aa = arena.allocator(); 214 215 var prev_id: ?[]const u8 = null; 216 var iter = if (root_key) |key| blk: { 217 const rank = try set.rank(key); 218 if (rank > 0 and rank <= page_size) { 219 prev_id = ""; 220 } else if (rank > page_size) { 221 const pair = try set.getIndexKeyValuePair(@intCast(rank - page_size)) orelse return error.NotFound; 222 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 223 _ = try pair.key_cursor.readBytes(&order_key); 224 const hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 225 prev_id = try aa.dupe(u8, &hex); 226 } 227 break :blk try set.iteratorFrom(key); 228 } else try set.iteratorFromIndex(0); 229 230 var discussions: std.ArrayList(DiscussionWithId) = .empty; 231 var next_id: ?[]const u8 = null; 232 while (try iter.next()) |cursor| { 233 const id = try evt.readOrderKeyId(DB, cursor); 234 const id_hex = std.fmt.bytesToHex(id, .lower); 235 if (discussions.items.len == page_size) { 236 next_id = try aa.dupe(u8, &id_hex); 237 break; 238 } 239 const record_cursor = try records.getCursor(hash.hashInt(hash_kind, &id)) orelse continue; 240 const record = try evt.read(evt.Discussion.Record, DB, hash_kind, arena, try DB.HashMap(.read_only).init(record_cursor)); 241 try discussions.append(aa, .{ 242 .id = try aa.dupe(u8, &id_hex), 243 .record = record, 244 .author = try ui.Author.initFromEmail(admin_moment, arena, record.author_email), 245 .comments = try Comment.loadWindow( 246 hash_kind, 247 arena, 248 admin_moment, 249 haxy_moment, 250 evt.Comment.thread_id_to_comment_id_set_key, 251 &id_hex, 252 if (std.mem.eql(u8, &id_hex, selected_id)) comments_start else 0, 253 ), 254 .attachments = try Attachment.load(hash_kind, arena, haxy_moment, &id_hex), 255 }); 256 } 257 258 return .{ 259 .items = discussions.items, 260 .prev_id = prev_id, 261 .next_id = next_id, 262 .count = @intCast(try set.count()), 263 }; 264 } 265 pub const View = thread.View(.discuss, Self); 266 267 pub const Header = thread.Header; 268 269 // tabs switching between the discussions page's views 270 pub fn initHeader(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 271 var header = try Header.init(allocator); 272 errdefer header.deinit(allocator); 273 const aa = session.page_arena.allocator(); 274 const selected_index = View.viewIndex(data.view); 275 const page_selected = std.meta.activeTag(session.data.current_page) == .repo_discussions; 276 277 // recent discussions 278 { 279 const route = ui.RoutablePage.repoDiscussionsRoute(data.identity, data.tag, "") orelse return error.RouteTooLong; 280 const link = try ui.inPageTabLink(session, route, page_selected and selected_index == 0); 281 var label_buf: [64]u8 = undefined; 282 const label = try std.fmt.bufPrint(&label_buf, "recent ({d})", .{data.recent.count}); 283 try header.addTab(allocator, label, link, 0); 284 } 285 286 // tags tab, labeled with the active tag filter 287 { 288 const tags_route = ui.RoutablePage.repoThreadTagsRoute(.discuss, data.identity, data.tag) orelse return error.RouteTooLong; 289 const tags_link = try ui.inPageTabLink(session, tags_route, page_selected and selected_index == View.viewIndex(.tags)); 290 const label = if (data.tag.len == 0) "tags" else blk: { 291 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); 292 break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); 293 }; 294 try header.addTab(allocator, label, tags_link, View.viewIndex(.tags)); 295 } 296 297 // new-discussion tab; an edit or comment url shows its tab in this place 298 { 299 const route = switch (data.view) { 300 .edit => ui.RoutablePage.repoThreadEditRoute(.discuss, data.identity, data.selected_id) orelse return error.RouteTooLong, 301 .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 302 .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 303 .remove => ui.RoutablePage.repoThreadRemoveRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 304 else => ui.RoutablePage.repoThreadNewRoute(.discuss, data.identity) orelse return error.RouteTooLong, 305 }; 306 const link = try ui.inPageTabLink(session, route, page_selected and selected_index == View.viewIndex(.new)); 307 const label: []const u8 = switch (data.view) { 308 .edit => "edit", 309 .new_comment => "reply", 310 .edit_comment => "edit", 311 .remove => "remove", 312 else => "new", 313 }; 314 try header.addTab(allocator, label, link, View.viewIndex(.new)); 315 } 316 317 header.select(View.viewIndex(data.view)); 318 return header; 319 } 320