╭ 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 many discussions one window shows before a "next" link appears. 15 pub const page_size = 20; 16 17 // how many labels the labels view shows at most. 18 pub const max_labels = 1000; 19 20 // one discussion from the repo's consumed event database, with its hex event id 21 // (the id lives in the event envelope, not the payload). 22 pub const DiscussionWithId = struct { 23 id: []const u8, 24 record: evt.Discussion.Record, 25 author: ui.Author = .unknown, 26 comments: Comment.Window = .empty, 27 attachments: []const Attachment.WithId = &.{}, 28 assignees: []const ui.Assignee = &.{}, 29 // whether the viewer may change the thread, computed here so the web ui has it 30 can_modify: bool = false, 31 // the viewer's notify button, null when hidden 32 notify_action: ?ui.NotifyAction = null, 33 }; 34 35 pub const Entry = DiscussionWithId; 36 37 pub const Window = struct { 38 items: []const DiscussionWithId, 39 prev_id: ?[]const u8, 40 next_id: ?[]const u8, 41 count: usize, 42 // whether a search stopped counting at its cap, so `count` is a floor. 43 more: bool = false, 44 45 pub const empty: Window = .{ .items = &.{}, .prev_id = null, .next_id = null, .count = 0 }; 46 }; 47 48 identity: []const u8, 49 label: []const u8, 50 // the decoded query the list is filtered to, or null when unfiltered. 51 search: ?[]const u8 = null, 52 selected_id: []const u8, 53 comment_id: []const u8, 54 comments_start: usize, 55 comment_page: ?Comment.Permalink = null, 56 // who the page is built for, null when logged out 57 viewer: ?ui.Viewer = null, 58 recent: Window, 59 view: ui.RoutablePage.DiscussionsView, 60 description_page: bool = false, 61 labels: []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 pub const thread_name = "discussion"; 70 pub const header_widget_name = "repo_discussions_header"; 71 72 pub fn listRoute(identity: []const u8, _: Status, label: []const u8, selected: []const u8) ?ui.RoutablePage { 73 return ui.RoutablePage.repoDiscussionsRoute(identity, label, selected); 74 } 75 76 pub fn selectedThread(self: *const Self) ?*const DiscussionWithId { 77 if (self.selected_id.len == 0) return null; 78 for (self.recent.items) |*entry| { 79 if (std.mem.eql(u8, entry.id, self.selected_id)) return entry; 80 } 81 return null; 82 } 83 84 pub fn window(self: *const Self, _: Status) *const Window { 85 return &self.recent; 86 } 87 88 pub fn emptyResult( 89 aa: std.mem.Allocator, 90 identity: []const u8, 91 label: []const u8, 92 search: []const u8, 93 selected_id: []const u8, 94 comment_id: []const u8, 95 comments_start: usize, 96 view: ui.RoutablePage.DiscussionsView, 97 ) !Self { 98 return .{ 99 .identity = try aa.dupe(u8, identity), 100 .label = try aa.dupe(u8, label), 101 .search = if (search.len == 0) null else std.Uri.percentDecodeInPlace(try aa.dupe(u8, search)), 102 .selected_id = try aa.dupe(u8, selected_id), 103 .comment_id = try aa.dupe(u8, comment_id), 104 .comments_start = comments_start, 105 .recent = .empty, 106 .view = if (view == .description) .recent else view, 107 .description_page = view == .description, 108 .labels = &.{}, 109 }; 110 } 111 112 pub fn init( 113 comptime repo_kind: rp.RepoKind, 114 comptime repo_opts: rp.RepoOpts(repo_kind), 115 arena: *std.heap.ArenaAllocator, 116 repo: *rp.Repo(repo_kind, repo_opts), 117 io: std.Io, 118 admin_moment: ?evt.AdminDB.HashMap(.read_only), 119 identity: []const u8, 120 label: []const u8, 121 search: []const u8, 122 selected_id: []const u8, 123 comment_id: []const u8, 124 comments_start: usize, 125 view: ui.RoutablePage.DiscussionsView, 126 viewer: ?ui.Viewer, 127 ) !Self { 128 var empty = try emptyResult(arena.allocator(), identity, label, search, selected_id, comment_id, comments_start, view); 129 // the early returns below still let the viewer write 130 empty.viewer = viewer; 131 const aa = arena.allocator(); 132 const DB = evt.EventDB(repo_opts.hash); 133 const rooted = empty.selected_id.len != 0; 134 const labeled = empty.label.len != 0; 135 const strict = rooted or labeled; 136 137 const gpa = arena.child_allocator; 138 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; 139 defer if (event_db_maybe) |*event_db| event_db.deinit(io, gpa); 140 const haxy_moment = (if (event_db_maybe) |*event_db| 141 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 142 else if (repo_kind == .git) 143 return empty 144 else 145 evt.currentMoment(repo_opts, repo)) catch { 146 if (strict) return error.NotFound; 147 return empty; 148 }; 149 150 const set_maybe: ?DB.SortedSet(.read_only) = if (labeled) blk: { 151 const labels_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.label_to_id_set_key)) orelse return error.NotFound; 152 const label_sets = try DB.SortedMap(.read_only).init(labels_cursor); 153 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, empty.label)); 154 const cursor = try label_sets.getCursor(decoded) orelse return error.NotFound; 155 break :blk try DB.SortedSet(.read_only).init(cursor); 156 } else if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.active_id_set_key))) |cursor| 157 try DB.SortedSet(.read_only).init(cursor) 158 else 159 null; 160 161 const records_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.record_map_key)) orelse { 162 if (strict) return error.NotFound; 163 return empty; 164 }; 165 const records = try DB.HashMap(.read_only).init(records_cursor); 166 167 var root_key: ?[]const u8 = null; 168 if (rooted) { 169 const id = evt.parseEventId(empty.selected_id) catch return error.NotFound; 170 const record_cursor = try records.getCursor(hash.hashInt(repo_opts.hash, &id)) orelse return error.NotFound; 171 const record = try evt.read(evt.Discussion.Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(record_cursor)); 172 if (record.removed) return error.NotFound; 173 const activity_order = try evt.Discussion.activityOrder(DB, repo_opts.hash, haxy_moment, &id); 174 const order_key = try aa.dupe(u8, &evt.orderKeyDesc(activity_order, &id)); 175 const set = set_maybe orelse return error.NotFound; 176 if (!try set.contains(order_key)) return error.NotFound; 177 root_key = order_key; 178 } 179 180 const discussion_comments_start = if (empty.comment_id.len == 0) comments_start else 0; 181 const loaded_window = try thread.loadWindow(Self, .discuss, repo_opts.hash, arena, admin_moment, haxy_moment, records, set_maybe, root_key, null, empty.selected_id, discussion_comments_start, empty.search, null, viewer); 182 const comment_page = if (empty.comment_id.len == 0) 183 null 184 else 185 try Comment.init(repo_opts.hash, arena, admin_moment, haxy_moment, empty.selected_id, empty.comment_id, comments_start, viewer); 186 187 var label_names: std.ArrayList([]const u8) = .empty; 188 if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Discussion.label_to_id_set_key))) |labels_cursor| { 189 const label_sets = try DB.SortedMap(.read_only).init(labels_cursor); 190 var label_iter = try label_sets.iterator(); 191 while (try label_iter.next()) |entry_cursor| { 192 if (label_names.items.len == max_labels) break; 193 var entry = entry_cursor; 194 const pair = try entry.readKeyValuePair(); 195 try label_names.append(aa, try pair.key_cursor.readBytesAlloc(aa, null)); 196 } 197 } 198 199 return .{ 200 .identity = empty.identity, 201 .label = empty.label, 202 .search = empty.search, 203 .selected_id = empty.selected_id, 204 .comment_id = empty.comment_id, 205 .comments_start = comments_start, 206 .comment_page = comment_page, 207 .viewer = viewer, 208 .recent = loaded_window, 209 .view = empty.view, 210 .description_page = empty.description_page, 211 .labels = label_names.items, 212 }; 213 } 214 215 pub const View = thread.View(.discuss, Self); 216 pub const Detail = thread.Detail(.discuss, Self); 217 pub const detail_widget_name = "repo_discussion_detail"; 218 219 pub const Header = thread.Header; 220 221 const recent_tab_label = "recent"; 222 const labels_tab_label = "labels"; 223 const edit_tab_label = "edit"; 224 const reply_tab_label = "reply"; 225 const remove_tab_label = "remove"; 226 const new_tab_label = "new"; 227 228 // tabs switching between the discussions page's views 229 pub fn initHeader(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 230 var header = try Header.init(allocator, session, data.search, null); 231 errdefer header.deinit(allocator); 232 const selected_index = View.viewIndex(data.view); 233 const page_selected = std.meta.activeTag(session.data.current_page) == .repo_discussions; 234 235 // recent discussions 236 { 237 const route = try thread.searchRoute(ui.RoutablePage.repoDiscussionsRoute(data.identity, data.label, ""), data.search); 238 const link = try ui.inPageTabLink(session, route, page_selected and selected_index == 0); 239 var label_buf: [64]u8 = undefined; 240 const label = try thread.countLabel(&label_buf, recent_tab_label, data.recent); 241 try header.addTab(allocator, label, link, 0); 242 } 243 244 // labels tab 245 { 246 const labels_route = try thread.searchRoute(ui.RoutablePage.repoThreadLabelsRoute(.discuss, data.identity, data.label), data.search); 247 const labels_link = try ui.inPageTabLink(session, labels_route, page_selected and selected_index == View.viewIndex(.labels)); 248 try header.addTab(allocator, labels_tab_label, labels_link, View.viewIndex(.labels)); 249 } 250 251 // new-discussion tab; an edit or comment url shows its tab in this place 252 { 253 const route = switch (data.view) { 254 .edit => ui.RoutablePage.repoThreadEditRoute(.discuss, data.identity, data.selected_id) orelse return error.RouteTooLong, 255 .new_comment => ui.RoutablePage.repoThreadCommentNewRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 256 .edit_comment => ui.RoutablePage.repoThreadCommentEditRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 257 .remove => ui.RoutablePage.repoThreadRemoveRoute(.discuss, data.identity, data.selected_id, data.comment_id) orelse return error.RouteTooLong, 258 else => ui.RoutablePage.repoThreadNewRoute(.discuss, data.identity) orelse return error.RouteTooLong, 259 }; 260 const link = try ui.inPageTabLink(session, route, page_selected and selected_index == View.viewIndex(.new)); 261 const label: []const u8 = switch (data.view) { 262 .edit => edit_tab_label, 263 .new_comment => reply_tab_label, 264 .edit_comment => edit_tab_label, 265 .remove => remove_tab_label, 266 else => new_tab_label, 267 }; 268 try header.addTab(allocator, label, link, View.viewIndex(.new)); 269 } 270 271 header.select(View.viewIndex(data.view)); 272 return header; 273 } 274