╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │316 switch (event_with_id.event) { │ │317 .user => |event_maybe| try User.consume(DB, repo_opts.hash, haxy_moment, &current_event_id, event_maybe, &arena, created_ts), │ │318 .repo => |event_maybe| try Repo.consume(DB, repo_opts.hash, haxy_moment, &current_event_id, event_maybe, &arena, created_ts), │ │319 - .issue => |event_maybe| try Issue.consume(DB, repo_opts.hash, haxy_moment, &current_event_id, event_maybe), │ │319 + .issue => |event_maybe| try Issue.consume(DB, repo_opts.hash, haxy_moment, &current_event_id, event_maybe, &arena, created_ts),│ │320 } │ │321 } │ │322 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/event/Issue.zig│ ╰───────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │ 6 title: []const u8, │ │ 7 description: []const u8, │ │ 8 tags: []const u8, │ │ 9 +created_ts: u64 = 0, // the commit timestamp of the event that first created this issue│ │10 │ │11 +// `created_ts` comes from the commit timestamp, not the event │ │12 +// payload, so it must not appear in the JSON we output │ │13 +pub fn jsonStringify(self: @This(), jw: anytype) !void { │ │14 + try jw.beginObject(); │ │15 + inline for (std.meta.fields(@This())) |field| { │ │16 + if (comptime std.mem.eql(u8, field.name, "created_ts")) continue; │ │17 + try jw.objectField(field.name); │ │18 + try jw.write(@field(self, field.name)); │ │19 + } │ │20 + try jw.endObject(); │ │21 +} │ │22 + │ │23 pub fn consume( │ │24 comptime DB: type, │ │25 comptime hash_kind: hash.HashKind, │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │26 haxy_moment: DB.HashMap(.read_write), │ │27 event_id: *const [evt.event_id_size]u8, │ │28 event_maybe: ?@This(), │ │29 + arena: *std.heap.ArenaAllocator, │ │30 + created_ts: u64, │ │31 ) !void { │ │32 const issue_key = hash.hashInt(hash_kind, event_id);│ │33 │ ╰────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │35 const event_id_to_issue = try DB.HashMap(.read_write).init(event_id_to_issue_cursor); │ │36 │ │37 if (event_maybe) |event| { │ │38 + var event_to_write = event; │ │39 + │ │40 + const existing_cursor_maybe = try event_id_to_issue.getCursor(issue_key); │ │41 + if (existing_cursor_maybe) |existing_cursor| { │ │42 + // updates preserve the original creation timestamp │ │43 + const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); │ │44 + const existing_event = try evt.read(@This(), DB, hash_kind, arena, existing_issue); │ │45 + event_to_write.created_ts = existing_event.created_ts; │ │46 + } else { │ │47 + // first time we've seen this issue: stamp it with the commit timestamp │ │48 + event_to_write.created_ts = created_ts; │ │49 + } │ │50 + │ │51 const issue_cursor = try event_id_to_issue.putCursor(issue_key); │ │52 const issue = try DB.HashMap(.read_write).init(issue_cursor); │ │25 - try evt.upsert(@This(), DB, hash_kind, issue, event); │ │53 + try evt.upsert(@This(), DB, hash_kind, issue, event_to_write); │ │54 + │ │55 + // first time we've seen this issue: add it to the set the issues view │ │56 + // lists, ordered by creation time. the key (orderKey) embeds the event │ │57 + // id, so the set needs no value. │ │58 + if (existing_cursor_maybe == null) { │ │59 + const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "issue-id-set"));│ │60 + const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); │ │61 + const order_key = evt.orderKey(event_to_write.created_ts, event_id); │ │62 + try issue_id_set.put(&order_key); │ │63 + } │ │64 } else { │ │65 + // drop it from the ordered set using its recorded creation timestamp │ │66 + if (try event_id_to_issue.getCursor(issue_key)) |existing_cursor| { │ │67 + const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); │ │68 + const existing_event = try evt.read(@This(), DB, hash_kind, arena, existing_issue); │ │69 + const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "issue-id-set"));│ │70 + const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); │ │71 + const order_key = evt.orderKey(existing_event.created_ts, event_id); │ │72 + _ = try issue_id_set.remove(&order_key); │ │73 + } │ │74 if (!try event_id_to_issue.remove(issue_key)) return error.EventNotFound; │ │75 } │ │76 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/try.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │363 var to_master = try template_repo.switchDir(io, allocator, .{ .target = .{ .ref = .{ .kind = .head, .name = "master" } } }); │ │364 defer to_master.deinit(); │ │365 } │ │366 + } │ │367 + │ │368 + // seed issues so every repo's issue tracker has content │ │369 + const issue_data = [_]struct { │ │370 + title: []const u8, │ │371 + description: []const u8, │ │372 + tags: []const u8, │ │373 + }{ │ │374 + .{ │ │375 + .title = "Crash on startup when config file is missing", │ │376 + .description = "Running the program without a config file present dereferences a null pointer and segfaults. Fall back to the built-in defaults and log a warning instead.", │ │377 + .tags = "bug\x00priority-high\x00crash", │ │378 + }, │ │379 + .{ │ │380 + .title = "Support dark mode in the settings panel", │ │381 + .description = "The settings panel is always rendered with the light palette even when the rest of the app is in dark mode. Read the active theme and pick colors accordingly.", │ │382 + .tags = "enhancement\x00ui\x00theme", │ │383 + }, │ │384 + .{ │ │385 + .title = "Memory leak when reconnecting after network failure", │ │386 + .description = "Each reconnect allocates a new connection state without freeing the previous one. After a flaky network session, memory usage grows by several megabytes per hour.",│ │387 + .tags = "bug\x00memory\x00networking", │ │388 + }, │ │389 + .{ │ │390 + .title = "Document the plugin API", │ │391 + .description = "The plugin interface has no documentation beyond the header comments. Add a guide covering the lifecycle hooks, the event callbacks, and a minimal working plugin.",│ │392 + .tags = "documentation\x00plugins", │ │393 + }, │ │394 + .{ │ │395 + .title = "Slow file indexing on large directories", │ │396 + .description = "Indexing a directory with more than 100k files takes several minutes because every entry is stat'd twice. Cache the first stat result and batch the reads.", │ │397 + .tags = "performance\x00indexing", │ │398 + }, │ │399 + .{ │ │400 + .title = "Add keyboard shortcut for quick search", │ │401 + .description = "Opening the search box currently requires clicking the toolbar icon. Bind a shortcut and show it in the tooltip so keyboard users can search without the mouse.", │ │402 + .tags = "enhancement\x00ux\x00keyboard", │ │403 + }, │ │404 + .{ │ │405 + .title = "Unicode filenames are garbled in the export dialog", │ │406 + .description = "Filenames containing non-ASCII characters display as replacement characters in the export dialog. The dialog decodes the path as Latin-1 instead of UTF-8.", │ │407 + .tags = "bug\x00unicode\x00i18n", │ │408 + }, │ │409 + .{ │ │410 + .title = "Flaky test: integration suite times out on CI", │ │411 + .description = "The integration suite intermittently exceeds the CI time limit because the server fixture waits for a fixed 30 seconds. Poll for readiness instead of sleeping.", │ │412 + .tags = "bug\x00testing\x00ci", │ │413 + }, │ │414 + .{ │ │415 + .title = "Upgrade bundled zlib to the latest release", │ │416 + .description = "The vendored zlib is two major releases behind and misses several upstream fixes. Update the bundled copy and re-run the compression benchmarks.", │ │417 + .tags = "dependencies\x00maintenance", │ │418 + }, │ │419 + .{ │ │420 + .title = "Progress bar overshoots 100% during resumed downloads", │ │421 + .description = "Resuming a partial download counts the already-downloaded bytes twice, so the progress bar reads up to 150%. Subtract the resume offset from the total.", │ │422 + .tags = "bug\x00ui\x00downloads", │ │423 + }, │ │424 + .{ │ │425 + .title = "Config parser rejects trailing commas", │ │426 + .description = "A trailing comma after the last entry in a config block is reported as a syntax error. Most editors add one automatically, so accept it.", │ │427 + .tags = "bug\x00config\x00parser", │ │428 + }, │ │429 + .{ │ │430 + .title = "Add a --json flag to the status command", │ │431 + .description = "Scripts currently scrape the human-readable status output, which breaks whenever the format changes. Emit a stable machine-readable JSON form behind a flag.", │ │432 + .tags = "enhancement\x00cli", │ │433 + }, │ │434 + .{ │ │435 + .title = "Race condition between autosave and manual save", │ │436 + .description = "Saving manually while an autosave is in flight can interleave the two writes and corrupt the file. Serialize saves through a single queue.", │ │437 + .tags = "bug\x00priority-high\x00data-loss", │ │438 + }, │ │439 + .{ │ │440 + .title = "Reduce binary size of release builds", │ │441 + .description = "The release binary has grown past 40 MB, mostly from debug info and an unused bundled font. Strip symbols and drop the font from the default build.", │ │442 + .tags = "performance\x00build", │ │443 + }, │ │444 + .{ │ │445 + .title = "Tooltips flicker when the cursor moves between adjacent buttons", │ │446 + .description = "Moving the cursor across a toolbar hides and re-shows the tooltip for every button. Keep the tooltip open with a short grace period between neighbors.", │ │447 + .tags = "bug\x00ui\x00polish", │ │448 + }, │ │449 + .{ │ │450 + .title = "Support environment variable expansion in config paths", │ │451 + .description = "Paths in the config file are taken literally, so shared configs can't refer to the home directory portably. Expand environment variables when loading.", │ │452 + .tags = "enhancement\x00config", │ │453 + }, │ │454 + .{ │ │455 + .title = "Log rotation deletes the newest file instead of the oldest", │ │456 + .description = "When the log directory hits its size cap, the rotation logic sorts by name rather than mtime and removes the most recent log. Sort by modification time.", │ │457 + .tags = "bug\x00logging", │ │458 + }, │ │459 + .{ │ │460 + .title = "Add man pages for all subcommands", │ │461 + .description = "Only the top-level command has a man page. Generate one per subcommand from the existing help text as part of the release build.", │ │462 + .tags = "documentation\x00cli", │ │463 + }, │ │464 + .{ │ │465 + .title = "High CPU usage while idle in the background", │ │466 + .description = "The main loop polls for file changes every 10 ms even when no window is visible. Switch to native file watching and idle at zero CPU.", │ │467 + .tags = "performance\x00priority-high", │ │468 + }, │ │469 + .{ │ │470 + .title = "Paste from clipboard drops the final newline", │ │471 + .description = "Pasting text that ends with a newline silently trims it, which breaks pasted shell snippets. Preserve the clipboard content exactly.", │ │472 + .tags = "bug\x00editor\x00clipboard", │ │473 + }, │ │474 + .{ │ │475 + .title = "Improve error message for expired credentials", │ │476 + .description = "An expired token currently surfaces as a bare 401 with no guidance. Detect the expiry case and tell the user how to re-authenticate.", │ │477 + .tags = "enhancement\x00ux\x00auth", │ │478 + }, │ │479 + .{ │ │480 + .title = "Crash when window is resized during startup animation", │ │481 + .description = "Resizing the window while the splash animation is running dereferences a freed layout node. Cancel the animation before rebuilding the layout.", │ │482 + .tags = "bug\x00crash\x00ui", │ │483 + }, │ │484 + .{ │ │485 + .title = "Add integration tests for the sync engine", │ │486 + .description = "The sync engine only has unit tests with mocked transports. Add end-to-end tests that run two instances against a real local server.", │ │487 + .tags = "testing\x00sync", │ │488 + }, │ │489 + .{ │ │490 + .title = "Timestamps display in UTC instead of local time", │ │491 + .description = "All timestamps in the activity feed render in UTC regardless of the system timezone. Convert to local time and include the offset in tooltips.", │ │492 + .tags = "bug\x00i18n\x00time", │ │493 + }, │ │494 + .{ │ │495 + .title = "Deprecate the legacy plugin format", │ │496 + .description = "Both plugin formats are currently loaded, doubling the maintenance surface. Warn on legacy plugins this release and drop support in the next.", │ │497 + .tags = "maintenance\x00plugins", │ │498 + }, │ │499 + }; │ │500 + │ │501 + var issue_events: [issue_data.len]evt.EventWithId = undefined; │ │502 + for (issue_data, 0..) |issue, i| { │ │503 + issue_events[i] = .{ │ │504 + .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), │ │505 + // stepped timestamps so the issues list in a stable order │ │506 + .timestamp = @intCast(i + 1), │ │507 + .event = .{ │ │508 + .issue = .{ │ │509 + .title = issue.title, │ │510 + .description = issue.description, │ │511 + .tags = issue.tags, │ │512 + }, │ │513 + }, │ │514 + }; │ │515 } │ │516 + try evt.commitAndConsume(.{}, io, allocator, &template_repo, evt.events_ref, &issue_events); │ │517 } │ │518 │ │519 // copy the template to each repo's on-disk location, named by its │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │46 else => return error.UnexpectedRoute, │ │47 }, │ │48 .repo => switch (route) { │ │49 - .repo_files, .repo_commits, .repo_refs, .repo_settings, .repo_auth => .{ .repo = try Repo.init(arena, session, route) }, │ │49 + .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_settings, .repo_auth => .{ .repo = try Repo.init(arena, session, route) },│ │50 else => return error.UnexpectedRoute, │ │51 }, │ │52 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │75 repo_files: struct { name: Array(repo_route_max_len), after: usize = 0 }, │ │76 repo_commits: struct { name: Array(repo_route_max_len), after: usize = 0 }, │ │77 repo_refs: struct { name: Array(repo_route_max_len), kind: RefKind = .branch, after: usize = 0 }, │ │78 + repo_issues: struct { name: Array(repo_route_max_len), selected: Array(evt.event_id_size * 2) = .{} },│ │79 repo_settings: Array(repo_route_max_len), │ │80 repo_auth: Array(repo_route_max_len), │ │81 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │198 // column to `after` (0 = the first window, the other column always at 0). │ │199 pub fn repoRefsRoute(identity: []const u8, kind: RefKind, after: usize) ?RoutablePage { │ │200 return .{ .repo_refs = .{ .name = Array(repo_route_max_len).from(identity) orelse return null, .kind = kind, .after = after } };│ │201 + } │ │202 + │ │203 + // build a `.repo_issues` route for "owner/name" (identity) rooted at the │ │204 + // issue with hex event id `selected` ("" = the first window). │ │205 + pub fn repoIssuesRoute(identity: []const u8, selected: []const u8) ?RoutablePage { │ │206 + return .{ .repo_issues = .{ │ │207 + .name = Array(repo_route_max_len).from(identity) orelse return null, │ │208 + .selected = Array(evt.event_id_size * 2).from(selected) orelse return null, │ │209 + } }; │ │210 } │ │211 │ │212 // an inline, owned array of data. keeping it in the route (rather than a │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │254 try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/refs", .{r.name.slice()}) │ │255 else │ │256 try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/refs?kind={s}&after={d}", .{ r.name.slice(), @tagName(r.kind), r.after }),│ │257 + .repo_issues => |i| if (i.selected.len == 0) │ │258 + try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/issues", .{i.name.slice()}) │ │259 + else │ │260 + try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/issues/{s}", .{ i.name.slice(), i.selected.slice() }), │ │261 .repo_settings => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/settings", .{name.slice()}), │ │262 .repo_auth => |name| try std.fmt.allocPrint(arena.allocator(), repo_segment ++ "{s}/auth", .{name.slice()}), │ │263 }; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │267 return switch (self) { │ │268 .home_users, .home_repos, .home_settings, .home_auth => .home, │ │269 .user_repos, .user_settings, .user_auth => .user, │ │256 - .repo_files, .repo_commits, .repo_refs, .repo_settings, .repo_auth => .repo, │ │270 + .repo_files, .repo_commits, .repo_refs, .repo_issues, .repo_settings, .repo_auth => .repo,│ │271 }; │ │272 } │ │273 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯