71 Commits

Author SHA1 Message Date
teernisse
386dd884ec test(ingestion): add MR + nonzero_summary tests, close bd-1au9 2026-02-19 10:02:03 -05:00
teernisse
61fbc234d8 test(ingestion): add 25 unit tests for issues.rs (bd-1au9 partial)
Cover parse_timestamp, cursor_filter_with_ts, sync cursor DB operations,
process_single_issue (upsert, labels, assignees, milestone, dirty tracking,
payload storage), and discussion sync queue population.
2026-02-19 09:53:50 -05:00
teernisse
5aca644da6 feat: implement lore brief command (bd-1n5q)
Composable capstone: replaces 5+ separate lore calls with a single
situational awareness command. Three modes:
- Topic: lore brief 'authentication'
- Path: lore brief --path src/auth/
- Person: lore brief --person username

Seven sections: open_issues, active_mrs, experts, recent_activity,
unresolved_threads, related (semantic), warnings.

Each section degrades gracefully if data is unavailable.
7 unit tests, robot-docs, autocorrect registry.
2026-02-19 09:51:37 -05:00
teernisse
20db46a514 refactor: split who.rs into who/ module (bd-2cbw)
Split 2447-line who.rs into focused submodules:
- who/scoring.rs: half_life_decay (20 lines)
- who/queries.rs: 5 query functions + helpers (~1400 lines)
- who/format.rs: human + JSON formatters (~570 lines)
- who.rs: slim module root with mode dispatch + re-exports (~260 lines)

All 1052 tests pass. No public API changes.
2026-02-19 09:45:12 -05:00
teernisse
e8ecb561cf feat: implement lore explain command (bd-9lbr)
Auto-generates structured narratives for issues and MRs from local DB:
- EntitySummary with title, state, author, labels, status
- Key decisions heuristic (correlates state/label changes with nearby notes)
- Activity summary with event counts and time span
- Open threads detection (unresolved discussions)
- Related entities (closing MRs, related issues)
- Timeline of all events in chronological order

7 unit tests, robot-docs entry, autocorrect registry, CLI dispatch wired.
2026-02-19 09:38:50 -05:00
teernisse
1e679a6d72 feat(sync): fetch and store GitLab issue links (bd-343o)
Add end-to-end support for GitLab issue link fetching:
- New GitLabIssueLink type + fetch_issue_links API client method
- Migration 029: add issue_links job type and watermark column
- issue_links.rs: bidirectional entity_reference storage with
  self-link skip, cross-project fallback, idempotent upsert
- Drain pipeline in orchestrator following mr_closes_issues pattern
- Display related issues in 'lore show issues' output
- --no-issue-links CLI flag with config, autocorrect, robot-docs
- 6 unit tests for storage logic
2026-02-19 09:26:47 -05:00
teernisse
9a1dbda522 docs: update AGENTS.md and CLAUDE.md with Phase B commands (bd-2fc)
Add temporal intelligence command examples: timeline, file-history,
trace, related, drift, who, count references, surgical sync.
Both AGENTS.md (project) and ~/.claude/CLAUDE.md (global) updated.
2026-02-19 09:05:19 -05:00
teernisse
a55f47161b docs(robot): update robot-docs manifest with Phase B commands (bd-1v8)
- Add 'related' command with entity/query modes and response schema
- Update 'count' to document 'references' entity type and its schema
- Expand temporal_intelligence workflow with file-history and trace steps
- Update lore_exclusive list with file-history, trace, related, drift
2026-02-19 09:03:24 -05:00
teernisse
2bbd1e3426 feat(cli): close bd-3jqx, add related to autocorrect registry, robot-docs updates
- Register related subcommand flags (--limit, --project) in COMMAND_FLAGS
- Robot-docs: add related command schema, count references schema
- Robot-docs: add file-history, trace, related, drift to capabilities
- Close bd-3jqx: all 4 integration tests passing (903 total, 0 failures)
- Beads sync
2026-02-19 09:03:16 -05:00
teernisse
574cd55eff feat(cli): add 'lore count references' command (bd-2ez)
Adds 'references' entity type to the count command with breakdowns
by reference_type (closes/mentioned/related), source_method
(api/note_parse/description_parse), and unresolved count.

Includes human and robot output formatters, 2 unit tests.
2026-02-19 09:01:05 -05:00
teernisse
c8dece8c60 feat(cli): add 'lore related' semantic similarity command (bd-8con)
Adds 'lore related' / 'lore similar' command for discovering semantically
related issues and MRs using vector embeddings.

Two modes:
- Entity mode: find entities similar to a specific issue/MR
- Query mode: embed free text and find matching entities

Includes distance-to-similarity conversion, label intersection,
human and robot output formatters, and 11 unit tests.
2026-02-19 08:56:16 -05:00
teernisse
3e96f19a11 feat(tui): add CLI/TUI parity tests (bd-wrw1)
10 parity tests verifying TUI and CLI query paths return consistent
results from the same SQLite database:
- Dashboard count parity (issues, MRs, discussions, notes)
- Issue list parity (IID ordering, state/author filters, ascending sort)
- MR list parity (IID ordering, state filter)
- Shared field parity (title, state, author, project_path)
- Empty database handling
- Terminal safety sanitization (dangerous sequences stripped)

Uses full-schema in-memory DB via create_connection + run_migrations.
Closes bd-wrw1, bd-2o49 (Phase 5.6 epic).
2026-02-19 08:01:55 -05:00
teernisse
8d24138655 chore: close Phase 5.5 epic (bd-1b6k) — 63 reliability tests 2026-02-19 07:49:59 -05:00
teernisse
01491b4180 feat(tui): add soak + pagination race tests (bd-14hv)
7 soak tests: 50k-event sustained load, watchdog timeout, render
interleaving, screen cycling, mode oscillation, depth bounds, multi-seed.
7 pagination race tests: concurrent read/write with snapshot fence,
multi-reader, within-fence writes, stress 1000 iterations.
2026-02-19 07:49:22 -05:00
teernisse
5143befe46 feat(tui): add 14 performance benchmark tests (bd-wnuo)
S/M/L tiered benchmarks measuring TUI update+render cycles with
synthetic data fixtures. SLO gates: S-tier <10ms update/<20ms render,
M-tier <50ms each. L-tier advisory only. All pass with generous margins.
2026-02-19 07:42:51 -05:00
teernisse
1e06cec3df feat(tui): add 10 navigation property tests (bd-3eis)
Deterministic seeded PRNG verifies NavigationStack invariants across
200k+ operations: depth >= 1, push/pop identity, forward cleared,
jump list only tracks detail screens, reset clears all, breadcrumbs
match depth, no panic under arbitrary sequences.
2026-02-19 01:13:20 -05:00
teernisse
9d6352a6af feat(tui): add 9 stress/fuzz tests for resize storm, rapid keys, event fuzz (bd-nu0d) 2026-02-19 01:09:02 -05:00
teernisse
656db00c04 feat(tui): add 16 race condition reliability tests (bd-3fjk)
- 4 stale response tests: issue list, dashboard, MR list, cross-screen isolation
- 4 SQLITE_BUSY error handling tests: toast display, nav preservation, idempotent toasts, error-then-success
- 7 cancel race tests: cancel/resubmit, rapid 5-submit sequence, key isolation, complete removes handle, stale completion no-op, stuck loading prevention, cancel_all
- 1 issue detail stale guard test
- Added active_cancel_token() method to TaskSupervisor for test observability
2026-02-19 01:03:25 -05:00
teernisse
9bcc512639 feat(tui): add 9 user flow integration tests (bd-2ygk)
Implements end-to-end flow tests covering all PRD Section 6 journeys:
- Morning triage (dashboard -> issue list -> detail -> back)
- Direct screen jumps (g-prefix chain: gt -> gw -> gi -> gh)
- Quick search (g/ -> results -> drill-in -> back with preserved state)
- Sync and browse (gs -> sync lifecycle -> complete -> browse)
- Expert navigation (gw -> Who -> verify expert mode default)
- Command palette (Ctrl+P -> verify open/filtered -> Esc close)
- Timeline navigation (gt -> events -> drill-in -> back)
- Bootstrap sync flow (Bootstrap -> gs -> SyncCompleted -> Dashboard)
- MR drill-in and back (gm -> detail -> Esc -> cursor preserved)

Key testing patterns:
- State generation alignment for dual-guard stale detection
- Key event injection via send_key/send_go helpers
- Data injection via supervisor.submit() + Msg handlers
- Cross-screen state preservation assertions
2026-02-19 00:52:58 -05:00
teernisse
403800be22 feat(tui): add snapshot test infrastructure + terminal compat matrix (bd-2nfs)
- 6 deterministic snapshot tests at 120x40 with FakeClock frozen at 2026-01-15T12:00:00Z
- Buffer-to-plaintext serializer resolving chars, graphemes, and wide-char continuations
- Golden file management with UPDATE_SNAPSHOTS=1 env var for regeneration
- Snapshot diff output on mismatch for easy debugging
- Tests: dashboard, issue list, issue detail, MR list, search results, empty state
- TERMINAL_COMPAT.md template for manual QA across iTerm2/tmux/Alacritty/kitty/WezTerm
2026-02-19 00:38:11 -05:00
teernisse
04ea1f7673 feat(tui): wire entity cache for near-instant detail view reopens (bd-3rjw)
- Add get_mut() and clear() methods to EntityCache<V>
- Add CachedIssuePayload / CachedMrPayload types to state
- Wire cache check in navigate_to for instant cache hits
- Populate cache on IssueDetailLoaded / MrDetailLoaded
- Update cache on DiscussionsLoaded
- Add 6 new entity_cache tests (get_mut, clear)
2026-02-19 00:25:28 -05:00
teernisse
026b3f0754 feat(tui): responsive breakpoints for detail views (bd-a6yb)
Apply breakpoint-aware layout to issue_detail and mr_detail views:
- Issue detail: hide labels on Xs, hide assignees on Xs/Sm, skip milestone row on Xs
- MR detail: hide branch names and merge status on Xs/Sm
- Issue detail allocate_sections gives description 60% on wide (Lg+) vs 40% narrow
- Add responsive tests for both detail views
- Close bd-a6yb: all TUI screens now adapt to terminal width

760 lib tests pass, clippy clean.
2026-02-19 00:10:43 -05:00
teernisse
ae1c3e3b05 chore: update beads tracking
Sync beads issue database to reflect current project state.
2026-02-18 23:59:40 -05:00
teernisse
bbfcfa2082 fix(tui): bounds-check scope picker selected index
Replace direct indexing (self.projects[self.selected_index - 1]) with
.get() to prevent panic if selected_index is somehow out of bounds.
Falls back to "All Projects" scope when the index is invalid instead
of panicking.
2026-02-18 23:59:11 -05:00
teernisse
45a989637c feat(tui): add per-screen responsive layout helpers
Introduce breakpoint-aware helper functions in layout.rs that
centralize per-screen responsive decisions. Each function maps a
Breakpoint to a screen-specific value, replacing scattered
hardcoded checks across view modules:

- detail_side_panel: show discussions side panel at Lg+
- info_screen_columns: 1 column on Xs/Sm, 2 on Md+
- search_show_project: hide project path column on narrow terminals
- timeline_time_width: compact time on Xs (5), full on Md+ (12)
- who_abbreviated_tabs: shorten tab labels on Xs/Sm
- sync_progress_bar_width: scale progress bar 15→50 with width

All functions are const fn with exhaustive match arms.
Includes 6 unit tests covering every breakpoint variant.
2026-02-18 23:59:04 -05:00
teernisse
1b66b80ac4 style(tui): apply rustfmt and clippy formatting across crate
Mechanical formatting pass to satisfy rustfmt line-width limits and
clippy pedantic/nursery lints. No behavioral changes.

Formatting (rustfmt line wrapping):
- action/sync.rs: multiline tuple destructure, function call args in tests
- state/sync.rs: if-let chain formatting, remove unnecessary Vec collect
- view/sync.rs: multiline array entries, format!(), vec! literals
- view/doctor.rs: multiline floor_char_boundary chain
- view/scope_picker.rs: multiline format!() with floor_char_boundary
- view/stats.rs: multiline render_stat_row call
- view/mod.rs: multiline assert!() in test
- app/update.rs: multiline enum variant destructure
- entity_cache.rs: multiline assert_eq!() with messages
- render_cache.rs: multiline retain() closure
- session.rs: multiline serde_json/File::create/parent() chains

Clippy:
- action/sync.rs: #[allow(clippy::too_many_arguments)] on test helper

Import/module ordering (alphabetical):
- state/mod.rs: move scope_picker mod + pub use to sorted position
- view/mod.rs: move scope_picker, stats, sync mod + use to sorted position
- view/scope_picker.rs: sort use imports (ScopeContext before ScopePickerState)
2026-02-18 23:58:29 -05:00
teernisse
09ffcfcf0f refactor(tui): deduplicate cursor_cell_offset into text_width module
Four view modules (search, command_palette, file_history, trace) each had
their own copy of cursor_cell_offset / text_cell_width for converting a
byte-offset cursor position to a display-column offset. Phase 5 introduced
a proper text_width module with these functions; this commit removes the
duplicates and rewires all call sites to use crate::text_width.

- search.rs: removed local text_cell_width + cursor_cell_offset definitions
- command_palette.rs: removed local cursor_cell_offset definition
- file_history.rs: replaced inline chars().count() cursor calc with import
- trace.rs: replaced inline chars().count() cursor calc with import
2026-02-18 23:58:13 -05:00
teernisse
146eb61623 feat(tui): Phase 4 completion + Phase 5 session/lock/text-width
Phase 4 (bd-1df9) — all 5 acceptance criteria met:
- Sync screen with delta ledger (bd-2x2h, bd-y095)
- Doctor screen with health checks (bd-2iqk)
- Stats screen with document counts (bd-2iqk)
- CLI integration: lore tui subcommand (bd-26lp)
- CLI integration: lore sync --tui flag (bd-3l56)

Phase 5 (bd-3h00) — session persistence + instance lock + text width:
- text_width.rs: Unicode-aware measurement, truncation, padding (16 tests)
- instance_lock.rs: Advisory PID lock with stale recovery (6 tests)
- session.rs: Atomic write + CRC32 checksum + quarantine (9 tests)

Closes: bd-26lp, bd-3h00, bd-3l56, bd-1df9, bd-y095
2026-02-18 23:51:54 -05:00
teernisse
418417b0f4 fix(tui): correct column names in file_history action queries + update beads
- file_history.rs: Fix SQL column references to match actual schema
  (position_new_path/position_old_path naming).

- beads: Update issue tracker state.
2026-02-18 22:58:14 -05:00
teernisse
fb40fdc677 feat(tui): Phase 3 power features — Who, Search, Timeline, Trace, File History screens
Complete TUI Phase 3 implementation with all 5 power feature screens:

- Who screen: 5 modes (expert/workload/reviews/active/overlap) with
  mode tabs, input bar, result rendering, and hint bar
- Search screen: full-text search with result list and scoring display
- Timeline screen: chronological event feed with time-relative display
- Trace screen: file provenance chains with expand/collapse, rename
  tracking, and linked issues/discussions
- File History screen: per-file MR timeline with rename chain display
  and discussion snippets

Also includes:
- Command palette overlay (fuzzy search)
- Bootstrap screen (initial sync flow)
- Action layer split from monolithic action.rs to per-screen modules
- Entity and render cache infrastructure
- Shared who_types module in core crate
- All screens wired into view/mod.rs dispatch
- 597 tests passing, clippy clean (pedantic + nursery), fmt clean
2026-02-18 22:56:38 -05:00
teernisse
f8d6180f06 fix: clippy lints — sort_by_key in drift, pub visibility for TUI, assert formatting
- drift.rs: Replace `sorted.sort_by(|a, b| b.1.cmp(&a.1))` with idiomatic
  `sort_by_key(|entry| std::cmp::Reverse(entry.1))` (clippy::use_sort_by_key).

- list.rs: Change `query_issues` and `query_mrs` from `fn` to `pub fn` so the
  TUI crate can call them directly instead of duplicating the query logic.

- vector.rs: Reformat multi-line assert! macro to satisfy rustfmt check
  (body moved onto separate lines with trailing message string).
2026-02-18 22:56:24 -05:00
teernisse
c1b1300675 refactor: extract who result types to core::who_types for TUI reuse
Move the 14 result structs and enums (WhoResult, ExpertResult, Expert,
ScoreComponents, ExpertMrDetail, WorkloadResult, WorkloadIssue, WorkloadMr,
WorkloadDiscussion, ReviewsResult, ReviewCategory, ActiveResult,
ActiveDiscussion, OverlapResult, OverlapUser) from cli::commands::who into
a new core::who_types module.

The TUI Who screen needs these types to render results, but importing from
the CLI layer would create a circular dependency (TUI -> CLI -> core). By
placing them in core, both the CLI and TUI can depend on them cleanly.

The CLI module re-exports all types via `pub use crate::core::who_types::*`
so existing consumers are unaffected.
2026-02-18 22:56:16 -05:00
teernisse
050e00345a feat(tui): Phase 2 detail screens — Issue Detail, MR Detail, discussion tree, cross-refs
Implements the remaining Phase 2 Core Screens:

- Discussion tree widget (view/common/discussion_tree.rs): DiscussionNode/NoteNode types,
  expand/collapse state, visual row flattening, format_relative_time with Clock trait
- Cross-reference widget (view/common/cross_ref.rs): CrossRefKind enum, navigable refs,
  badge rendering ([MR]/[REL]/[REF])
- Issue Detail (state + action + view): progressive hydration (metadata Phase 1,
  discussions Phase 2), section cycling, description scroll, sanitized GitLab content
- MR Detail (state + action + view): tab bar (Overview/Files/Discussions), file changes
  with change type indicators, branch info, draft/merge status, diff note support
- Message + update wiring: IssueDetailLoaded, MrDetailLoaded, DiscussionsLoaded handlers
  with TaskSupervisor stale-result guards

Closes bd-1d6z, bd-8ab7, bd-3t1b, bd-1cl9 (Phase 2 epic).
389 tests passing, clippy clean, fmt clean.
2026-02-18 15:37:23 -05:00
teernisse
90c8b43267 feat(tui): Phase 2 Issue List + MR List screens
Implement state, action, and view layers for both list screens:
- Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation
- MR List: mirrors Issue pattern with draft/reviewer/target branch filters
- Migration 027: covering indexes for TUI list screen queries
- Updated Msg types to use typed Page structs instead of raw Vec<Row>
- 303 tests passing, clippy clean

Beads: bd-3ei1, bd-2kr0, bd-3pm2
2026-02-18 14:48:15 -05:00
teernisse
c5b7f4c864 (no description set) 2026-02-18 12:59:07 -05:00
teernisse
28ce63f818 refactor: split common/mod.rs into per-widget modules 2026-02-18 12:58:38 -05:00
teernisse
eb5b464d03 feat: TUI Phase 1 common widgets + scoring/path beads 2026-02-18 12:58:12 -05:00
teernisse
4664e0cfe3 feat: complete TUI Phase 0 — Toolchain Gate 2026-02-18 12:47:10 -05:00
teernisse
63bd58c9b4 feat(who): filter unresolved discussions to open entities only
Workload and active modes now exclude discussions on closed issues and
merged/closed MRs by default. Adds --include-closed flag to restore
the previous behavior when needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:34:28 -05:00
teernisse
714c8c2623 feat(path): rename-aware ambiguity resolution for suffix probe
When a bare filename like 'operators.ts' matches multiple full paths,
check if they are the same file connected by renames (via BFS on
mr_file_changes). If so, auto-resolve to the newest path instead of
erroring. Also wires path resolution into file-history and trace
commands so bare filenames work everywhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:34:28 -05:00
teernisse
171260a772 feat(cli): implement 'lore trace' command (bd-2n4, bd-9dd)
Gate 5 Code Trace - Tier 1 (API-only, no git blame).
Answers 'Why was this code introduced?' by building
file -> MR -> issue -> discussion chains.

New files:
- src/core/trace.rs: run_trace() query logic with rename-aware
  path resolution, entity_reference-based issue linking, and
  DiffNote discussion extraction
- src/core/trace_tests.rs: 7 unit tests for query logic
- src/cli/commands/trace.rs: CLI command with human output,
  robot JSON output, and :line suffix parsing (5 tests)

Human output shows full content (no truncation).
Robot JSON truncates discussion bodies to 500 chars for token efficiency.

Wiring:
- TraceArgs + Commands::Trace in cli/mod.rs
- handle_trace in main.rs
- VALID_COMMANDS + robot-docs manifest entry
- COMMAND_FLAGS autocorrect registry entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:57:21 -05:00
teernisse
a1bca10408 feat(cli): implement 'lore file-history' command (bd-z94)
Adds file-history command showing which MRs touched a file, with:
- Rename chain resolution via BFS (resolve_rename_chain from bd-1yx)
- DiffNote discussion snippets with --discussions flag
- --merged filter, --no-follow-renames, -n limit
- Human output with styled MR list and rename chain display
- Robot JSON output with {ok, data, meta} envelope
- Autocorrect registry and robot-docs manifest entry
- Fixes pre-existing --no-status missing from sync autocorrect registry
2026-02-17 12:57:56 -05:00
teernisse
491dc52864 release: v0.8.3 2026-02-16 10:29:52 -05:00
teernisse
b9063aa17a feat(cli): add --no-status flag to skip GraphQL status enrichment during sync 2026-02-16 10:29:11 -05:00
teernisse
fc0d9cb1d3 feat(sync): colored stage output, functional sub-rows, and error visibility
Overhaul the sync command's human output to use semantic colors and a
cleaner rendering architecture. The changes fall into four areas:

Stage lines: Replace direct finish_stage() calls with an
emit_stage_line/emit_stage_block pattern that clears the spinner first,
then prints static lines via MultiProgress::suspend. Stage icons are
now color-coded green (success) or yellow (warning) via color_icon().
A separate "Status" stage line now appears after Issues, summarizing
work-item status enrichment across all projects.

Sub-rows: Replace the imperative print_issue_sub_rows/print_mr_sub_rows
functions with functional issue_sub_rows(), mr_sub_rows(), and new
status_sub_rows() that return Vec<String>. Project paths use
Theme::muted(), error/failure counts use Theme::warning(), and
separators use the dim middle-dot style. Sub-rows are printed atomically
with their parent stage line to avoid interleaving with spinners.

Summary: In print_sync(), counts now use Theme::info().bold() for visual
pop, detail-line separators are individually styled (dim middle-dot),
and a new "Sync completed with issues" headline appears when any stage
had failures. Document errors and embedding failures are surfaced in
both the doc-parts line and the errors line.

Tests: Full coverage for append_failures, summarize_status_enrichment,
should_print_timings, issue_sub_rows, mr_sub_rows, and status_sub_rows.
2026-02-16 09:43:36 -05:00
teernisse
c8b47bf8f8 feat(cli): add --timings flag and enrich error tracking fields
Add -t/--timings flag to the sync subcommand, allowing users to opt
into a per-stage timing breakdown after the sync summary. Wire the flag
through main.rs into print_sync() which passes it to the new
should_print_timings() gate.

Enrich the data structures that flow through the sync pipeline so
downstream renderers have full error visibility:

- ProjectSummary gains status_errors (issue-side status enrichment
  failures per project)
- ProjectStatusEnrichment gains path (project path for sub-row display)
- SyncResult gains documents_errored and embedding_failed so the
  summary can surface doc-gen and embed failures separately
- Autocorrect table updated with --timings for fuzzy flag matching
2026-02-16 09:43:22 -05:00
teernisse
a570327a6b refactor(progress): extract format_stage_line with themed styling
Pull the line-formatting logic out of finish_stage() into a standalone
public format_stage_line() so that sync.rs can build stage lines without
needing a live ProgressBar (e.g. for static multi-line blocks printed
after the spinner is cleared).

The new function applies Theme::info().bold() to the label and
Theme::timing() to the elapsed column, giving every stage line
consistent color treatment. finish_stage() now delegates to it.

Includes a unit test asserting the formatted output contains the
expected icon, label, summary, and elapsed components.
2026-02-16 09:43:13 -05:00
teernisse
eef73decb5 fix(cli): timeline tag width, test env isolation, and logging verbosity
Miscellaneous fixes across CLI and core modules:

- Timeline: widen TAG_WIDTH from 10 to 11 to accommodate longer event
  type labels without truncation
- render.rs: save and restore LORE_ICONS env var in glyph_mode test to
  prevent interference from the test environment leaking into or from
  other tests that set LORE_ICONS
- logging.rs: adjust verbose=1 to info level (was debug), verbose=2 to
  debug — this reduces noise at -v while keeping -vv as the full debug
  experience
- issues.rs, merge_requests.rs: use infodebug! macro consistently for
  ingestion summary logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:25:42 -05:00
teernisse
bb6660178c feat(sync): per-project breakdown, status enrichment progress bars, and summary polish
Add per-project detail rows beneath stage completion lines during multi-project
syncs, showing itemized counts (issues/MRs, discussions, events, statuses, diffs)
for each project. Previously, only aggregate totals were visible, making it hard
to diagnose which project contributed what during a sync.

Status enrichment gets proper progress bars replacing the old spinner-only
display: StatusEnrichmentStarted now carries a total count so the CLI can
render a determinate bar with rate and ETA. The enrichment SQL is tightened
to use IS NOT comparisons for diff-only UPDATEs (skip rows where values
haven't changed), and a follow-up touch_stmt ensures status_synced_at is
updated even for unchanged rows so staleness detection works correctly.

Other improvements:
- New ProjectSummary struct aggregates per-project metrics during ingestion
- SyncResult gains statuses_enriched + per-project summary vectors
- "Already up to date" message when sync finds zero changes
- Remove Arc<AtomicBool> tick_started pattern from docs/embed stages
  (enable_steady_tick is idempotent, the guard was unnecessary)
- Progress bar styling: dim spinner, dark_gray track, per_sec + eta display
- Tick intervals tightened from 100ms to 60ms for smoother animation
- statuses_without_widget calculation uses fetch_result.statuses.len()
  instead of subtracting enriched (more accurate when some statuses lack
  work item widgets)
- Status enrichment completion log downgraded from info to debug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:25:33 -05:00
teernisse
64e73b1cab fix(graphql): handle past HTTP dates in retry-after header gracefully
Extract parse_retry_after_value(header, now) as a pure function to enable
deterministic testing of Retry-After header parsing. The previous
implementation used let-chains with SystemTime::now() inline, which made
it untestable and would panic on negative durations when the server
clock was behind or the header contained a date in the past.

Changes:
- Extract parse_retry_after_value() taking an explicit `now` parameter
- Handle past HTTP dates by returning 1 second instead of panicking on
  negative Duration (date.duration_since(now) returns Err for past dates)
- Trim whitespace from header values before parsing
- Add test for past HTTP date returning 1 second minimum
- Add test for delta-seconds with surrounding whitespace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:25:19 -05:00
teernisse
361757568f refactor(cli): remove deprecated stage_spinner, migrate remaining callers to v2
Phase 7 cleanup: migrate timeline.rs and main.rs search spinner
from stage_spinner() to stage_spinner_v2() with proper icon labels,
then remove the now-unused stage_spinner() function and its tests.

No external callers remain for the old numbered-stage API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:13:06 -05:00
Taylor Eernisse
8572f6cc04 refactor(cli): polish secondary commands with icons, number formatting, and section dividers
Phase 6 of the UX overhaul. Applies consistent visual treatment across
the remaining command outputs: stats, doctor, timeline, who, count,
and drift.

Stats (stats.rs):
- Apply render::format_number() to all numeric values (documents,
  FTS indexed, embedding counts, chunks) for thousand-separator
  formatting in large databases

Doctor (doctor.rs):
- Replace Unicode check/warning/cross symbols with Icons::success(),
  Icons::warning(), Icons::error() for glyph-mode awareness
- Add summary line after checks showing "Ready/Not ready" with counts
  of passed, warnings, and failed checks separated by middle dots
- Remove "lore doctor" title header for cleaner output

Count (count.rs):
- Right-align numeric values with {:>10} format for columnar output
  in count and state breakdown displays

Timeline (timeline.rs):
- Add entity icons (issue/MR) before entity references in event rows
- Refactor format_event_tag to pad plain text before applying style,
  preventing ANSI codes from breaking column alignment
- Extract style_padded() helper for width-then-style pattern

Who (who.rs):
- Add Icons::user() before usernames in expert, workload, reviews,
  and overlap displays
- Replace manual bold section headers with render::section_divider()
  in workload view (Assigned Issues, Authored MRs, Reviewing MRs,
  Unresolved Discussions)

Drift (drift.rs):
- Add Icons::error()/success() before drift detection status line
- Replace '#' bar character with Unicode full block for similarity
  curve visualization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
d0744039ef refactor(show): polish issue and MR detail views with section dividers and icons
Phase 4 of the UX overhaul. Restructures the show issue and show MR
detail displays with consistent section layout, state icons, and
improved typography.

Issue detail changes:
- Replace bold header + box-drawing underline with indented title using
  Theme::bold() for the title text only
- Organize fields into named sections using render::section_divider():
  Details, Development, Description, Discussions
- Add state icons (Icons::issue_opened/closed) alongside text labels
- Add relative time in parentheses next to Created/Updated dates
- Switch labels from "Labels: (none)" to only showing when present,
  using format_labels_bare for clean comma-separated output
- Move URL and confidential indicator into Details section
- Closing MRs show state-colored icons (merged/opened/closed)
- Discussions use section_divider instead of bold text, remove colons
  from author lines, adjust wrap widths for consistent indentation

MR detail changes:
- Same section-divider layout: Details, Description, Discussions
- State icons for opened/merged/closed using Icons::mr_* helpers
- Draft indicator uses Icons::mr_draft() instead of [Draft] text prefix
- Relative times added to Created, Updated, Merged, Closed dates
- Reviewers and Assignees fields aligned with fixed-width labels
- Labels shown only when present, using format_labels_bare
- Discussion formatting matches issue detail style

Both views use 5-space left indent for field alignment and consistent
wrap widths (72 for descriptions, 68/66 for discussion notes/replies).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
4b372dfb38 refactor(list): polish list commands with icons, compact timestamps, and styled discussions
Phase 3 of the UX overhaul. Enhances the issues, merge requests, and
notes list displays with visual indicators and improved formatting.

List display changes (src/cli/commands/list.rs):
- Add state icons to issues (opened/closed) and merge requests
  (opened/merged/closed) using Icons:: helpers alongside text labels
- Replace [DRAFT] prefix with Icons::mr_draft() glyph for draft MRs
- Switch from format_relative_time to format_relative_time_compact for
  tighter column widths in tabular output
- Switch from format_labels to format_labels_bare for unlabeled style
- Change format_discussions() return type from String to StyledCell so
  unresolved counts render with Theme::warning() color inline
- Bold the section headers ("Issues", "Merge Requests", "Notes")
  with count separated from the label for cleaner scanning
- Import Icons from render module

Test updates (src/cli/commands/list_tests.rs):
- Update format_discussions tests to assert on StyledCell.text field
  instead of raw String, since the function now returns styled output
- The unresolved-count test checks starts_with/contains to handle
  embedded ANSI escape codes from Theme::warning()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
af8fc4af76 refactor(sync): overhaul progress display with stage spinners and summaries
Phase 2 of the UX overhaul. Replaces the old numbered-stage progress
system (1/4, 2/4...) and manual indicatif ProgressBar/ProgressStyle
setup with the new centralized progress helpers.

Sync command changes (src/cli/commands/sync.rs):
- Replace stage_spinner(n, total, msg) with stage_spinner_v2(icon, label, status)
  removing the rigid numbered-stage counter in favor of named stages
- Replace manual ProgressBar::new + ProgressStyle::default_bar for docs
  and embed sub-progress with nested_progress(label, len, robot_mode)
- Add finish_stage() calls that display a completion summary with
  elapsed time, e.g. "Issues  42 issues from 3 projects  1.2s"
- Each stage (Issues, MRs, Docs, Embed) now reports what it did on
  completion rather than just clearing the spinner silently
- Embed failure path uses Icons::warning() instead of inline Theme
  formatting, keeping error display consistent with success path
- Remove indicatif direct dependency from sync.rs (now handled by
  progress module)

Main entry point changes (src/main.rs):
- Add GlyphMode detection: auto-detect Unicode/Nerd Font support or
  fall back to ASCII based on --icons flag, --color=never, NO_COLOR,
  or robot mode
- Update all LoreRenderer::init() calls to pass GlyphMode alongside
  ColorMode for icon-aware rendering throughout the CLI
- Overhaul handle_error() formatting: use Icons::error() glyph,
  bold error text, arrow prefixed action suggestions, and breathing
  room with blank lines for scannability
- Migrate handle_embed() progress bar from manual ProgressBar +
  ProgressStyle to nested_progress() helper, matching sync command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
96b288ccdd refactor(search): polish search results rendering with semantic Theme styles
Phase 5 of the UX overhaul. Migrates search result display from raw
console styling to the centralized Theme system with semantic methods,
improving visual consistency and readability.

Search result changes:
- Type badges now use semantic styles (issue_ref, mr_ref) with
  fixed-width alignment for clean columnar layout
- Snippet rendering uses Theme::highlight() for matched terms and
  Theme::muted() for surrounding context, replacing bold+underline
- Metadata line uses Theme::username() for authors and per-part
  styling with middle-dot separators instead of a single dim line
- Result numbering uses muted style with right-aligned width
- Consistent 8-space indent for metadata, snippets, and explain lines
- Header line uses muted style for search mode instead of dim+parens
- Trailing blank line moved after the result loop instead of per-result

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
teernisse
d710403567 feat(cli): add GlyphMode icon system, Theme extensions, and progress API
Phase 1 of UX skin overhaul: foundation layer that all subsequent
phases build upon.

Icons: 3-tier glyph system (Nerd Font / Unicode / ASCII) with
auto-detection from TERM_PROGRAM, LORE_ICONS env, or --icons flag.
16 semantic icon methods on Icons struct (success, warning, error,
issue states, MR states, note, search, user, sync, waiting).

Theme: 4 new semantic styles — muted (#6b7280), highlight (#fbbf24),
timing (#94a3b8), state_draft (#6b7280).

Progress: stage_spinner_v2 with icon prefix, nested_progress with
bounded bar/throughput/ETA, finish_stage for static completion lines,
format_elapsed for compact duration strings.

Utilities: format_relative_time_compact (3h, 2d, 1w, 3mo),
format_labels_bare (comma-separated without brackets).

CLI: --icons global flag, GLOBAL_FLAGS registry updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
ebf64816c9 fix(search): correct FTS5 raw mode fallback test assertion
Update test_raw_mode_leading_wildcard_falls_back_to_safe to match the
actual Safe mode behavior: OR is a recognized FTS5 boolean operator and
passes through unquoted, so the expected output is '"*" OR "auth"' not
'"*" "OR" "auth"'. The previous assertion was incorrect since the Safe
mode operator-passthrough logic was added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:34:01 -05:00
Taylor Eernisse
450951dee1 feat(timeline): rename --expand-mentions to --no-mentions, default mentions on
Invert the timeline mention-expansion flag semantics. Previously, mention
edges were excluded by default and --expand-mentions opted in. Now mention
edges are included by default (matching the more common use case) and
--no-mentions opts out to reduce fan-out when needed.

This is a breaking CLI change but aligns with the principle that the
default behavior should produce the most useful output. Users who were
passing --expand-mentions get the same behavior without any flag. Users
who want reduced output can pass --no-mentions.

Updated: CLI args (TimelineArgs), autocorrect flag list, robot-docs
schema, README documentation and flag reference table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:34 -05:00
Taylor Eernisse
81f049a7fa refactor(main): wire LoreRenderer init, migrate to Theme, improve UX polish
Wire the LoreRenderer singleton initialization into main.rs color mode
handling, replacing the console::style import with Theme throughout.

Key changes:

- Color initialization: LoreRenderer::init() called for all code paths
  (NO_COLOR, --color never/always/auto, unknown mode fallback) alongside
  the existing console::set_colors_enabled() calls. Both systems must
  agree since some transitive code still uses console (e.g. dialoguer).

- Tracing: Replace .with_target(false) with .event_format(CompactHumanFormat)
  for the stderr layer, producing the clean 'HH:MM:SS LEVEL  message' format.

- Error handling: handle_error() now shows machine-actionable recovery
  commands from gi_error.actions() below the hint, formatted with dim '$'
  prefix and bold command text.

- Deprecation warnings: All 'lore list', 'lore show', 'lore auth-test',
  'lore sync-status' warnings migrated to Theme::warning().

- Init wizard: All success/info/error messages migrated. Unicode check
  marks use explicit \u{2713} escapes instead of literal symbols.

- Embed command: Added progress bar with indicatif for embedding stage,
  showing position/total with steady tick. Elapsed time shown on completion.

- Generate-docs and ingest commands: Added 'Done in Xs' elapsed time and
  next-step hints (run embed after generate-docs, run generate-docs after
  ingest) for better workflow guidance.

- Sync output: Interrupt message and lock release migrated to Theme.

- Health command: Status labels and overall healthy/unhealthy styled.

- Robot-docs: Added drift command schema, updated sync flags to include
  --no-file-changes, updated who flags with new options.

- Timeline --expand-mentions -> --no-mentions flag rename wired through
  params and robot-docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:09 -05:00
Taylor Eernisse
dd00a2b840 refactor(cli): migrate all command modules from console::style to Theme
Replace all console::style() calls in command modules with the centralized
Theme API and render:: utility functions. This ensures consistent color
behavior across the entire CLI, proper NO_COLOR/--color never support via
the LoreRenderer singleton, and eliminates duplicated formatting code.

Changes per module:

- count.rs: Theme for table headers, render::format_number replacing local
  duplicate. Removed local format_number implementation.
- doctor.rs: Theme::success/warning/error for check status symbols and
  messages. Unicode escapes for check/warning/cross symbols.
- drift.rs: Theme::bold/error/success for drift detection headers and
  status messages.
- embed.rs: Compact output format — headline with count, zero-suppressed
  detail lines, 'nothing to embed' short-circuit for no-op runs.
- generate_docs.rs: Same compact pattern — headline + detail + hint for
  next step. No-op short-circuit when regenerated==0.
- ingest.rs: Theme for project summaries, sync status, dry-run preview.
  All console::style -> Theme replacements.
- list.rs: Replace comfy-table with render::LoreTable for issue/MR listing.
  Remove local colored_cell, colored_cell_hex, format_relative_time,
  truncate_with_ellipsis, and format_labels (all moved to render.rs).
- list_tests.rs: Update test assertions to use render:: functions.
- search.rs: Add render_snippet() for FTS5 <mark> tag highlighting via
  Theme::bold().underline(). Compact result layout with type badges.
- show.rs: Theme for entity detail views, delegate format_date and
  wrap_text to render module.
- stats.rs: Section-based layout using render::section_divider. Compact
  middle-dot format for document counts. Color-coded embedding coverage
  percentage (green >=95%, yellow >=50%, red <50%).
- sync.rs: Compact sync summary — headline with counts and elapsed time,
  zero-suppressed detail lines, visually prominent error-only section.
- sync_status.rs: Theme for run history headers, removed local
  format_number duplicate.
- timeline.rs: Theme for headers/footers, render:: for date/truncate,
  standard format! padding replacing console::pad_str.
- who.rs: Theme for all expert/workload/active/overlap/review output
  modes, render:: for relative time and truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:32:35 -05:00
Taylor Eernisse
c6a5461d41 refactor(ingestion): compact log summaries and quieter shutdown messages
Migrate all ingestion completion logs to use nonzero_summary() for compact,
zero-suppressed output. Before: 8-14 individual key=value structured fields
per completion message. After: a single summary field like
'42 fetched · 3 labels · 12 notes' that only shows non-zero counters.

Also downgrade all 'Shutdown requested...' messages from info! to debug!.
These are emitted on every Ctrl+C and add noise to the partial results
output that immediately follows. They remain visible at -vv for debugging
graceful shutdown behavior.

Affected modules:
- issues.rs: issue ingestion completion
- merge_requests.rs: MR ingestion completion, full-sync cursor reset
- mr_discussions.rs: discussion ingestion completion
- orchestrator.rs: project-level issue and MR completion summaries,
  all shutdown-requested checkpoints across discussion sync, resource
  events drain, closes-issues drain, and MR diffs drain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:57 -05:00
Taylor Eernisse
a7f86b26e4 refactor(core): compact human log format, quieter lock lifecycle, nonzero_summary helper
Three quality-of-life improvements to reduce log noise and improve readability:

1. logging.rs: Add CompactHumanFormat for stderr tracing output. Replaces the
   default format with a minimal 'HH:MM:SS LEVEL  message key=value' layout —
   no span context, no full timestamps, no target module. The JSON file log
   layer is unaffected. This makes watching 'lore sync' output much cleaner.

2. lock.rs: Downgrade AppLock acquire/release messages from info! to debug!.
   Lock lifecycle events (acquired new, acquired existing, released) are
   operational bookkeeping that clutters normal output. They remain visible
   at -vv verbosity for troubleshooting.

3. ingestion/mod.rs: Add nonzero_summary() utility that formats named counters
   as a compact middle-dot-separated string, suppressing zero values. Produces
   output like '42 fetched · 3 labels · 12 notes' instead of verbose key=value
   structured fields. Returns 'nothing to update' when all values are zero.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:30 -05:00
Taylor Eernisse
5ee8b0841c feat(cli): add centralized render module with semantic Theme and LoreRenderer
Introduce src/cli/render.rs as the single source of truth for all terminal
output styling and formatting utilities. Key components:

- LoreRenderer: global singleton initialized once at startup, resolving
  color mode (Auto/Always/Never) against TTY state and NO_COLOR env var.
  This fixes lipgloss's limitation of hardcoded TrueColor rendering by
  gating all style application through a colors_on() check.

- Theme: semantic style constants (success/warning/error/info/accent,
  entity refs, state colors, structural styles) that return plain
  Style::new() when colors are disabled. Replaces ad-hoc console::style()
  calls scattered across 15+ command modules.

- Shared formatting utilities consolidated from duplicated implementations:
  format_relative_time (was in list.rs and who.rs), format_number (was in
  count.rs and sync_status.rs), truncate (was truncate_with_ellipsis in
  list.rs and truncate_summary in timeline.rs), format_labels, format_date,
  wrap_indent, section_divider.

- LoreTable: lightweight table renderer replacing comfy-table with simple
  column alignment (Left/Right/Center), adaptive terminal width, and
  NO_COLOR-safe output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:02 -05:00
Taylor Eernisse
7062a3f1fd deps: replace comfy-table with lipgloss (charmed-lipgloss)
Switch from comfy-table to the lipgloss Rust port for terminal styling.
lipgloss provides a composable Style API better suited to our new semantic
theming approach (Theme::success(), Theme::error(), etc.) where we apply
styles to individual text spans rather than constructing styled table cells.
The comfy-table dependency was only used by the list command's human output
and is no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:30:31 -05:00
teernisse
159c490ad7 docs: update README with notes, drift, error tolerance, scoring config, and expanded command reference
Major additions:
- lore notes command: full documentation of rich note querying with
  filters (author, type, path, resolution, time range, body substring),
  sort/format options, field selection, and browser opening
- lore drift command: discussion divergence detection documentation
- Error Tolerance section: table of all 8 auto-correction types with
  examples and mode behavior, stderr JSON warning format, fuzzy
  suggestion format for unrecognized commands
- Command Aliases table: primary commands and their accepted aliases
- scoring config section: all weight/half-life/decay parameters for
  the who-expert scoring engine (authorWeight, reviewerWeight, noteBonus,
  half-life periods, closedMrMultiplier, excludedUsernames)

Updates to existing sections:
- Timeline: entity-direct seeding syntax (issue:N, i:N, mr:N, m:N),
  hybrid search pipeline description replacing pure FTS5, discussion
  thread collection, --fields flag, numbered progress spinners
- Search: --after/--updated-after renamed to --since/--updated-since,
  progress spinner behavior, note type filter
- Who: --explain-score, --as-of, --include-bots, --all-history, --detail
- Sync: --no-file-changes flag
- Robot-docs: --brief flag
- Field selection: expanded to note which commands support --fields
2026-02-13 17:27:59 -05:00
teernisse
e0041ed4d9 feat(cli): improve error recovery with alias-aware suggestions and error tolerance manifest
Two related improvements to agent ergonomics in main.rs:

1. suggest_similar_command now matches against aliases (issue->issues,
   mr->mrs, find->search, stat->stats, note->notes, etc.) and provides
   contextual usage examples via a new command_example() helper, so
   agents get actionable recovery hints like "Did you mean 'lore mrs'?
   Example: lore --robot mrs -n 10" instead of just the command name.

2. robot-docs now includes an error_tolerance section documenting every
   auto-correction the CLI performs: types (single_dash_long_flag,
   case_normalization, flag_prefix, fuzzy_flag, subcommand_alias,
   value_normalization, value_fuzzy, prefix_matching), examples, and
   mode behavior (threshold differences). Also expands the aliases
   section with command_aliases and pre_clap_aliases maps for complete
   agent self-discovery.

Together these ensure agents can programmatically discover and recover
from any CLI input error without human intervention.
2026-02-13 17:27:49 -05:00
teernisse
a34751bd47 feat(autocorrect): expand pre-clap correction to 3-phase pipeline with subcommand aliases, value normalization, and flag prefix matching
Three-phase pipeline replacing the single-pass correction:

- Phase A: Subcommand alias correction — handles forms clap can't
  express (merge_requests, mergerequests, robotdocs, generatedocs,
  gen-docs, etc.) via case-insensitive alias map lookup.
- Phase B: Per-arg flag corrections — adds unambiguous prefix expansion
  (--proj -> --project) alongside existing single-dash, case, and fuzzy
  rules. New FlagPrefix rule with 0.95 confidence.
- Phase C: Enum value normalization — auto-corrects casing, prefixes,
  and typos for flags with known valid values. Handles both --flag value
  and --flag=value forms. Respects POSIX -- option terminator.

Changes strict/robot mode from disabling fuzzy matching entirely to using
a higher threshold (0.9 vs 0.8), still catching obvious typos like
--projct while avoiding speculative corrections that mislead agents.

New CorrectionRule variants: SubcommandAlias, ValueNormalization,
ValueFuzzy, FlagPrefix. Each has a corresponding teaching note.
Comprehensive test coverage for all new correction types including
subcommand aliases, value normalization (case, prefix, fuzzy, eq-form),
flag prefix (ambiguous rejection, eq-value preservation), and updated
strict mode behavior.
2026-02-13 17:27:39 -05:00
teernisse
0aecbf33c0 feat(xref): extract cross-references from descriptions, user notes, and fix system note regex
- Fix MENTIONED_RE/CLOSED_BY_RE to match real GitLab format
  ('mentioned in issue #N' / 'mentioned in merge request !N')
- Add GITLAB_URL_RE + parse_url_refs() for full URL extraction
- Add extract_refs_from_descriptions() -> source_method='description_parse'
- Add extract_refs_from_user_notes() -> source_method='note_parse'
- Wire both into orchestrator after system note extraction
- 36 tests: regex fix, URL parsing, integration, idempotency
2026-02-13 17:19:36 -05:00
teernisse
c10471ddb9 feat(timeline): add entity-direct seeding (issue:N, mr:N syntax)
Adds issue:N / i:N / mr:N / m:N query syntax to bypass hybrid search
and seed the timeline directly from a known entity. All discussions for
the entity are gathered without needing Ollama.

- parse_timeline_query() detects entity-direct patterns
- resolve_entity_by_iid() resolves IID to EntityRef with ambiguity handling
- seed_timeline_direct() gathers all discussions for the entity
- 20 new tests (5 resolve, 6 direct seed, 9 parse)
- Updated CLI help text and robot-docs manifest
2026-02-13 15:22:45 -05:00
teernisse
cbce4c9f59 release: v0.8.2 2026-02-13 15:01:28 -05:00
216 changed files with 69555 additions and 7294 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
bd-1yx
bd-1au9

99
.claude/plan.md Normal file
View File

@@ -0,0 +1,99 @@
# Plan: Add Colors to Sync Command Output
## Current State
The sync output has three layers, each needing color treatment:
### Layer 1: Stage Lines (during sync)
```
✓ Issues 10 issues from 2 projects 4.2s
✓ Status 3 statuses updated · 5 seen 4.2s
vs/typescript-code 2 issues · 1 statuses updated
✓ MRs 5 merge requests from 2 projects 12.3s
vs/python-code 3 MRs · 10 discussions
✓ Docs 1,200 documents generated 8.1s
✓ Embed 3,400 chunks embedded 45.2s
```
**What's uncolored:** icons, labels, numbers, elapsed times, sub-row project paths, failure counts in parentheses.
### Layer 2: Summary (after sync)
```
Synced 10 issues and 5 MRs in 42.3s
120 discussions · 45 events · 12 diffs · 3 statuses updated
1,200 docs regenerated · 3,400 embedded
```
**What's already colored:** headline ("Synced" = green bold, "Sync completed with issues" = warning bold), issue/MR counts (bold), error line (red). Detail lines are all dim.
### Layer 3: Timing breakdown (`-t` flag)
```
── Timing ──────────────────────
issues .............. 4.2s
merge_requests ...... 12.3s
```
**What's already colored:** dots (dim), time (bold), errors (red), rate limits (warning).
---
## Color Plan
Using only existing `Theme` methods — no new colors needed.
### Stage Lines (`format_stage_line` + callers in sync.rs)
| Element | Current | Proposed | Theme method |
|---------|---------|----------|-------------|
| Icon (✓/⚠) | plain | green for success, yellow for warning | `Theme::success()` / `Theme::warning()` |
| Label ("Issues", "MRs", etc.) | plain | bold | `Theme::bold()` |
| Numbers in summary text | plain | bold | `Theme::bold()` (just the count) |
| Elapsed time | plain | muted gray | `Theme::timing()` |
| Failure text in parens | plain | warning/error color | `Theme::warning()` |
### Sub-rows (project breakdown lines)
| Element | Current | Proposed |
|---------|---------|----------|
| Project path | dim | `Theme::muted()` (slightly brighter than dim) |
| Counts (numbers only) | dim | `Theme::dim()` but numbers in normal weight |
| Error/failure counts | dim | `Theme::warning()` |
| Middle dots | dim | keep dim (they're separators, should recede) |
### Summary (`print_sync`)
| Element | Current | Proposed |
|---------|---------|----------|
| Issue/MR counts in headline | bold only | `Theme::info()` + bold (cyan numbers pop) |
| Time in headline | plain | `Theme::timing()` |
| Detail line numbers | all dim | numbers in `Theme::info()`, rest stays dim |
| Doc line numbers | all dim | numbers in `Theme::info()`, rest stays dim |
| "Already up to date" time | plain | `Theme::timing()` |
---
## Files to Change
1. **`src/cli/progress.rs`** — `format_stage_line()`: apply color to icon, bold to label, `Theme::timing()` to elapsed
2. **`src/cli/commands/sync.rs`** —
- Pass colored icons to `format_stage_line` / `emit_stage_line` / `emit_stage_block`
- Color failure text in `append_failures()`
- Color numbers and time in `print_sync()`
- Color error/failure counts in sub-row functions (`issue_sub_rows`, `mr_sub_rows`, `status_sub_rows`)
## Approach
- `format_stage_line` already receives the icon string — color it before passing
- Add a `color_icon` helper that applies success/warning color to the icon glyph
- Bold the label in `format_stage_line`
- Apply `Theme::timing()` to elapsed in `format_stage_line`
- In `append_failures`, wrap failure text in `Theme::warning()`
- In `print_sync`, wrap count numbers with `Theme::info().bold()`
- In sub-row functions, apply `Theme::warning()` to error/failure parts only (keep rest dim)
## Non-goals
- No changes to robot mode (JSON output)
- No changes to dry-run output (already reasonably colored)
- No new Theme colors — use existing palette
- No changes to timing breakdown (already colored)

23
.gitignore vendored
View File

@@ -1,11 +1,6 @@
# Dependencies
node_modules/
# Build output
dist/
# Test coverage
coverage/
# Rust build output
/target
**/target/
# IDE
.idea/
@@ -25,14 +20,11 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local config files
lore.config.json
# beads
# beads viewer cache
.bv/
# SQLite databases (local development)
@@ -40,10 +32,15 @@ lore.config.json
*.db-wal
*.db-shm
# Mock seed data
tools/mock-seed/
# Added by cargo
/target
# Profiling / benchmarks
perf.data
perf.data.old
flamegraph.svg
*.profraw

13
.liquid-mail.toml Normal file
View File

@@ -0,0 +1,13 @@
# Liquid Mail config (TOML)
#
# Prefer env vars for secrets:
# LIQUID_MAIL_HONCHO_API_KEY
# LIQUID_MAIL_HONCHO_WORKSPACE_ID
#
[honcho]
api_key = "hch-v3-pmx23gk9k60xlqffpxpyjj8pywnxkpjkic9bdygx21iydvyxbeialioz5ehhcp1r"
# workspace_id is optional.
# If omitted, Liquid Mail defaults it to the repo name (git root folder name).
# Honcho uses get-or-create semantics for workspaces, so it will be created on first use.
# workspace_id = "my-repo"
base_url = "https://api.honcho.dev"

185
AGENTS.md
View File

@@ -655,9 +655,40 @@ lore --robot sync
# Run sync without resource events
lore --robot sync --no-events
# Run sync without MR file change fetching
lore --robot sync --no-file-changes
# Surgical sync: specific entities by IID
lore --robot sync --issue 42 -p group/repo
lore --robot sync --mr 99 --mr 100 -p group/repo
# Run ingestion only
lore --robot ingest issues
# Trace why code was introduced
lore --robot trace src/main.rs -p group/repo
# File-level MR history
lore --robot file-history src/auth/ -p group/repo
# Chronological timeline of events
lore --robot timeline "authentication" --since 30d
lore --robot timeline issue:42
# Find semantically related entities
lore --robot related issues 42 -n 5
lore --robot related "authentication bug"
# Detect discussion divergence from original intent
lore --robot drift issues 42 --threshold 0.4
# People intelligence
lore --robot who src/features/auth/
lore --robot who @username --reviews
# Count references (cross-reference statistics)
lore --robot count references
# Check environment health
lore --robot doctor
@@ -818,3 +849,157 @@ Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fa
- ❌ Full scan per edit → ✅ Scope to file
- ❌ Fix symptom (`if (x) { x.y }`) → ✅ Root cause (`x?.y`)
````
<!-- BEGIN LIQUID MAIL (v:48d7b3fc) -->
## Integrating Liquid Mail with Beads
**Beads** manages task status, priority, and dependencies (`br` CLI).
**Liquid Mail** provides the shared log—progress, decisions, and context that survives sessions.
### Conventions
- **Single source of truth**: Beads owns task state; Liquid Mail owns conversation/decisions
- **Shared identifiers**: Include the Beads issue ID in posts (e.g., `[lm-jht] Topic validation rules`)
- **Decisions before action**: Post `DECISION:` messages before risky changes, not after
- **Identity in user updates**: In every user-facing reply, include your window-name (derived from `LIQUID_MAIL_WINDOW_ID`) so humans can distinguish concurrent agents.
### Typical Flow
**1. Pick ready work (Beads)**
```bash
br ready # Find available work (no blockers)
br show lm-jht # Review details
br update lm-jht --status in_progress
```
**2. Check context (Liquid Mail)**
```bash
liquid-mail notify # See what changed since last session
liquid-mail query "lm-jht" # Find prior discussion on this issue
```
**3. Work and log progress (topic required)**
The `--topic` flag is required for your first post. After that, the topic is pinned to your window.
```bash
liquid-mail post --topic auth-system "[lm-jht] START: Reviewing current topic id patterns"
liquid-mail post "[lm-jht] FINDING: IDs like lm3189... are being used as topic names"
liquid-mail post "[lm-jht] NEXT: Add validation + rename guidance"
```
**4. Decisions before risky changes**
```bash
liquid-mail post --decision "[lm-jht] DECISION: Reject UUID-like topic names; require slugs"
# Then implement
```
### Decision Conflicts (Preflight)
When you post a decision (via `--decision` or a `DECISION:` line), Liquid Mail can preflight-check for conflicts with prior decisions **in the same topic**.
- If a conflict is detected, `liquid-mail post` fails with `DECISION_CONFLICT`.
- Review prior decisions: `liquid-mail decisions --topic <topic>`.
- If you intend to supersede the old decision, re-run with `--yes` and include what changed and why.
**5. Complete (Beads is authority)**
```bash
br close lm-jht # Mark complete in Beads
liquid-mail post "[lm-jht] Completed: Topic validation shipped in 177267d"
```
### Posting Format
- **Short** (5-15 lines, not walls of text)
- **Prefixed** with ALL-CAPS tags: `FINDING:`, `DECISION:`, `QUESTION:`, `NEXT:`
- **Include file paths** so others can jump in: `src/services/auth.ts:42`
- **Include issue IDs** in brackets: `[lm-jht]`
- **User-facing replies**: include `AGENT: <window-name>` near the top. Get it with `liquid-mail window name`.
### Topics (Required)
Liquid Mail organizes messages into **topics** (Honcho sessions). Topics are **soft boundaries**—search spans all topics by default.
**Rule:** `liquid-mail post` requires a topic:
- Provide `--topic <name>`, OR
- Post inside a window that already has a pinned topic.
Topic names must be:
- 450 characters
- lowercase letters/numbers with hyphens
- start with a letter, end with a letter/number
- no consecutive hyphens
- not reserved (`all`, `new`, `help`, `merge`, `rename`, `list`)
- not UUID-like (`lm<32-hex>` or standard UUIDs)
Good examples: `auth-system`, `db-system`, `dashboards`
Commands:
- **List topics (newest first)**: `liquid-mail topics`
- **Find context across topics**: `liquid-mail query "auth"`, then pick a topic name
- **Rename a topic (alias)**: `liquid-mail topic rename <old> <new>`
- **Merge two topics into a new one**: `liquid-mail topic merge <A> <B> --into <C>`
Examples (component topic + Beads id in the subject):
```bash
liquid-mail post --topic auth-system "[lm-jht] START: Investigating token refresh failures"
liquid-mail post --topic auth-system "[lm-jht] FINDING: refresh happens in middleware, not service layer"
liquid-mail post --topic auth-system --decision "[lm-jht] DECISION: Move refresh logic into AuthService"
liquid-mail post --topic dashboards "[lm-1p5] START: Adding latency panel"
```
### Context Refresh (Before New Work / After Redirects)
If you see redirect/merge messages, refresh context before acting:
```bash
liquid-mail notify
liquid-mail window status --json
liquid-mail summarize --topic <topic>
liquid-mail decisions --topic <topic>
```
If you discover a newer "canonical" topic (for example after a topic merge), switch to it explicitly:
```bash
liquid-mail post --topic <new-topic> "[lm-xxxx] CONTEXT: Switching topics (rename/merge)"
```
### Live Updates (Polling)
Liquid Mail is pull-based by default (you run `notify`). For near-real-time updates:
```bash
liquid-mail watch --topic <topic> # watch a topic
liquid-mail watch # or watch your pinned topic
```
### Mapping Cheat-Sheet
| Concept | In Beads | In Liquid Mail |
|---------|----------|----------------|
| Work item | `lm-jht` (issue ID) | Include `[lm-jht]` in posts |
| Workstream | — | `--topic auth-system` |
| Subject prefix | — | `[lm-jht] ...` |
| Commit message | Include `lm-jht` | — |
| Status | `br update --status` | Post progress messages |
### Pitfalls
- **Don't manage tasks in Liquid Mail**—Beads is the single task queue
- **Always include `lm-xxx`** in posts to avoid ID drift across tools
- **Don't dump logs**—keep posts short and structured
### Quick Reference
| Need | Command |
|------|---------|
| What changed? | `liquid-mail notify` |
| Log progress | `liquid-mail post "[lm-xxx] ..."` |
| Before risky change | `liquid-mail post --decision "[lm-xxx] DECISION: ..."` |
| Find history | `liquid-mail query "search term"` |
| Prior decisions | `liquid-mail decisions --topic <topic>` |
| Show config | `liquid-mail config` |
| List topics | `liquid-mail topics` |
| Rename topic | `liquid-mail topic rename <old> <new>` |
| Merge topics | `liquid-mail topic merge <A> <B> --into <C>` |
| Polling watch | `liquid-mail watch [--topic <topic>]` |
<!-- END LIQUID MAIL -->

943
CLAUDE.md Normal file
View File

@@ -0,0 +1,943 @@
# CLAUDE.md
## RULE 0 - THE FUNDAMENTAL OVERRIDE PEROGATIVE
If I tell you to do something, even if it goes against what follows below, YOU MUST LISTEN TO ME. I AM IN CHARGE, NOT YOU.
---
## RULE NUMBER 1: NO FILE DELETION
**YOU ARE NEVER ALLOWED TO DELETE A FILE WITHOUT EXPRESS PERMISSION.** Even a new file that you yourself created, such as a test code file. You have a horrible track record of deleting critically important files or otherwise throwing away tons of expensive work. As a result, you have permanently lost any and all rights to determine that a file or folder should be deleted.
**YOU MUST ALWAYS ASK AND RECEIVE CLEAR, WRITTEN PERMISSION BEFORE EVER DELETING A FILE OR FOLDER OF ANY KIND.**
---
## Version Control: jj-First (CRITICAL)
**ALWAYS prefer jj (Jujutsu) over git for all VCS operations.** This is a colocated repo with both `.jj/` and `.git/`. When instructed to use git by anything — even later in this file — use the best jj replacement commands instead. Only fall back to raw `git` for things jj cannot do (hooks, LFS, submodules, `gh` CLI interop).
See `~/.claude/rules/jj-vcs/` for the full command reference, translation table, revsets, patterns, and recovery recipes.
---
## Irreversible Git & Filesystem Actions — DO NOT EVER BREAK GLASS
> **Note:** Treat destructive commands as break-glass. If there's any doubt, stop and ask.
1. **Absolutely forbidden commands:** `git reset --hard`, `git clean -fd`, `rm -rf`, or any command that can delete or overwrite code/data must never be run unless the user explicitly provides the exact command and states, in the same message, that they understand and want the irreversible consequences.
2. **No guessing:** If there is any uncertainty about what a command might delete or overwrite, stop immediately and ask the user for specific approval. "I think it's safe" is never acceptable.
3. **Safer alternatives first:** When cleanup or rollbacks are needed, request permission to use non-destructive options (`git status`, `git diff`, `git stash`, copying to backups) before ever considering a destructive command.
4. **Mandatory explicit plan:** Even after explicit user authorization, restate the command verbatim, list exactly what will be affected, and wait for a confirmation that your understanding is correct. Only then may you execute it—if anything remains ambiguous, refuse and escalate.
5. **Document the confirmation:** When running any approved destructive command, record (in the session notes / final response) the exact user text that authorized it, the command actually run, and the execution time. If that record is absent, the operation did not happen.
---
## Toolchain: Rust & Cargo
We only use **Cargo** in this project, NEVER any other package manager.
- **Edition/toolchain:** Follow `rust-toolchain.toml` (if present). Do not assume stable vs nightly.
- **Dependencies:** Explicit versions for stability; keep the set minimal.
- **Configuration:** Cargo.toml only
- **Unsafe code:** Forbidden (`#![forbid(unsafe_code)]`)
When writing Rust code, reference RUST_CLI_TOOLS_BEST_PRACTICES.md
### Release Profile
Use the release profile defined in `Cargo.toml`. If you need to change it, justify the
performance/size tradeoff and how it impacts determinism and cancellation behavior.
---
## Code Editing Discipline
### No Script-Based Changes
**NEVER** run a script that processes/changes code files in this repo. Brittle regex-based transformations create far more problems than they solve.
- **Always make code changes manually**, even when there are many instances
- For many simple changes: use parallel subagents
- For subtle/complex changes: do them methodically yourself
### No File Proliferation
If you want to change something or add a feature, **revise existing code files in place**.
**NEVER** create variations like:
- `mainV2.rs`
- `main_improved.rs`
- `main_enhanced.rs`
New files are reserved for **genuinely new functionality** that makes zero sense to include in any existing file. The bar for creating new files is **incredibly high**.
---
## Backwards Compatibility
We do not care about backwards compatibility—we're in early development with no users. We want to do things the **RIGHT** way with **NO TECH DEBT**.
- Never create "compatibility shims"
- Never create wrapper functions for deprecated APIs
- Just fix the code directly
---
## Compiler Checks (CRITICAL)
**After any substantive code changes, you MUST verify no errors were introduced:**
```bash
# Check for compiler errors and warnings
cargo check --all-targets
# Check for clippy lints (pedantic + nursery are enabled)
cargo clippy --all-targets -- -D warnings
# Verify formatting
cargo fmt --check
```
If you see errors, **carefully understand and resolve each issue**. Read sufficient context to fix them the RIGHT way.
---
## Testing
### Unit & Property Tests
```bash
# Run all tests
cargo test
# Run with output
cargo test -- --nocapture
```
When adding or changing primitives, add tests that assert the core invariants:
- no task leaks
- no obligation leaks
- losers are drained after races
- region close implies quiescence
Prefer deterministic lab-runtime tests for concurrency-sensitive behavior.
---
---
## Beads (br) — Dependency-Aware Issue Tracking
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements Liquid Mail's shared log for progress, decisions, and cross-session context.
**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`.
### Conventions
- **Single source of truth:** Beads for task status/priority/dependencies; Liquid Mail for conversation/decisions
- **Shared identifiers:** Include the Beads issue ID in posts (e.g., `[br-123] Topic validation rules`)
- **Decisions before action:** Post `DECISION:` messages before risky changes, not after
### Typical Agent Flow
1. **Pick ready work (Beads):**
```bash
br ready --json # Choose highest priority, no blockers
```
2. **Check context (Liquid Mail):**
```bash
liquid-mail notify # See what changed since last session
liquid-mail query "br-123" # Find prior discussion on this issue
```
3. **Work and log progress:**
```bash
liquid-mail post --topic <workstream> "[br-123] START: <description>"
liquid-mail post "[br-123] FINDING: <what you discovered>"
liquid-mail post --decision "[br-123] DECISION: <what you decided and why>"
```
4. **Complete (Beads is authority):**
```bash
br close br-123 --reason "Completed"
liquid-mail post "[br-123] Completed: <summary with commit ref>"
```
### Mapping Cheat Sheet
| Concept | In Beads | In Liquid Mail |
|---------|----------|----------------|
| Work item | `br-###` (issue ID) | Include `[br-###]` in posts |
| Workstream | — | `--topic auth-system` |
| Subject prefix | — | `[br-###] ...` |
| Commit message | Include `br-###` | — |
| Status | `br update --status` | Post progress messages |
---
## bv — Graph-Aware Triage Engine
bv is a graph-aware triage engine for Beads projects (`.beads/beads.jsonl`). It computes PageRank, betweenness, critical path, cycles, HITS, eigenvector, and k-core metrics deterministically.
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (progress logging, decisions, cross-session context), use Liquid Mail.
**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**
### The Workflow: Start With Triage
**`bv --robot-triage` is your single entry point.** It returns:
- `quick_ref`: at-a-glance counts + top 3 picks
- `recommendations`: ranked actionable items with scores, reasons, unblock info
- `quick_wins`: low-effort high-impact items
- `blockers_to_clear`: items that unblock the most downstream work
- `project_health`: status/type/priority distributions, graph metrics
- `commands`: copy-paste shell commands for next steps
```bash
bv --robot-triage # THE MEGA-COMMAND: start here
bv --robot-next # Minimal: just the single top pick + claim command
```
### Command Reference
**Planning:**
| Command | Returns |
|---------|---------|
| `--robot-plan` | Parallel execution tracks with `unblocks` lists |
| `--robot-priority` | Priority misalignment detection with confidence |
**Graph Analysis:**
| Command | Returns |
|---------|---------|
| `--robot-insights` | Full metrics: PageRank, betweenness, HITS, eigenvector, critical path, cycles, k-core, articulation points, slack |
| `--robot-label-health` | Per-label health: `health_level`, `velocity_score`, `staleness`, `blocked_count` |
| `--robot-label-flow` | Cross-label dependency: `flow_matrix`, `dependencies`, `bottleneck_labels` |
| `--robot-label-attention [--attention-limit=N]` | Attention-ranked labels |
**History & Change Tracking:**
| Command | Returns |
|---------|---------|
| `--robot-history` | Bead-to-commit correlations |
| `--robot-diff --diff-since <ref>` | Changes since ref: new/closed/modified issues, cycles |
**Other:**
| Command | Returns |
|---------|---------|
| `--robot-burndown <sprint>` | Sprint burndown, scope changes, at-risk items |
| `--robot-forecast <id\|all>` | ETA predictions with dependency-aware scheduling |
| `--robot-alerts` | Stale issues, blocking cascades, priority mismatches |
| `--robot-suggest` | Hygiene: duplicates, missing deps, label suggestions |
| `--robot-graph [--graph-format=json\|dot\|mermaid]` | Dependency graph export |
| `--export-graph <file.html>` | Interactive HTML visualization |
### Scoping & Filtering
```bash
bv --robot-plan --label backend # Scope to label's subgraph
bv --robot-insights --as-of HEAD~30 # Historical point-in-time
bv --recipe actionable --robot-plan # Pre-filter: ready to work
bv --recipe high-impact --robot-triage # Pre-filter: top PageRank
bv --robot-triage --robot-triage-by-track # Group by parallel work streams
bv --robot-triage --robot-triage-by-label # Group by domain
```
### Understanding Robot Output
**All robot JSON includes:**
- `data_hash` — Fingerprint of source beads.jsonl
- `status` — Per-metric state: `computed|approx|timeout|skipped` + elapsed ms
- `as_of` / `as_of_commit` — Present when using `--as-of`
**Two-phase analysis:**
- **Phase 1 (instant):** degree, topo sort, density
- **Phase 2 (async, 500ms timeout):** PageRank, betweenness, HITS, eigenvector, cycles
### jq Quick Reference
```bash
bv --robot-triage | jq '.quick_ref' # At-a-glance summary
bv --robot-triage | jq '.recommendations[0]' # Top recommendation
bv --robot-plan | jq '.plan.summary.highest_impact' # Best unblock target
bv --robot-insights | jq '.status' # Check metric readiness
bv --robot-insights | jq '.Cycles' # Circular deps (must fix!)
```
---
## UBS — Ultimate Bug Scanner
**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run.
### Commands
```bash
ubs file.rs file2.rs # Specific files (< 1s) — USE THIS
ubs $(jj diff --name-only) # Changed files — before commit
ubs --only=rust,toml src/ # Language filter (3-5x faster)
ubs --ci --fail-on-warning . # CI mode — before PR
ubs . # Whole project (ignores target/, Cargo.lock)
```
### Output Format
```
⚠️ Category (N errors)
file.rs:42:5 Issue description
💡 Suggested fix
Exit code: 1
```
Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fail
### Fix Workflow
1. Read finding → category + fix suggestion
2. Navigate `file:line:col` → view context
3. Verify real issue (not false positive)
4. Fix root cause (not symptom)
5. Re-run `ubs <file>` → exit 0
6. Commit
### Bug Severity
- **Critical (always fix):** Memory safety, use-after-free, data races, SQL injection
- **Important (production):** Unwrap panics, resource leaks, overflow checks
- **Contextual (judgment):** TODO/FIXME, println! debugging
---
## ast-grep vs ripgrep
**Use `ast-grep` when structure matters.** It parses code and matches AST nodes, ignoring comments/strings, and can **safely rewrite** code.
- Refactors/codemods: rename APIs, change import forms
- Policy checks: enforce patterns across a repo
- Editor/automation: LSP mode, `--json` output
**Use `ripgrep` when text is enough.** Fastest way to grep literals/regex.
- Recon: find strings, TODOs, log lines, config values
- Pre-filter: narrow candidate files before ast-grep
### Rule of Thumb
- Need correctness or **applying changes** → `ast-grep`
- Need raw speed or **hunting text** → `rg`
- Often combine: `rg` to shortlist files, then `ast-grep` to match/modify
### Rust Examples
```bash
# Find structured code (ignores comments)
ast-grep run -l Rust -p 'fn $NAME($$$ARGS) -> $RET { $$$BODY }'
# Find all unwrap() calls
ast-grep run -l Rust -p '$EXPR.unwrap()'
# Quick textual hunt
rg -n 'println!' -t rust
# Combine speed + precision
rg -l -t rust 'unwrap\(' | xargs ast-grep run -l Rust -p '$X.unwrap()' --json
```
---
## Morph Warp Grep — AI-Powered Code Search
**Use `mcp__morph-mcp__warp_grep` for exploratory "how does X work?" questions.** An AI agent expands your query, greps the codebase, reads relevant files, and returns precise line ranges with full context.
**Use `ripgrep` for targeted searches.** When you know exactly what you're looking for.
**Use `ast-grep` for structural patterns.** When you need AST precision for matching/rewriting.
### When to Use What
| Scenario | Tool | Why |
|----------|------|-----|
| "How is pattern matching implemented?" | `warp_grep` | Exploratory; don't know where to start |
| "Where is the quick reject filter?" | `warp_grep` | Need to understand architecture |
| "Find all uses of `Regex::new`" | `ripgrep` | Targeted literal search |
| "Find files with `println!`" | `ripgrep` | Simple pattern |
| "Replace all `unwrap()` with `expect()`" | `ast-grep` | Structural refactor |
### warp_grep Usage
```
mcp__morph-mcp__warp_grep(
repoPath: "/path/to/dcg",
query: "How does the safe pattern whitelist work?"
)
```
Returns structured results with file paths, line ranges, and extracted code snippets.
### Anti-Patterns
- **Don't** use `warp_grep` to find a specific function name → use `ripgrep`
- **Don't** use `ripgrep` to understand "how does X work" → wastes time with manual reads
- **Don't** use `ripgrep` for codemods → risks collateral edits
<!-- bv-agent-instructions-v1 -->
---
## Beads Workflow Integration
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in version control.
**Note:** `br` is non-invasive—it never executes VCS commands directly. You must commit manually after `br sync --flush-only`.
### Essential Commands
```bash
# View issues (launches TUI - avoid in automated sessions)
bv
# CLI commands for agents (use these instead)
br ready # Show issues ready to work (no blockers)
br list --status=open # All open issues
br show <id> # Full issue details with dependencies
br create --title="..." --type=task --priority=2
br update <id> --status=in_progress
br close <id> --reason="Completed"
br close <id1> <id2> # Close multiple issues at once
br sync --flush-only # Export to JSONL (then: jj commit -m "Update beads")
```
### Workflow Pattern
1. **Start**: Run `br ready` to find actionable work
2. **Claim**: Use `br update <id> --status=in_progress`
3. **Work**: Implement the task
4. **Complete**: Use `br close <id>`
5. **Sync**: Run `br sync --flush-only`, then `git add .beads/ && git commit -m "Update beads"`
### Key Concepts
- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work.
- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words)
- **Types**: task, bug, feature, epic, question, docs
- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies
### Session Protocol
**Before ending any session, run this checklist (solo/lead only — workers skip VCS):**
```bash
jj status # Check what changed
br sync --flush-only # Export beads to JSONL
jj commit -m "..." # Commit code and beads (jj auto-tracks all changes)
jj bookmark set <name> -r @- # Point bookmark at committed work
jj git push -b <name> # Push to remote
```
### Best Practices
- Check `br ready` at session start to find available work
- Update status as you work (in_progress → closed)
- Create new issues with `br create` when you discover tasks
- Use descriptive titles and set appropriate priority/type
- Always run `br sync --flush-only` then commit before ending session (jj auto-tracks .beads/)
<!-- end-bv-agent-instructions -->
## Landing the Plane (Session Completion)
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until push succeeds.
**WHO RUNS THIS:** Solo agents run it themselves. In multi-agent sessions, ONLY the team lead runs this. Workers skip VCS entirely.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
jj git fetch # Get latest remote state
jj rebase -d trunk() # Rebase onto latest trunk if needed
br sync --flush-only # Export beads to JSONL
jj commit -m "Update beads" # Commit (jj auto-tracks .beads/ changes)
jj bookmark set <name> -r @- # Point bookmark at committed work
jj git push -b <name> # Push to remote
jj log -r '<name>' # Verify bookmark position
```
5. **Clean up** - Abandon empty orphan changes if any (`jj abandon <rev>`)
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `jj git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds
---
## cass — Cross-Agent Session Search
`cass` indexes prior agent conversations (Claude Code, Codex, Cursor, Gemini, ChatGPT, etc.) so we can reuse solved problems.
**Rules:** Never run bare `cass` (TUI). Always use `--robot` or `--json`.
### Examples
```bash
cass health
cass search "async runtime" --robot --limit 5
cass view /path/to/session.jsonl -n 42 --json
cass expand /path/to/session.jsonl -n 42 -C 3 --json
cass capabilities --json
cass robot-docs guide
```
### Tips
- Use `--fields minimal` for lean output
- Filter by agent with `--agent`
- Use `--days N` to limit to recent history
stdout is data-only, stderr is diagnostics; exit code 0 means success.
Treat cass as a way to avoid re-solving problems other agents already handled.
---
## Note for Codex/GPT-5.2
You constantly bother me and stop working with concerned questions that look similar to this:
```
Unexpected changes (need guidance)
- Working tree still shows edits I did not make in Cargo.toml, Cargo.lock, src/runtime.rs, src/scope.rs. Please advise whether to keep/commit/revert these before any further work. I did not touch them.
Next steps (pick one)
1. Decide how to handle the unrelated modified files above so we can resume cleanly.
```
NEVER EVER DO THAT AGAIN. The answer is literally ALWAYS the same: those are changes created by the potentially dozen of other agents working on the project at the same time. This is not only a common occurence, it happens multiple times PER MINUTE. The way to deal with it is simple: you NEVER, under ANY CIRCUMSTANCE, stash, revert, overwrite, or otherwise disturb in ANY way the work of other agents. Just treat those changes identically to changes that you yourself made. Just fool yourself into thinking YOU made the changes and simply don't recall it for some reason.
---
## Note on Built-in TODO Functionality
Also, if I ask you to explicitly use your built-in TODO functionality, don't complain about this and say you need to use beads. You can use built-in TODOs if I tell you specifically to do so. Always comply with such orders.
## TDD Requirements
Test-first development is mandatory:
1. **RED** - Write failing test first
2. **GREEN** - Minimal implementation to pass
3. **REFACTOR** - Clean up while green
## Key Patterns
Find the simplest solution that meets all acceptance criteria.
Use third party libraries whenever there's a well-maintained, active, and widely adopted solution (for example, date-fns for TS date math)
Build extensible pieces of logic that can easily be integrated with other pieces.
DRY principles should be loosely held.
Architecture MUST be clear and well thought-out. Ask the user for clarification whenever ambiguity is discovered around architecture, or you think a better approach than planned exists.
---
## Third-Party Library Usage
If you aren't 100% sure how to use a third-party library, **SEARCH ONLINE** to find the latest documentation and mid-2025 best practices.
---
## Gitlore Robot Mode
The `lore` CLI has a robot mode optimized for AI agent consumption with compact JSON output, structured errors with machine-actionable recovery steps, meaningful exit codes, response timing metadata, field selection for token efficiency, and TTY auto-detection.
### Activation
```bash
# Explicit flag
lore --robot issues -n 10
# JSON shorthand (-J)
lore -J issues -n 10
# Auto-detection (when stdout is not a TTY)
lore issues | jq .
# Environment variable
LORE_ROBOT=1 lore issues
```
### Robot Mode Commands
```bash
# List issues/MRs with JSON output
lore --robot issues -n 10
lore --robot mrs -s opened
# Filter issues by work item status (case-insensitive)
lore --robot issues --status "In progress"
# List with field selection (reduces token usage ~60%)
lore --robot issues --fields minimal
lore --robot mrs --fields iid,title,state,draft
# Show detailed entity info
lore --robot issues 123
lore --robot mrs 456 -p group/repo
# Count entities
lore --robot count issues
lore --robot count discussions --for mr
# Search indexed documents
lore --robot search "authentication bug"
# Check sync status
lore --robot status
# Run full sync pipeline
lore --robot sync
# Run sync without resource events
lore --robot sync --no-events
# Surgical sync: specific entities by IID
lore --robot sync --issue 42 -p group/repo
lore --robot sync --mr 99 --mr 100 -p group/repo
# Run ingestion only
lore --robot ingest issues
# Trace why code was introduced
lore --robot trace src/main.rs -p group/repo
# File-level MR history
lore --robot file-history src/auth/ -p group/repo
# Manage cron-based auto-sync (Unix)
lore --robot cron status
lore --robot cron install --interval 15
# Token management
lore --robot token show
# Check environment health
lore --robot doctor
# Document and index statistics
lore --robot stats
# Quick health pre-flight check (exit 0 = healthy, 19 = unhealthy)
lore --robot health
# Generate searchable documents from ingested data
lore --robot generate-docs
# Generate vector embeddings via Ollama
lore --robot embed
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
lore robot-docs
# Version information
lore --robot version
```
### Response Format
All commands return compact JSON with a uniform envelope and timing metadata:
```json
{"ok":true,"data":{...},"meta":{"elapsed_ms":42}}
```
Errors return structured JSON to stderr with machine-actionable recovery steps:
```json
{"error":{"code":"CONFIG_NOT_FOUND","message":"...","suggestion":"Run 'lore init'","actions":["lore init"]}}
```
The `actions` array contains executable shell commands for automated recovery. It is omitted when empty.
### Field Selection
The `--fields` flag on `issues` and `mrs` list commands controls which fields appear in the JSON response:
```bash
lore -J issues --fields minimal # Preset: iid, title, state, updated_at_iso
lore -J mrs --fields iid,title,state,draft,labels # Custom field list
```
### Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Internal error / not implemented |
| 2 | Usage error (invalid flags or arguments) |
| 3 | Config invalid |
| 4 | Token not set |
| 5 | GitLab auth failed |
| 6 | Resource not found |
| 7 | Rate limited |
| 8 | Network error |
| 9 | Database locked |
| 10 | Database error |
| 11 | Migration failed |
| 12 | I/O error |
| 13 | Transform error |
| 14 | Ollama unavailable |
| 15 | Ollama model not found |
| 16 | Embedding failed |
| 17 | Not found (entity does not exist) |
| 18 | Ambiguous match (use `-p` to specify project) |
| 19 | Health check failed |
| 20 | Config not found |
### Configuration Precedence
1. CLI flags (highest priority)
2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`)
3. Config file (`~/.config/lore/config.json`)
4. Built-in defaults (lowest priority)
### Best Practices
- Use `lore --robot` or `lore -J` for all agent interactions
- Check exit codes for error handling
- Parse JSON errors from stderr; use `actions` array for automated recovery
- Use `--fields minimal` to reduce token usage (~60% fewer tokens)
- Use `-n` / `--limit` to control response size
- Use `-q` / `--quiet` to suppress progress bars and non-essential output
- Use `--color never` in non-TTY automation for ANSI-free output
- Use `-v` / `-vv` / `-vvv` for increasing verbosity (debug/trace logging)
- Use `--log-format json` for machine-readable log output to stderr
- TTY detection handles piped commands automatically
- Use `lore --robot health` as a fast pre-flight check before queries
- Use `lore robot-docs` for response schema discovery
- The `-p` flag supports fuzzy project matching (suffix and substring)
---
## Read/Write Split: lore vs glab
| Operation | Tool | Why |
|-----------|------|-----|
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
| Search across entities | lore | FTS5 + vector hybrid search |
| Expert/workload analysis | lore | who command — no glab equivalent |
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
| Create/update/close | glab | Write operations |
| Approve/merge MR | glab | Write operations |
| CI/CD pipelines | glab | Not in lore scope |
````markdown
## UBS Quick Reference for AI Agents
UBS stands for "Ultimate Bug Scanner": **The AI Coding Agent's Secret Weapon: Flagging Likely Bugs for Fixing Early On**
**Install:** `curl -sSL https://raw.githubusercontent.com/Dicklesworthstone/ultimate_bug_scanner/master/install.sh | bash`
**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run.
**Commands:**
```bash
ubs file.ts file2.py # Specific files (< 1s) — USE THIS
ubs $(git diff --name-only --cached) # Staged files — before commit
ubs --only=js,python src/ # Language filter (3-5x faster)
ubs --ci --fail-on-warning . # CI mode — before PR
ubs --help # Full command reference
ubs sessions --entries 1 # Tail the latest install session log
ubs . # Whole project (ignores things like .venv and node_modules automatically)
```
**Output Format:**
```
⚠️ Category (N errors)
file.ts:42:5 Issue description
💡 Suggested fix
Exit code: 1
```
Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fail
**Fix Workflow:**
1. Read finding → category + fix suggestion
2. Navigate `file:line:col` → view context
3. Verify real issue (not false positive)
4. Fix root cause (not symptom)
5. Re-run `ubs <file>` → exit 0
6. Commit
**Speed Critical:** Scope to changed files. `ubs src/file.ts` (< 1s) vs `ubs .` (30s). Never full scan for small edits.
**Bug Severity:**
- **Critical** (always fix): Null safety, XSS/injection, async/await, memory leaks
- **Important** (production): Type narrowing, division-by-zero, resource leaks
- **Contextual** (judgment): TODO/FIXME, console logs
**Anti-Patterns:**
- ❌ Ignore findings → ✅ Investigate each
- ❌ Full scan per edit → ✅ Scope to file
- ❌ Fix symptom (`if (x) { x.y }`) → ✅ Root cause (`x?.y`)
````
<!-- BEGIN LIQUID MAIL (v:48d7b3fc) -->
## Integrating Liquid Mail with Beads
**Beads** manages task status, priority, and dependencies (`br` CLI).
**Liquid Mail** provides the shared log—progress, decisions, and context that survives sessions.
### Conventions
- **Single source of truth**: Beads owns task state; Liquid Mail owns conversation/decisions
- **Shared identifiers**: Include the Beads issue ID in posts (e.g., `[lm-jht] Topic validation rules`)
- **Decisions before action**: Post `DECISION:` messages before risky changes, not after
- **Identity in user updates**: In every user-facing reply, include your window-name (derived from `LIQUID_MAIL_WINDOW_ID`) so humans can distinguish concurrent agents.
### Typical Flow
**1. Pick ready work (Beads)**
```bash
br ready # Find available work (no blockers)
br show lm-jht # Review details
br update lm-jht --status in_progress
```
**2. Check context (Liquid Mail)**
```bash
liquid-mail notify # See what changed since last session
liquid-mail query "lm-jht" # Find prior discussion on this issue
```
**3. Work and log progress (topic required)**
The `--topic` flag is required for your first post. After that, the topic is pinned to your window.
```bash
liquid-mail post --topic auth-system "[lm-jht] START: Reviewing current topic id patterns"
liquid-mail post "[lm-jht] FINDING: IDs like lm3189... are being used as topic names"
liquid-mail post "[lm-jht] NEXT: Add validation + rename guidance"
```
**4. Decisions before risky changes**
```bash
liquid-mail post --decision "[lm-jht] DECISION: Reject UUID-like topic names; require slugs"
# Then implement
```
### Decision Conflicts (Preflight)
When you post a decision (via `--decision` or a `DECISION:` line), Liquid Mail can preflight-check for conflicts with prior decisions **in the same topic**.
- If a conflict is detected, `liquid-mail post` fails with `DECISION_CONFLICT`.
- Review prior decisions: `liquid-mail decisions --topic <topic>`.
- If you intend to supersede the old decision, re-run with `--yes` and include what changed and why.
**5. Complete (Beads is authority)**
```bash
br close lm-jht # Mark complete in Beads
liquid-mail post "[lm-jht] Completed: Topic validation shipped in 177267d"
```
### Posting Format
- **Short** (5-15 lines, not walls of text)
- **Prefixed** with ALL-CAPS tags: `FINDING:`, `DECISION:`, `QUESTION:`, `NEXT:`
- **Include file paths** so others can jump in: `src/services/auth.ts:42`
- **Include issue IDs** in brackets: `[lm-jht]`
- **User-facing replies**: include `AGENT: <window-name>` near the top. Get it with `liquid-mail window name`.
### Topics (Required)
Liquid Mail organizes messages into **topics** (Honcho sessions). Topics are **soft boundaries**—search spans all topics by default.
**Rule:** `liquid-mail post` requires a topic:
- Provide `--topic <name>`, OR
- Post inside a window that already has a pinned topic.
Topic names must be:
- 450 characters
- lowercase letters/numbers with hyphens
- start with a letter, end with a letter/number
- no consecutive hyphens
- not reserved (`all`, `new`, `help`, `merge`, `rename`, `list`)
- not UUID-like (`lm<32-hex>` or standard UUIDs)
Good examples: `auth-system`, `db-system`, `dashboards`
Commands:
- **List topics (newest first)**: `liquid-mail topics`
- **Find context across topics**: `liquid-mail query "auth"`, then pick a topic name
- **Rename a topic (alias)**: `liquid-mail topic rename <old> <new>`
- **Merge two topics into a new one**: `liquid-mail topic merge <A> <B> --into <C>`
Examples (component topic + Beads id in the subject):
```bash
liquid-mail post --topic auth-system "[lm-jht] START: Investigating token refresh failures"
liquid-mail post --topic auth-system "[lm-jht] FINDING: refresh happens in middleware, not service layer"
liquid-mail post --topic auth-system --decision "[lm-jht] DECISION: Move refresh logic into AuthService"
liquid-mail post --topic dashboards "[lm-1p5] START: Adding latency panel"
```
### Context Refresh (Before New Work / After Redirects)
If you see redirect/merge messages, refresh context before acting:
```bash
liquid-mail notify
liquid-mail window status --json
liquid-mail summarize --topic <topic>
liquid-mail decisions --topic <topic>
```
If you discover a newer "canonical" topic (for example after a topic merge), switch to it explicitly:
```bash
liquid-mail post --topic <new-topic> "[lm-xxxx] CONTEXT: Switching topics (rename/merge)"
```
### Live Updates (Polling)
Liquid Mail is pull-based by default (you run `notify`). For near-real-time updates:
```bash
liquid-mail watch --topic <topic> # watch a topic
liquid-mail watch # or watch your pinned topic
```
### Mapping Cheat-Sheet
| Concept | In Beads | In Liquid Mail |
|---------|----------|----------------|
| Work item | `lm-jht` (issue ID) | Include `[lm-jht]` in posts |
| Workstream | — | `--topic auth-system` |
| Subject prefix | — | `[lm-jht] ...` |
| Commit message | Include `lm-jht` | — |
| Status | `br update --status` | Post progress messages |
### Pitfalls
- **Don't manage tasks in Liquid Mail**—Beads is the single task queue
- **Always include `lm-xxx`** in posts to avoid ID drift across tools
- **Don't dump logs**—keep posts short and structured
### Quick Reference
| Need | Command |
|------|---------|
| What changed? | `liquid-mail notify` |
| Log progress | `liquid-mail post "[lm-xxx] ..."` |
| Before risky change | `liquid-mail post --decision "[lm-xxx] DECISION: ..."` |
| Find history | `liquid-mail query "search term"` |
| Prior decisions | `liquid-mail decisions --topic <topic>` |
| Show config | `liquid-mail config` |
| List topics | `liquid-mail topics` |
| Rename topic | `liquid-mail topic rename <old> <new>` |
| Merge topics | `liquid-mail topic merge <A> <B> --into <C>` |
| Polling watch | `liquid-mail watch [--topic <topic>]` |
<!-- END LIQUID MAIL -->

204
Cargo.lock generated
View File

@@ -169,6 +169,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "charmed-lipgloss"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
dependencies = [
"bitflags",
"colored",
"crossterm",
"serde",
"serde_json",
"thiserror",
"toml",
"tracing",
"unicode-width 0.1.14",
]
[[package]]
name = "chrono"
version = "0.4.43"
@@ -239,14 +256,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "comfy-table"
version = "7.2.2"
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"crossterm",
"unicode-segmentation",
"unicode-width",
"lazy_static",
"windows-sys 0.52.0",
]
[[package]]
@@ -258,10 +274,19 @@ dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"unicode-width 0.2.2",
"windows-sys 0.61.2",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -319,9 +344,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
@@ -371,6 +400,28 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "dialoguer"
version = "0.12.0"
@@ -434,6 +485,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -449,6 +506,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -976,7 +1039,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [
"console",
"portable-atomic",
"unicode-width",
"unicode-width 0.2.2",
"unit-prefix",
"web-time",
]
@@ -1106,13 +1169,13 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lore"
version = "0.8.1"
version = "0.8.3"
dependencies = [
"async-stream",
"charmed-lipgloss",
"chrono",
"clap",
"clap_complete",
"comfy-table",
"console",
"dialoguer",
"dirs",
@@ -1140,6 +1203,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"which",
"wiremock",
]
@@ -1181,6 +1245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -1574,6 +1639,15 @@ dependencies = [
"sqlite-wasm-rs",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.3"
@@ -1670,6 +1744,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1713,6 +1793,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1757,6 +1846,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
@@ -2028,6 +2138,47 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
@@ -2183,6 +2334,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -2363,6 +2520,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -2611,6 +2780,21 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wiremock"
version = "0.6.5"

View File

@@ -1,6 +1,6 @@
[package]
name = "lore"
version = "0.8.1"
version = "0.8.3"
edition = "2024"
description = "Gitlore - Local GitLab data management with semantic search"
authors = ["Taylor Eernisse"]
@@ -25,7 +25,7 @@ clap_complete = "4"
dialoguer = "0.12"
console = "0.16"
indicatif = "0.18"
comfy-table = "7"
lipgloss = { package = "charmed-lipgloss", version = "0.1", default-features = false, features = ["native"] }
open = "5"
# HTTP
@@ -49,6 +49,7 @@ httpdate = "1"
uuid = { version = "1", features = ["v4"] }
regex = "1"
strsim = "0.11"
which = "7"
[target.'cfg(unix)'.dependencies]
libc = "0.2"

174
README.md
View File

@@ -19,7 +19,10 @@ Local GitLab data management with semantic search, people intelligence, and temp
- **Cross-reference tracking**: Automatic extraction of "closes", "mentioned" relationships between MRs and issues
- **Work item status enrichment**: Fetches issue statuses (e.g., "To do", "In progress", "Done") from GitLab's GraphQL API with adaptive page sizing, color-coded display, and case-insensitive filtering
- **Resource event history**: Tracks state changes, label events, and milestone events for issues and MRs
- **Note querying**: Rich filtering over discussion notes by author, type, path, resolution status, time range, and body content
- **Discussion drift detection**: Semantic analysis of how discussions diverge from original issue intent
- **Robot mode**: Machine-readable JSON output with structured errors, meaningful exit codes, and actionable recovery steps
- **Error tolerance**: Auto-corrects common CLI mistakes (case, typos, single-dash flags, value casing) with teaching feedback
- **Observability**: Verbosity controls, JSON log format, structured metrics, and stage timing
## Installation
@@ -71,6 +74,12 @@ lore who @asmith
# Timeline of events related to deployments
lore timeline "deployment"
# Timeline for a specific issue
lore timeline issue:42
# Query notes by author
lore notes --author alice --since 7d
# Robot mode (machine-readable JSON)
lore -J issues -n 5 | jq .
```
@@ -109,6 +118,15 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
"model": "nomic-embed-text",
"baseUrl": "http://localhost:11434",
"concurrency": 4
},
"scoring": {
"authorWeight": 25,
"reviewerWeight": 10,
"noteBonus": 1,
"authorHalfLifeDays": 180,
"reviewerHalfLifeDays": 90,
"noteHalfLifeDays": 45,
"excludedUsernames": ["bot-user"]
}
}
```
@@ -135,6 +153,15 @@ Configuration is stored in `~/.config/lore/config.json` (or `$XDG_CONFIG_HOME/lo
| `embedding` | `model` | `nomic-embed-text` | Model name for embeddings |
| `embedding` | `baseUrl` | `http://localhost:11434` | Ollama server URL |
| `embedding` | `concurrency` | `4` | Concurrent embedding requests |
| `scoring` | `authorWeight` | `25` | Points per MR where the user authored code touching the path |
| `scoring` | `reviewerWeight` | `10` | Points per MR where the user reviewed code touching the path |
| `scoring` | `noteBonus` | `1` | Bonus per inline review comment (DiffNote) |
| `scoring` | `reviewerAssignmentWeight` | `3` | Points per MR where the user was assigned as reviewer |
| `scoring` | `authorHalfLifeDays` | `180` | Half-life in days for author contribution decay |
| `scoring` | `reviewerHalfLifeDays` | `90` | Half-life in days for reviewer contribution decay |
| `scoring` | `noteHalfLifeDays` | `45` | Half-life in days for note/comment decay |
| `scoring` | `closedMrMultiplier` | `0.5` | Score multiplier for closed (not merged) MRs |
| `scoring` | `excludedUsernames` | `[]` | Usernames excluded from expert results (e.g., bots) |
### Config File Resolution
@@ -262,18 +289,21 @@ lore search "login flow" --mode semantic # Vector similarity only
lore search "auth" --type issue # Filter by source type
lore search "auth" --type mr # MR documents only
lore search "auth" --type discussion # Discussion documents only
lore search "auth" --type note # Individual notes only
lore search "deploy" --author username # Filter by author
lore search "deploy" -p group/repo # Filter by project
lore search "deploy" --label backend # Filter by label (AND logic)
lore search "deploy" --path src/ # Filter by file path (trailing / for prefix)
lore search "deploy" --after 7d # Created after (7d, 2w, 1m, or YYYY-MM-DD)
lore search "deploy" --updated-after 2w # Updated after
lore search "deploy" --since 7d # Created since (7d, 2w, 1m, or YYYY-MM-DD)
lore search "deploy" --updated-since 2w # Updated since
lore search "deploy" -n 50 # Limit results (default 20, max 100)
lore search "deploy" --explain # Show ranking explanation per result
lore search "deploy" --fts-mode raw # Raw FTS5 query syntax (advanced)
```
The `--fts-mode` flag defaults to `safe`, which sanitizes user input into valid FTS5 queries with automatic fallback. Use `raw` for advanced FTS5 query syntax (AND, OR, NOT, phrase matching, prefix queries).
The `--fts-mode` flag defaults to `safe`, which sanitizes user input into valid FTS5 queries with automatic fallback. FTS5 boolean operators (`AND`, `OR`, `NOT`, `NEAR`) are passed through in safe mode, so queries like `"switch AND health"` work without switching to raw mode. Use `raw` for advanced FTS5 query syntax (phrase matching, column filters, prefix queries).
A progress spinner displays during search, showing the active mode (e.g., `Searching (hybrid)...`). In robot mode, spinners are suppressed for clean JSON output.
Requires `lore generate-docs` (or `lore sync`) to have been run at least once. Semantic and hybrid modes require `lore embed` (or `lore sync`) to have generated vector embeddings via Ollama.
@@ -283,7 +313,7 @@ People intelligence: discover experts, analyze workloads, review patterns, activ
#### Expert Mode
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis).
Find who has expertise in a code area based on authoring and reviewing history (DiffNote analysis). Scores use exponential half-life decay so recent contributions count more than older ones. Scoring weights and half-life periods are configurable via the `scoring` config section.
```bash
lore who src/features/auth/ # Who knows about this directory?
@@ -292,6 +322,9 @@ lore who --path README.md # Root files need --path flag
lore who --path Makefile # Dotless root files too
lore who src/ --since 3m # Limit to recent 3 months
lore who src/ -p group/repo # Scope to project
lore who src/ --explain-score # Show per-component score breakdown
lore who src/ --as-of 30d # Score as if "now" was 30 days ago
lore who src/ --include-bots # Include bot users in results
```
The target is auto-detected as a path when it contains `/`. For root files without `/` (e.g., `README.md`), use the `--path` flag. Default time window: 6 months.
@@ -348,21 +381,32 @@ Shows: users with touch counts (author vs. review), linked MR references. Defaul
| `-p` / `--project` | Scope to a project (fuzzy match) |
| `--since` | Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode. |
| `-n` / `--limit` | Max results per section (1-500, default 20) |
| `--all-history` | Remove the default time window, query all history |
| `--detail` | Show per-MR detail breakdown (expert mode only) |
| `--explain-score` | Show per-component score breakdown (expert mode only) |
| `--as-of` | Score as if "now" is a past date (ISO 8601 or duration like 30d, expert mode only) |
| `--include-bots` | Include bot users normally excluded via `scoring.excludedUsernames` |
### `lore timeline`
Reconstruct a chronological timeline of events matching a keyword query. The pipeline discovers related entities through cross-reference graph traversal and assembles a unified, time-ordered event stream.
```bash
lore timeline "deployment" # Events related to deployments
lore timeline "deployment" # Search-based seeding (hybrid search)
lore timeline issue:42 # Direct entity seeding by issue IID
lore timeline i:42 # Shorthand for issue:42
lore timeline mr:99 # Direct entity seeding by MR IID
lore timeline m:99 # Shorthand for mr:99
lore timeline "auth" -p group/repo # Scoped to a project
lore timeline "auth" --since 30d # Only recent events
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
lore timeline "migration" --expand-mentions # Follow 'mentioned' edges (high fan-out)
lore timeline "migration" --no-mentions # Skip 'mentioned' edges (reduces fan-out)
lore timeline "deploy" -n 50 # Limit event count
lore timeline "auth" --max-seeds 5 # Fewer seed entities
```
The query can be either a search string (hybrid search finds matching entities) or an entity reference (`issue:N`, `i:N`, `mr:N`, `m:N`) which directly seeds the timeline from a specific entity and its cross-references.
#### Flags
| Flag | Default | Description |
@@ -370,18 +414,21 @@ lore timeline "auth" --max-seeds 5 # Fewer seed entities
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
| `--expand-mentions` | off | Also follow "mentioned" edges during expansion |
| `--no-mentions` | off | Skip "mentioned" edges during expansion (reduces fan-out) |
| `-n` / `--limit` | `100` | Maximum events to display |
| `--max-seeds` | `10` | Maximum seed entities from search |
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
| `--max-evidence` | `10` | Maximum evidence notes included |
| `--fields` | all | Select output fields (comma-separated, or 'minimal' preset) |
#### Pipeline Stages
1. **SEED** -- Full-text search identifies the most relevant issues and MRs matching the query. Documents are ranked by BM25 relevance.
2. **HYDRATE** -- Evidence notes are extracted: the top FTS-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced.
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and optionally "mentioned" references up to the configured depth.
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, and evidence notes. Events are sorted chronologically with stable tiebreaking.
Each stage displays a numbered progress spinner (e.g., `[1/3] Seeding timeline...`). In robot mode, spinners are suppressed for clean JSON output.
1. **SEED** -- Hybrid search (FTS5 lexical + Ollama vector similarity via Reciprocal Rank Fusion) identifies the most relevant issues and MRs. Falls back to lexical-only if Ollama is unavailable. Discussion notes matching the query are also discovered and attached to their parent entities.
2. **HYDRATE** -- Evidence notes are extracted: the top search-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced. Matched discussions are collected as full thread candidates.
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and "mentioned" references up to the configured depth. Use `--no-mentions` to exclude "mentioned" edges and reduce fan-out.
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, evidence notes, and full discussion threads. Events are sorted chronologically with stable tiebreaking.
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).
#### Event Types
@@ -395,13 +442,70 @@ lore timeline "auth" --max-seeds 5 # Fewer seed entities
| `MilestoneSet` | Milestone assigned |
| `MilestoneRemoved` | Milestone removed |
| `Merged` | MR merged (deduplicated against state events) |
| `NoteEvidence` | Discussion note matched by FTS, with snippet |
| `NoteEvidence` | Discussion note matched by search, with snippet |
| `DiscussionThread` | Full discussion thread with all non-system notes |
| `CrossReferenced` | Reference to another entity |
#### Unresolved References
When graph expansion encounters cross-project references to entities not yet synced locally, these are collected as unresolved references in the output. This enables discovery of external dependencies and can inform future sync targets.
### `lore notes`
Query individual notes from discussions with rich filtering options.
```bash
lore notes # List 50 most recent notes
lore notes --author alice --since 7d # Notes by alice in last 7 days
lore notes --for-issue 42 -p group/repo # Notes on issue #42
lore notes --for-mr 99 -p group/repo # Notes on MR !99
lore notes --path src/ --resolution unresolved # Unresolved diff notes in src/
lore notes --note-type DiffNote # Only inline code review comments
lore notes --contains "TODO" # Substring search in note body
lore notes --include-system # Include system-generated notes
lore notes --since 2w --until 2024-12-31 # Time-bounded range
lore notes --sort updated --asc # Sort by update time, ascending
lore notes --format csv # CSV output
lore notes --format jsonl # Line-delimited JSON
lore notes -o # Open first result in browser
# Field selection (robot mode)
lore -J notes --fields minimal # Compact: id, author_username, body, created_at_iso
```
#### Filters
| Flag | Description |
|------|-------------|
| `-a` / `--author` | Filter by note author username |
| `--note-type` | Filter by note type (DiffNote, DiscussionNote) |
| `--contains` | Substring search in note body |
| `--note-id` | Filter by internal note ID |
| `--gitlab-note-id` | Filter by GitLab note ID |
| `--discussion-id` | Filter by discussion ID |
| `--include-system` | Include system notes (excluded by default) |
| `--for-issue` | Notes on a specific issue IID (requires `-p`) |
| `--for-mr` | Notes on a specific MR IID (requires `-p`) |
| `-p` / `--project` | Scope to a project (fuzzy match) |
| `--since` | Notes created since (7d, 2w, 1m, or YYYY-MM-DD) |
| `--until` | Notes created until (YYYY-MM-DD, inclusive end-of-day) |
| `--path` | Filter by file path (DiffNotes only; trailing `/` for prefix match) |
| `--resolution` | Filter by resolution status (`any`, `unresolved`, `resolved`) |
| `--sort` | Sort by `created` (default) or `updated` |
| `--asc` | Sort ascending (default: descending) |
| `--format` | Output format: `table` (default), `json`, `jsonl`, `csv` |
| `-o` / `--open` | Open first result in browser |
### `lore drift`
Detect discussion divergence from the original intent of an issue by comparing the semantic similarity of discussion content against the issue description.
```bash
lore drift issues 42 # Check divergence on issue #42
lore drift issues 42 --threshold 0.6 # Higher threshold (stricter)
lore drift issues 42 -p group/repo # Scope to project
```
### `lore sync`
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings.
@@ -413,6 +517,7 @@ lore sync --force # Override stale lock
lore sync --no-embed # Skip embedding step
lore sync --no-docs # Skip document regeneration
lore sync --no-events # Skip resource event fetching
lore sync --no-file-changes # Skip MR file change fetching
lore sync --dry-run # Preview what would be synced
```
@@ -571,6 +676,7 @@ Machine-readable command manifest for agent self-discovery. Returns a JSON schem
```bash
lore robot-docs # Pretty-printed JSON
lore --robot robot-docs # Compact JSON for parsing
lore robot-docs --brief # Omit response_schema (~60% smaller)
```
### `lore version`
@@ -622,7 +728,7 @@ The `actions` array contains executable shell commands an agent can run to recov
### Field Selection
The `--fields` flag on `issues` and `mrs` list commands controls which fields appear in the JSON response, reducing token usage for AI agent workflows:
The `--fields` flag controls which fields appear in the JSON response, reducing token usage for AI agent workflows. Supported on `issues`, `mrs`, `notes`, `search`, `timeline`, and `who` list commands:
```bash
# Minimal preset (~60% fewer tokens)
@@ -639,6 +745,48 @@ Valid fields for issues: `iid`, `title`, `state`, `author_username`, `labels`, `
Valid fields for MRs: `iid`, `title`, `state`, `author_username`, `labels`, `draft`, `target_branch`, `source_branch`, `discussion_count`, `unresolved_count`, `created_at_iso`, `updated_at_iso`, `web_url`, `project_path`, `reviewers`
### Error Tolerance
The CLI auto-corrects common mistakes before parsing, emitting a teaching note to stderr. Corrections work in both human and robot modes:
| Correction | Example | Mode |
|-----------|---------|------|
| Single-dash long flag | `-robot` -> `--robot` | All |
| Case normalization | `--Robot` -> `--robot` | All |
| Flag prefix expansion | `--proj` -> `--project` (unambiguous only) | All |
| Fuzzy flag match | `--projct` -> `--project` | All (threshold 0.9 in robot, 0.8 in human) |
| Subcommand alias | `merge_requests` -> `mrs`, `robotdocs` -> `robot-docs` | All |
| Value normalization | `--state Opened` -> `--state opened` | All |
| Value fuzzy match | `--state opend` -> `--state opened` | All |
| Subcommand prefix | `lore iss` -> `lore issues` (unambiguous only, via clap) | All |
In robot mode, corrections emit structured JSON to stderr:
```json
{"warning":{"type":"ARG_CORRECTED","corrections":[...],"teaching":["Use double-dash for long flags: --robot (not -robot)"]}}
```
When a command or flag is still unrecognized after corrections, the error response includes a fuzzy suggestion and, for enum-like flags, lists valid values:
```json
{"error":{"code":"UNKNOWN_COMMAND","message":"...","suggestion":"Did you mean 'lore issues'? Example: lore --robot issues -n 10. Run 'lore robot-docs' for all commands"}}
```
### Command Aliases
Commands accept aliases for common variations:
| Primary | Aliases |
|---------|---------|
| `issues` | `issue` |
| `mrs` | `mr`, `merge-requests`, `merge-request` |
| `notes` | `note` |
| `search` | `find`, `query` |
| `stats` | `stat` |
| `status` | `st` |
Unambiguous prefixes also work via subcommand inference (e.g., `lore iss` -> `lore issues`, `lore time` -> `lore timeline`).
### Agent Self-Discovery
The `robot-docs` command provides a complete machine-readable manifest including response schemas for every command:

3286
crates/lore-tui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
[package]
name = "lore-tui"
version = "0.1.0"
edition = "2024"
description = "Terminal UI for Gitlore — local GitLab data explorer"
authors = ["Taylor Eernisse"]
license = "MIT"
[[bin]]
name = "lore-tui"
path = "src/main.rs"
[dependencies]
# FrankenTUI (Elm-architecture TUI framework)
ftui = "0.1.1"
# Lore library (config, db, ingestion, search, etc.)
lore = { path = "../.." }
# CLI
clap = { version = "4", features = ["derive", "env"] }
# Error handling
anyhow = "1"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Paths
dirs = "6"
# Database (read-only queries from TUI)
rusqlite = { version = "0.38", features = ["bundled"] }
# Terminal (crossterm for raw mode + event reading, used by ftui runtime)
crossterm = "0.28"
# Serialization (crash context NDJSON dumps)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Regex (used by safety module for PII/secret redaction)
regex = "1"
# Unicode text measurement
unicode-width = "0.2"
unicode-segmentation = "1"
# Session persistence (CRC32 checksum)
crc32fast = "1"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,61 @@
# Terminal Compatibility Matrix
Manual verification checklist for lore-tui rendering across terminal emulators.
**How to use:** Run `cargo run -p lore-tui` in each terminal, navigate through
all screens, and mark each cell with one of:
- OK — works correctly
- PARTIAL — works with minor visual glitches (describe in Notes)
- FAIL — broken or unusable (describe in Notes)
- N/T — not tested
Last verified: _not yet_
## Rendering Features
| Feature | iTerm2 | tmux | Alacritty | kitty | WezTerm |
|----------------------|--------|------|-----------|-------|---------|
| True color (RGB) | | | | | |
| Unicode box-drawing | | | | | |
| CJK wide characters | | | | | |
| Bold text | | | | | |
| Italic text | | | | | |
| Underline | | | | | |
| Dim / faint | | | | | |
| Strikethrough | | | | | |
## Interaction Features
| Feature | iTerm2 | tmux | Alacritty | kitty | WezTerm |
|----------------------|--------|------|-----------|-------|---------|
| Keyboard input | | | | | |
| Mouse click | | | | | |
| Mouse scroll | | | | | |
| Resize handling | | | | | |
| Alt screen toggle | | | | | |
| Bracketed paste | | | | | |
## Screen-Specific Checks
| Screen | iTerm2 | tmux | Alacritty | kitty | WezTerm |
|----------------------|--------|------|-----------|-------|---------|
| Dashboard | | | | | |
| Issue list | | | | | |
| Issue detail | | | | | |
| MR list | | | | | |
| MR detail | | | | | |
| Search | | | | | |
| Command palette | | | | | |
| Help overlay | | | | | |
## Minimum Sizes
| Terminal size | Renders correctly? | Notes |
|---------------|-------------------|-------|
| 80x24 | | |
| 120x40 | | |
| 200x60 | | |
## Notes
_Record any issues, workarounds, or version-specific quirks here._

View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly-2026-02-08"
profile = "minimal"
components = ["rustfmt", "clippy"]

View File

@@ -0,0 +1,316 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::state::bootstrap::{DataReadiness, SchemaCheck};
/// Minimum schema version required by this TUI version.
pub const MINIMUM_SCHEMA_VERSION: i32 = 20;
/// Check the schema version of the database.
///
/// Returns [`SchemaCheck::NoDB`] if the `schema_version` table doesn't exist,
/// [`SchemaCheck::Incompatible`] if the version is below the minimum,
/// or [`SchemaCheck::Compatible`] if all is well.
pub fn check_schema_version(conn: &Connection, minimum: i32) -> SchemaCheck {
// Check if schema_version table exists.
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_version'",
[],
|r| r.get::<_, i64>(0),
)
.map(|c| c > 0)
.unwrap_or(false);
if !table_exists {
return SchemaCheck::NoDB;
}
// Read the highest version (one row per migration).
match conn.query_row("SELECT MAX(version) FROM schema_version", [], |r| {
r.get::<_, i32>(0)
}) {
Ok(version) if version >= minimum => SchemaCheck::Compatible { version },
Ok(found) => SchemaCheck::Incompatible { found, minimum },
Err(_) => SchemaCheck::NoDB,
}
}
/// Check whether the database has enough data to skip the bootstrap screen.
///
/// Counts issues, merge requests, and search documents. The `documents` table
/// may not exist on older schemas, so its absence is treated as "no documents."
pub fn check_data_readiness(conn: &Connection) -> Result<DataReadiness> {
let has_issues: bool = conn
.query_row("SELECT EXISTS(SELECT 1 FROM issues LIMIT 1)", [], |r| {
r.get(0)
})
.context("checking issues")?;
let has_mrs: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM merge_requests LIMIT 1)",
[],
|r| r.get(0),
)
.context("checking merge requests")?;
// documents table may not exist yet (created by generate-docs).
let has_documents: bool = conn
.query_row("SELECT EXISTS(SELECT 1 FROM documents LIMIT 1)", [], |r| {
r.get(0)
})
.unwrap_or(false);
let schema_version = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| {
r.get::<_, i32>(0)
})
.unwrap_or(0);
Ok(DataReadiness {
has_issues,
has_mrs,
has_documents,
schema_version,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Create the minimal schema needed for bootstrap / data-readiness queries.
fn create_dashboard_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
created_at INTEGER,
updated_at INTEGER,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
noteable_type TEXT NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
source_type TEXT NOT NULL,
source_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
content_text TEXT NOT NULL,
content_hash TEXT NOT NULL
);
CREATE TABLE embedding_metadata (
document_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL,
dims INTEGER NOT NULL,
document_hash TEXT NOT NULL,
chunk_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY(document_id, chunk_index)
);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY,
started_at INTEGER NOT NULL,
heartbeat_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
command TEXT NOT NULL,
error TEXT
);
",
)
.expect("create dashboard schema");
}
fn insert_issue(conn: &Connection, iid: i64, state: &str, updated_at: i64) {
conn.execute(
"INSERT INTO issues (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)",
rusqlite::params![iid * 100, iid, format!("Issue {iid}"), state, updated_at],
)
.expect("insert issue");
}
fn insert_mr(conn: &Connection, iid: i64, state: &str, updated_at: i64) {
conn.execute(
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)",
rusqlite::params![iid * 100 + 50, iid, format!("MR {iid}"), state, updated_at],
)
.expect("insert mr");
}
/// TDD anchor test from bead spec.
#[test]
fn test_schema_preflight_rejects_old() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER);
INSERT INTO schema_version (version) VALUES (1);",
)
.unwrap();
let result = check_schema_version(&conn, 20);
assert!(matches!(
result,
SchemaCheck::Incompatible {
found: 1,
minimum: 20
}
));
}
#[test]
fn test_schema_preflight_accepts_compatible() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER);
INSERT INTO schema_version (version) VALUES (26);",
)
.unwrap();
let result = check_schema_version(&conn, 20);
assert!(matches!(result, SchemaCheck::Compatible { version: 26 }));
}
#[test]
fn test_schema_preflight_exact_minimum() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER);
INSERT INTO schema_version (version) VALUES (20);",
)
.unwrap();
let result = check_schema_version(&conn, 20);
assert!(matches!(result, SchemaCheck::Compatible { version: 20 }));
}
#[test]
fn test_schema_preflight_no_db() {
let conn = Connection::open_in_memory().unwrap();
let result = check_schema_version(&conn, 20);
assert!(matches!(result, SchemaCheck::NoDB));
}
#[test]
fn test_schema_preflight_empty_schema_version_table() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE schema_version (version INTEGER);")
.unwrap();
let result = check_schema_version(&conn, 20);
assert!(matches!(result, SchemaCheck::NoDB));
}
#[test]
fn test_schema_preflight_multiple_migration_rows() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER, applied_at INTEGER, description TEXT);
INSERT INTO schema_version VALUES (1, 0, 'Initial');
INSERT INTO schema_version VALUES (2, 0, 'Second');
INSERT INTO schema_version VALUES (27, 0, 'Latest');",
)
.unwrap();
let result = check_schema_version(&conn, 20);
assert!(
matches!(result, SchemaCheck::Compatible { version: 27 }),
"should use MAX(version), not first row: {result:?}"
);
}
#[test]
fn test_check_data_readiness_empty() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER);
INSERT INTO schema_version (version) VALUES (26);",
)
.unwrap();
let readiness = check_data_readiness(&conn).unwrap();
assert!(!readiness.has_issues);
assert!(!readiness.has_mrs);
assert!(!readiness.has_documents);
assert_eq!(readiness.schema_version, 26);
assert!(!readiness.has_any_data());
}
#[test]
fn test_check_data_readiness_with_data() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER);
INSERT INTO schema_version (version) VALUES (26);",
)
.unwrap();
insert_issue(&conn, 1, "opened", 1_700_000_000_000);
insert_mr(&conn, 1, "merged", 1_700_000_000_000);
let readiness = check_data_readiness(&conn).unwrap();
assert!(readiness.has_issues);
assert!(readiness.has_mrs);
assert!(!readiness.has_documents);
assert_eq!(readiness.schema_version, 26);
assert!(readiness.has_any_data());
}
#[test]
fn test_check_data_readiness_documents_table_missing() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
// No documents table — should still work.
let readiness = check_data_readiness(&conn).unwrap();
assert!(!readiness.has_documents);
}
}

View File

@@ -0,0 +1,485 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::clock::Clock;
use crate::state::dashboard::{
DashboardData, EntityCounts, LastSyncInfo, ProjectSyncInfo, RecentActivityItem,
};
/// Fetch all data for the dashboard screen.
///
/// Runs aggregation queries for entity counts, per-project sync freshness,
/// recent activity, and the last sync run summary.
pub fn fetch_dashboard(conn: &Connection, clock: &dyn Clock) -> Result<DashboardData> {
let counts = fetch_entity_counts(conn)?;
let projects = fetch_project_sync_info(conn, clock)?;
let recent = fetch_recent_activity(conn, clock)?;
let last_sync = fetch_last_sync(conn)?;
Ok(DashboardData {
counts,
projects,
recent,
last_sync,
})
}
/// Count all entities in the database.
fn fetch_entity_counts(conn: &Connection) -> Result<EntityCounts> {
let issues_total: i64 = conn
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
.context("counting issues")?;
let issues_open: i64 = conn
.query_row(
"SELECT COUNT(*) FROM issues WHERE state = 'opened'",
[],
|r| r.get(0),
)
.context("counting open issues")?;
let mrs_total: i64 = conn
.query_row("SELECT COUNT(*) FROM merge_requests", [], |r| r.get(0))
.context("counting merge requests")?;
let mrs_open: i64 = conn
.query_row(
"SELECT COUNT(*) FROM merge_requests WHERE state = 'opened'",
[],
|r| r.get(0),
)
.context("counting open merge requests")?;
let discussions: i64 = conn
.query_row("SELECT COUNT(*) FROM discussions", [], |r| r.get(0))
.context("counting discussions")?;
let notes_total: i64 = conn
.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))
.context("counting notes")?;
let notes_system: i64 = conn
.query_row("SELECT COUNT(*) FROM notes WHERE is_system = 1", [], |r| {
r.get(0)
})
.context("counting system notes")?;
let notes_system_pct = if notes_total > 0 {
u8::try_from(notes_system * 100 / notes_total).unwrap_or(100)
} else {
0
};
let documents: i64 = conn
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
.context("counting documents")?;
let embeddings: i64 = conn
.query_row("SELECT COUNT(*) FROM embedding_metadata", [], |r| r.get(0))
.context("counting embeddings")?;
#[allow(clippy::cast_sign_loss)] // SQL COUNT(*) is always >= 0
Ok(EntityCounts {
issues_open: issues_open as u64,
issues_total: issues_total as u64,
mrs_open: mrs_open as u64,
mrs_total: mrs_total as u64,
discussions: discussions as u64,
notes_total: notes_total as u64,
notes_system_pct,
documents: documents as u64,
embeddings: embeddings as u64,
})
}
/// Per-project sync freshness based on the most recent sync_runs entry.
fn fetch_project_sync_info(conn: &Connection, clock: &dyn Clock) -> Result<Vec<ProjectSyncInfo>> {
let now_ms = clock.now_ms();
let mut stmt = conn
.prepare(
"SELECT p.path_with_namespace,
MAX(sr.finished_at) as last_sync_ms
FROM projects p
LEFT JOIN sync_runs sr ON sr.status = 'succeeded'
AND sr.finished_at IS NOT NULL
GROUP BY p.id
ORDER BY p.path_with_namespace",
)
.context("preparing project sync query")?;
let rows = stmt
.query_map([], |row| {
let path: String = row.get(0)?;
let last_sync_ms: Option<i64> = row.get(1)?;
Ok((path, last_sync_ms))
})
.context("querying project sync info")?;
let mut result = Vec::new();
for row in rows {
let (path, last_sync_ms) = row.context("reading project sync row")?;
let minutes_since_sync = match last_sync_ms {
Some(ms) => {
let elapsed_ms = now_ms.saturating_sub(ms);
u64::try_from(elapsed_ms / 60_000).unwrap_or(u64::MAX)
}
None => u64::MAX, // Never synced.
};
result.push(ProjectSyncInfo {
path,
minutes_since_sync,
});
}
Ok(result)
}
/// Recent activity: the 20 most recently updated issues and MRs.
fn fetch_recent_activity(conn: &Connection, clock: &dyn Clock) -> Result<Vec<RecentActivityItem>> {
let now_ms = clock.now_ms();
let mut stmt = conn
.prepare(
"SELECT entity_type, iid, title, state, updated_at FROM (
SELECT 'issue' AS entity_type, iid, title, state, updated_at
FROM issues
UNION ALL
SELECT 'mr' AS entity_type, iid, title, state, updated_at
FROM merge_requests
)
ORDER BY updated_at DESC
LIMIT 20",
)
.context("preparing recent activity query")?;
let rows = stmt
.query_map([], |row| {
let entity_type: String = row.get(0)?;
let iid: i64 = row.get(1)?;
let title: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let state: String = row.get::<_, Option<String>>(3)?.unwrap_or_default();
let updated_at: i64 = row.get(4)?;
Ok((entity_type, iid, title, state, updated_at))
})
.context("querying recent activity")?;
let mut result = Vec::new();
for row in rows {
let (entity_type, iid, title, state, updated_at) =
row.context("reading recent activity row")?;
let elapsed_ms = now_ms.saturating_sub(updated_at);
let minutes_ago = u64::try_from(elapsed_ms / 60_000).unwrap_or(u64::MAX);
result.push(RecentActivityItem {
entity_type,
iid: iid as u64,
title,
state,
minutes_ago,
});
}
Ok(result)
}
/// The most recent sync run summary.
fn fetch_last_sync(conn: &Connection) -> Result<Option<LastSyncInfo>> {
let result = conn.query_row(
"SELECT status, finished_at, command, error
FROM sync_runs
ORDER BY id DESC
LIMIT 1",
[],
|row| {
Ok(LastSyncInfo {
status: row.get(0)?,
finished_at: row.get(1)?,
command: row.get(2)?,
error: row.get(3)?,
})
},
);
match result {
Ok(info) => Ok(Some(info)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e).context("querying last sync run"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
/// Create the minimal schema needed for dashboard queries.
fn create_dashboard_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
created_at INTEGER,
updated_at INTEGER,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
noteable_type TEXT NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
source_type TEXT NOT NULL,
source_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
content_text TEXT NOT NULL,
content_hash TEXT NOT NULL
);
CREATE TABLE embedding_metadata (
document_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL,
dims INTEGER NOT NULL,
document_hash TEXT NOT NULL,
chunk_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY(document_id, chunk_index)
);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY,
started_at INTEGER NOT NULL,
heartbeat_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
command TEXT NOT NULL,
error TEXT
);
",
)
.expect("create dashboard schema");
}
/// Insert a test issue.
fn insert_issue(conn: &Connection, iid: i64, state: &str, updated_at: i64) {
conn.execute(
"INSERT INTO issues (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)",
rusqlite::params![iid * 100, iid, format!("Issue {iid}"), state, updated_at],
)
.expect("insert issue");
}
/// Insert a test MR.
fn insert_mr(conn: &Connection, iid: i64, state: &str, updated_at: i64) {
conn.execute(
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)",
rusqlite::params![iid * 100 + 50, iid, format!("MR {iid}"), state, updated_at],
)
.expect("insert mr");
}
#[test]
fn test_fetch_dashboard_counts() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
// 5 issues: 3 open, 2 closed.
let now_ms = 1_700_000_000_000_i64;
insert_issue(&conn, 1, "opened", now_ms - 10_000);
insert_issue(&conn, 2, "opened", now_ms - 20_000);
insert_issue(&conn, 3, "opened", now_ms - 30_000);
insert_issue(&conn, 4, "closed", now_ms - 40_000);
insert_issue(&conn, 5, "closed", now_ms - 50_000);
let clock = FakeClock::from_ms(now_ms);
let data = fetch_dashboard(&conn, &clock).unwrap();
assert_eq!(data.counts.issues_open, 3);
assert_eq!(data.counts.issues_total, 5);
}
#[test]
fn test_fetch_dashboard_mr_counts() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
let now_ms = 1_700_000_000_000_i64;
insert_mr(&conn, 1, "opened", now_ms);
insert_mr(&conn, 2, "merged", now_ms);
insert_mr(&conn, 3, "opened", now_ms);
insert_mr(&conn, 4, "closed", now_ms);
let clock = FakeClock::from_ms(now_ms);
let data = fetch_dashboard(&conn, &clock).unwrap();
assert_eq!(data.counts.mrs_open, 2);
assert_eq!(data.counts.mrs_total, 4);
}
#[test]
fn test_fetch_dashboard_empty_database() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
let clock = FakeClock::from_ms(1_700_000_000_000);
let data = fetch_dashboard(&conn, &clock).unwrap();
assert_eq!(data.counts.issues_open, 0);
assert_eq!(data.counts.issues_total, 0);
assert_eq!(data.counts.mrs_open, 0);
assert_eq!(data.counts.mrs_total, 0);
assert_eq!(data.counts.notes_system_pct, 0);
assert!(data.projects.is_empty());
assert!(data.recent.is_empty());
assert!(data.last_sync.is_none());
}
#[test]
fn test_fetch_dashboard_notes_system_pct() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
// 4 notes: 1 system, 3 user -> 25% system.
for i in 0..4 {
conn.execute(
"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, created_at, updated_at, last_seen_at)
VALUES (?1, 1, 1, ?2, 1000, 1000, 1000)",
rusqlite::params![i, if i == 0 { 1 } else { 0 }],
)
.unwrap();
}
let clock = FakeClock::from_ms(1_700_000_000_000);
let data = fetch_dashboard(&conn, &clock).unwrap();
assert_eq!(data.counts.notes_total, 4);
assert_eq!(data.counts.notes_system_pct, 25);
}
#[test]
fn test_fetch_dashboard_project_sync_info() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace) VALUES (1, 'group/alpha')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace) VALUES (2, 'group/beta')",
[],
)
.unwrap();
// Sync ran 30 minutes ago. sync_runs is global (no project_id),
// so all projects see the same last-sync time.
let now_ms = 1_700_000_000_000_i64;
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, finished_at, status, command)
VALUES (?1, ?1, ?2, 'succeeded', 'sync')",
[now_ms - 30 * 60_000, now_ms - 30 * 60_000],
)
.unwrap();
let clock = FakeClock::from_ms(now_ms);
let data = fetch_dashboard(&conn, &clock).unwrap();
assert_eq!(data.projects.len(), 2);
assert_eq!(data.projects[0].path, "group/alpha");
assert_eq!(data.projects[0].minutes_since_sync, 30);
assert_eq!(data.projects[1].path, "group/beta");
assert_eq!(data.projects[1].minutes_since_sync, 30); // Same: sync_runs is global.
}
#[test]
fn test_fetch_dashboard_recent_activity_ordered() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
let now_ms = 1_700_000_000_000_i64;
insert_issue(&conn, 1, "opened", now_ms - 60_000); // 1 min ago
insert_mr(&conn, 1, "merged", now_ms - 120_000); // 2 min ago
insert_issue(&conn, 2, "closed", now_ms - 180_000); // 3 min ago
let clock = FakeClock::from_ms(now_ms);
let data = fetch_dashboard(&conn, &clock).unwrap();
assert_eq!(data.recent.len(), 3);
assert_eq!(data.recent[0].entity_type, "issue");
assert_eq!(data.recent[0].iid, 1);
assert_eq!(data.recent[0].minutes_ago, 1);
assert_eq!(data.recent[1].entity_type, "mr");
assert_eq!(data.recent[1].minutes_ago, 2);
assert_eq!(data.recent[2].entity_type, "issue");
assert_eq!(data.recent[2].minutes_ago, 3);
}
#[test]
fn test_fetch_dashboard_last_sync() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
let now_ms = 1_700_000_000_000_i64;
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, finished_at, status, command, error)
VALUES (?1, ?1, ?2, 'failed', 'sync', 'network timeout')",
[now_ms - 60_000, now_ms - 50_000],
)
.unwrap();
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, finished_at, status, command)
VALUES (?1, ?1, ?2, 'succeeded', 'sync')",
[now_ms - 30_000, now_ms - 20_000],
)
.unwrap();
let clock = FakeClock::from_ms(now_ms);
let data = fetch_dashboard(&conn, &clock).unwrap();
let sync = data.last_sync.unwrap();
assert_eq!(sync.status, "succeeded");
assert_eq!(sync.command, "sync");
assert!(sync.error.is_none());
}
}

View File

@@ -0,0 +1,379 @@
#![allow(dead_code)]
//! File History screen actions — query MRs that touched a file path.
//!
//! Wraps the SQL queries from `lore::cli::commands::file_history` but uses
//! an injected `Connection` (TUI manages its own DB connection).
use anyhow::Result;
use rusqlite::Connection;
use lore::core::file_history::resolve_rename_chain;
use crate::state::file_history::{FileDiscussion, FileHistoryMr, FileHistoryResult};
/// Maximum rename chain BFS depth.
const MAX_RENAME_HOPS: usize = 10;
/// Default result limit.
const DEFAULT_LIMIT: usize = 50;
/// Fetch file history: MRs that touched a file path, with optional rename resolution.
pub fn fetch_file_history(
conn: &Connection,
project_id: Option<i64>,
path: &str,
follow_renames: bool,
merged_only: bool,
include_discussions: bool,
) -> Result<FileHistoryResult> {
// Resolve rename chain unless disabled.
let (all_paths, renames_followed) = if !follow_renames {
(vec![path.to_string()], false)
} else if let Some(pid) = project_id {
let chain = resolve_rename_chain(conn, pid, path, MAX_RENAME_HOPS)?;
let followed = chain.len() > 1;
(chain, followed)
} else {
// Without project scope, can't resolve renames.
(vec![path.to_string()], false)
};
let paths_searched = all_paths.len();
// Build IN clause placeholders.
let placeholders: Vec<String> = (0..all_paths.len())
.map(|i| format!("?{}", i + 2))
.collect();
let in_clause = placeholders.join(", ");
let merged_filter = if merged_only {
" AND mr.state = 'merged'"
} else {
""
};
let project_filter = if project_id.is_some() {
"AND mfc.project_id = ?1"
} else {
""
};
let limit_param = all_paths.len() + 2;
let sql = format!(
"SELECT DISTINCT \
mr.iid, mr.title, mr.state, mr.author_username, \
mfc.change_type, mr.merged_at, mr.updated_at, mr.merge_commit_sha \
FROM mr_file_changes mfc \
JOIN merge_requests mr ON mr.id = mfc.merge_request_id \
WHERE mfc.new_path IN ({in_clause}) {project_filter} {merged_filter} \
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC \
LIMIT ?{limit_param}"
);
let mut stmt = conn.prepare(&sql)?;
// Bind: ?1=project_id, ?2..?N+1=paths, ?N+2=limit.
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(project_id.unwrap_or(0)));
for p in &all_paths {
params.push(Box::new(p.clone()));
}
params.push(Box::new(DEFAULT_LIMIT as i64));
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let merge_requests: Vec<FileHistoryMr> = stmt
.query_map(param_refs.as_slice(), |row| {
Ok(FileHistoryMr {
iid: row.get(0)?,
title: row.get(1)?,
state: row.get(2)?,
author_username: row.get(3)?,
change_type: row.get(4)?,
merged_at_ms: row.get(5)?,
updated_at_ms: row.get::<_, i64>(6)?,
merge_commit_sha: row.get(7)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
let total_mrs = merge_requests.len();
// Optionally fetch DiffNote discussions.
let discussions = if include_discussions && !merge_requests.is_empty() {
fetch_file_discussions(conn, &all_paths, project_id)?
} else {
Vec::new()
};
Ok(FileHistoryResult {
path: path.to_string(),
rename_chain: all_paths,
renames_followed,
merge_requests,
discussions,
total_mrs,
paths_searched,
})
}
/// Fetch DiffNote discussions referencing the given file paths.
fn fetch_file_discussions(
conn: &Connection,
paths: &[String],
project_id: Option<i64>,
) -> Result<Vec<FileDiscussion>> {
let placeholders: Vec<String> = (0..paths.len()).map(|i| format!("?{}", i + 2)).collect();
let in_clause = placeholders.join(", ");
let project_filter = if project_id.is_some() {
"AND d.project_id = ?1"
} else {
""
};
let sql = format!(
"SELECT d.gitlab_discussion_id, n.author_username, n.body, n.position_new_path, n.created_at \
FROM notes n \
JOIN discussions d ON d.id = n.discussion_id \
WHERE n.position_new_path IN ({in_clause}) {project_filter} \
AND n.is_system = 0 \
ORDER BY n.created_at DESC \
LIMIT 50"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(project_id.unwrap_or(0)));
for p in paths {
params.push(Box::new(p.clone()));
}
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let discussions: Vec<FileDiscussion> = stmt
.query_map(param_refs.as_slice(), |row| {
let body: String = row.get(2)?;
let snippet = if body.len() > 200 {
format!("{}...", &body[..body.floor_char_boundary(200)])
} else {
body
};
Ok(FileDiscussion {
discussion_id: row.get(0)?,
author_username: row.get(1)?,
body_snippet: snippet,
path: row.get(3)?,
created_at_ms: row.get(4)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(discussions)
}
/// Fetch distinct file paths from mr_file_changes for autocomplete.
pub fn fetch_file_history_paths(conn: &Connection, project_id: Option<i64>) -> Result<Vec<String>> {
let sql = if project_id.is_some() {
"SELECT DISTINCT new_path FROM mr_file_changes WHERE project_id = ?1 ORDER BY new_path LIMIT 5000"
} else {
"SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path LIMIT 5000"
};
let mut stmt = conn.prepare(sql)?;
let paths: Vec<String> = if let Some(pid) = project_id {
stmt.query_map([pid], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?
} else {
stmt.query_map([], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?
};
Ok(paths)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Minimal schema for file history queries.
fn create_file_history_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_id INTEGER,
author_username TEXT,
draft INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
updated_at INTEGER,
merged_at INTEGER,
merge_commit_sha TEXT,
web_url TEXT,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE mr_file_changes (
id INTEGER PRIMARY KEY,
merge_request_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
new_path TEXT NOT NULL,
old_path TEXT,
change_type TEXT NOT NULL
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
noteable_type TEXT NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
note_type TEXT,
position_new_path TEXT,
position_old_path TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
",
)
.expect("create file history schema");
}
#[test]
fn test_fetch_file_history_empty_db() {
let conn = Connection::open_in_memory().unwrap();
create_file_history_schema(&conn);
let result = fetch_file_history(&conn, None, "src/lib.rs", false, false, false).unwrap();
assert!(result.merge_requests.is_empty());
assert_eq!(result.total_mrs, 0);
assert_eq!(result.path, "src/lib.rs");
}
#[test]
fn test_fetch_file_history_returns_mrs() {
let conn = Connection::open_in_memory().unwrap();
create_file_history_schema(&conn);
// Insert project, MR, and file change.
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'grp/repo')",
[],
).unwrap();
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at) \
VALUES (1, 1000, 1, 42, 'Fix auth', 'merged', 'alice', 1700000000000, 1700000000000)",
[],
).unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) \
VALUES (1, 1, 'src/auth.rs', 'modified')",
[],
)
.unwrap();
let result =
fetch_file_history(&conn, Some(1), "src/auth.rs", false, false, false).unwrap();
assert_eq!(result.merge_requests.len(), 1);
assert_eq!(result.merge_requests[0].iid, 42);
assert_eq!(result.merge_requests[0].title, "Fix auth");
assert_eq!(result.merge_requests[0].change_type, "modified");
}
#[test]
fn test_fetch_file_history_merged_only() {
let conn = Connection::open_in_memory().unwrap();
create_file_history_schema(&conn);
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'grp/repo')",
[],
).unwrap();
// Merged MR.
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at) \
VALUES (1, 1000, 1, 42, 'Merged MR', 'merged', 'alice', 1700000000000, 1700000000000)",
[],
).unwrap();
// Open MR.
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at) \
VALUES (2, 1001, 1, 43, 'Open MR', 'opened', 'bob', 1700000000000, 1700000000000)",
[],
).unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (1, 1, 'src/lib.rs', 'modified')",
[],
).unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (2, 1, 'src/lib.rs', 'modified')",
[],
).unwrap();
// Without merged_only: both MRs.
let all = fetch_file_history(&conn, Some(1), "src/lib.rs", false, false, false).unwrap();
assert_eq!(all.merge_requests.len(), 2);
// With merged_only: only the merged one.
let merged = fetch_file_history(&conn, Some(1), "src/lib.rs", false, true, false).unwrap();
assert_eq!(merged.merge_requests.len(), 1);
assert_eq!(merged.merge_requests[0].state, "merged");
}
#[test]
fn test_fetch_file_history_paths_empty() {
let conn = Connection::open_in_memory().unwrap();
create_file_history_schema(&conn);
let paths = fetch_file_history_paths(&conn, None).unwrap();
assert!(paths.is_empty());
}
#[test]
fn test_fetch_file_history_paths_returns_distinct() {
let conn = Connection::open_in_memory().unwrap();
create_file_history_schema(&conn);
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (1, 1, 'src/a.rs', 'modified')",
[],
).unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (2, 1, 'src/a.rs', 'modified')",
[],
).unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) VALUES (3, 1, 'src/b.rs', 'added')",
[],
).unwrap();
let paths = fetch_file_history_paths(&conn, None).unwrap();
assert_eq!(paths, vec!["src/a.rs", "src/b.rs"]);
}
}

View File

@@ -0,0 +1,611 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::message::EntityKey;
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
/// Fetch issue metadata and cross-references (Phase 1 load).
///
/// Runs inside a single read transaction for snapshot consistency.
/// Returns metadata + cross-refs; discussions are loaded separately.
pub fn fetch_issue_detail(conn: &Connection, key: &EntityKey) -> Result<IssueDetailData> {
let metadata = fetch_issue_metadata(conn, key)?;
let cross_refs = fetch_issue_cross_refs(conn, key)?;
Ok(IssueDetailData {
metadata,
cross_refs,
})
}
/// Fetch issue metadata from the local DB.
fn fetch_issue_metadata(conn: &Connection, key: &EntityKey) -> Result<IssueMetadata> {
let row = conn
.query_row(
"SELECT i.iid, p.path_with_namespace, i.title,
COALESCE(i.description, ''), i.state, i.author_username,
COALESCE(i.milestone_title, ''),
i.due_date, i.created_at, i.updated_at,
COALESCE(i.web_url, ''),
(SELECT COUNT(*) FROM discussions d
WHERE d.issue_id = i.id AND d.noteable_type = 'Issue')
FROM issues i
JOIN projects p ON p.id = i.project_id
WHERE i.project_id = ?1 AND i.iid = ?2",
rusqlite::params![key.project_id, key.iid],
|row| {
Ok(IssueMetadata {
iid: row.get(0)?,
project_path: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
state: row.get(4)?,
author: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
assignees: Vec::new(), // Fetched separately below.
labels: Vec::new(), // Fetched separately below.
milestone: {
let m: String = row.get(6)?;
if m.is_empty() { None } else { Some(m) }
},
due_date: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
web_url: row.get(10)?,
discussion_count: row.get::<_, i64>(11)? as usize,
})
},
)
.context("fetching issue metadata")?;
// Fetch assignees.
let mut assignees_stmt = conn
.prepare("SELECT username FROM issue_assignees WHERE issue_id = (SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2)")
.context("preparing assignees query")?;
let assignees: Vec<String> = assignees_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |r| r.get(0))
.context("fetching assignees")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading assignee row")?;
// Fetch labels.
let mut labels_stmt = conn
.prepare(
"SELECT l.name FROM issue_labels il
JOIN labels l ON l.id = il.label_id
WHERE il.issue_id = (SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2)
ORDER BY l.name",
)
.context("preparing labels query")?;
let labels: Vec<String> = labels_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |r| r.get(0))
.context("fetching labels")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading label row")?;
Ok(IssueMetadata {
assignees,
labels,
..row
})
}
/// Fetch cross-references for an issue from the entity_references table.
fn fetch_issue_cross_refs(conn: &Connection, key: &EntityKey) -> Result<Vec<CrossRef>> {
let mut stmt = conn
.prepare(
"SELECT er.reference_type, er.target_entity_type, er.target_entity_id,
er.target_entity_iid, er.target_project_path,
CASE
WHEN er.target_entity_type = 'issue'
THEN (SELECT title FROM issues WHERE id = er.target_entity_id)
WHEN er.target_entity_type = 'merge_request'
THEN (SELECT title FROM merge_requests WHERE id = er.target_entity_id)
ELSE NULL
END as entity_title,
CASE
WHEN er.target_entity_id IS NOT NULL
THEN (SELECT project_id FROM issues WHERE id = er.target_entity_id
UNION ALL
SELECT project_id FROM merge_requests WHERE id = er.target_entity_id
LIMIT 1)
ELSE NULL
END as target_project_id
FROM entity_references er
WHERE er.source_entity_type = 'issue'
AND er.source_entity_id = (SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2)
ORDER BY er.reference_type, er.target_entity_iid",
)
.context("preparing cross-ref query")?;
let refs = stmt
.query_map(rusqlite::params![key.project_id, key.iid], |row| {
let ref_type: String = row.get(0)?;
let target_type: String = row.get(1)?;
let target_id: Option<i64> = row.get(2)?;
let target_iid: Option<i64> = row.get(3)?;
let target_path: Option<String> = row.get(4)?;
let title: Option<String> = row.get(5)?;
let target_project_id: Option<i64> = row.get(6)?;
let kind = match (ref_type.as_str(), target_type.as_str()) {
("closes", "merge_request") => CrossRefKind::ClosingMr,
("related", "issue") => CrossRefKind::RelatedIssue,
_ => CrossRefKind::MentionedIn,
};
let iid = target_iid.unwrap_or(0);
let project_id = target_project_id.unwrap_or(key.project_id);
let entity_key = match target_type.as_str() {
"merge_request" => EntityKey::mr(project_id, iid),
_ => EntityKey::issue(project_id, iid),
};
let label = title.unwrap_or_else(|| {
let prefix = if target_type == "merge_request" {
"!"
} else {
"#"
};
let path = target_path.unwrap_or_default();
if path.is_empty() {
format!("{prefix}{iid}")
} else {
format!("{path}{prefix}{iid}")
}
});
let navigable = target_id.is_some();
Ok(CrossRef {
kind,
entity_key,
label,
navigable,
})
})
.context("fetching cross-refs")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading cross-ref row")?;
Ok(refs)
}
/// Fetch discussions for an issue (Phase 2 async load).
///
/// Returns `DiscussionNode` tree suitable for the discussion tree widget.
pub fn fetch_issue_discussions(conn: &Connection, key: &EntityKey) -> Result<Vec<DiscussionNode>> {
let issue_id: i64 = conn
.query_row(
"SELECT id FROM issues WHERE project_id = ?1 AND iid = ?2",
rusqlite::params![key.project_id, key.iid],
|r| r.get(0),
)
.context("looking up issue id")?;
let mut disc_stmt = conn
.prepare(
"SELECT d.id, d.gitlab_discussion_id, d.resolvable, d.resolved
FROM discussions d
WHERE d.issue_id = ?1 AND d.noteable_type = 'Issue'
ORDER BY d.first_note_at ASC, d.id ASC",
)
.context("preparing discussions query")?;
let mut note_stmt = conn
.prepare(
"SELECT n.author_username, n.body, n.created_at, n.is_system,
n.note_type, n.position_new_path, n.position_new_line
FROM notes n
WHERE n.discussion_id = ?1
ORDER BY n.position ASC, n.created_at ASC",
)
.context("preparing notes query")?;
let disc_rows: Vec<_> = disc_stmt
.query_map(rusqlite::params![issue_id], |row| {
Ok((
row.get::<_, i64>(0)?, // id
row.get::<_, String>(1)?, // gitlab_discussion_id
row.get::<_, bool>(2)?, // resolvable
row.get::<_, bool>(3)?, // resolved
))
})
.context("fetching discussions")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading discussion row")?;
let mut discussions = Vec::new();
for (disc_db_id, discussion_id, resolvable, resolved) in disc_rows {
let notes: Vec<NoteNode> = note_stmt
.query_map(rusqlite::params![disc_db_id], |row| {
Ok(NoteNode {
author: row.get::<_, Option<String>>(0)?.unwrap_or_default(),
body: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
created_at: row.get(2)?,
is_system: row.get(3)?,
is_diff_note: row.get::<_, Option<String>>(4)?.as_deref() == Some("DiffNote"),
diff_file_path: row.get(5)?,
diff_new_line: row.get(6)?,
})
})
.context("fetching notes")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading note row")?;
discussions.push(DiscussionNode {
discussion_id,
notes,
resolvable,
resolved,
});
}
Ok(discussions)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_issue_detail_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
state TEXT NOT NULL DEFAULT 'opened',
author_username TEXT,
milestone_title TEXT,
due_date TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
web_url TEXT,
UNIQUE(project_id, iid)
);
CREATE TABLE issue_assignees (
issue_id INTEGER NOT NULL,
username TEXT NOT NULL,
UNIQUE(issue_id, username)
);
CREATE TABLE labels (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE issue_labels (
issue_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
UNIQUE(issue_id, label_id)
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
noteable_type TEXT NOT NULL,
resolvable INTEGER NOT NULL DEFAULT 0,
resolved INTEGER NOT NULL DEFAULT 0,
first_note_at INTEGER
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
note_type TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
position INTEGER,
position_new_path TEXT,
position_new_line INTEGER
);
CREATE TABLE entity_references (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
source_entity_type TEXT NOT NULL,
source_entity_id INTEGER NOT NULL,
target_entity_type TEXT NOT NULL,
target_entity_id INTEGER,
target_project_path TEXT,
target_entity_iid INTEGER,
reference_type TEXT NOT NULL,
source_method TEXT NOT NULL DEFAULT 'api',
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'opened',
UNIQUE(project_id, iid)
);
",
)
.unwrap();
}
fn setup_issue_detail_data(conn: &Connection) {
// Project.
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
[],
)
.unwrap();
// Issue.
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, milestone_title, due_date, created_at, updated_at, web_url)
VALUES (1, 1000, 1, 42, 'Fix authentication flow', 'Detailed description here', 'opened', 'alice', 'v1.0', '2026-03-01', 1700000000000, 1700000060000, 'https://gitlab.com/group/project/-/issues/42')",
[],
)
.unwrap();
// Assignees.
conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'bob')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'charlie')",
[],
)
.unwrap();
// Labels.
conn.execute(
"INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO labels (id, project_id, name) VALUES (2, 1, 'urgent')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 2)",
[],
)
.unwrap();
// Discussions + notes.
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, resolvable, resolved, first_note_at)
VALUES (1, 'disc-aaa', 1, 1, 'Issue', 0, 0, 1700000010000)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, position, is_system, note_type)
VALUES (1, 10001, 1, 1, 'alice', 'This looks good overall', 1700000010000, 1700000010000, 0, 0, 'DiscussionNote')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, position, is_system, note_type)
VALUES (2, 10002, 1, 1, 'bob', 'Agreed, but see my comment below', 1700000020000, 1700000020000, 1, 0, 'DiscussionNote')",
[],
)
.unwrap();
// System note discussion.
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at)
VALUES (2, 'disc-bbb', 1, 1, 'Issue', 1700000030000)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, position, is_system, note_type)
VALUES (3, 10003, 2, 1, 'system', 'changed the description', 1700000030000, 1700000030000, 0, 1, NULL)",
[],
)
.unwrap();
// Closing MR cross-ref.
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state)
VALUES (1, 2000, 1, 10, 'Fix auth MR', 'opened')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_entity_iid, reference_type)
VALUES (1, 'issue', 1, 'merge_request', 1, 10, 'closes')",
[],
)
.unwrap();
}
#[test]
fn test_fetch_issue_detail_basic() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let data = fetch_issue_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.iid, 42);
assert_eq!(data.metadata.title, "Fix authentication flow");
assert_eq!(data.metadata.state, "opened");
assert_eq!(data.metadata.author, "alice");
assert_eq!(data.metadata.project_path, "group/project");
assert_eq!(data.metadata.milestone, Some("v1.0".to_string()));
assert_eq!(data.metadata.due_date, Some("2026-03-01".to_string()));
assert_eq!(
data.metadata.web_url,
"https://gitlab.com/group/project/-/issues/42"
);
}
#[test]
fn test_fetch_issue_detail_assignees() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let data = fetch_issue_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.assignees.len(), 2);
assert!(data.metadata.assignees.contains(&"bob".to_string()));
assert!(data.metadata.assignees.contains(&"charlie".to_string()));
}
#[test]
fn test_fetch_issue_detail_labels() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let data = fetch_issue_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.labels, vec!["backend", "urgent"]);
}
#[test]
fn test_fetch_issue_detail_cross_refs() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let data = fetch_issue_detail(&conn, &key).unwrap();
assert_eq!(data.cross_refs.len(), 1);
assert_eq!(data.cross_refs[0].kind, CrossRefKind::ClosingMr);
assert_eq!(data.cross_refs[0].entity_key, EntityKey::mr(1, 10));
assert_eq!(data.cross_refs[0].label, "Fix auth MR");
assert!(data.cross_refs[0].navigable);
}
#[test]
fn test_fetch_issue_detail_discussion_count() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let data = fetch_issue_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.discussion_count, 2);
}
#[test]
fn test_fetch_issue_discussions_basic() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let discussions = fetch_issue_discussions(&conn, &key).unwrap();
assert_eq!(discussions.len(), 2);
}
#[test]
fn test_fetch_issue_discussions_notes() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let discussions = fetch_issue_discussions(&conn, &key).unwrap();
// First discussion has 2 notes.
assert_eq!(discussions[0].notes.len(), 2);
assert_eq!(discussions[0].notes[0].author, "alice");
assert_eq!(discussions[0].notes[0].body, "This looks good overall");
assert_eq!(discussions[0].notes[1].author, "bob");
assert!(!discussions[0].notes[0].is_system);
}
#[test]
fn test_fetch_issue_discussions_system_note() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let discussions = fetch_issue_discussions(&conn, &key).unwrap();
// Second discussion is a system note.
assert_eq!(discussions[1].notes.len(), 1);
assert!(discussions[1].notes[0].is_system);
assert_eq!(discussions[1].notes[0].body, "changed the description");
}
#[test]
fn test_fetch_issue_discussions_ordering() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 42);
let discussions = fetch_issue_discussions(&conn, &key).unwrap();
// Ordered by first_note_at.
assert_eq!(discussions[0].discussion_id, "disc-aaa");
assert_eq!(discussions[1].discussion_id, "disc-bbb");
}
#[test]
fn test_fetch_issue_detail_not_found() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
setup_issue_detail_data(&conn);
let key = EntityKey::issue(1, 999);
let result = fetch_issue_detail(&conn, &key);
assert!(result.is_err());
}
#[test]
fn test_fetch_issue_detail_no_description() {
let conn = Connection::open_in_memory().unwrap();
create_issue_detail_schema(&conn);
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'g/p')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, created_at, updated_at)
VALUES (1, 1000, 1, 1, 'No desc', NULL, 'opened', 0, 0)",
[],
)
.unwrap();
let key = EntityKey::issue(1, 1);
let data = fetch_issue_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.description, "");
}
}

View File

@@ -0,0 +1,532 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::state::issue_list::{
IssueCursor, IssueFilter, IssueListPage, IssueListRow, SortField, SortOrder,
};
/// Page size for issue list queries.
const ISSUE_PAGE_SIZE: usize = 50;
/// Fetch a page of issues matching the given filter and sort.
///
/// Uses keyset pagination: when `cursor` is `Some`, returns rows after
/// (less-than for DESC, greater-than for ASC) the cursor boundary.
/// When `snapshot_fence` is `Some`, limits results to rows updated_at <= fence
/// to prevent newly synced items from shifting the page window.
pub fn fetch_issue_list(
conn: &Connection,
filter: &IssueFilter,
sort_field: SortField,
sort_order: SortOrder,
cursor: Option<&IssueCursor>,
snapshot_fence: Option<i64>,
) -> Result<IssueListPage> {
// -- Build dynamic WHERE conditions and params --------------------------
let mut conditions: Vec<String> = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
// Filter: project_id
if let Some(pid) = filter.project_id {
conditions.push("i.project_id = ?".into());
params.push(Box::new(pid));
}
// Filter: state
if let Some(ref state) = filter.state {
conditions.push("i.state = ?".into());
params.push(Box::new(state.clone()));
}
// Filter: author
if let Some(ref author) = filter.author {
conditions.push("i.author_username = ?".into());
params.push(Box::new(author.clone()));
}
// Filter: label (via join)
let label_join = if let Some(ref label) = filter.label {
conditions.push("fl.name = ?".into());
params.push(Box::new(label.clone()));
"JOIN issue_labels fil ON fil.issue_id = i.id \
JOIN labels fl ON fl.id = fil.label_id"
} else {
""
};
// Filter: free_text (LIKE on title)
if let Some(ref text) = filter.free_text {
conditions.push("i.title LIKE ?".into());
params.push(Box::new(format!("%{text}%")));
}
// Snapshot fence
if let Some(fence) = snapshot_fence {
conditions.push("i.updated_at <= ?".into());
params.push(Box::new(fence));
}
// -- Count query (before cursor filter) ---------------------------------
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let count_sql = format!(
"SELECT COUNT(DISTINCT i.id) FROM issues i \
JOIN projects p ON p.id = i.project_id \
{label_join} {where_clause}"
);
let count_params: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let total_count: i64 = conn
.query_row(&count_sql, count_params.as_slice(), |r| r.get(0))
.context("counting issues for list")?;
// -- Keyset cursor condition -------------------------------------------
let (sort_col, sort_dir) = sort_column_and_dir(sort_field, sort_order);
let cursor_op = if sort_dir == "DESC" { "<" } else { ">" };
if let Some(c) = cursor {
conditions.push(format!("({sort_col}, i.iid) {cursor_op} (?, ?)"));
params.push(Box::new(c.updated_at));
params.push(Box::new(c.iid));
}
// -- Data query ---------------------------------------------------------
let where_clause_full = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let data_sql = format!(
"SELECT p.path_with_namespace, i.iid, i.title, i.state, \
i.author_username, i.updated_at, \
GROUP_CONCAT(DISTINCT l.name) AS label_names \
FROM issues i \
JOIN projects p ON p.id = i.project_id \
{label_join} \
LEFT JOIN issue_labels il ON il.issue_id = i.id \
LEFT JOIN labels l ON l.id = il.label_id \
{where_clause_full} \
GROUP BY i.id \
ORDER BY {sort_col} {sort_dir}, i.iid {sort_dir} \
LIMIT ?"
);
// +1 to detect if there's a next page
let fetch_limit = (ISSUE_PAGE_SIZE + 1) as i64;
params.push(Box::new(fetch_limit));
let all_params: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn
.prepare(&data_sql)
.context("preparing issue list query")?;
let rows_result = stmt
.query_map(all_params.as_slice(), |row| {
let project_path: String = row.get(0)?;
let iid: i64 = row.get(1)?;
let title: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let state: String = row.get::<_, Option<String>>(3)?.unwrap_or_default();
let author: String = row.get::<_, Option<String>>(4)?.unwrap_or_default();
let updated_at: i64 = row.get(5)?;
let label_names: Option<String> = row.get(6)?;
let labels = label_names
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
Ok(IssueListRow {
project_path,
iid,
title,
state,
author,
labels,
updated_at,
})
})
.context("querying issue list")?;
let mut rows: Vec<IssueListRow> = Vec::new();
for row in rows_result {
rows.push(row.context("reading issue list row")?);
}
// Determine next cursor from the last row (if we got more than page size)
let has_next = rows.len() > ISSUE_PAGE_SIZE;
if has_next {
rows.truncate(ISSUE_PAGE_SIZE);
}
let next_cursor = if has_next {
rows.last().map(|r| IssueCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
#[allow(clippy::cast_sign_loss)]
Ok(IssueListPage {
rows,
next_cursor,
total_count: total_count as u64,
})
}
/// Map sort field + order to SQL column name and direction keyword.
fn sort_column_and_dir(field: SortField, order: SortOrder) -> (&'static str, &'static str) {
let col = match field {
SortField::UpdatedAt => "i.updated_at",
SortField::Iid => "i.iid",
SortField::Title => "i.title",
SortField::State => "i.state",
SortField::Author => "i.author_username",
};
let dir = match order {
SortOrder::Desc => "DESC",
SortOrder::Asc => "ASC",
};
(col, dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// Create the minimal schema needed for issue list queries.
fn create_issue_list_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE labels (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
description TEXT
);
CREATE TABLE issue_labels (
issue_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
PRIMARY KEY(issue_id, label_id)
);
",
)
.expect("create issue list schema");
}
/// Insert a test issue with an author.
fn insert_issue_full(conn: &Connection, iid: i64, state: &str, author: &str, updated_at: i64) {
conn.execute(
"INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?6, ?6)",
rusqlite::params![
iid * 100,
iid,
format!("Issue {iid}"),
state,
author,
updated_at
],
)
.expect("insert issue full");
}
/// Attach a label to an issue.
fn attach_label(conn: &Connection, issue_iid: i64, label_name: &str) {
// Find issue id.
let issue_id: i64 = conn
.query_row("SELECT id FROM issues WHERE iid = ?", [issue_iid], |r| {
r.get(0)
})
.expect("find issue");
// Ensure label exists.
conn.execute(
"INSERT OR IGNORE INTO labels (project_id, name) VALUES (1, ?)",
[label_name],
)
.expect("insert label");
let label_id: i64 = conn
.query_row("SELECT id FROM labels WHERE name = ?", [label_name], |r| {
r.get(0)
})
.expect("find label");
conn.execute(
"INSERT INTO issue_labels (issue_id, label_id) VALUES (?, ?)",
[issue_id, label_id],
)
.expect("attach label");
}
fn setup_issue_list_data(conn: &Connection) {
let base = 1_700_000_000_000_i64;
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace) VALUES (1, 'group/project')",
[],
)
.unwrap();
insert_issue_full(conn, 1, "opened", "alice", base - 10_000);
insert_issue_full(conn, 2, "opened", "bob", base - 20_000);
insert_issue_full(conn, 3, "closed", "alice", base - 30_000);
insert_issue_full(conn, 4, "opened", "charlie", base - 40_000);
insert_issue_full(conn, 5, "closed", "bob", base - 50_000);
attach_label(conn, 1, "bug");
attach_label(conn, 1, "critical");
attach_label(conn, 2, "feature");
attach_label(conn, 4, "bug");
}
#[test]
fn test_fetch_issue_list_basic() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter::default();
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 5);
assert_eq!(page.rows.len(), 5);
// Newest first.
assert_eq!(page.rows[0].iid, 1);
assert_eq!(page.rows[4].iid, 5);
assert!(page.next_cursor.is_none());
}
#[test]
fn test_fetch_issue_list_filter_state() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 3);
assert_eq!(page.rows.len(), 3);
assert!(page.rows.iter().all(|r| r.state == "opened"));
}
#[test]
fn test_fetch_issue_list_filter_author() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter {
author: Some("alice".into()),
..Default::default()
};
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 2);
assert_eq!(page.rows.len(), 2);
assert!(page.rows.iter().all(|r| r.author == "alice"));
}
#[test]
fn test_fetch_issue_list_filter_label() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter {
label: Some("bug".into()),
..Default::default()
};
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 2); // issues 1 and 4
assert_eq!(page.rows.len(), 2);
}
#[test]
fn test_fetch_issue_list_labels_aggregated() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter::default();
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
// Issue 1 has labels "bug" and "critical".
let issue1 = page.rows.iter().find(|r| r.iid == 1).unwrap();
assert_eq!(issue1.labels.len(), 2);
assert!(issue1.labels.contains(&"bug".to_string()));
assert!(issue1.labels.contains(&"critical".to_string()));
// Issue 5 has no labels.
let issue5 = page.rows.iter().find(|r| r.iid == 5).unwrap();
assert!(issue5.labels.is_empty());
}
#[test]
fn test_fetch_issue_list_sort_ascending() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter::default();
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Asc,
None,
None,
)
.unwrap();
// Oldest first.
assert_eq!(page.rows[0].iid, 5);
assert_eq!(page.rows[4].iid, 1);
}
#[test]
fn test_fetch_issue_list_snapshot_fence() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let base = 1_700_000_000_000_i64;
// Fence at base-25000: should exclude issues 1 (at base-10000) and 2 (at base-20000).
let fence = base - 25_000;
let filter = IssueFilter::default();
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
Some(fence),
)
.unwrap();
assert_eq!(page.total_count, 3);
assert_eq!(page.rows.len(), 3);
assert!(page.rows.iter().all(|r| r.updated_at <= fence));
}
#[test]
fn test_fetch_issue_list_empty() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace) VALUES (1, 'g/p')",
[],
)
.unwrap();
let page = fetch_issue_list(
&conn,
&IssueFilter::default(),
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 0);
assert!(page.rows.is_empty());
assert!(page.next_cursor.is_none());
}
#[test]
fn test_fetch_issue_list_free_text() {
let conn = Connection::open_in_memory().unwrap();
create_issue_list_schema(&conn);
setup_issue_list_data(&conn);
let filter = IssueFilter {
free_text: Some("Issue 3".into()),
..Default::default()
};
let page = fetch_issue_list(
&conn,
&filter,
SortField::UpdatedAt,
SortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 1);
assert_eq!(page.rows[0].iid, 3);
}
}

View File

@@ -0,0 +1,31 @@
//! Action layer — pure data-fetching functions for TUI screens.
//!
//! Actions query the local SQLite database and return data structs.
//! They never touch terminal state, never spawn tasks, and use injected
//! [`Clock`] for time calculations (deterministic tests).
mod bootstrap;
mod dashboard;
mod file_history;
mod issue_detail;
mod issue_list;
mod mr_detail;
mod mr_list;
mod search;
mod sync;
mod timeline;
mod trace;
mod who;
pub use bootstrap::*;
pub use dashboard::*;
pub use file_history::*;
pub use issue_detail::*;
pub use issue_list::*;
pub use mr_detail::*;
pub use mr_list::*;
pub use search::*;
pub use sync::*;
pub use timeline::*;
pub use trace::*;
pub use who::*;

View File

@@ -0,0 +1,694 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::message::EntityKey;
use crate::state::mr_detail::{FileChange, FileChangeType, MrDetailData, MrMetadata};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
/// Fetch MR metadata + cross-refs + file changes (Phase 1 composite).
pub fn fetch_mr_detail(conn: &Connection, key: &EntityKey) -> Result<MrDetailData> {
let metadata = fetch_mr_metadata(conn, key)?;
let cross_refs = fetch_mr_cross_refs(conn, key)?;
let file_changes = fetch_mr_file_changes(conn, key)?;
Ok(MrDetailData {
metadata,
cross_refs,
file_changes,
})
}
/// Fetch MR metadata from the local DB.
fn fetch_mr_metadata(conn: &Connection, key: &EntityKey) -> Result<MrMetadata> {
let row = conn
.query_row(
"SELECT m.iid, p.path_with_namespace, m.title,
COALESCE(m.description, ''), m.state, m.draft,
m.author_username, m.source_branch, m.target_branch,
COALESCE(m.detailed_merge_status, ''),
m.created_at, m.updated_at, m.merged_at,
COALESCE(m.web_url, ''),
(SELECT COUNT(*) FROM discussions d WHERE d.merge_request_id = m.id) AS disc_count,
(SELECT COUNT(*) FROM mr_file_changes fc WHERE fc.merge_request_id = m.id) AS fc_count
FROM merge_requests m
JOIN projects p ON p.id = m.project_id
WHERE m.project_id = ?1 AND m.iid = ?2",
rusqlite::params![key.project_id, key.iid],
|row| {
Ok(MrMetadata {
iid: row.get(0)?,
project_path: row.get(1)?,
title: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
description: row.get(3)?,
state: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
draft: row.get(5)?,
author: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
assignees: Vec::new(),
reviewers: Vec::new(),
labels: Vec::new(),
source_branch: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
target_branch: row.get::<_, Option<String>>(8)?.unwrap_or_default(),
merge_status: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
merged_at: row.get(12)?,
web_url: row.get(13)?,
discussion_count: row.get::<_, i64>(14)? as usize,
file_change_count: row.get::<_, i64>(15)? as usize,
})
},
)
.context("fetching MR metadata")?;
// Fetch assignees.
let mut assignees_stmt = conn
.prepare(
"SELECT username FROM mr_assignees
WHERE merge_request_id = (
SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2
)
ORDER BY username",
)
.context("preparing assignees query")?;
let assignees: Vec<String> = assignees_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |row| row.get(0))
.context("fetching assignees")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading assignee row")?;
// Fetch reviewers.
let mut reviewers_stmt = conn
.prepare(
"SELECT username FROM mr_reviewers
WHERE merge_request_id = (
SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2
)
ORDER BY username",
)
.context("preparing reviewers query")?;
let reviewers: Vec<String> = reviewers_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |row| row.get(0))
.context("fetching reviewers")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading reviewer row")?;
// Fetch labels.
let mut labels_stmt = conn
.prepare(
"SELECT l.name FROM mr_labels ml
JOIN labels l ON ml.label_id = l.id
WHERE ml.merge_request_id = (
SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2
)
ORDER BY l.name",
)
.context("preparing labels query")?;
let labels: Vec<String> = labels_stmt
.query_map(rusqlite::params![key.project_id, key.iid], |row| row.get(0))
.context("fetching labels")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading label row")?;
let mut result = row;
result.assignees = assignees;
result.reviewers = reviewers;
result.labels = labels;
Ok(result)
}
/// Fetch cross-references for an MR.
fn fetch_mr_cross_refs(conn: &Connection, key: &EntityKey) -> Result<Vec<CrossRef>> {
let mut stmt = conn
.prepare(
"SELECT er.reference_type, er.target_entity_type,
er.target_entity_id, er.target_entity_iid,
er.target_project_path,
CASE
WHEN er.target_entity_type = 'issue'
THEN (SELECT title FROM issues WHERE id = er.target_entity_id)
WHEN er.target_entity_type = 'merge_request'
THEN (SELECT title FROM merge_requests WHERE id = er.target_entity_id)
ELSE NULL
END as entity_title,
CASE
WHEN er.target_entity_id IS NOT NULL
THEN (SELECT project_id FROM issues WHERE id = er.target_entity_id
UNION ALL
SELECT project_id FROM merge_requests WHERE id = er.target_entity_id
LIMIT 1)
ELSE NULL
END as target_project_id
FROM entity_references er
WHERE er.source_entity_type = 'merge_request'
AND er.source_entity_id = (SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2)
ORDER BY er.reference_type, er.target_entity_iid",
)
.context("preparing MR cross-refs query")?;
let refs: Vec<CrossRef> = stmt
.query_map(rusqlite::params![key.project_id, key.iid], |row| {
let ref_type: String = row.get(0)?;
let target_type: String = row.get(1)?;
let _target_id: Option<i64> = row.get(2)?;
let target_iid: Option<i64> = row.get(3)?;
let target_path: Option<String> = row.get(4)?;
let title: Option<String> = row.get(5)?;
let target_project_id: Option<i64> = row.get(6)?;
let kind = match (ref_type.as_str(), target_type.as_str()) {
("closes", "issue") => CrossRefKind::ClosingMr,
("related", "issue") => CrossRefKind::RelatedIssue,
_ => CrossRefKind::MentionedIn,
};
let iid = target_iid.unwrap_or(0);
let project_id = target_project_id.unwrap_or(key.project_id);
let entity_key = match target_type.as_str() {
"merge_request" => EntityKey::mr(project_id, iid),
_ => EntityKey::issue(project_id, iid),
};
let label = title.unwrap_or_else(|| {
let prefix = if target_type == "merge_request" {
"!"
} else {
"#"
};
let path = target_path.clone().unwrap_or_default();
if path.is_empty() {
format!("{prefix}{iid}")
} else {
format!("{path}{prefix}{iid}")
}
});
Ok(CrossRef {
kind,
entity_key,
label,
navigable: target_project_id.is_some(),
})
})
.context("fetching MR cross-refs")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading cross-ref row")?;
Ok(refs)
}
/// Fetch file changes for an MR.
fn fetch_mr_file_changes(conn: &Connection, key: &EntityKey) -> Result<Vec<FileChange>> {
let mut stmt = conn
.prepare(
"SELECT fc.old_path, fc.new_path, fc.change_type
FROM mr_file_changes fc
WHERE fc.merge_request_id = (
SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2
)
ORDER BY fc.new_path",
)
.context("preparing file changes query")?;
let changes: Vec<FileChange> = stmt
.query_map(rusqlite::params![key.project_id, key.iid], |row| {
Ok(FileChange {
old_path: row.get(0)?,
new_path: row.get(1)?,
change_type: FileChangeType::parse_db(&row.get::<_, String>(2).unwrap_or_default()),
})
})
.context("fetching file changes")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading file change row")?;
Ok(changes)
}
/// Fetch discussions for an MR (Phase 2 async load).
pub fn fetch_mr_discussions(conn: &Connection, key: &EntityKey) -> Result<Vec<DiscussionNode>> {
let mr_id: i64 = conn
.query_row(
"SELECT id FROM merge_requests WHERE project_id = ?1 AND iid = ?2",
rusqlite::params![key.project_id, key.iid],
|row| row.get(0),
)
.context("looking up MR id for discussions")?;
let mut disc_stmt = conn
.prepare(
"SELECT d.id, d.gitlab_discussion_id, d.resolvable, d.resolved
FROM discussions d
WHERE d.merge_request_id = ?1
ORDER BY d.first_note_at ASC",
)
.context("preparing MR discussions query")?;
let mut note_stmt = conn
.prepare(
"SELECT n.author_username, n.body, n.created_at, n.is_system,
n.note_type, n.position_new_path, n.position_new_line
FROM notes n
WHERE n.discussion_id = ?1
ORDER BY n.position ASC, n.created_at ASC",
)
.context("preparing MR notes query")?;
let disc_rows: Vec<_> = disc_stmt
.query_map(rusqlite::params![mr_id], |row| {
Ok((
row.get::<_, i64>(0)?, // id
row.get::<_, String>(1)?, // gitlab_discussion_id
row.get::<_, bool>(2)?, // resolvable
row.get::<_, bool>(3)?, // resolved
))
})
.context("fetching MR discussions")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading discussion row")?;
let mut discussions = Vec::new();
for (disc_db_id, discussion_id, resolvable, resolved) in disc_rows {
let notes: Vec<NoteNode> = note_stmt
.query_map(rusqlite::params![disc_db_id], |row| {
Ok(NoteNode {
author: row.get::<_, Option<String>>(0)?.unwrap_or_default(),
body: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
created_at: row.get(2)?,
is_system: row.get(3)?,
is_diff_note: row.get::<_, Option<String>>(4)?.as_deref() == Some("DiffNote"),
diff_file_path: row.get(5)?,
diff_new_line: row.get(6)?,
})
})
.context("fetching notes")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("reading note row")?;
discussions.push(DiscussionNode {
discussion_id,
notes,
resolvable,
resolved,
});
}
Ok(discussions)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_issue_detail_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
state TEXT NOT NULL DEFAULT 'opened',
author_username TEXT,
milestone_title TEXT,
due_date TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
web_url TEXT,
UNIQUE(project_id, iid)
);
CREATE TABLE issue_assignees (
issue_id INTEGER NOT NULL,
username TEXT NOT NULL,
UNIQUE(issue_id, username)
);
CREATE TABLE labels (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE issue_labels (
issue_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
UNIQUE(issue_id, label_id)
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
noteable_type TEXT NOT NULL,
resolvable INTEGER NOT NULL DEFAULT 0,
resolved INTEGER NOT NULL DEFAULT 0,
first_note_at INTEGER
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
note_type TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
position INTEGER,
position_new_path TEXT,
position_new_line INTEGER
);
CREATE TABLE entity_references (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
source_entity_type TEXT NOT NULL,
source_entity_id INTEGER NOT NULL,
target_entity_type TEXT NOT NULL,
target_entity_id INTEGER,
target_project_path TEXT,
target_entity_iid INTEGER,
reference_type TEXT NOT NULL,
source_method TEXT NOT NULL DEFAULT 'api',
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'opened',
UNIQUE(project_id, iid)
);
",
)
.unwrap();
}
fn create_mr_detail_schema(conn: &Connection) {
create_issue_detail_schema(conn);
// Add MR-specific columns and tables on top of the base schema.
conn.execute_batch(
"
-- Add columns to merge_requests that the detail query needs.
ALTER TABLE merge_requests ADD COLUMN description TEXT;
ALTER TABLE merge_requests ADD COLUMN draft INTEGER NOT NULL DEFAULT 0;
ALTER TABLE merge_requests ADD COLUMN author_username TEXT;
ALTER TABLE merge_requests ADD COLUMN source_branch TEXT;
ALTER TABLE merge_requests ADD COLUMN target_branch TEXT;
ALTER TABLE merge_requests ADD COLUMN detailed_merge_status TEXT;
ALTER TABLE merge_requests ADD COLUMN created_at INTEGER;
ALTER TABLE merge_requests ADD COLUMN updated_at INTEGER;
ALTER TABLE merge_requests ADD COLUMN merged_at INTEGER;
ALTER TABLE merge_requests ADD COLUMN web_url TEXT;
CREATE TABLE mr_assignees (
merge_request_id INTEGER NOT NULL,
username TEXT NOT NULL,
UNIQUE(merge_request_id, username)
);
CREATE TABLE mr_reviewers (
merge_request_id INTEGER NOT NULL,
username TEXT NOT NULL,
UNIQUE(merge_request_id, username)
);
CREATE TABLE mr_labels (
merge_request_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
UNIQUE(merge_request_id, label_id)
);
CREATE TABLE mr_file_changes (
id INTEGER PRIMARY KEY,
merge_request_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
old_path TEXT,
new_path TEXT NOT NULL,
change_type TEXT NOT NULL
);
",
)
.unwrap();
}
fn setup_mr_detail_data(conn: &Connection) {
// Project (if not already inserted).
conn.execute(
"INSERT OR IGNORE INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
[],
)
.unwrap();
// MR.
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, description, state, draft, author_username, source_branch, target_branch, detailed_merge_status, created_at, updated_at, merged_at, web_url)
VALUES (1, 2000, 1, 10, 'Fix auth flow', 'MR description', 'opened', 0, 'alice', 'fix-auth', 'main', 'mergeable', 1700000000000, 1700000060000, NULL, 'https://gitlab.com/group/project/-/merge_requests/10')",
[],
)
.unwrap();
// Assignees.
conn.execute(
"INSERT INTO mr_assignees (merge_request_id, username) VALUES (1, 'bob')",
[],
)
.unwrap();
// Reviewers.
conn.execute(
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (1, 'carol')",
[],
)
.unwrap();
// Labels.
conn.execute(
"INSERT OR IGNORE INTO labels (id, project_id, name) VALUES (10, 1, 'backend')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO mr_labels (merge_request_id, label_id) VALUES (1, 10)",
[],
)
.unwrap();
// File changes.
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
VALUES (1, 1, NULL, 'src/auth.rs', 'modified')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
VALUES (1, 1, NULL, 'src/lib.rs', 'added')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
VALUES (1, 1, 'src/old.rs', 'src/new.rs', 'renamed')",
[],
)
.unwrap();
// Discussion with a note.
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, resolvable, resolved, first_note_at)
VALUES (1, 'mr_disc_1', 1, 1, 'MergeRequest', 1, 0, 1700000010000)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, position, position_new_path, position_new_line)
VALUES (1, 5001, 1, 1, 'DiffNote', 0, 'alice', 'Please fix this', 1700000010000, 1700000010000, 0, 'src/auth.rs', 42)",
[],
)
.unwrap();
// Cross-reference (MR closes issue).
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at)
VALUES (1, 1000, 1, 5, 'Auth bug', 'opened', 0, 0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method)
VALUES (1, 'merge_request', 1, 'issue', 1, 'group/project', 5, 'closes', 'api')",
[],
)
.unwrap();
}
#[test]
fn test_fetch_mr_detail_basic_metadata() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
setup_mr_detail_data(&conn);
let key = EntityKey::mr(1, 10);
let data = fetch_mr_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.iid, 10);
assert_eq!(data.metadata.title, "Fix auth flow");
assert_eq!(data.metadata.description, "MR description");
assert_eq!(data.metadata.state, "opened");
assert!(!data.metadata.draft);
assert_eq!(data.metadata.author, "alice");
assert_eq!(data.metadata.source_branch, "fix-auth");
assert_eq!(data.metadata.target_branch, "main");
assert_eq!(data.metadata.merge_status, "mergeable");
assert!(data.metadata.merged_at.is_none());
assert_eq!(
data.metadata.web_url,
"https://gitlab.com/group/project/-/merge_requests/10"
);
}
#[test]
fn test_fetch_mr_detail_assignees_reviewers_labels() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
setup_mr_detail_data(&conn);
let key = EntityKey::mr(1, 10);
let data = fetch_mr_detail(&conn, &key).unwrap();
assert_eq!(data.metadata.assignees, vec!["bob"]);
assert_eq!(data.metadata.reviewers, vec!["carol"]);
assert_eq!(data.metadata.labels, vec!["backend"]);
}
#[test]
fn test_fetch_mr_detail_file_changes() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
setup_mr_detail_data(&conn);
let key = EntityKey::mr(1, 10);
let data = fetch_mr_detail(&conn, &key).unwrap();
assert_eq!(data.file_changes.len(), 3);
assert_eq!(data.metadata.file_change_count, 3);
// Ordered by new_path.
assert_eq!(data.file_changes[0].new_path, "src/auth.rs");
assert_eq!(data.file_changes[0].change_type, FileChangeType::Modified);
assert_eq!(data.file_changes[1].new_path, "src/lib.rs");
assert_eq!(data.file_changes[1].change_type, FileChangeType::Added);
assert_eq!(data.file_changes[2].new_path, "src/new.rs");
assert_eq!(data.file_changes[2].change_type, FileChangeType::Renamed);
assert_eq!(data.file_changes[2].old_path.as_deref(), Some("src/old.rs"));
}
#[test]
fn test_fetch_mr_detail_cross_refs() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
setup_mr_detail_data(&conn);
let key = EntityKey::mr(1, 10);
let data = fetch_mr_detail(&conn, &key).unwrap();
assert_eq!(data.cross_refs.len(), 1);
assert_eq!(data.cross_refs[0].kind, CrossRefKind::ClosingMr);
assert_eq!(data.cross_refs[0].label, "Auth bug");
}
#[test]
fn test_fetch_mr_discussions() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
setup_mr_detail_data(&conn);
let key = EntityKey::mr(1, 10);
let discussions = fetch_mr_discussions(&conn, &key).unwrap();
assert_eq!(discussions.len(), 1);
assert_eq!(discussions[0].discussion_id, "mr_disc_1");
assert!(discussions[0].resolvable);
assert!(!discussions[0].resolved);
assert_eq!(discussions[0].notes.len(), 1);
assert_eq!(discussions[0].notes[0].author, "alice");
assert_eq!(discussions[0].notes[0].body, "Please fix this");
assert!(discussions[0].notes[0].is_diff_note);
assert_eq!(
discussions[0].notes[0].diff_file_path.as_deref(),
Some("src/auth.rs")
);
assert_eq!(discussions[0].notes[0].diff_new_line, Some(42));
}
#[test]
fn test_fetch_mr_detail_not_found() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
// Insert project but no MR.
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'g/p')",
[],
)
.unwrap();
let key = EntityKey::mr(1, 99);
assert!(fetch_mr_detail(&conn, &key).is_err());
}
#[test]
fn test_fetch_mr_detail_no_file_changes() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'g/p')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, web_url)
VALUES (1, 2000, 1, 10, 'Empty MR', 'opened', 0, 0, '')",
[],
)
.unwrap();
let key = EntityKey::mr(1, 10);
let data = fetch_mr_detail(&conn, &key).unwrap();
assert!(data.file_changes.is_empty());
assert_eq!(data.metadata.file_change_count, 0);
}
#[test]
fn test_fetch_mr_detail_draft() {
let conn = Connection::open_in_memory().unwrap();
create_mr_detail_schema(&conn);
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'g/p')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, draft, created_at, updated_at, web_url)
VALUES (1, 2000, 1, 10, 'Draft: WIP', 'opened', 1, 0, 0, '')",
[],
)
.unwrap();
let key = EntityKey::mr(1, 10);
let data = fetch_mr_detail(&conn, &key).unwrap();
assert!(data.metadata.draft);
}
}

View File

@@ -0,0 +1,629 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::state::mr_list::{MrCursor, MrFilter, MrListPage, MrListRow, MrSortField, MrSortOrder};
/// Page size for MR list queries.
const MR_PAGE_SIZE: usize = 50;
/// Fetch a page of merge requests matching the given filter and sort.
///
/// Uses keyset pagination and snapshot fence — same pattern as issues.
pub fn fetch_mr_list(
conn: &Connection,
filter: &MrFilter,
sort_field: MrSortField,
sort_order: MrSortOrder,
cursor: Option<&MrCursor>,
snapshot_fence: Option<i64>,
) -> Result<MrListPage> {
// -- Build dynamic WHERE conditions and params --------------------------
let mut conditions: Vec<String> = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(pid) = filter.project_id {
conditions.push("m.project_id = ?".into());
params.push(Box::new(pid));
}
if let Some(ref state) = filter.state {
conditions.push("m.state = ?".into());
params.push(Box::new(state.clone()));
}
if let Some(ref author) = filter.author {
conditions.push("m.author_username = ?".into());
params.push(Box::new(author.clone()));
}
if let Some(draft) = filter.draft {
conditions.push("m.draft = ?".into());
params.push(Box::new(i64::from(draft)));
}
if let Some(ref target) = filter.target_branch {
conditions.push("m.target_branch = ?".into());
params.push(Box::new(target.clone()));
}
if let Some(ref source) = filter.source_branch {
conditions.push("m.source_branch = ?".into());
params.push(Box::new(source.clone()));
}
// Filter: reviewer (via join on mr_reviewers)
let reviewer_join = if let Some(ref reviewer) = filter.reviewer {
conditions.push("rv.username = ?".into());
params.push(Box::new(reviewer.clone()));
"JOIN mr_reviewers rv ON rv.merge_request_id = m.id"
} else {
""
};
// Filter: label (via join on mr_labels + labels)
let label_join = if let Some(ref label) = filter.label {
conditions.push("fl.name = ?".into());
params.push(Box::new(label.clone()));
"JOIN mr_labels fil ON fil.merge_request_id = m.id \
JOIN labels fl ON fl.id = fil.label_id"
} else {
""
};
// Filter: free_text (LIKE on title)
if let Some(ref text) = filter.free_text {
conditions.push("m.title LIKE ?".into());
params.push(Box::new(format!("%{text}%")));
}
// Snapshot fence
if let Some(fence) = snapshot_fence {
conditions.push("m.updated_at <= ?".into());
params.push(Box::new(fence));
}
// -- Count query (before cursor filter) ---------------------------------
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let count_sql = format!(
"SELECT COUNT(DISTINCT m.id) FROM merge_requests m \
JOIN projects p ON p.id = m.project_id \
{reviewer_join} {label_join} {where_clause}"
);
let count_params: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|b| b.as_ref()).collect();
let total_count: i64 = conn
.query_row(&count_sql, count_params.as_slice(), |r| r.get(0))
.context("counting MRs for list")?;
// -- Keyset cursor condition -------------------------------------------
let (sort_col, sort_dir) = mr_sort_column_and_dir(sort_field, sort_order);
let cursor_op = if sort_dir == "DESC" { "<" } else { ">" };
if let Some(c) = cursor {
conditions.push(format!("({sort_col}, m.iid) {cursor_op} (?, ?)"));
params.push(Box::new(c.updated_at));
params.push(Box::new(c.iid));
}
// -- Data query ---------------------------------------------------------
let where_clause_full = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
let data_sql = format!(
"SELECT p.path_with_namespace, m.iid, m.title, m.state, \
m.author_username, m.target_branch, m.updated_at, m.draft, \
GROUP_CONCAT(DISTINCT l.name) AS label_names \
FROM merge_requests m \
JOIN projects p ON p.id = m.project_id \
{reviewer_join} \
{label_join} \
LEFT JOIN mr_labels ml ON ml.merge_request_id = m.id \
LEFT JOIN labels l ON l.id = ml.label_id \
{where_clause_full} \
GROUP BY m.id \
ORDER BY {sort_col} {sort_dir}, m.iid {sort_dir} \
LIMIT ?"
);
let fetch_limit = (MR_PAGE_SIZE + 1) as i64;
params.push(Box::new(fetch_limit));
let all_params: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn.prepare(&data_sql).context("preparing MR list query")?;
let rows_result = stmt
.query_map(all_params.as_slice(), |row| {
let project_path: String = row.get(0)?;
let iid: i64 = row.get(1)?;
let title: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let state: String = row.get::<_, Option<String>>(3)?.unwrap_or_default();
let author: String = row.get::<_, Option<String>>(4)?.unwrap_or_default();
let target_branch: String = row.get::<_, Option<String>>(5)?.unwrap_or_default();
let updated_at: i64 = row.get(6)?;
let draft_int: i64 = row.get(7)?;
let label_names: Option<String> = row.get(8)?;
let labels = label_names
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
Ok(MrListRow {
project_path,
iid,
title,
state,
author,
target_branch,
labels,
updated_at,
draft: draft_int != 0,
})
})
.context("querying MR list")?;
let mut rows: Vec<MrListRow> = Vec::new();
for row in rows_result {
rows.push(row.context("reading MR list row")?);
}
let has_next = rows.len() > MR_PAGE_SIZE;
if has_next {
rows.truncate(MR_PAGE_SIZE);
}
let next_cursor = if has_next {
rows.last().map(|r| MrCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
#[allow(clippy::cast_sign_loss)]
Ok(MrListPage {
rows,
next_cursor,
total_count: total_count as u64,
})
}
/// Map MR sort field + order to SQL column name and direction keyword.
fn mr_sort_column_and_dir(field: MrSortField, order: MrSortOrder) -> (&'static str, &'static str) {
let col = match field {
MrSortField::UpdatedAt => "m.updated_at",
MrSortField::Iid => "m.iid",
MrSortField::Title => "m.title",
MrSortField::State => "m.state",
MrSortField::Author => "m.author_username",
MrSortField::TargetBranch => "m.target_branch",
};
let dir = match order {
MrSortOrder::Desc => "DESC",
MrSortOrder::Asc => "ASC",
};
(col, dir)
}
#[cfg(test)]
mod tests {
use super::*;
/// Create the schema needed for MR list tests.
fn create_mr_list_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
created_at INTEGER,
updated_at INTEGER,
last_seen_at INTEGER NOT NULL,
draft INTEGER NOT NULL DEFAULT 0,
target_branch TEXT,
source_branch TEXT
);
CREATE TABLE labels (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
description TEXT
);
CREATE TABLE mr_labels (
merge_request_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
PRIMARY KEY(merge_request_id, label_id)
);
CREATE TABLE mr_reviewers (
merge_request_id INTEGER NOT NULL,
username TEXT NOT NULL,
PRIMARY KEY(merge_request_id, username)
);
",
)
.expect("create MR list schema");
}
/// Insert a test MR with full fields.
fn insert_mr_full(
conn: &Connection,
iid: i64,
state: &str,
author: &str,
target_branch: &str,
draft: bool,
updated_at: i64,
) {
conn.execute(
"INSERT INTO merge_requests \
(gitlab_id, project_id, iid, title, state, author_username, \
target_branch, draft, created_at, updated_at, last_seen_at) \
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?8)",
rusqlite::params![
iid * 100 + 50,
iid,
format!("MR {iid}"),
state,
author,
target_branch,
i64::from(draft),
updated_at,
],
)
.expect("insert mr full");
}
/// Attach a label to an MR.
fn attach_mr_label(conn: &Connection, mr_iid: i64, label_name: &str) {
let mr_id: i64 = conn
.query_row(
"SELECT id FROM merge_requests WHERE iid = ?",
[mr_iid],
|r| r.get(0),
)
.expect("find mr");
conn.execute(
"INSERT OR IGNORE INTO labels (project_id, name) VALUES (1, ?)",
[label_name],
)
.expect("insert label");
let label_id: i64 = conn
.query_row("SELECT id FROM labels WHERE name = ?", [label_name], |r| {
r.get(0)
})
.expect("find label");
conn.execute(
"INSERT INTO mr_labels (merge_request_id, label_id) VALUES (?, ?)",
[mr_id, label_id],
)
.expect("attach mr label");
}
/// Add a reviewer to an MR.
fn add_mr_reviewer(conn: &Connection, mr_iid: i64, username: &str) {
let mr_id: i64 = conn
.query_row(
"SELECT id FROM merge_requests WHERE iid = ?",
[mr_iid],
|r| r.get(0),
)
.expect("find mr");
conn.execute(
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?, ?)",
rusqlite::params![mr_id, username],
)
.expect("add mr reviewer");
}
fn setup_mr_list_data(conn: &Connection) {
let base = 1_700_000_000_000_i64;
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace) VALUES (1, 'group/project')",
[],
)
.unwrap();
insert_mr_full(conn, 1, "opened", "alice", "main", false, base - 10_000);
insert_mr_full(conn, 2, "opened", "bob", "main", true, base - 20_000);
insert_mr_full(conn, 3, "merged", "alice", "develop", false, base - 30_000);
insert_mr_full(conn, 4, "opened", "charlie", "main", true, base - 40_000);
insert_mr_full(conn, 5, "closed", "bob", "release", false, base - 50_000);
attach_mr_label(conn, 1, "backend");
attach_mr_label(conn, 1, "urgent");
attach_mr_label(conn, 2, "frontend");
attach_mr_label(conn, 4, "backend");
add_mr_reviewer(conn, 1, "diana");
add_mr_reviewer(conn, 2, "diana");
add_mr_reviewer(conn, 3, "edward");
}
#[test]
fn test_fetch_mr_list_basic() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter::default();
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 5);
assert_eq!(page.rows.len(), 5);
assert_eq!(page.rows[0].iid, 1); // newest first
assert_eq!(page.rows[4].iid, 5);
assert!(page.next_cursor.is_none());
}
#[test]
fn test_fetch_mr_list_filter_state() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter {
state: Some("opened".into()),
..Default::default()
};
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 3);
assert!(page.rows.iter().all(|r| r.state == "opened"));
}
#[test]
fn test_fetch_mr_list_filter_draft() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter {
draft: Some(true),
..Default::default()
};
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 2); // MRs 2 and 4
assert!(page.rows.iter().all(|r| r.draft));
}
#[test]
fn test_fetch_mr_list_filter_target_branch() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter {
target_branch: Some("main".into()),
..Default::default()
};
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 3); // MRs 1, 2, 4
assert!(page.rows.iter().all(|r| r.target_branch == "main"));
}
#[test]
fn test_fetch_mr_list_filter_reviewer() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter {
reviewer: Some("diana".into()),
..Default::default()
};
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 2); // MRs 1 and 2
}
#[test]
fn test_fetch_mr_list_filter_label() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter {
label: Some("backend".into()),
..Default::default()
};
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 2); // MRs 1 and 4
}
#[test]
fn test_fetch_mr_list_labels_aggregated() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter::default();
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
let mr1 = page.rows.iter().find(|r| r.iid == 1).unwrap();
assert_eq!(mr1.labels.len(), 2);
assert!(mr1.labels.contains(&"backend".to_string()));
assert!(mr1.labels.contains(&"urgent".to_string()));
let mr5 = page.rows.iter().find(|r| r.iid == 5).unwrap();
assert!(mr5.labels.is_empty());
}
#[test]
fn test_fetch_mr_list_sort_ascending() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter::default();
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Asc,
None,
None,
)
.unwrap();
assert_eq!(page.rows[0].iid, 5); // oldest first
assert_eq!(page.rows[4].iid, 1);
}
#[test]
fn test_fetch_mr_list_snapshot_fence() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let base = 1_700_000_000_000_i64;
let fence = base - 25_000;
let filter = MrFilter::default();
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
Some(fence),
)
.unwrap();
assert_eq!(page.total_count, 3);
assert!(page.rows.iter().all(|r| r.updated_at <= fence));
}
#[test]
fn test_fetch_mr_list_empty() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
conn.execute(
"INSERT INTO projects (gitlab_project_id, path_with_namespace) VALUES (1, 'g/p')",
[],
)
.unwrap();
let page = fetch_mr_list(
&conn,
&MrFilter::default(),
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 0);
assert!(page.rows.is_empty());
assert!(page.next_cursor.is_none());
}
#[test]
fn test_fetch_mr_list_free_text() {
let conn = Connection::open_in_memory().unwrap();
create_mr_list_schema(&conn);
setup_mr_list_data(&conn);
let filter = MrFilter {
free_text: Some("MR 3".into()),
..Default::default()
};
let page = fetch_mr_list(
&conn,
&filter,
MrSortField::UpdatedAt,
MrSortOrder::Desc,
None,
None,
)
.unwrap();
assert_eq!(page.total_count, 1);
assert_eq!(page.rows[0].iid, 3);
}
}

View File

@@ -0,0 +1,361 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::message::{EntityKey, EntityKind, SearchMode, SearchResult};
use crate::state::search::SearchCapabilities;
/// Probe the database to detect available search indexes.
///
/// Checks for FTS5 documents and embedding metadata. Returns capabilities
/// that the UI uses to gate available search modes.
pub fn fetch_search_capabilities(conn: &Connection) -> Result<SearchCapabilities> {
// FTS: check if documents_fts has rows via the docsize shadow table
// (B-tree, not virtual table scan).
let has_fts = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM documents_fts_docsize LIMIT 1)",
[],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false);
// Embeddings: count rows in embedding_metadata.
let embedding_count: i64 = conn
.query_row("SELECT COUNT(*) FROM embedding_metadata", [], |r| r.get(0))
.unwrap_or(0);
let has_embeddings = embedding_count > 0;
// Coverage: embeddings / documents percentage.
let doc_count: i64 = conn
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
.unwrap_or(0);
let embedding_coverage_pct = if doc_count > 0 {
(embedding_count as f32 / doc_count as f32 * 100.0).min(100.0)
} else {
0.0
};
Ok(SearchCapabilities {
has_fts,
has_embeddings,
embedding_coverage_pct,
})
}
/// Execute a search query against the local database.
///
/// Dispatches to the correct search backend based on mode:
/// - Lexical: FTS5 only (documents_fts)
/// - Hybrid: FTS5 + vector merge via RRF
/// - Semantic: vector cosine similarity only
///
/// Returns results sorted by score descending.
pub fn execute_search(
conn: &Connection,
query: &str,
mode: SearchMode,
limit: usize,
) -> Result<Vec<SearchResult>> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
match mode {
SearchMode::Lexical => execute_fts_search(conn, query, limit),
SearchMode::Hybrid | SearchMode::Semantic => {
// Hybrid and Semantic require the full search pipeline from the
// core crate (async, Ollama client). For now, fall back to FTS
// for Hybrid and return empty for Semantic-only.
// TODO: Wire up async search dispatch when core search is integrated.
if mode == SearchMode::Hybrid {
execute_fts_search(conn, query, limit)
} else {
Ok(Vec::new())
}
}
}
}
/// FTS5 full-text search against the documents table.
fn execute_fts_search(conn: &Connection, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
// Sanitize the query for FTS5 (escape special chars, wrap terms in quotes).
let safe_query = sanitize_fts_query(query);
if safe_query.is_empty() {
return Ok(Vec::new());
}
// Resolve project_path via JOIN through projects table.
// Resolve iid via JOIN through the source entity table (issues or merge_requests).
// snippet column 1 = content_text (column 0 is title).
let mut stmt = conn
.prepare(
"SELECT d.source_type, d.source_id, d.title, d.project_id,
p.path_with_namespace,
snippet(documents_fts, 1, '>>>', '<<<', '...', 32) AS snip,
bm25(documents_fts) AS score,
COALESCE(i.iid, mr.iid) AS entity_iid
FROM documents_fts
JOIN documents d ON documents_fts.rowid = d.id
JOIN projects p ON p.id = d.project_id
LEFT JOIN issues i ON d.source_type = 'issue' AND i.id = d.source_id
LEFT JOIN merge_requests mr ON d.source_type = 'merge_request' AND mr.id = d.source_id
WHERE documents_fts MATCH ?1
ORDER BY score
LIMIT ?2",
)
.context("preparing FTS search query")?;
let rows = stmt
.query_map(rusqlite::params![safe_query, limit as i64], |row| {
let source_type: String = row.get(0)?;
let _source_id: i64 = row.get(1)?;
let title: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
let project_id: i64 = row.get(3)?;
let project_path: String = row.get::<_, Option<String>>(4)?.unwrap_or_default();
let snippet: String = row.get::<_, Option<String>>(5)?.unwrap_or_default();
let score: f64 = row.get(6)?;
let entity_iid: Option<i64> = row.get(7)?;
Ok((
source_type,
project_id,
title,
project_path,
snippet,
score,
entity_iid,
))
})
.context("executing FTS search")?;
let mut results = Vec::new();
for row in rows {
let (source_type, project_id, title, project_path, snippet, score, entity_iid) =
row.context("reading FTS search row")?;
let kind = match source_type.as_str() {
"issue" => EntityKind::Issue,
"merge_request" | "mr" => EntityKind::MergeRequest,
_ => continue, // Skip unknown source types (discussion, note).
};
// Skip if we couldn't resolve the entity's iid (orphaned document).
let Some(iid) = entity_iid else {
continue;
};
let key = EntityKey {
project_id,
iid,
kind,
};
results.push(SearchResult {
key,
title,
score: score.abs(), // bm25 returns negative scores; lower = better.
snippet,
project_path,
});
}
Ok(results)
}
/// Sanitize a user query for FTS5 MATCH syntax.
///
/// Wraps individual terms in double quotes to prevent FTS5 syntax errors
/// from user-typed operators (AND, OR, NOT, *, etc.).
fn sanitize_fts_query(query: &str) -> String {
query
.split_whitespace()
.map(|term| {
// Strip any existing quotes and re-wrap.
let clean = term.replace('"', "");
if clean.is_empty() {
String::new()
} else {
format!("\"{clean}\"")
}
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
/// Create the minimal schema needed for search queries.
fn create_dashboard_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
created_at INTEGER,
updated_at INTEGER,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
noteable_type TEXT NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
source_type TEXT NOT NULL,
source_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
content_text TEXT NOT NULL,
content_hash TEXT NOT NULL
);
CREATE TABLE embedding_metadata (
document_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL,
dims INTEGER NOT NULL,
document_hash TEXT NOT NULL,
chunk_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY(document_id, chunk_index)
);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY,
started_at INTEGER NOT NULL,
heartbeat_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
command TEXT NOT NULL,
error TEXT
);
",
)
.expect("create dashboard schema");
}
#[test]
fn test_sanitize_fts_query_wraps_terms() {
let result = sanitize_fts_query("hello world");
assert_eq!(result, r#""hello" "world""#);
}
#[test]
fn test_sanitize_fts_query_strips_quotes() {
let result = sanitize_fts_query(r#""hello" "world""#);
assert_eq!(result, r#""hello" "world""#);
}
#[test]
fn test_sanitize_fts_query_empty() {
assert_eq!(sanitize_fts_query(""), "");
assert_eq!(sanitize_fts_query(" "), "");
}
#[test]
fn test_sanitize_fts_query_special_chars() {
// FTS5 operators should be safely wrapped in quotes.
let result = sanitize_fts_query("NOT AND OR");
assert_eq!(result, r#""NOT" "AND" "OR""#);
}
#[test]
fn test_fetch_search_capabilities_no_tables() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
let caps = fetch_search_capabilities(&conn).unwrap();
assert!(!caps.has_fts);
assert!(!caps.has_embeddings);
assert!(!caps.has_any_index());
}
#[test]
fn test_fetch_search_capabilities_with_fts() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
// Create FTS table and its shadow table.
conn.execute_batch(
"CREATE VIRTUAL TABLE documents_fts USING fts5(content);
INSERT INTO documents_fts(content) VALUES ('test document');",
)
.unwrap();
let caps = fetch_search_capabilities(&conn).unwrap();
assert!(caps.has_fts);
assert!(!caps.has_embeddings);
}
#[test]
fn test_fetch_search_capabilities_with_embeddings() {
let conn = Connection::open_in_memory().unwrap();
create_dashboard_schema(&conn);
// Insert a document so coverage calculation works.
conn.execute_batch(
"INSERT INTO documents(id, source_type, source_id, project_id, content_text, content_hash)
VALUES (1, 'issue', 1, 1, 'body text', 'abc');
INSERT INTO embedding_metadata(document_id, chunk_index, model, dims, document_hash, chunk_hash, created_at)
VALUES (1, 0, 'test', 384, 'abc', 'def', 1700000000);",
)
.unwrap();
let caps = fetch_search_capabilities(&conn).unwrap();
assert!(!caps.has_fts);
assert!(caps.has_embeddings);
assert!(caps.embedding_coverage_pct > 0.0);
}
#[test]
fn test_execute_search_empty_query_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
let results = execute_search(&conn, "", SearchMode::Lexical, 10).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_execute_search_whitespace_only_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
let results = execute_search(&conn, " ", SearchMode::Lexical, 10).unwrap();
assert!(results.is_empty());
}
}

View File

@@ -0,0 +1,668 @@
#![allow(dead_code)]
//! Sync screen actions — query sync run history and detect running syncs.
//!
//! With cron-driven syncs as the primary mechanism, the TUI's sync screen
//! acts as a status dashboard. These pure query functions read `sync_runs`
//! and `projects` to populate the screen.
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::clock::Clock;
/// How many recent runs to display in the sync history.
const HISTORY_LIMIT: usize = 10;
/// If a "running" sync hasn't heartbeated in this many milliseconds,
/// consider it stale (likely crashed).
const STALE_HEARTBEAT_MS: i64 = 120_000; // 2 minutes
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
/// Overview data for the sync screen.
#[derive(Debug, Default)]
pub struct SyncOverview {
/// Info about a currently running sync, if any.
pub running: Option<RunningSyncInfo>,
/// Most recent completed (succeeded or failed) run.
pub last_completed: Option<SyncRunInfo>,
/// Recent sync run history (newest first).
pub recent_runs: Vec<SyncRunInfo>,
/// Configured project paths.
pub projects: Vec<String>,
}
/// A sync that is currently in progress.
#[derive(Debug, Clone)]
pub struct RunningSyncInfo {
/// Row ID in sync_runs.
pub id: i64,
/// When this sync started (ms epoch).
pub started_at: i64,
/// Last heartbeat (ms epoch).
pub heartbeat_at: i64,
/// How long it's been running (ms).
pub elapsed_ms: u64,
/// Whether the heartbeat is stale (sync may have crashed).
pub stale: bool,
/// Items processed so far.
pub items_processed: u64,
}
/// Summary of a single sync run.
#[derive(Debug, Clone)]
pub struct SyncRunInfo {
/// Row ID in sync_runs.
pub id: i64,
/// 'succeeded', 'failed', or 'running'.
pub status: String,
/// The command that was run (e.g., 'sync', 'ingest issues').
pub command: String,
/// When this sync started (ms epoch).
pub started_at: i64,
/// When this sync finished (ms epoch), if completed.
pub finished_at: Option<i64>,
/// Duration in ms (computed from started_at/finished_at).
pub duration_ms: Option<u64>,
/// Total items processed.
pub items_processed: u64,
/// Total errors encountered.
pub errors: u64,
/// Error message if the run failed.
pub error: Option<String>,
/// Correlation ID for log matching.
pub run_id: Option<String>,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Fetch the complete sync overview for the sync screen.
///
/// Combines running sync detection, last completed run, recent history,
/// and configured projects into a single struct.
pub fn fetch_sync_overview(conn: &Connection, clock: &dyn Clock) -> Result<SyncOverview> {
let running = detect_running_sync(conn, clock)?;
let recent_runs = fetch_recent_runs(conn, HISTORY_LIMIT)?;
let last_completed = recent_runs
.iter()
.find(|r| r.status == "succeeded" || r.status == "failed")
.cloned();
let projects = fetch_configured_projects(conn)?;
Ok(SyncOverview {
running,
last_completed,
recent_runs,
projects,
})
}
/// Detect a currently running sync from the `sync_runs` table.
///
/// A sync is considered "running" if `status = 'running'`. It's marked
/// stale if the heartbeat is older than [`STALE_HEARTBEAT_MS`].
pub fn detect_running_sync(
conn: &Connection,
clock: &dyn Clock,
) -> Result<Option<RunningSyncInfo>> {
let result = conn.query_row(
"SELECT id, started_at, heartbeat_at, total_items_processed
FROM sync_runs
WHERE status = 'running'
ORDER BY id DESC
LIMIT 1",
[],
|row| {
let id: i64 = row.get(0)?;
let started_at: i64 = row.get(1)?;
let heartbeat_at: i64 = row.get(2)?;
let items: Option<i64> = row.get(3)?;
Ok((id, started_at, heartbeat_at, items.unwrap_or(0)))
},
);
match result {
Ok((id, started_at, heartbeat_at, items)) => {
let now = clock.now_ms();
let elapsed_ms = now.saturating_sub(started_at);
let stale = (now - heartbeat_at) > STALE_HEARTBEAT_MS;
#[allow(clippy::cast_sign_loss)]
Ok(Some(RunningSyncInfo {
id,
started_at,
heartbeat_at,
elapsed_ms: elapsed_ms as u64,
stale,
items_processed: items as u64,
}))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e).context("detecting running sync"),
}
}
/// Fetch recent sync runs (newest first).
pub fn fetch_recent_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
let mut stmt = conn
.prepare(
"SELECT id, status, command, started_at, finished_at,
total_items_processed, total_errors, error, run_id
FROM sync_runs
ORDER BY id DESC
LIMIT ?1",
)
.context("preparing sync runs query")?;
let rows = stmt
.query_map([limit as i64], |row| {
let id: i64 = row.get(0)?;
let status: String = row.get(1)?;
let command: String = row.get(2)?;
let started_at: i64 = row.get(3)?;
let finished_at: Option<i64> = row.get(4)?;
let items: Option<i64> = row.get(5)?;
let errors: Option<i64> = row.get(6)?;
let error: Option<String> = row.get(7)?;
let run_id: Option<String> = row.get(8)?;
Ok((
id,
status,
command,
started_at,
finished_at,
items,
errors,
error,
run_id,
))
})
.context("querying sync runs")?;
let mut result = Vec::new();
for row in rows {
let (id, status, command, started_at, finished_at, items, errors, error, run_id) =
row.context("reading sync run row")?;
#[allow(clippy::cast_sign_loss)]
let duration_ms = finished_at.map(|f| (f - started_at) as u64);
#[allow(clippy::cast_sign_loss)]
result.push(SyncRunInfo {
id,
status,
command,
started_at,
finished_at,
duration_ms,
items_processed: items.unwrap_or(0) as u64,
errors: errors.unwrap_or(0) as u64,
error,
run_id,
});
}
Ok(result)
}
/// Fetch configured project paths from the `projects` table.
pub fn fetch_configured_projects(conn: &Connection) -> Result<Vec<String>> {
let mut stmt = conn
.prepare("SELECT path_with_namespace FROM projects ORDER BY path_with_namespace")
.context("preparing projects query")?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.context("querying projects")?;
let mut result = Vec::new();
for row in rows {
result.push(row.context("reading project row")?);
}
Ok(result)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
/// Create the minimal schema needed for sync queries.
fn create_sync_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY,
started_at INTEGER NOT NULL,
heartbeat_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
command TEXT NOT NULL,
error TEXT,
metrics_json TEXT,
run_id TEXT,
total_items_processed INTEGER DEFAULT 0,
total_errors INTEGER DEFAULT 0
);
",
)
.expect("create sync schema");
}
fn insert_project(conn: &Connection, id: i64, path: &str) {
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace)
VALUES (?1, ?2, ?3)",
rusqlite::params![id, id * 100, path],
)
.expect("insert project");
}
#[allow(clippy::too_many_arguments)]
fn insert_sync_run(
conn: &Connection,
started_at: i64,
finished_at: Option<i64>,
status: &str,
command: &str,
items: i64,
errors: i64,
error: Option<&str>,
) -> i64 {
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, finished_at, status, command,
total_items_processed, total_errors, error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
started_at,
finished_at.unwrap_or(started_at),
finished_at,
status,
command,
items,
errors,
error,
],
)
.expect("insert sync run");
conn.last_insert_rowid()
}
// -----------------------------------------------------------------------
// detect_running_sync
// -----------------------------------------------------------------------
#[test]
fn test_detect_running_sync_none_when_empty() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let clock = FakeClock::from_ms(1_700_000_000_000);
let result = detect_running_sync(&conn, &clock).unwrap();
assert!(result.is_none());
}
#[test]
fn test_detect_running_sync_none_when_all_completed() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_sync_run(
&conn,
now - 60_000,
Some(now - 30_000),
"succeeded",
"sync",
100,
0,
None,
);
insert_sync_run(
&conn,
now - 120_000,
Some(now - 90_000),
"failed",
"sync",
50,
2,
Some("timeout"),
);
let clock = FakeClock::from_ms(now);
let result = detect_running_sync(&conn, &clock).unwrap();
assert!(result.is_none());
}
#[test]
fn test_detect_running_sync_found() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
let started = now - 30_000; // 30 seconds ago
// Heartbeat at started_at (fresh since we just set it)
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, total_items_processed)
VALUES (?1, ?2, 'running', 'sync', 42)",
[started, now - 5_000], // heartbeat 5 seconds ago
)
.unwrap();
let clock = FakeClock::from_ms(now);
let running = detect_running_sync(&conn, &clock).unwrap().unwrap();
assert_eq!(running.elapsed_ms, 30_000);
assert_eq!(running.items_processed, 42);
assert!(!running.stale);
}
#[test]
fn test_detect_running_sync_stale_heartbeat() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
let started = now - 300_000; // 5 minutes ago
// Heartbeat 3 minutes ago — stale
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)
VALUES (?1, ?2, 'running', 'sync')",
[started, now - 180_000],
)
.unwrap();
let clock = FakeClock::from_ms(now);
let running = detect_running_sync(&conn, &clock).unwrap().unwrap();
assert!(running.stale);
assert_eq!(running.elapsed_ms, 300_000);
}
// -----------------------------------------------------------------------
// fetch_recent_runs
// -----------------------------------------------------------------------
#[test]
fn test_fetch_recent_runs_empty() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let runs = fetch_recent_runs(&conn, 10).unwrap();
assert!(runs.is_empty());
}
#[test]
fn test_fetch_recent_runs_ordered_newest_first() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_sync_run(
&conn,
now - 120_000,
Some(now - 90_000),
"succeeded",
"sync",
100,
0,
None,
);
insert_sync_run(
&conn,
now - 60_000,
Some(now - 30_000),
"succeeded",
"sync",
200,
0,
None,
);
let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs.len(), 2);
// Newest first (higher id)
assert_eq!(runs[0].items_processed, 200);
assert_eq!(runs[1].items_processed, 100);
}
#[test]
fn test_fetch_recent_runs_respects_limit() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
for i in 0..5 {
insert_sync_run(
&conn,
now - (5 - i) * 60_000,
Some(now - (5 - i) * 60_000 + 30_000),
"succeeded",
"sync",
i * 10,
0,
None,
);
}
let runs = fetch_recent_runs(&conn, 3).unwrap();
assert_eq!(runs.len(), 3);
}
#[test]
fn test_fetch_recent_runs_duration_computed() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_sync_run(
&conn,
now - 60_000,
Some(now - 15_000),
"succeeded",
"sync",
0,
0,
None,
);
let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs[0].duration_ms, Some(45_000));
}
#[test]
fn test_fetch_recent_runs_running_no_duration() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_sync_run(&conn, now - 60_000, None, "running", "sync", 0, 0, None);
let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs[0].status, "running");
assert!(runs[0].duration_ms.is_none());
}
#[test]
fn test_fetch_recent_runs_failed_with_error() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_sync_run(
&conn,
now - 60_000,
Some(now - 30_000),
"failed",
"sync",
50,
3,
Some("network timeout"),
);
let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs[0].status, "failed");
assert_eq!(runs[0].errors, 3);
assert_eq!(runs[0].error.as_deref(), Some("network timeout"));
}
// -----------------------------------------------------------------------
// fetch_configured_projects
// -----------------------------------------------------------------------
#[test]
fn test_fetch_configured_projects_empty() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let projects = fetch_configured_projects(&conn).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_fetch_configured_projects_sorted() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
insert_project(&conn, 1, "group/beta");
insert_project(&conn, 2, "group/alpha");
insert_project(&conn, 3, "other/gamma");
let projects = fetch_configured_projects(&conn).unwrap();
assert_eq!(projects, vec!["group/alpha", "group/beta", "other/gamma"]);
}
// -----------------------------------------------------------------------
// fetch_sync_overview (integration)
// -----------------------------------------------------------------------
#[test]
fn test_fetch_sync_overview_empty_db() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let clock = FakeClock::from_ms(1_700_000_000_000);
let overview = fetch_sync_overview(&conn, &clock).unwrap();
assert!(overview.running.is_none());
assert!(overview.last_completed.is_none());
assert!(overview.recent_runs.is_empty());
assert!(overview.projects.is_empty());
}
#[test]
fn test_fetch_sync_overview_with_history() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_project(&conn, 1, "group/repo");
insert_sync_run(
&conn,
now - 120_000,
Some(now - 90_000),
"succeeded",
"sync",
150,
0,
None,
);
insert_sync_run(
&conn,
now - 60_000,
Some(now - 30_000),
"failed",
"sync",
50,
2,
Some("db locked"),
);
let clock = FakeClock::from_ms(now);
let overview = fetch_sync_overview(&conn, &clock).unwrap();
assert!(overview.running.is_none());
assert_eq!(overview.recent_runs.len(), 2);
assert_eq!(overview.projects, vec!["group/repo"]);
// last_completed should be the newest completed run (failed, id=2)
let last = overview.last_completed.unwrap();
assert_eq!(last.status, "failed");
assert_eq!(last.errors, 2);
}
#[test]
fn test_fetch_sync_overview_with_running_sync() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_project(&conn, 1, "group/repo");
// A completed run.
insert_sync_run(
&conn,
now - 600_000,
Some(now - 570_000),
"succeeded",
"sync",
200,
0,
None,
);
// A currently running sync.
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, total_items_processed)
VALUES (?1, ?2, 'running', 'sync', 75)",
[now - 20_000, now - 2_000],
)
.unwrap();
let clock = FakeClock::from_ms(now);
let overview = fetch_sync_overview(&conn, &clock).unwrap();
assert!(overview.running.is_some());
let running = overview.running.unwrap();
assert_eq!(running.elapsed_ms, 20_000);
assert_eq!(running.items_processed, 75);
assert!(!running.stale);
// last_completed should find the succeeded run, not the running one.
let last = overview.last_completed.unwrap();
assert_eq!(last.status, "succeeded");
assert_eq!(last.items_processed, 200);
}
#[test]
fn test_sync_run_info_with_run_id() {
let conn = Connection::open_in_memory().unwrap();
create_sync_schema(&conn);
let now = 1_700_000_000_000_i64;
conn.execute(
"INSERT INTO sync_runs (started_at, heartbeat_at, finished_at, status, command,
total_items_processed, total_errors, run_id)
VALUES (?1, ?1, ?2, 'succeeded', 'sync', 100, 0, 'abc-123')",
[now - 60_000, now - 30_000],
)
.unwrap();
let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs[0].run_id.as_deref(), Some("abc-123"));
}
}

View File

@@ -0,0 +1,871 @@
#![allow(dead_code)]
use anyhow::{Context, Result};
use rusqlite::Connection;
use crate::message::{EntityKey, EntityKind, TimelineEvent, TimelineEventKind};
use crate::state::timeline::TimelineScope;
/// Internal filter resolved from a [`TimelineScope`].
///
/// Translates the user-facing scope (which uses `EntityKey` with project_id + iid)
/// into internal DB ids for efficient querying.
enum TimelineFilter {
/// No filtering — return all events.
All,
/// Filter to events for a specific issue (internal DB id).
Issue(i64),
/// Filter to events for a specific MR (internal DB id).
MergeRequest(i64),
/// Filter to events by a specific actor.
Actor(String),
}
/// Resolve a [`TimelineScope`] into a concrete [`TimelineFilter`].
fn resolve_timeline_scope(conn: &Connection, scope: &TimelineScope) -> Result<TimelineFilter> {
match scope {
TimelineScope::All => Ok(TimelineFilter::All),
TimelineScope::Entity(key) => {
let (table, kind_label) = match key.kind {
EntityKind::Issue => ("issues", "issue"),
EntityKind::MergeRequest => ("merge_requests", "merge request"),
};
let sql = format!("SELECT id FROM {table} WHERE project_id = ?1 AND iid = ?2");
let id: i64 = conn
.query_row(&sql, rusqlite::params![key.project_id, key.iid], |r| {
r.get(0)
})
.with_context(|| {
format!(
"resolving {kind_label} #{} in project {}",
key.iid, key.project_id
)
})?;
match key.kind {
EntityKind::Issue => Ok(TimelineFilter::Issue(id)),
EntityKind::MergeRequest => Ok(TimelineFilter::MergeRequest(id)),
}
}
TimelineScope::Author(name) => Ok(TimelineFilter::Actor(name.clone())),
}
}
/// Fetch timeline events from raw resource event tables.
///
/// Queries `issues`/`merge_requests` for Created events, plus
/// `resource_state_events`, `resource_label_events`, and
/// `resource_milestone_events` for lifecycle events. Results are sorted
/// by timestamp descending (most recent first) and truncated to `limit`.
pub fn fetch_timeline_events(
conn: &Connection,
scope: &TimelineScope,
limit: usize,
) -> Result<Vec<TimelineEvent>> {
let filter = resolve_timeline_scope(conn, scope)?;
let mut events = Vec::new();
// Each collector is given the full limit. After merge-sorting, we truncate
// to `limit`. Worst case we hold 4*limit events in memory (bounded).
collect_tl_created_events(conn, &filter, limit, &mut events)?;
collect_tl_state_events(conn, &filter, limit, &mut events)?;
collect_tl_label_events(conn, &filter, limit, &mut events)?;
collect_tl_milestone_events(conn, &filter, limit, &mut events)?;
// Sort by timestamp descending (most recent first), with stable tiebreak.
events.sort_by(|a, b| {
b.timestamp_ms
.cmp(&a.timestamp_ms)
.then_with(|| a.entity_key.kind.cmp(&b.entity_key.kind))
.then_with(|| a.entity_key.iid.cmp(&b.entity_key.iid))
});
events.truncate(limit);
Ok(events)
}
/// Collect Created events from issues and merge_requests tables.
fn collect_tl_created_events(
conn: &Connection,
filter: &TimelineFilter,
limit: usize,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
// Issue created events.
if !matches!(filter, TimelineFilter::MergeRequest(_)) {
let (where_clause, mut params) = match filter {
TimelineFilter::All => (
"1=1".to_string(),
Vec::<Box<dyn rusqlite::types::ToSql>>::new(),
),
TimelineFilter::Issue(id) => (
"i.id = ?1".to_string(),
vec![Box::new(*id) as Box<dyn rusqlite::types::ToSql>],
),
TimelineFilter::Actor(name) => (
"i.author_username = ?1".to_string(),
vec![Box::new(name.clone()) as Box<dyn rusqlite::types::ToSql>],
),
TimelineFilter::MergeRequest(_) => unreachable!(),
};
let limit_param = params.len() + 1;
let sql = format!(
"SELECT i.created_at, i.iid, i.title, i.author_username, i.project_id, p.path_with_namespace
FROM issues i
JOIN projects p ON p.id = i.project_id
WHERE {where_clause}
ORDER BY i.created_at DESC
LIMIT ?{limit_param}"
);
params.push(Box::new(limit as i64));
let mut stmt = conn
.prepare(&sql)
.context("preparing issue created query")?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(AsRef::as_ref).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, String>(5)?,
))
})
.context("querying issue created events")?;
for row in rows {
let (created_at, iid, title, author, project_id, project_path) =
row.context("reading issue created row")?;
let title_str = title.as_deref().unwrap_or("(untitled)");
events.push(TimelineEvent {
timestamp_ms: created_at,
entity_key: EntityKey::issue(project_id, iid),
event_kind: TimelineEventKind::Created,
summary: format!("Issue #{iid} created: {title_str}"),
detail: title,
actor: author,
project_path,
});
}
}
// MR created events.
if !matches!(filter, TimelineFilter::Issue(_)) {
let (where_clause, mut params) = match filter {
TimelineFilter::All => (
"1=1".to_string(),
Vec::<Box<dyn rusqlite::types::ToSql>>::new(),
),
TimelineFilter::MergeRequest(id) => (
"mr.id = ?1".to_string(),
vec![Box::new(*id) as Box<dyn rusqlite::types::ToSql>],
),
TimelineFilter::Actor(name) => (
"mr.author_username = ?1".to_string(),
vec![Box::new(name.clone()) as Box<dyn rusqlite::types::ToSql>],
),
TimelineFilter::Issue(_) => unreachable!(),
};
let limit_param = params.len() + 1;
let sql = format!(
"SELECT mr.created_at, mr.iid, mr.title, mr.author_username, mr.project_id, p.path_with_namespace
FROM merge_requests mr
JOIN projects p ON p.id = mr.project_id
WHERE {where_clause}
ORDER BY mr.created_at DESC
LIMIT ?{limit_param}"
);
params.push(Box::new(limit as i64));
let mut stmt = conn.prepare(&sql).context("preparing MR created query")?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(AsRef::as_ref).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, String>(5)?,
))
})
.context("querying MR created events")?;
for row in rows {
let (created_at, iid, title, author, project_id, project_path) =
row.context("reading MR created row")?;
let title_str = title.as_deref().unwrap_or("(untitled)");
events.push(TimelineEvent {
timestamp_ms: created_at,
entity_key: EntityKey::mr(project_id, iid),
event_kind: TimelineEventKind::Created,
summary: format!("MR !{iid} created: {title_str}"),
detail: title,
actor: author,
project_path,
});
}
}
Ok(())
}
/// Helper: build WHERE clause and params for resource event tables.
///
/// Resource event tables have `issue_id` and `merge_request_id` columns
/// (exactly one is non-NULL per row), plus `actor_username`.
fn resource_event_where(filter: &TimelineFilter) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
match filter {
TimelineFilter::All => ("1=1".to_string(), Vec::new()),
TimelineFilter::Issue(id) => (
"e.issue_id = ?1".to_string(),
vec![Box::new(*id) as Box<dyn rusqlite::types::ToSql>],
),
TimelineFilter::MergeRequest(id) => (
"e.merge_request_id = ?1".to_string(),
vec![Box::new(*id) as Box<dyn rusqlite::types::ToSql>],
),
TimelineFilter::Actor(name) => (
"e.actor_username = ?1".to_string(),
vec![Box::new(name.clone()) as Box<dyn rusqlite::types::ToSql>],
),
}
}
/// Resolve a resource event row's entity to an EntityKey.
fn resolve_event_entity(
issue_id: Option<i64>,
mr_id: Option<i64>,
issue_iid: Option<i64>,
mr_iid: Option<i64>,
issue_project_id: Option<i64>,
mr_project_id: Option<i64>,
) -> Option<(EntityKey, i64)> {
if let (Some(iid), Some(pid)) = (issue_iid, issue_project_id) {
Some((EntityKey::issue(pid, iid), pid))
} else if let (Some(iid), Some(pid)) = (mr_iid, mr_project_id) {
Some((EntityKey::mr(pid, iid), pid))
} else {
// Orphaned event — entity was deleted.
let _ = (issue_id, mr_id); // suppress unused warnings
None
}
}
/// Collect state change events from `resource_state_events`.
fn collect_tl_state_events(
conn: &Connection,
filter: &TimelineFilter,
limit: usize,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
let (where_clause, mut params) = resource_event_where(filter);
let limit_param = params.len() + 1;
let sql = format!(
"SELECT e.created_at, e.state, e.actor_username,
e.issue_id, e.merge_request_id,
i.iid, mr.iid, i.project_id, mr.project_id,
COALESCE(pi.path_with_namespace, pm.path_with_namespace) AS project_path
FROM resource_state_events e
LEFT JOIN issues i ON i.id = e.issue_id
LEFT JOIN merge_requests mr ON mr.id = e.merge_request_id
LEFT JOIN projects pi ON pi.id = i.project_id
LEFT JOIN projects pm ON pm.id = mr.project_id
WHERE {where_clause}
ORDER BY e.created_at DESC
LIMIT ?{limit_param}"
);
params.push(Box::new(limit as i64));
let mut stmt = conn.prepare(&sql).context("preparing state events query")?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<i64>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<i64>>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<String>>(9)?,
))
})
.context("querying state events")?;
for row in rows {
let (
created_at,
state,
actor,
issue_id,
mr_id,
issue_iid,
mr_iid,
issue_pid,
mr_pid,
project_path,
) = row.context("reading state event row")?;
let Some((entity_key, _pid)) =
resolve_event_entity(issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid)
else {
continue;
};
let (event_kind, summary) = if state == "merged" {
(
TimelineEventKind::Merged,
format!("MR !{} merged", entity_key.iid),
)
} else {
(
TimelineEventKind::StateChanged,
format!("State changed to {state}"),
)
};
events.push(TimelineEvent {
timestamp_ms: created_at,
entity_key,
event_kind,
summary,
detail: Some(state),
actor,
project_path: project_path.unwrap_or_default(),
});
}
Ok(())
}
/// Collect label change events from `resource_label_events`.
fn collect_tl_label_events(
conn: &Connection,
filter: &TimelineFilter,
limit: usize,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
let (where_clause, mut params) = resource_event_where(filter);
let limit_param = params.len() + 1;
let sql = format!(
"SELECT e.created_at, e.action, e.label_name, e.actor_username,
e.issue_id, e.merge_request_id,
i.iid, mr.iid, i.project_id, mr.project_id,
COALESCE(pi.path_with_namespace, pm.path_with_namespace) AS project_path
FROM resource_label_events e
LEFT JOIN issues i ON i.id = e.issue_id
LEFT JOIN merge_requests mr ON mr.id = e.merge_request_id
LEFT JOIN projects pi ON pi.id = i.project_id
LEFT JOIN projects pm ON pm.id = mr.project_id
WHERE {where_clause}
ORDER BY e.created_at DESC
LIMIT ?{limit_param}"
);
params.push(Box::new(limit as i64));
let mut stmt = conn.prepare(&sql).context("preparing label events query")?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<i64>>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<String>>(10)?,
))
})
.context("querying label events")?;
for row in rows {
let (
created_at,
action,
label_name,
actor,
issue_id,
mr_id,
issue_iid,
mr_iid,
issue_pid,
mr_pid,
project_path,
) = row.context("reading label event row")?;
let Some((entity_key, _pid)) =
resolve_event_entity(issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid)
else {
continue;
};
let (event_kind, summary) = match action.as_str() {
"add" => (
TimelineEventKind::LabelAdded,
format!("Label added: {label_name}"),
),
"remove" => (
TimelineEventKind::LabelRemoved,
format!("Label removed: {label_name}"),
),
_ => continue,
};
events.push(TimelineEvent {
timestamp_ms: created_at,
entity_key,
event_kind,
summary,
detail: Some(label_name),
actor,
project_path: project_path.unwrap_or_default(),
});
}
Ok(())
}
/// Collect milestone change events from `resource_milestone_events`.
fn collect_tl_milestone_events(
conn: &Connection,
filter: &TimelineFilter,
limit: usize,
events: &mut Vec<TimelineEvent>,
) -> Result<()> {
let (where_clause, mut params) = resource_event_where(filter);
let limit_param = params.len() + 1;
let sql = format!(
"SELECT e.created_at, e.action, e.milestone_title, e.actor_username,
e.issue_id, e.merge_request_id,
i.iid, mr.iid, i.project_id, mr.project_id,
COALESCE(pi.path_with_namespace, pm.path_with_namespace) AS project_path
FROM resource_milestone_events e
LEFT JOIN issues i ON i.id = e.issue_id
LEFT JOIN merge_requests mr ON mr.id = e.merge_request_id
LEFT JOIN projects pi ON pi.id = i.project_id
LEFT JOIN projects pm ON pm.id = mr.project_id
WHERE {where_clause}
ORDER BY e.created_at DESC
LIMIT ?{limit_param}"
);
params.push(Box::new(limit as i64));
let mut stmt = conn
.prepare(&sql)
.context("preparing milestone events query")?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(AsRef::as_ref).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<i64>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<i64>>(7)?,
row.get::<_, Option<i64>>(8)?,
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<String>>(10)?,
))
})
.context("querying milestone events")?;
for row in rows {
let (
created_at,
action,
milestone_title,
actor,
issue_id,
mr_id,
issue_iid,
mr_iid,
issue_pid,
mr_pid,
project_path,
) = row.context("reading milestone event row")?;
let Some((entity_key, _pid)) =
resolve_event_entity(issue_id, mr_id, issue_iid, mr_iid, issue_pid, mr_pid)
else {
continue;
};
let (event_kind, summary) = match action.as_str() {
"add" => (
TimelineEventKind::MilestoneSet,
format!("Milestone set: {milestone_title}"),
),
"remove" => (
TimelineEventKind::MilestoneRemoved,
format!("Milestone removed: {milestone_title}"),
),
_ => continue,
};
events.push(TimelineEvent {
timestamp_ms: created_at,
entity_key,
event_kind,
summary,
detail: Some(milestone_title),
actor,
project_path: project_path.unwrap_or_default(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// Create the minimal schema needed for timeline queries.
fn create_dashboard_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
created_at INTEGER,
updated_at INTEGER,
last_seen_at INTEGER NOT NULL
);
",
)
.expect("create dashboard schema");
}
fn insert_issue(conn: &Connection, iid: i64, state: &str, updated_at: i64) {
conn.execute(
"INSERT INTO issues (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)",
rusqlite::params![iid * 100, iid, format!("Issue {iid}"), state, updated_at],
)
.expect("insert issue");
}
fn insert_mr(conn: &Connection, iid: i64, state: &str, updated_at: i64) {
conn.execute(
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?5, ?5)",
rusqlite::params![iid * 100 + 50, iid, format!("MR {iid}"), state, updated_at],
)
.expect("insert mr");
}
/// Add resource event tables to an existing schema.
fn add_resource_event_tables(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS resource_state_events (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
state TEXT NOT NULL,
actor_gitlab_id INTEGER,
actor_username TEXT,
created_at INTEGER NOT NULL,
source_commit TEXT,
source_merge_request_iid INTEGER
);
CREATE TABLE IF NOT EXISTS resource_label_events (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
action TEXT NOT NULL,
label_name TEXT NOT NULL,
actor_gitlab_id INTEGER,
actor_username TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resource_milestone_events (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
action TEXT NOT NULL,
milestone_title TEXT NOT NULL,
milestone_id INTEGER,
actor_gitlab_id INTEGER,
actor_username TEXT,
created_at INTEGER NOT NULL
);
",
)
.expect("create resource event tables");
}
/// Create a full timeline test schema (dashboard schema + resource events).
fn create_timeline_schema(conn: &Connection) {
create_dashboard_schema(conn);
add_resource_event_tables(conn);
// Insert a project for test entities.
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
[],
)
.expect("insert test project");
}
fn insert_state_event(
conn: &Connection,
gitlab_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
state: &str,
actor: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![gitlab_id, issue_id, mr_id, state, actor, created_at],
)
.expect("insert state event");
}
#[allow(clippy::too_many_arguments)]
fn insert_label_event(
conn: &Connection,
gitlab_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
action: &str,
label: &str,
actor: &str,
created_at: i64,
) {
conn.execute(
"INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at)
VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![gitlab_id, issue_id, mr_id, action, label, actor, created_at],
)
.expect("insert label event");
}
#[test]
fn test_fetch_timeline_scoped() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
// Create two issues.
let now = 1_700_000_000_000_i64;
insert_issue(&conn, 1, "opened", now - 100_000);
insert_issue(&conn, 2, "opened", now - 50_000);
// Get internal IDs.
let issue1_id: i64 = conn
.query_row("SELECT id FROM issues WHERE iid = 1", [], |r| r.get(0))
.unwrap();
let issue2_id: i64 = conn
.query_row("SELECT id FROM issues WHERE iid = 2", [], |r| r.get(0))
.unwrap();
// State events: issue 1 closed, issue 2 label added.
insert_state_event(
&conn,
1,
Some(issue1_id),
None,
"closed",
"alice",
now - 80_000,
);
insert_label_event(
&conn,
2,
Some(issue2_id),
None,
"add",
"bug",
"bob",
now - 30_000,
);
// Fetch scoped to issue 1.
let scope = TimelineScope::Entity(EntityKey::issue(1, 1));
let events = fetch_timeline_events(&conn, &scope, 100).unwrap();
// Should only have issue 1's events: Created + StateChanged.
assert_eq!(events.len(), 2);
for event in &events {
assert_eq!(event.entity_key.iid, 1, "All events should be for issue #1");
}
// Most recent first.
assert!(events[0].timestamp_ms >= events[1].timestamp_ms);
}
#[test]
fn test_fetch_timeline_all_scope() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_issue(&conn, 1, "opened", now - 100_000);
insert_issue(&conn, 2, "opened", now - 50_000);
let events = fetch_timeline_events(&conn, &TimelineScope::All, 100).unwrap();
// Should have Created events for both issues.
assert_eq!(events.len(), 2);
}
#[test]
fn test_fetch_timeline_author_scope() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_issue(&conn, 1, "opened", now - 100_000); // default: no author_username in insert_issue
let issue1_id: i64 = conn
.query_row("SELECT id FROM issues WHERE iid = 1", [], |r| r.get(0))
.unwrap();
// State events by different actors.
insert_state_event(
&conn,
1,
Some(issue1_id),
None,
"closed",
"alice",
now - 80_000,
);
insert_state_event(
&conn,
2,
Some(issue1_id),
None,
"reopened",
"bob",
now - 60_000,
);
let scope = TimelineScope::Author("alice".into());
let events = fetch_timeline_events(&conn, &scope, 100).unwrap();
// Should only get alice's state event (Created events don't have author set via insert_issue).
assert!(events.iter().all(|e| e.actor.as_deref() == Some("alice")));
}
#[test]
fn test_fetch_timeline_respects_limit() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
let now = 1_700_000_000_000_i64;
for i in 1..=10 {
insert_issue(&conn, i, "opened", now - (i * 10_000));
}
let events = fetch_timeline_events(&conn, &TimelineScope::All, 3).unwrap();
assert_eq!(events.len(), 3);
}
#[test]
fn test_fetch_timeline_sorted_most_recent_first() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_issue(&conn, 1, "opened", now - 200_000);
insert_issue(&conn, 2, "opened", now - 100_000);
insert_issue(&conn, 3, "opened", now - 300_000);
let events = fetch_timeline_events(&conn, &TimelineScope::All, 100).unwrap();
for window in events.windows(2) {
assert!(
window[0].timestamp_ms >= window[1].timestamp_ms,
"Events should be sorted most-recent-first"
);
}
}
#[test]
fn test_fetch_timeline_state_merged_is_merged_kind() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
let now = 1_700_000_000_000_i64;
insert_mr(&conn, 1, "merged", now - 100_000);
let mr_id: i64 = conn
.query_row("SELECT id FROM merge_requests WHERE iid = 1", [], |r| {
r.get(0)
})
.unwrap();
insert_state_event(&conn, 1, None, Some(mr_id), "merged", "alice", now - 50_000);
let scope = TimelineScope::Entity(EntityKey::mr(1, 1));
let events = fetch_timeline_events(&conn, &scope, 100).unwrap();
let merged_events: Vec<_> = events
.iter()
.filter(|e| e.event_kind == TimelineEventKind::Merged)
.collect();
assert_eq!(merged_events.len(), 1);
assert_eq!(merged_events[0].summary, "MR !1 merged");
}
#[test]
fn test_fetch_timeline_empty_db() {
let conn = Connection::open_in_memory().unwrap();
create_timeline_schema(&conn);
let events = fetch_timeline_events(&conn, &TimelineScope::All, 100).unwrap();
assert!(events.is_empty());
}
}

View File

@@ -0,0 +1,234 @@
#![allow(dead_code)]
//! Trace screen actions — fetch file provenance chains from the local database.
//!
//! Wraps `run_trace()` from `lore::core::trace` and provides an autocomplete
//! path query for the input field.
use anyhow::Result;
use rusqlite::Connection;
use lore::core::trace::{self, TraceResult};
/// Default limit for trace chain results in TUI queries.
const DEFAULT_LIMIT: usize = 50;
/// Fetch trace chains for a file path.
///
/// Wraps [`trace::run_trace()`] with TUI defaults.
pub fn fetch_trace(
conn: &Connection,
project_id: Option<i64>,
path: &str,
follow_renames: bool,
include_discussions: bool,
) -> Result<TraceResult> {
Ok(trace::run_trace(
conn,
project_id,
path,
follow_renames,
include_discussions,
DEFAULT_LIMIT,
)?)
}
/// Fetch known file paths from `mr_file_changes` for autocomplete.
///
/// Returns distinct `new_path` values scoped to the given project (or all
/// projects if `None`), sorted alphabetically.
pub fn fetch_known_paths(conn: &Connection, project_id: Option<i64>) -> Result<Vec<String>> {
let paths = if let Some(pid) = project_id {
let mut stmt = conn.prepare(
"SELECT DISTINCT new_path FROM mr_file_changes \
WHERE project_id = ?1 ORDER BY new_path LIMIT 5000",
)?;
let rows = stmt.query_map([pid], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
} else {
let mut stmt = conn.prepare(
"SELECT DISTINCT new_path FROM mr_file_changes ORDER BY new_path LIMIT 5000",
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal schema for trace queries.
fn create_trace_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
draft INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
updated_at INTEGER,
merged_at INTEGER,
closed_at INTEGER,
web_url TEXT,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE mr_file_changes (
id INTEGER PRIMARY KEY,
merge_request_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
new_path TEXT NOT NULL,
old_path TEXT,
change_type TEXT NOT NULL
);
CREATE TABLE entity_references (
id INTEGER PRIMARY KEY,
source_entity_type TEXT NOT NULL,
source_entity_id INTEGER NOT NULL,
target_entity_type TEXT NOT NULL,
target_entity_id INTEGER,
target_iid INTEGER NOT NULL,
project_id INTEGER NOT NULL,
reference_type TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
web_url TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
noteable_type TEXT NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
resolvable INTEGER NOT NULL DEFAULT 0,
resolved INTEGER NOT NULL DEFAULT 0,
last_note_at INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
note_type TEXT,
position_new_path TEXT,
position_old_path TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE INDEX idx_mfc_new_path_project_mr
ON mr_file_changes(new_path, project_id, merge_request_id);
CREATE INDEX idx_mfc_old_path_project_mr
ON mr_file_changes(old_path, project_id, merge_request_id);
",
)
.expect("create trace schema");
}
#[test]
fn test_fetch_trace_empty_db() {
let conn = Connection::open_in_memory().unwrap();
create_trace_schema(&conn);
let result = fetch_trace(&conn, None, "src/main.rs", true, true).unwrap();
assert!(result.trace_chains.is_empty());
assert_eq!(result.total_chains, 0);
}
#[test]
fn test_fetch_trace_with_mr() {
let conn = Connection::open_in_memory().unwrap();
create_trace_schema(&conn);
// Insert a project, MR, and file change.
conn.execute_batch(
"
INSERT INTO projects(id, gitlab_project_id, path_with_namespace)
VALUES (1, 100, 'group/project');
INSERT INTO merge_requests(id, gitlab_id, project_id, iid, title, state, author_username, updated_at, last_seen_at)
VALUES (1, 200, 1, 42, 'Add main.rs', 'merged', 'alice', 1700000000000, 1700000000000);
INSERT INTO mr_file_changes(id, merge_request_id, project_id, new_path, change_type)
VALUES (1, 1, 1, 'src/main.rs', 'added');
",
)
.unwrap();
let result = fetch_trace(&conn, Some(1), "src/main.rs", true, false).unwrap();
assert_eq!(result.trace_chains.len(), 1);
assert_eq!(result.trace_chains[0].mr_iid, 42);
assert_eq!(result.trace_chains[0].mr_author, "alice");
assert_eq!(result.trace_chains[0].change_type, "added");
}
#[test]
fn test_fetch_known_paths_empty() {
let conn = Connection::open_in_memory().unwrap();
create_trace_schema(&conn);
let paths = fetch_known_paths(&conn, None).unwrap();
assert!(paths.is_empty());
}
#[test]
fn test_fetch_known_paths_with_data() {
let conn = Connection::open_in_memory().unwrap();
create_trace_schema(&conn);
conn.execute_batch(
"
INSERT INTO mr_file_changes(id, merge_request_id, project_id, new_path, change_type)
VALUES (1, 1, 1, 'src/b.rs', 'added'),
(2, 1, 1, 'src/a.rs', 'modified'),
(3, 2, 1, 'src/b.rs', 'modified');
",
)
.unwrap();
let paths = fetch_known_paths(&conn, None).unwrap();
assert_eq!(paths, vec!["src/a.rs", "src/b.rs"]);
}
#[test]
fn test_fetch_known_paths_scoped_to_project() {
let conn = Connection::open_in_memory().unwrap();
create_trace_schema(&conn);
conn.execute_batch(
"
INSERT INTO mr_file_changes(id, merge_request_id, project_id, new_path, change_type)
VALUES (1, 1, 1, 'src/a.rs', 'added'),
(2, 2, 2, 'src/b.rs', 'added');
",
)
.unwrap();
let paths = fetch_known_paths(&conn, Some(1)).unwrap();
assert_eq!(paths, vec!["src/a.rs"]);
}
}

View File

@@ -0,0 +1,285 @@
#![allow(dead_code)]
//! Who screen actions — fetch people-intelligence data from the local database.
//!
//! Each function wraps a `query_*` function from `lore::cli::commands::who`
//! and returns the appropriate [`WhoResult`] variant.
use anyhow::Result;
use rusqlite::Connection;
use lore::cli::commands::who;
use lore::core::config::ScoringConfig;
use lore::core::who_types::WhoResult;
/// Default limit for result rows in TUI who queries.
const DEFAULT_LIMIT: usize = 20;
/// Default time window: 6 months in milliseconds.
const SIX_MONTHS_MS: i64 = 180 * 24 * 60 * 60 * 1000;
/// Fetch expert results for a file path.
pub fn fetch_who_expert(
conn: &Connection,
path: &str,
project_id: Option<i64>,
scoring: &ScoringConfig,
now_ms: i64,
) -> Result<WhoResult> {
let since_ms = now_ms - SIX_MONTHS_MS;
let result = who::query_expert(
conn,
path,
project_id,
since_ms,
now_ms,
DEFAULT_LIMIT,
scoring,
false, // detail
false, // explain_score
false, // include_bots
)?;
Ok(WhoResult::Expert(result))
}
/// Fetch workload summary for a username.
pub fn fetch_who_workload(
conn: &Connection,
username: &str,
project_id: Option<i64>,
include_closed: bool,
) -> Result<WhoResult> {
let result = who::query_workload(
conn,
username,
project_id,
None, // since_ms — show all for workload
DEFAULT_LIMIT,
include_closed,
)?;
Ok(WhoResult::Workload(result))
}
/// Fetch review activity breakdown for a username.
pub fn fetch_who_reviews(
conn: &Connection,
username: &str,
project_id: Option<i64>,
now_ms: i64,
) -> Result<WhoResult> {
let since_ms = now_ms - SIX_MONTHS_MS;
let result = who::query_reviews(conn, username, project_id, since_ms)?;
Ok(WhoResult::Reviews(result))
}
/// Fetch recent active (unresolved) discussions.
pub fn fetch_who_active(
conn: &Connection,
project_id: Option<i64>,
include_closed: bool,
now_ms: i64,
) -> Result<WhoResult> {
// Active mode default window: 7 days.
let seven_days_ms: i64 = 7 * 24 * 60 * 60 * 1000;
let since_ms = now_ms - seven_days_ms;
let result = who::query_active(conn, project_id, since_ms, DEFAULT_LIMIT, include_closed)?;
Ok(WhoResult::Active(result))
}
/// Fetch overlap (shared file knowledge) for a path.
pub fn fetch_who_overlap(
conn: &Connection,
path: &str,
project_id: Option<i64>,
now_ms: i64,
) -> Result<WhoResult> {
let since_ms = now_ms - SIX_MONTHS_MS;
let result = who::query_overlap(conn, path, project_id, since_ms, DEFAULT_LIMIT)?;
Ok(WhoResult::Overlap(result))
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal schema for who queries (matches the real DB schema).
fn create_who_schema(conn: &Connection) {
conn.execute_batch(
"
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
);
CREATE TABLE issues (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT NOT NULL,
author_username TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE merge_requests (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
project_id INTEGER NOT NULL,
iid INTEGER NOT NULL,
title TEXT,
state TEXT,
author_username TEXT,
draft INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
updated_at INTEGER,
merged_at INTEGER,
closed_at INTEGER,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE issue_assignees (
issue_id INTEGER NOT NULL,
username TEXT NOT NULL,
PRIMARY KEY(issue_id, username)
);
CREATE TABLE mr_reviewers (
merge_request_id INTEGER NOT NULL,
username TEXT NOT NULL,
PRIMARY KEY(merge_request_id, username)
);
CREATE TABLE mr_file_changes (
id INTEGER PRIMARY KEY,
merge_request_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
new_path TEXT NOT NULL,
old_path TEXT,
change_type TEXT NOT NULL
);
CREATE TABLE discussions (
id INTEGER PRIMARY KEY,
gitlab_discussion_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
noteable_type TEXT NOT NULL,
issue_id INTEGER,
merge_request_id INTEGER,
resolvable INTEGER NOT NULL DEFAULT 0,
resolved INTEGER NOT NULL DEFAULT 0,
last_note_at INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
gitlab_id INTEGER UNIQUE NOT NULL,
discussion_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
is_system INTEGER NOT NULL DEFAULT 0,
author_username TEXT,
body TEXT,
note_type TEXT,
position_new_path TEXT,
position_old_path TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
-- Indexes needed by who queries
CREATE INDEX idx_notes_diffnote_path_created
ON notes(position_new_path, created_at)
WHERE note_type = 'DiffNote' AND is_system = 0;
CREATE INDEX idx_notes_old_path_author
ON notes(position_old_path, author_username)
WHERE note_type = 'DiffNote' AND is_system = 0;
CREATE INDEX idx_mfc_new_path_project_mr
ON mr_file_changes(new_path, project_id, merge_request_id);
CREATE INDEX idx_mfc_old_path_project_mr
ON mr_file_changes(old_path, project_id, merge_request_id);
",
)
.expect("create who schema");
}
fn default_scoring() -> ScoringConfig {
ScoringConfig::default()
}
fn now_ms() -> i64 {
1_700_000_000_000 // Fixed timestamp for deterministic tests.
}
#[test]
fn test_fetch_who_expert_empty_db_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
create_who_schema(&conn);
let result = fetch_who_expert(&conn, "src/", None, &default_scoring(), now_ms()).unwrap();
match result {
WhoResult::Expert(expert) => {
assert!(expert.experts.is_empty());
assert!(!expert.truncated);
}
_ => panic!("Expected Expert variant"),
}
}
#[test]
fn test_fetch_who_workload_empty_db_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
create_who_schema(&conn);
let result = fetch_who_workload(&conn, "alice", None, false).unwrap();
match result {
WhoResult::Workload(wl) => {
assert_eq!(wl.username, "alice");
assert!(wl.assigned_issues.is_empty());
assert!(wl.authored_mrs.is_empty());
}
_ => panic!("Expected Workload variant"),
}
}
#[test]
fn test_fetch_who_reviews_empty_db_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
create_who_schema(&conn);
let result = fetch_who_reviews(&conn, "alice", None, now_ms()).unwrap();
match result {
WhoResult::Reviews(rev) => {
assert_eq!(rev.username, "alice");
assert_eq!(rev.total_diffnotes, 0);
}
_ => panic!("Expected Reviews variant"),
}
}
#[test]
fn test_fetch_who_active_empty_db_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
create_who_schema(&conn);
let result = fetch_who_active(&conn, None, false, now_ms()).unwrap();
match result {
WhoResult::Active(active) => {
assert!(active.discussions.is_empty());
assert_eq!(active.total_unresolved_in_window, 0);
}
_ => panic!("Expected Active variant"),
}
}
#[test]
fn test_fetch_who_overlap_empty_db_returns_empty() {
let conn = Connection::open_in_memory().unwrap();
create_who_schema(&conn);
let result = fetch_who_overlap(&conn, "src/", None, now_ms()).unwrap();
match result {
WhoResult::Overlap(overlap) => {
assert!(overlap.users.is_empty());
assert!(!overlap.truncated);
}
_ => panic!("Expected Overlap variant"),
}
}
}

View File

@@ -0,0 +1,73 @@
#![allow(dead_code)] // Phase 1: methods consumed as screens are implemented
//! Full FrankenTUI Model implementation for the lore TUI.
//!
//! LoreApp is the central coordinator: it owns all state, dispatches
//! messages through a 5-stage key pipeline, records crash context
//! breadcrumbs, manages async tasks via the supervisor, and routes
//! view() to per-screen render functions.
mod tests;
mod update;
use crate::clock::{Clock, SystemClock};
use crate::commands::{CommandRegistry, build_registry};
use crate::crash_context::CrashContext;
use crate::db::DbManager;
use crate::message::InputMode;
use crate::navigation::NavigationStack;
use crate::state::AppState;
use crate::task_supervisor::TaskSupervisor;
// ---------------------------------------------------------------------------
// LoreApp
// ---------------------------------------------------------------------------
/// Root model for the lore TUI.
///
/// Owns all state and implements the FrankenTUI Model trait. The
/// update() method is the single entry point for all state transitions.
pub struct LoreApp {
pub state: AppState,
pub navigation: NavigationStack,
pub supervisor: TaskSupervisor,
pub crash_context: CrashContext,
pub command_registry: CommandRegistry,
pub input_mode: InputMode,
pub clock: Box<dyn Clock>,
pub db: Option<DbManager>,
}
impl LoreApp {
/// Create a new LoreApp with default state.
///
/// Uses a real system clock and no DB connection (set separately).
#[must_use]
pub fn new() -> Self {
Self {
state: AppState::default(),
navigation: NavigationStack::new(),
supervisor: TaskSupervisor::new(),
crash_context: CrashContext::new(),
command_registry: build_registry(),
input_mode: InputMode::Normal,
clock: Box::new(SystemClock),
db: None,
}
}
/// Create a LoreApp for testing with a custom clock.
#[cfg(test)]
fn with_clock(clock: Box<dyn Clock>) -> Self {
Self {
clock,
..Self::new()
}
}
}
impl Default for LoreApp {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,496 @@
//! Tests for LoreApp.
#![cfg(test)]
use chrono::TimeDelta;
use ftui::{Cmd, Event, KeyCode, KeyEvent, Model, Modifiers};
use crate::clock::FakeClock;
use crate::message::{InputMode, Msg, Screen};
use super::LoreApp;
fn test_app() -> LoreApp {
LoreApp::with_clock(Box::new(FakeClock::new(chrono::Utc::now())))
}
/// Verify that `App::fullscreen(LoreApp::new()).run()` compiles.
fn _assert_app_fullscreen_compiles() {
fn _inner() {
use ftui::App;
let _app_builder = App::fullscreen(LoreApp::new());
}
}
/// Verify that `App::inline(LoreApp::new(), 12).run()` compiles.
fn _assert_app_inline_compiles() {
fn _inner() {
use ftui::App;
let _app_builder = App::inline(LoreApp::new(), 12);
}
}
#[test]
fn test_lore_app_init_returns_none() {
let mut app = test_app();
let cmd = app.init();
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn test_lore_app_quit_returns_quit_cmd() {
let mut app = test_app();
let cmd = app.update(Msg::Quit);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn test_lore_app_tick_returns_none() {
let mut app = test_app();
let cmd = app.update(Msg::Tick);
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn test_lore_app_navigate_to_updates_nav_stack() {
let mut app = test_app();
let cmd = app.update(Msg::NavigateTo(Screen::IssueList));
assert!(matches!(cmd, Cmd::None));
assert!(app.navigation.is_at(&Screen::IssueList));
assert_eq!(app.navigation.depth(), 2);
}
#[test]
fn test_lore_app_go_back() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::GoBack);
assert!(app.navigation.is_at(&Screen::Dashboard));
}
#[test]
fn test_lore_app_go_forward() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::GoBack);
app.update(Msg::GoForward);
assert!(app.navigation.is_at(&Screen::IssueList));
}
#[test]
fn test_ctrl_c_always_quits() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL);
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn test_q_key_quits_in_normal_mode() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('q'));
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn test_q_key_blocked_in_text_mode() {
let mut app = test_app();
app.input_mode = InputMode::Text;
let key = KeyEvent::new(KeyCode::Char('q'));
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
// q in text mode should NOT quit.
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn test_esc_blurs_text_mode() {
let mut app = test_app();
app.input_mode = InputMode::Text;
app.state.search.query_focused = true;
let key = KeyEvent::new(KeyCode::Escape);
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::Normal));
assert!(!app.state.has_text_focus());
}
#[test]
fn test_g_prefix_enters_go_mode() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
}
#[test]
fn test_g_then_i_navigates_to_issues() {
let mut app = test_app();
// First key: 'g'
let key_g = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key_g)));
// Second key: 'i'
let key_i = KeyEvent::new(KeyCode::Char('i'));
app.update(Msg::RawEvent(Event::Key(key_i)));
assert!(app.navigation.is_at(&Screen::IssueList));
}
#[test]
fn test_g_then_s_on_bootstrap_starts_sync_in_place() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::Bootstrap));
// First key: 'g'
let key_g = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key_g)));
// Second key: 's'
let key_s = KeyEvent::new(KeyCode::Char('s'));
app.update(Msg::RawEvent(Event::Key(key_s)));
assert!(app.navigation.is_at(&Screen::Bootstrap));
assert!(app.state.bootstrap.sync_started);
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_g_then_s_from_dashboard_navigates_to_sync_screen() {
let mut app = test_app();
// First key: 'g'
let key_g = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key_g)));
// Second key: 's'
let key_s = KeyEvent::new(KeyCode::Char('s'));
app.update(Msg::RawEvent(Event::Key(key_s)));
assert!(app.navigation.is_at(&Screen::Sync));
}
#[test]
fn test_go_prefix_timeout_cancels() {
let clock = FakeClock::new(chrono::Utc::now());
let mut app = LoreApp::with_clock(Box::new(clock.clone()));
// Press 'g'.
let key_g = KeyEvent::new(KeyCode::Char('g'));
app.update(Msg::RawEvent(Event::Key(key_g)));
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
// Advance clock past timeout.
clock.advance(TimeDelta::milliseconds(600));
// Press 'i' after timeout — should NOT navigate to issues.
let key_i = KeyEvent::new(KeyCode::Char('i'));
app.update(Msg::RawEvent(Event::Key(key_i)));
// Should still be at Dashboard (no navigation happened).
assert!(app.navigation.is_at(&Screen::Dashboard));
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_show_help_toggles() {
let mut app = test_app();
assert!(!app.state.show_help);
app.update(Msg::ShowHelp);
assert!(app.state.show_help);
app.update(Msg::ShowHelp);
assert!(!app.state.show_help);
}
#[test]
fn test_error_msg_sets_toast() {
let mut app = test_app();
app.update(Msg::Error(crate::message::AppError::DbBusy));
assert!(app.state.error_toast.is_some());
assert!(app.state.error_toast.as_ref().unwrap().contains("busy"));
}
#[test]
fn test_resize_updates_terminal_size() {
let mut app = test_app();
app.update(Msg::Resize {
width: 120,
height: 40,
});
assert_eq!(app.state.terminal_size, (120, 40));
}
#[test]
fn test_stale_result_dropped() {
use crate::message::Screen;
use crate::task_supervisor::TaskKey;
let mut app = test_app();
// Submit two tasks for IssueList — second supersedes first.
let gen1 = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let gen2 = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
// Stale result with gen1 should be ignored.
app.update(Msg::IssueListLoaded {
generation: gen1,
page: crate::state::issue_list::IssueListPage {
rows: vec![crate::state::issue_list::IssueListRow {
project_path: "group/project".into(),
iid: 1,
title: "stale".into(),
state: "opened".into(),
author: "taylor".into(),
labels: vec![],
updated_at: 1_700_000_000_000,
}],
next_cursor: None,
total_count: 1,
},
});
assert!(app.state.issue_list.rows.is_empty());
// Current result with gen2 should be applied.
app.update(Msg::IssueListLoaded {
generation: gen2,
page: crate::state::issue_list::IssueListPage {
rows: vec![crate::state::issue_list::IssueListRow {
project_path: "group/project".into(),
iid: 2,
title: "fresh".into(),
state: "opened".into(),
author: "taylor".into(),
labels: vec![],
updated_at: 1_700_000_000_000,
}],
next_cursor: None,
total_count: 1,
},
});
assert_eq!(app.state.issue_list.rows.len(), 1);
assert_eq!(app.state.issue_list.rows[0].title, "fresh");
}
#[test]
fn test_crash_context_records_events() {
let mut app = test_app();
app.update(Msg::Tick);
app.update(Msg::NavigateTo(Screen::IssueList));
// Should have recorded at least 2 events.
assert!(app.crash_context.len() >= 2);
}
#[test]
fn test_navigate_sets_loading_initial_on_first_visit() {
use crate::state::LoadState;
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::IssueList));
// First visit should show full-screen spinner (LoadingInitial).
assert_eq!(
*app.state.load_state.get(&Screen::IssueList),
LoadState::LoadingInitial
);
}
#[test]
fn test_navigate_sets_refreshing_on_revisit() {
use crate::state::LoadState;
let mut app = test_app();
// First visit → LoadingInitial.
app.update(Msg::NavigateTo(Screen::IssueList));
// Simulate load completing.
app.state.set_loading(Screen::IssueList, LoadState::Idle);
// Go back, then revisit.
app.update(Msg::GoBack);
app.update(Msg::NavigateTo(Screen::IssueList));
// Second visit should show corner spinner (Refreshing).
assert_eq!(
*app.state.load_state.get(&Screen::IssueList),
LoadState::Refreshing
);
}
#[test]
fn test_command_palette_opens_from_ctrl_p() {
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('p')).with_modifiers(Modifiers::CTRL);
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::Palette));
assert!(app.state.command_palette.query_focused);
}
#[test]
fn test_esc_closes_palette() {
let mut app = test_app();
app.input_mode = InputMode::Palette;
let key = KeyEvent::new(KeyCode::Escape);
app.update(Msg::RawEvent(Event::Key(key)));
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_blur_text_input_msg() {
let mut app = test_app();
app.input_mode = InputMode::Text;
app.state.search.query_focused = true;
app.update(Msg::BlurTextInput);
assert!(matches!(app.input_mode, InputMode::Normal));
assert!(!app.state.has_text_focus());
}
#[test]
fn test_default_is_new() {
let app = LoreApp::default();
assert!(app.navigation.is_at(&Screen::Dashboard));
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_sync_completed_from_bootstrap_resets_navigation_and_state() {
let mut app = test_app();
app.update(Msg::NavigateTo(Screen::Bootstrap));
app.update(Msg::SyncStarted);
assert!(app.state.bootstrap.sync_started);
assert!(app.navigation.is_at(&Screen::Bootstrap));
app.update(Msg::SyncCompleted { elapsed_ms: 1234 });
assert!(app.navigation.is_at(&Screen::Dashboard));
assert_eq!(app.navigation.depth(), 1);
assert!(!app.state.bootstrap.sync_started);
}
#[test]
fn test_sync_completed_flushes_entity_caches() {
use crate::message::EntityKey;
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
use crate::state::mr_detail::{MrDetailData, MrMetadata};
use crate::state::{CachedIssuePayload, CachedMrPayload};
use crate::view::common::cross_ref::CrossRef;
let mut app = test_app();
// Populate caches with dummy data.
let issue_key = EntityKey::issue(1, 42);
app.state.issue_cache.put(
issue_key,
CachedIssuePayload {
data: IssueDetailData {
metadata: IssueMetadata {
iid: 42,
project_path: "g/p".into(),
title: "Test".into(),
description: String::new(),
state: "opened".into(),
author: "alice".into(),
assignees: vec![],
labels: vec![],
milestone: None,
due_date: None,
created_at: 0,
updated_at: 0,
web_url: String::new(),
discussion_count: 0,
},
cross_refs: Vec::<CrossRef>::new(),
},
discussions: vec![],
},
);
let mr_key = EntityKey::mr(1, 99);
app.state.mr_cache.put(
mr_key,
CachedMrPayload {
data: MrDetailData {
metadata: MrMetadata {
iid: 99,
project_path: "g/p".into(),
title: "MR".into(),
description: String::new(),
state: "opened".into(),
draft: false,
author: "bob".into(),
assignees: vec![],
reviewers: vec![],
labels: vec![],
source_branch: "feat".into(),
target_branch: "main".into(),
merge_status: String::new(),
created_at: 0,
updated_at: 0,
merged_at: None,
web_url: String::new(),
discussion_count: 0,
file_change_count: 0,
},
cross_refs: Vec::<CrossRef>::new(),
file_changes: vec![],
},
discussions: vec![],
},
);
assert_eq!(app.state.issue_cache.len(), 1);
assert_eq!(app.state.mr_cache.len(), 1);
// Sync completes — caches should be flushed.
app.update(Msg::SyncCompleted { elapsed_ms: 500 });
assert!(
app.state.issue_cache.is_empty(),
"issue cache should be flushed after sync"
);
assert!(
app.state.mr_cache.is_empty(),
"MR cache should be flushed after sync"
);
}
#[test]
fn test_sync_completed_refreshes_current_detail_view() {
use crate::message::EntityKey;
use crate::state::LoadState;
let mut app = test_app();
// Navigate to an issue detail screen.
let key = EntityKey::issue(1, 42);
app.update(Msg::NavigateTo(Screen::IssueDetail(key)));
// Simulate load completion so LoadState goes to Idle.
app.state.set_loading(
Screen::IssueDetail(EntityKey::issue(1, 42)),
LoadState::Idle,
);
// Sync completes while viewing the detail.
app.update(Msg::SyncCompleted { elapsed_ms: 300 });
// The detail screen should have been set to Refreshing.
assert_eq!(
*app.state
.load_state
.get(&Screen::IssueDetail(EntityKey::issue(1, 42))),
LoadState::Refreshing,
"detail view should refresh after sync"
);
}

View File

@@ -0,0 +1,732 @@
//! Model trait impl and key dispatch for LoreApp.
use chrono::TimeDelta;
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
use crate::crash_context::CrashEvent;
use crate::message::{EntityKind, InputMode, Msg, Screen};
use crate::state::{CachedIssuePayload, CachedMrPayload, LoadState};
use crate::task_supervisor::TaskKey;
use super::LoreApp;
/// Timeout for the g-prefix key sequence.
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
impl LoreApp {
// -----------------------------------------------------------------------
// Key dispatch
// -----------------------------------------------------------------------
/// Normalize terminal key variants for cross-terminal consistency.
fn normalize_key(key: &mut KeyEvent) {
// BackTab -> Shift+Tab canonical form.
if key.code == KeyCode::BackTab {
key.code = KeyCode::Tab;
key.modifiers |= Modifiers::SHIFT;
}
}
/// 5-stage key dispatch pipeline.
///
/// Returns the Cmd to execute (Quit, None, or a task command).
pub(crate) fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
Self::normalize_key(&mut key);
let screen = self.navigation.current().clone();
// Record key press in crash context.
self.crash_context.push(CrashEvent::KeyPress {
key: format!("{:?}", key.code),
mode: format!("{:?}", self.input_mode),
screen: screen.label().to_string(),
});
// --- Stage 1: Quit check ---
// Ctrl+C always quits regardless of mode.
if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) {
return Cmd::quit();
}
// --- Stage 2: InputMode routing ---
match &self.input_mode {
InputMode::Text => {
return self.handle_text_mode_key(&key, &screen);
}
InputMode::Palette => {
return self.handle_palette_mode_key(&key, &screen);
}
InputMode::GoPrefix { started_at } => {
let elapsed = self.clock.now().signed_duration_since(*started_at);
if elapsed > GO_PREFIX_TIMEOUT {
// Timeout expired — cancel prefix and re-process as normal.
self.input_mode = InputMode::Normal;
} else {
return self.handle_go_prefix_key(&key, &screen);
}
}
InputMode::Normal => {}
}
// --- Stage 3: Global shortcuts (Normal mode) ---
// 'q' quits.
if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE {
return Cmd::quit();
}
// 'g' starts prefix sequence.
if self
.command_registry
.is_sequence_starter(&key.code, &key.modifiers)
{
self.input_mode = InputMode::GoPrefix {
started_at: self.clock.now(),
};
return Cmd::none();
}
// Registry-based single-key lookup.
if let Some(cmd_def) =
self.command_registry
.lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode)
{
return self.execute_command(cmd_def.id, &screen);
}
// --- Stage 4: Screen-local keys ---
// Delegated to AppState::interpret_screen_key in future phases.
// --- Stage 5: Fallback (unhandled) ---
Cmd::none()
}
/// Handle keys in Text input mode.
///
/// Only Esc and Ctrl+P pass through; everything else is consumed by
/// the focused text widget (handled in future phases).
fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
// Esc blurs the text input.
if key.code == KeyCode::Escape {
self.state.blur_text_focus();
self.input_mode = InputMode::Normal;
return Cmd::none();
}
// Ctrl+P opens palette even in text mode.
if let Some(cmd_def) =
self.command_registry
.lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text)
{
return self.execute_command(cmd_def.id, screen);
}
// All other keys consumed by text widget (future).
Cmd::none()
}
/// Handle keys in Palette mode.
fn handle_palette_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
match key.code {
KeyCode::Escape => {
self.state.command_palette.close();
self.input_mode = InputMode::Normal;
Cmd::none()
}
KeyCode::Enter => {
if let Some(cmd_id) = self.state.command_palette.selected_command_id() {
self.state.command_palette.close();
self.input_mode = InputMode::Normal;
self.execute_command(cmd_id, screen)
} else {
Cmd::none()
}
}
KeyCode::Up => {
self.state.command_palette.select_prev();
Cmd::none()
}
KeyCode::Down => {
self.state.command_palette.select_next();
Cmd::none()
}
KeyCode::Backspace => {
self.state
.command_palette
.delete_back(&self.command_registry, screen);
Cmd::none()
}
KeyCode::Char(c) => {
self.state
.command_palette
.insert_char(c, &self.command_registry, screen);
Cmd::none()
}
_ => Cmd::none(),
}
}
/// Handle the second key of a g-prefix sequence.
fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
self.input_mode = InputMode::Normal;
if let Some(cmd_def) = self.command_registry.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&key.code,
&key.modifiers,
screen,
) {
return self.execute_command(cmd_def.id, screen);
}
// Invalid second key — cancel prefix silently.
Cmd::none()
}
/// Execute a command by ID.
fn execute_command(&mut self, id: &str, screen: &Screen) -> Cmd<Msg> {
match id {
"quit" => Cmd::quit(),
"go_back" => {
self.navigation.pop();
Cmd::none()
}
"show_help" => {
self.state.show_help = !self.state.show_help;
Cmd::none()
}
"command_palette" => {
self.input_mode = InputMode::Palette;
let screen = self.navigation.current().clone();
self.state
.command_palette
.open(&self.command_registry, &screen);
Cmd::none()
}
"open_in_browser" => {
// Will dispatch OpenInBrowser msg in future phase.
Cmd::none()
}
"show_cli" => {
// Will show CLI equivalent in future phase.
Cmd::none()
}
"go_home" => self.navigate_to(Screen::Dashboard),
"go_issues" => self.navigate_to(Screen::IssueList),
"go_mrs" => self.navigate_to(Screen::MrList),
"go_search" => self.navigate_to(Screen::Search),
"go_timeline" => self.navigate_to(Screen::Timeline),
"go_who" => self.navigate_to(Screen::Who),
"go_file_history" => self.navigate_to(Screen::FileHistory),
"go_trace" => self.navigate_to(Screen::Trace),
"go_doctor" => self.navigate_to(Screen::Doctor),
"go_stats" => self.navigate_to(Screen::Stats),
"go_sync" => {
if screen == &Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
Cmd::none()
} else {
self.navigate_to(Screen::Sync)
}
}
"jump_back" => {
self.navigation.jump_back();
Cmd::none()
}
"jump_forward" => {
self.navigation.jump_forward();
Cmd::none()
}
"toggle_scope" => {
if self.state.scope_picker.visible {
self.state.scope_picker.close();
Cmd::none()
} else {
// Fetch projects and open picker asynchronously.
Cmd::task(move || {
// The actual DB query runs in the task; for now, open
// immediately with cached projects if available.
Msg::ScopeProjectsLoaded { projects: vec![] }
})
}
}
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
// Screen-specific actions — delegated in future phases.
Cmd::none()
}
_ => Cmd::none(),
}
}
// -----------------------------------------------------------------------
// Navigation helpers
// -----------------------------------------------------------------------
/// Navigate to a screen, pushing the nav stack and starting a data load.
///
/// For detail views (issue/MR), checks the entity cache first. On a
/// cache hit, applies cached data immediately and uses `Refreshing`
/// (background re-fetch) instead of `LoadingInitial` (full spinner).
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
let screen_label = screen.label().to_string();
let current_label = self.navigation.current().label().to_string();
self.crash_context.push(CrashEvent::StateTransition {
from: current_label,
to: screen_label,
});
self.navigation.push(screen.clone());
// Check entity cache for detail views — apply cached data instantly.
let cache_hit = self.try_apply_detail_cache(&screen);
// Cache hit → background refresh; first visit → full spinner; revisit → stale refresh.
let load_state = if cache_hit || self.state.load_state.was_visited(&screen) {
LoadState::Refreshing
} else {
LoadState::LoadingInitial
};
self.state.set_loading(screen.clone(), load_state);
// Always spawn a data load (even on cache hit, to ensure freshness).
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
Cmd::none()
}
/// Try to populate a detail view from the entity cache. Returns true on hit.
fn try_apply_detail_cache(&mut self, screen: &Screen) -> bool {
match screen {
Screen::IssueDetail(key) => {
if let Some(payload) = self.state.issue_cache.get(key).cloned() {
self.state.issue_detail.load_new(key.clone());
self.state.issue_detail.apply_metadata(payload.data);
if !payload.discussions.is_empty() {
self.state
.issue_detail
.apply_discussions(payload.discussions);
}
true
} else {
false
}
}
Screen::MrDetail(key) => {
if let Some(payload) = self.state.mr_cache.get(key).cloned() {
self.state.mr_detail.load_new(key.clone());
self.state.mr_detail.apply_metadata(payload.data);
if !payload.discussions.is_empty() {
self.state.mr_detail.apply_discussions(payload.discussions);
}
true
} else {
false
}
}
_ => false,
}
}
// -----------------------------------------------------------------------
// Message dispatch (non-key)
// -----------------------------------------------------------------------
/// Handle non-key messages.
pub(crate) fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
// Record in crash context.
self.crash_context.push(CrashEvent::MsgDispatched {
msg_name: msg.variant_name().to_string(),
screen: self.navigation.current().label().to_string(),
});
match msg {
Msg::Quit => Cmd::quit(),
// --- Navigation ---
Msg::NavigateTo(screen) => self.navigate_to(screen),
Msg::GoBack => {
self.navigation.pop();
Cmd::none()
}
Msg::GoForward => {
self.navigation.go_forward();
Cmd::none()
}
Msg::GoHome => self.navigate_to(Screen::Dashboard),
Msg::JumpBack(_) => {
self.navigation.jump_back();
Cmd::none()
}
Msg::JumpForward(_) => {
self.navigation.jump_forward();
Cmd::none()
}
// --- Error ---
Msg::Error(err) => {
self.state.set_error(err.to_string());
Cmd::none()
}
// --- Help / UI ---
Msg::ShowHelp => {
self.state.show_help = !self.state.show_help;
Cmd::none()
}
Msg::BlurTextInput => {
self.state.blur_text_focus();
self.input_mode = InputMode::Normal;
Cmd::none()
}
// --- Terminal ---
Msg::Resize { width, height } => {
self.state.terminal_size = (width, height);
Cmd::none()
}
Msg::Tick => Cmd::none(),
// --- Loaded results (stale guard) ---
Msg::IssueListLoaded { generation, page } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
{
self.state.issue_list.apply_page(page);
self.state.set_loading(Screen::IssueList, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
}
Cmd::none()
}
Msg::MrListLoaded { generation, page } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
{
self.state.mr_list.apply_page(page);
self.state.set_loading(Screen::MrList, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
}
Cmd::none()
}
Msg::DashboardLoaded { generation, data } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
{
self.state.dashboard.update(*data);
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
}
Cmd::none()
}
// --- Issue detail ---
Msg::IssueDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
// Populate entity cache (metadata only; discussions added later).
self.state.issue_cache.put(
key,
CachedIssuePayload {
data: (*data).clone(),
discussions: Vec::new(),
},
);
self.state.issue_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
Msg::DiscussionsLoaded {
generation: _,
key,
discussions,
} => {
// Progressive hydration: the parent detail task already called
// supervisor.complete(), so is_current() would return false.
// Instead, check that the detail state still expects this key.
match key.kind {
EntityKind::Issue => {
if self.state.issue_detail.current_key.as_ref() == Some(&key) {
self.state
.issue_detail
.apply_discussions(discussions.clone());
// Update cache with discussions.
if let Some(cached) = self.state.issue_cache.get_mut(&key) {
cached.discussions = discussions;
}
}
}
EntityKind::MergeRequest => {
if self.state.mr_detail.current_key.as_ref() == Some(&key) {
self.state.mr_detail.apply_discussions(discussions.clone());
// Update cache with discussions.
if let Some(cached) = self.state.mr_cache.get_mut(&key) {
cached.discussions = discussions;
}
}
}
}
Cmd::none()
}
// --- MR detail ---
Msg::MrDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::MrDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
// Populate entity cache (metadata only; discussions added later).
self.state.mr_cache.put(
key,
CachedMrPayload {
data: (*data).clone(),
discussions: Vec::new(),
},
);
self.state.mr_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
// --- Sync lifecycle ---
Msg::SyncStarted => {
self.state.sync.start();
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
}
Cmd::none()
}
Msg::SyncProgress {
stage,
current,
total,
} => {
self.state.sync.update_progress(&stage, current, total);
Cmd::none()
}
Msg::SyncProgressBatch { stage, batch_size } => {
self.state.sync.update_batch(&stage, batch_size);
Cmd::none()
}
Msg::SyncLogLine(line) => {
self.state.sync.add_log_line(line);
Cmd::none()
}
Msg::SyncBackpressureDrop => {
// Silently drop — the coalescer already handles throttling.
Cmd::none()
}
Msg::SyncCompleted { elapsed_ms } => {
self.state.sync.complete(elapsed_ms);
// Flush entity caches — sync may have updated any entity's
// metadata, discussions, or cross-refs in the DB.
self.state.issue_cache.clear();
self.state.mr_cache.clear();
// If we came from Bootstrap, replace nav history with Dashboard.
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = false;
self.navigation.reset_to(Screen::Dashboard);
// Trigger a fresh dashboard load without preserving Bootstrap in history.
let dashboard = Screen::Dashboard;
let load_state = if self.state.load_state.was_visited(&dashboard) {
LoadState::Refreshing
} else {
LoadState::LoadingInitial
};
self.state.set_loading(dashboard.clone(), load_state);
let _handle = self.supervisor.submit(TaskKey::LoadScreen(dashboard));
}
// If currently on a detail view, refresh it so the user sees
// updated data without navigating away and back.
let current = self.navigation.current().clone();
match &current {
Screen::IssueDetail(_) | Screen::MrDetail(_) => {
self.state
.set_loading(current.clone(), LoadState::Refreshing);
let _handle = self.supervisor.submit(TaskKey::LoadScreen(current));
}
_ => {}
}
Cmd::none()
}
Msg::SyncCancelled => {
self.state.sync.cancel();
Cmd::none()
}
Msg::SyncFailed(err) => {
self.state.sync.fail(err);
Cmd::none()
}
Msg::SyncStreamStats { bytes, items } => {
self.state.sync.update_stream_stats(bytes, items);
Cmd::none()
}
// --- Who screen ---
Msg::WhoResultLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Who), generation)
{
self.state.who.apply_results(generation, *result);
self.state.set_loading(Screen::Who, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Who), generation);
}
Cmd::none()
}
Msg::WhoModeChanged => {
// Mode tab changed — view will re-render from state.
Cmd::none()
}
// --- File History screen ---
Msg::FileHistoryLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::FileHistory), generation)
{
self.state.file_history.apply_results(generation, *result);
self.state.set_loading(Screen::FileHistory, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::FileHistory), generation);
}
Cmd::none()
}
Msg::FileHistoryKnownPathsLoaded { paths } => {
self.state.file_history.known_paths = paths;
Cmd::none()
}
// --- Trace screen ---
Msg::TraceResultLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Trace), generation)
{
self.state.trace.apply_result(generation, *result);
self.state.set_loading(Screen::Trace, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Trace), generation);
}
Cmd::none()
}
Msg::TraceKnownPathsLoaded { paths } => {
self.state.trace.known_paths = paths;
Cmd::none()
}
// --- Doctor ---
Msg::DoctorLoaded { checks } => {
self.state.doctor.apply_checks(checks);
self.state.set_loading(Screen::Doctor, LoadState::Idle);
Cmd::none()
}
// --- Stats ---
Msg::StatsLoaded { data } => {
self.state.stats.apply_data(data);
self.state.set_loading(Screen::Stats, LoadState::Idle);
Cmd::none()
}
// --- Timeline ---
Msg::TimelineLoaded { generation, events } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Timeline), generation)
{
self.state.timeline.apply_results(generation, events);
self.state.set_loading(Screen::Timeline, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Timeline), generation);
}
Cmd::none()
}
// --- Search ---
Msg::SearchExecuted {
generation,
results,
} => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Search), generation)
{
self.state.search.apply_results(generation, results);
self.state.set_loading(Screen::Search, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Search), generation);
}
Cmd::none()
}
// --- Scope ---
Msg::ScopeProjectsLoaded { projects } => {
self.state
.scope_picker
.open(projects, &self.state.global_scope);
Cmd::none()
}
// All other message variants: no-op for now.
// Future phases will fill these in as screens are implemented.
_ => Cmd::none(),
}
}
}
impl Model for LoreApp {
type Message = Msg;
fn init(&mut self) -> Cmd<Self::Message> {
// Install crash context panic hook.
crate::crash_context::CrashContext::install_panic_hook(&self.crash_context);
crate::crash_context::CrashContext::prune_crash_files();
// Navigate to dashboard (will trigger data load in future phase).
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
// Route raw key events through the 5-stage pipeline.
if let Msg::RawEvent(Event::Key(key)) = msg {
return self.interpret_key(key);
}
// Everything else goes through message dispatch.
self.handle_msg(msg)
}
fn view(&self, frame: &mut Frame) {
crate::view::render_screen(frame, self);
}
}

View File

@@ -0,0 +1,165 @@
//! Injected clock for deterministic time in tests and consistent frame timestamps.
//!
//! All relative-time rendering (e.g., "3h ago") uses [`Clock::now()`] rather
//! than wall-clock time directly. This enables:
//! - Deterministic snapshot tests via [`FakeClock`]
//! - Consistent timestamps within a single frame render pass
use std::sync::{Arc, Mutex};
use chrono::{DateTime, TimeDelta, Utc};
/// Trait for obtaining the current time.
///
/// Inject via `Arc<dyn Clock>` to allow swapping between real and fake clocks.
pub trait Clock: Send + Sync {
/// Returns the current time.
fn now(&self) -> DateTime<Utc>;
/// Returns the current time as milliseconds since the Unix epoch.
fn now_ms(&self) -> i64 {
self.now().timestamp_millis()
}
}
// ---------------------------------------------------------------------------
// SystemClock
// ---------------------------------------------------------------------------
/// Real wall-clock time via `chrono::Utc::now()`.
#[derive(Debug, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
// ---------------------------------------------------------------------------
// FakeClock
// ---------------------------------------------------------------------------
/// A controllable clock for tests. Returns a frozen time that can be
/// advanced or set explicitly.
///
/// `FakeClock` is `Clone` (shares the inner `Arc`) and `Send + Sync`
/// for use across `Cmd::task` threads.
#[derive(Debug, Clone)]
pub struct FakeClock {
inner: Arc<Mutex<DateTime<Utc>>>,
}
impl FakeClock {
/// Create a fake clock frozen at the given time.
#[must_use]
pub fn new(time: DateTime<Utc>) -> Self {
Self {
inner: Arc::new(Mutex::new(time)),
}
}
/// Create a fake clock frozen at the given millisecond epoch timestamp.
///
/// Convenience for action tests that work with raw epoch milliseconds.
#[must_use]
pub fn from_ms(epoch_ms: i64) -> Self {
let time = DateTime::from_timestamp_millis(epoch_ms).expect("valid millisecond timestamp");
Self::new(time)
}
/// Advance the clock by `duration`. Uses `checked_add` to handle overflow
/// gracefully — if the addition would overflow, the time is not changed.
pub fn advance(&self, duration: TimeDelta) {
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
if let Some(advanced) = guard.checked_add_signed(duration) {
*guard = advanced;
}
}
/// Set the clock to an exact time.
pub fn set(&self, time: DateTime<Utc>) {
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
*guard = time;
}
}
impl Clock for FakeClock {
fn now(&self) -> DateTime<Utc> {
*self.inner.lock().expect("FakeClock mutex poisoned")
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed_time() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 2, 12, 12, 0, 0).unwrap()
}
#[test]
fn test_fake_clock_frozen() {
let clock = FakeClock::new(fixed_time());
let t1 = clock.now();
let t2 = clock.now();
assert_eq!(t1, t2);
assert_eq!(t1, fixed_time());
}
#[test]
fn test_fake_clock_advance() {
let clock = FakeClock::new(fixed_time());
clock.advance(TimeDelta::hours(3));
let expected = Utc.with_ymd_and_hms(2026, 2, 12, 15, 0, 0).unwrap();
assert_eq!(clock.now(), expected);
}
#[test]
fn test_fake_clock_set() {
let clock = FakeClock::new(fixed_time());
let new_time = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
clock.set(new_time);
assert_eq!(clock.now(), new_time);
}
#[test]
fn test_fake_clock_clone_shares_state() {
let clock1 = FakeClock::new(fixed_time());
let clock2 = clock1.clone();
clock1.advance(TimeDelta::minutes(30));
// Both clones see the advanced time.
assert_eq!(clock1.now(), clock2.now());
}
#[test]
fn test_system_clock_returns_reasonable_time() {
let clock = SystemClock;
let now = clock.now();
// Sanity: time should be after 2025.
assert!(now.year() >= 2025);
}
#[test]
fn test_fake_clock_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<FakeClock>();
assert_send_sync::<SystemClock>();
}
#[test]
fn test_clock_trait_object_works() {
let fake: Arc<dyn Clock> = Arc::new(FakeClock::new(fixed_time()));
assert_eq!(fake.now(), fixed_time());
let real: Arc<dyn Clock> = Arc::new(SystemClock);
let _ = real.now(); // Just verify it doesn't panic.
}
use chrono::Datelike;
}

View File

@@ -0,0 +1,807 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Command registry — single source of truth for all TUI actions.
//!
//! Every keybinding, palette entry, help text, CLI equivalent, and
//! status hint is generated from [`CommandRegistry`]. No hardcoded
//! duplicate maps exist in view/state modules.
//!
//! Supports single-key and two-key sequences (g-prefix vim bindings).
use std::collections::HashMap;
use ftui::{KeyCode, Modifiers};
use crate::message::{InputMode, Screen};
// ---------------------------------------------------------------------------
// Key formatting
// ---------------------------------------------------------------------------
/// Format a key code + modifiers as a human-readable string.
fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
let mut parts = Vec::new();
if modifiers.contains(Modifiers::CTRL) {
parts.push("Ctrl");
}
if modifiers.contains(Modifiers::ALT) {
parts.push("Alt");
}
if modifiers.contains(Modifiers::SHIFT) {
parts.push("Shift");
}
let key_name = match code {
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Escape => "Esc".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Delete => "Del".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PgUp".to_string(),
KeyCode::PageDown => "PgDn".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => "?".to_string(),
};
parts.push(&key_name);
// We need to own the joined string.
let joined: String = parts.join("+");
joined
}
// ---------------------------------------------------------------------------
// KeyCombo
// ---------------------------------------------------------------------------
/// A keybinding: either a single key or a two-key sequence.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyCombo {
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
Single { code: KeyCode, modifiers: Modifiers },
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
Sequence {
first_code: KeyCode,
first_modifiers: Modifiers,
second_code: KeyCode,
second_modifiers: Modifiers,
},
}
impl KeyCombo {
/// Convenience: single key with no modifiers.
#[must_use]
pub const fn key(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::NONE,
}
}
/// Convenience: single key with Ctrl modifier.
#[must_use]
pub const fn ctrl(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::CTRL,
}
}
/// Convenience: g-prefix sequence (g + char).
#[must_use]
pub const fn g_then(c: char) -> Self {
Self::Sequence {
first_code: KeyCode::Char('g'),
first_modifiers: Modifiers::NONE,
second_code: KeyCode::Char(c),
second_modifiers: Modifiers::NONE,
}
}
/// Human-readable display string for this key combo.
#[must_use]
pub fn display(&self) -> String {
match self {
Self::Single { code, modifiers } => format_key(*code, *modifiers),
Self::Sequence {
first_code,
first_modifiers,
second_code,
second_modifiers,
} => {
let first = format_key(*first_code, *first_modifiers);
let second = format_key(*second_code, *second_modifiers);
format!("{first} {second}")
}
}
}
/// Whether this combo starts with the given key.
#[must_use]
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
match self {
Self::Single {
code: c,
modifiers: m,
} => c == code && m == modifiers,
Self::Sequence {
first_code,
first_modifiers,
..
} => first_code == code && first_modifiers == modifiers,
}
}
}
// ---------------------------------------------------------------------------
// ScreenFilter
// ---------------------------------------------------------------------------
/// Specifies which screens a command is available on.
#[derive(Debug, Clone)]
pub enum ScreenFilter {
/// Available on all screens.
Global,
/// Available only on specific screens.
Only(Vec<Screen>),
}
impl ScreenFilter {
/// Whether the command is available on the given screen.
#[must_use]
pub fn matches(&self, screen: &Screen) -> bool {
match self {
Self::Global => true,
Self::Only(screens) => screens.contains(screen),
}
}
}
// ---------------------------------------------------------------------------
// CommandDef
// ---------------------------------------------------------------------------
/// Unique command identifier.
pub type CommandId = &'static str;
/// A registered command with its keybinding, help text, and scope.
#[derive(Debug, Clone)]
pub struct CommandDef {
/// Unique identifier (e.g., "quit", "go_issues").
pub id: CommandId,
/// Human-readable label for palette and help overlay.
pub label: &'static str,
/// Keybinding (if any).
pub keybinding: Option<KeyCombo>,
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
pub cli_equivalent: Option<&'static str>,
/// Description for help overlay.
pub help_text: &'static str,
/// Short hint for status bar (e.g., "q:quit").
pub status_hint: &'static str,
/// Which screens this command is available on.
pub available_in: ScreenFilter,
/// Whether this command works in Text input mode.
pub available_in_text_mode: bool,
}
// ---------------------------------------------------------------------------
// CommandRegistry
// ---------------------------------------------------------------------------
/// Single source of truth for all TUI commands.
///
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
/// by keybinding and per-screen filtering.
pub struct CommandRegistry {
commands: Vec<CommandDef>,
/// Single-key -> command IDs that start with this key.
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
/// Full sequence -> command index (for two-key combos).
by_sequence: HashMap<KeyCombo, usize>,
}
impl CommandRegistry {
/// Look up a command by a single key press on a given screen and input mode.
///
/// Returns `None` if no matching command is found. For sequence starters
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
/// that case.
#[must_use]
pub fn lookup_key(
&self,
code: &KeyCode,
modifiers: &Modifiers,
screen: &Screen,
mode: &InputMode,
) -> Option<&CommandDef> {
let is_text = matches!(mode, InputMode::Text);
let key = (*code, *modifiers);
let indices = self.by_single_key.get(&key)?;
for &idx in indices {
let cmd = &self.commands[idx];
if !cmd.available_in.matches(screen) {
continue;
}
if is_text && !cmd.available_in_text_mode {
continue;
}
// Only match Single combos here, not sequence starters.
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
return Some(cmd);
}
}
None
}
/// Complete a two-key sequence.
///
/// Called after the first key of a sequence is detected (e.g., after 'g').
#[must_use]
pub fn complete_sequence(
&self,
first_code: &KeyCode,
first_modifiers: &Modifiers,
second_code: &KeyCode,
second_modifiers: &Modifiers,
screen: &Screen,
) -> Option<&CommandDef> {
let combo = KeyCombo::Sequence {
first_code: *first_code,
first_modifiers: *first_modifiers,
second_code: *second_code,
second_modifiers: *second_modifiers,
};
let &idx = self.by_sequence.get(&combo)?;
let cmd = &self.commands[idx];
if cmd.available_in.matches(screen) {
Some(cmd)
} else {
None
}
}
/// Whether a key starts a multi-key sequence (e.g., 'g').
#[must_use]
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
self.by_sequence
.keys()
.any(|combo| combo.starts_with(code, modifiers))
}
/// Commands available for the command palette on a given screen.
///
/// Returned sorted by label.
#[must_use]
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
let mut entries: Vec<&CommandDef> = self
.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.collect();
entries.sort_by_key(|c| c.label);
entries
}
/// Commands for the help overlay on a given screen.
#[must_use]
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| c.keybinding.is_some())
.collect()
}
/// Status bar hints for the current screen.
#[must_use]
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| !c.status_hint.is_empty())
.map(|c| c.status_hint)
.collect()
}
/// Total number of registered commands.
#[must_use]
pub fn len(&self) -> usize {
self.commands.len()
}
/// Whether the registry has no commands.
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
// ---------------------------------------------------------------------------
// build_registry
// ---------------------------------------------------------------------------
/// Build the command registry with all TUI commands.
///
/// This is the single source of truth — every keybinding, help text,
/// and palette entry originates here.
#[must_use]
pub fn build_registry() -> CommandRegistry {
let commands = vec![
// --- Global commands ---
CommandDef {
id: "quit",
label: "Quit",
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
cli_equivalent: None,
help_text: "Exit the TUI",
status_hint: "q:quit",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_back",
label: "Go Back",
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
cli_equivalent: None,
help_text: "Go back to previous screen",
status_hint: "esc:back",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "show_help",
label: "Help",
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
cli_equivalent: None,
help_text: "Show keybinding help overlay",
status_hint: "?:help",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "command_palette",
label: "Command Palette",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
cli_equivalent: None,
help_text: "Open command palette",
status_hint: "C-p:palette",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "open_in_browser",
label: "Open in Browser",
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Open current entity in browser",
status_hint: "o:browser",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "show_cli",
label: "Show CLI Equivalent",
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
cli_equivalent: None,
help_text: "Show equivalent lore CLI command",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Navigation: g-prefix sequences ---
CommandDef {
id: "go_home",
label: "Go to Dashboard",
keybinding: Some(KeyCombo::g_then('h')),
cli_equivalent: None,
help_text: "Jump to dashboard",
status_hint: "gh:home",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_issues",
label: "Go to Issues",
keybinding: Some(KeyCombo::g_then('i')),
cli_equivalent: Some("lore issues"),
help_text: "Jump to issue list",
status_hint: "gi:issues",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_mrs",
label: "Go to Merge Requests",
keybinding: Some(KeyCombo::g_then('m')),
cli_equivalent: Some("lore mrs"),
help_text: "Jump to MR list",
status_hint: "gm:mrs",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_search",
label: "Go to Search",
keybinding: Some(KeyCombo::g_then('/')),
cli_equivalent: Some("lore search"),
help_text: "Jump to search",
status_hint: "g/:search",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_timeline",
label: "Go to Timeline",
keybinding: Some(KeyCombo::g_then('t')),
cli_equivalent: Some("lore timeline"),
help_text: "Jump to timeline",
status_hint: "gt:timeline",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_who",
label: "Go to Who",
keybinding: Some(KeyCombo::g_then('w')),
cli_equivalent: Some("lore who"),
help_text: "Jump to people intelligence",
status_hint: "gw:who",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_sync",
label: "Go to Sync",
keybinding: Some(KeyCombo::g_then('s')),
cli_equivalent: Some("lore sync"),
help_text: "Jump to sync status",
status_hint: "gs:sync",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Vim-style jump list ---
CommandDef {
id: "jump_back",
label: "Jump Back",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Jump backward through visited detail views",
status_hint: "C-o:jump back",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "jump_forward",
label: "Jump Forward",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
cli_equivalent: None,
help_text: "Jump forward through visited detail views",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- List navigation ---
CommandDef {
id: "move_down",
label: "Move Down",
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
cli_equivalent: None,
help_text: "Move cursor down",
status_hint: "j:down",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "move_up",
label: "Move Up",
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
cli_equivalent: None,
help_text: "Move cursor up",
status_hint: "k:up",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "select_item",
label: "Select",
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
cli_equivalent: None,
help_text: "Open selected item",
status_hint: "enter:open",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
]),
available_in_text_mode: false,
},
// --- Filter ---
CommandDef {
id: "focus_filter",
label: "Filter",
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
cli_equivalent: None,
help_text: "Focus the filter input",
status_hint: "/:filter",
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
available_in_text_mode: false,
},
// --- Scroll ---
CommandDef {
id: "scroll_to_top",
label: "Scroll to Top",
keybinding: Some(KeyCombo::g_then('g')),
cli_equivalent: None,
help_text: "Scroll to the top of the current view",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
];
build_from_defs(commands)
}
/// Build index maps from a list of command definitions.
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
for (idx, cmd) in commands.iter().enumerate() {
if let Some(combo) = &cmd.keybinding {
match combo {
KeyCombo::Single { code, modifiers } => {
by_single_key
.entry((*code, *modifiers))
.or_default()
.push(idx);
}
KeyCombo::Sequence { .. } => {
by_sequence.insert(combo.clone(), idx);
// Also index the first key so is_sequence_starter works via by_single_key.
if let KeyCombo::Sequence {
first_code,
first_modifiers,
..
} = combo
{
by_single_key
.entry((*first_code, *first_modifiers))
.or_default()
.push(idx);
}
}
}
}
}
CommandRegistry {
commands,
by_single_key,
by_sequence,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_registry_builds_successfully() {
let reg = build_registry();
assert!(!reg.is_empty());
assert!(reg.len() >= 15);
}
#[test]
fn test_registry_lookup_quit() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_registry_lookup_quit_blocked_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Text,
);
assert!(cmd.is_none());
}
#[test]
fn test_registry_esc_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Escape,
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_back");
}
#[test]
fn test_registry_ctrl_p_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('p'),
&Modifiers::CTRL,
&Screen::Search,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "command_palette");
}
#[test]
fn test_g_is_sequence_starter() {
let reg = build_registry();
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
}
#[test]
fn test_complete_sequence_gi() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('i'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_issues");
}
#[test]
fn test_complete_sequence_invalid_second_key() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('x'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_none());
}
#[test]
fn test_screen_specific_command() {
let reg = build_registry();
// 'j' (move_down) should work on IssueList
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "move_down");
// 'j' should NOT match on Dashboard (move_down is list-only).
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_none());
}
#[test]
fn test_palette_entries_sorted_by_label() {
let reg = build_registry();
let entries = reg.palette_entries(&Screen::Dashboard);
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
let mut sorted = labels.clone();
sorted.sort();
assert_eq!(labels, sorted);
}
#[test]
fn test_help_entries_only_include_keybindings() {
let reg = build_registry();
let entries = reg.help_entries(&Screen::Dashboard);
for entry in &entries {
assert!(
entry.keybinding.is_some(),
"help entry without keybinding: {}",
entry.id
);
}
}
#[test]
fn test_status_hints_non_empty() {
let reg = build_registry();
let hints = reg.status_hints(&Screen::Dashboard);
assert!(!hints.is_empty());
// All returned hints should be non-empty strings.
for hint in &hints {
assert!(!hint.is_empty());
}
}
#[test]
fn test_cli_equivalents_populated() {
let reg = build_registry();
let with_cli: Vec<&CommandDef> = reg
.commands
.iter()
.filter(|c| c.cli_equivalent.is_some())
.collect();
assert!(
with_cli.len() >= 5,
"expected at least 5 commands with cli_equivalent, got {}",
with_cli.len()
);
}
#[test]
fn test_go_prefix_timeout_detection() {
let reg = build_registry();
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
// Simulate InputMode::GoPrefix with timeout check.
let started = Utc::now();
let mode = InputMode::GoPrefix {
started_at: started,
};
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&mode,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_all_commands_have_nonempty_help() {
let reg = build_registry();
for cmd in &reg.commands {
assert!(
!cmd.help_text.is_empty(),
"command {} has empty help_text",
cmd.id
);
}
}
}

View File

@@ -0,0 +1,180 @@
//! Command definitions — types for keybindings, screen filtering, and command metadata.
use ftui::{KeyCode, Modifiers};
use crate::message::Screen;
// ---------------------------------------------------------------------------
// Key formatting
// ---------------------------------------------------------------------------
/// Format a key code + modifiers as a human-readable string.
pub(crate) fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
let mut parts = Vec::new();
if modifiers.contains(Modifiers::CTRL) {
parts.push("Ctrl");
}
if modifiers.contains(Modifiers::ALT) {
parts.push("Alt");
}
if modifiers.contains(Modifiers::SHIFT) {
parts.push("Shift");
}
let key_name = match code {
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Escape => "Esc".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Delete => "Del".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PgUp".to_string(),
KeyCode::PageDown => "PgDn".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => "?".to_string(),
};
parts.push(&key_name);
// We need to own the joined string.
let joined: String = parts.join("+");
joined
}
// ---------------------------------------------------------------------------
// KeyCombo
// ---------------------------------------------------------------------------
/// A keybinding: either a single key or a two-key sequence.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyCombo {
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
Single { code: KeyCode, modifiers: Modifiers },
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
Sequence {
first_code: KeyCode,
first_modifiers: Modifiers,
second_code: KeyCode,
second_modifiers: Modifiers,
},
}
impl KeyCombo {
/// Convenience: single key with no modifiers.
#[must_use]
pub const fn key(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::NONE,
}
}
/// Convenience: single key with Ctrl modifier.
#[must_use]
pub const fn ctrl(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::CTRL,
}
}
/// Convenience: g-prefix sequence (g + char).
#[must_use]
pub const fn g_then(c: char) -> Self {
Self::Sequence {
first_code: KeyCode::Char('g'),
first_modifiers: Modifiers::NONE,
second_code: KeyCode::Char(c),
second_modifiers: Modifiers::NONE,
}
}
/// Human-readable display string for this key combo.
#[must_use]
pub fn display(&self) -> String {
match self {
Self::Single { code, modifiers } => format_key(*code, *modifiers),
Self::Sequence {
first_code,
first_modifiers,
second_code,
second_modifiers,
} => {
let first = format_key(*first_code, *first_modifiers);
let second = format_key(*second_code, *second_modifiers);
format!("{first} {second}")
}
}
}
/// Whether this combo starts with the given key.
#[must_use]
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
match self {
Self::Single {
code: c,
modifiers: m,
} => c == code && m == modifiers,
Self::Sequence {
first_code,
first_modifiers,
..
} => first_code == code && first_modifiers == modifiers,
}
}
}
// ---------------------------------------------------------------------------
// ScreenFilter
// ---------------------------------------------------------------------------
/// Specifies which screens a command is available on.
#[derive(Debug, Clone)]
pub enum ScreenFilter {
/// Available on all screens.
Global,
/// Available only on specific screens.
Only(Vec<Screen>),
}
impl ScreenFilter {
/// Whether the command is available on the given screen.
#[must_use]
pub fn matches(&self, screen: &Screen) -> bool {
match self {
Self::Global => true,
Self::Only(screens) => screens.contains(screen),
}
}
}
// ---------------------------------------------------------------------------
// CommandDef
// ---------------------------------------------------------------------------
/// Unique command identifier.
pub type CommandId = &'static str;
/// A registered command with its keybinding, help text, and scope.
#[derive(Debug, Clone)]
pub struct CommandDef {
/// Unique identifier (e.g., "quit", "go_issues").
pub id: CommandId,
/// Human-readable label for palette and help overlay.
pub label: &'static str,
/// Keybinding (if any).
pub keybinding: Option<KeyCombo>,
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
pub cli_equivalent: Option<&'static str>,
/// Description for help overlay.
pub help_text: &'static str,
/// Short hint for status bar (e.g., "q:quit").
pub status_hint: &'static str,
/// Which screens this command is available on.
pub available_in: ScreenFilter,
/// Whether this command works in Text input mode.
pub available_in_text_mode: bool,
}

View File

@@ -0,0 +1,227 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Command registry — single source of truth for all TUI actions.
//!
//! Every keybinding, palette entry, help text, CLI equivalent, and
//! status hint is generated from [`CommandRegistry`]. No hardcoded
//! duplicate maps exist in view/state modules.
//!
//! Supports single-key and two-key sequences (g-prefix vim bindings).
mod defs;
mod registry;
// Re-export public API — preserves `crate::commands::{CommandRegistry, build_registry, ...}`.
pub use defs::{CommandDef, CommandId, KeyCombo, ScreenFilter};
pub use registry::{CommandRegistry, build_registry};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use ftui::{KeyCode, Modifiers};
use crate::message::{InputMode, Screen};
#[test]
fn test_registry_builds_successfully() {
let reg = build_registry();
assert!(!reg.is_empty());
assert!(reg.len() >= 15);
}
#[test]
fn test_registry_lookup_quit() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_registry_lookup_quit_blocked_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Text,
);
assert!(cmd.is_none());
}
#[test]
fn test_registry_esc_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Escape,
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_back");
}
#[test]
fn test_registry_ctrl_p_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('p'),
&Modifiers::CTRL,
&Screen::Search,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "command_palette");
}
#[test]
fn test_g_is_sequence_starter() {
let reg = build_registry();
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
}
#[test]
fn test_complete_sequence_gi() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('i'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_issues");
}
#[test]
fn test_complete_sequence_invalid_second_key() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('z'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_none());
}
#[test]
fn test_screen_specific_command() {
let reg = build_registry();
// 'j' (move_down) should work on IssueList
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "move_down");
// 'j' should NOT match on Dashboard (move_down is list-only).
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_none());
}
#[test]
fn test_palette_entries_sorted_by_label() {
let reg = build_registry();
let entries = reg.palette_entries(&Screen::Dashboard);
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
let mut sorted = labels.clone();
sorted.sort();
assert_eq!(labels, sorted);
}
#[test]
fn test_help_entries_only_include_keybindings() {
let reg = build_registry();
let entries = reg.help_entries(&Screen::Dashboard);
for entry in &entries {
assert!(
entry.keybinding.is_some(),
"help entry without keybinding: {}",
entry.id
);
}
}
#[test]
fn test_status_hints_non_empty() {
let reg = build_registry();
let hints = reg.status_hints(&Screen::Dashboard);
assert!(!hints.is_empty());
// All returned hints should be non-empty strings.
for hint in &hints {
assert!(!hint.is_empty());
}
}
#[test]
fn test_cli_equivalents_populated() {
let reg = build_registry();
let with_cli: Vec<&CommandDef> = reg
.commands
.iter()
.filter(|c| c.cli_equivalent.is_some())
.collect();
assert!(
with_cli.len() >= 5,
"expected at least 5 commands with cli_equivalent, got {}",
with_cli.len()
);
}
#[test]
fn test_go_prefix_timeout_detection() {
let reg = build_registry();
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
// Simulate InputMode::GoPrefix with timeout check.
let started = Utc::now();
let mode = InputMode::GoPrefix {
started_at: started,
};
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&mode,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_all_commands_have_nonempty_help() {
let reg = build_registry();
for cmd in &reg.commands {
assert!(
!cmd.help_text.is_empty(),
"command {} has empty help_text",
cmd.id
);
}
}
}

View File

@@ -0,0 +1,468 @@
//! Command registry — lookup, indexing, and the canonical command list.
use std::collections::HashMap;
use ftui::{KeyCode, Modifiers};
use crate::message::{InputMode, Screen};
use super::defs::{CommandDef, KeyCombo, ScreenFilter};
// ---------------------------------------------------------------------------
// CommandRegistry
// ---------------------------------------------------------------------------
/// Single source of truth for all TUI commands.
///
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
/// by keybinding and per-screen filtering.
pub struct CommandRegistry {
pub(crate) commands: Vec<CommandDef>,
/// Single-key -> command IDs that start with this key.
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
/// Full sequence -> command index (for two-key combos).
by_sequence: HashMap<KeyCombo, usize>,
}
impl CommandRegistry {
/// Look up a command by a single key press on a given screen and input mode.
///
/// Returns `None` if no matching command is found. For sequence starters
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
/// that case.
#[must_use]
pub fn lookup_key(
&self,
code: &KeyCode,
modifiers: &Modifiers,
screen: &Screen,
mode: &InputMode,
) -> Option<&CommandDef> {
let is_text = matches!(mode, InputMode::Text);
let key = (*code, *modifiers);
let indices = self.by_single_key.get(&key)?;
for &idx in indices {
let cmd = &self.commands[idx];
if !cmd.available_in.matches(screen) {
continue;
}
if is_text && !cmd.available_in_text_mode {
continue;
}
// Only match Single combos here, not sequence starters.
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
return Some(cmd);
}
}
None
}
/// Complete a two-key sequence.
///
/// Called after the first key of a sequence is detected (e.g., after 'g').
#[must_use]
pub fn complete_sequence(
&self,
first_code: &KeyCode,
first_modifiers: &Modifiers,
second_code: &KeyCode,
second_modifiers: &Modifiers,
screen: &Screen,
) -> Option<&CommandDef> {
let combo = KeyCombo::Sequence {
first_code: *first_code,
first_modifiers: *first_modifiers,
second_code: *second_code,
second_modifiers: *second_modifiers,
};
let &idx = self.by_sequence.get(&combo)?;
let cmd = &self.commands[idx];
if cmd.available_in.matches(screen) {
Some(cmd)
} else {
None
}
}
/// Whether a key starts a multi-key sequence (e.g., 'g').
#[must_use]
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
self.by_sequence
.keys()
.any(|combo| combo.starts_with(code, modifiers))
}
/// Commands available for the command palette on a given screen.
///
/// Returned sorted by label.
#[must_use]
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
let mut entries: Vec<&CommandDef> = self
.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.collect();
entries.sort_by_key(|c| c.label);
entries
}
/// Commands for the help overlay on a given screen.
#[must_use]
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| c.keybinding.is_some())
.collect()
}
/// Status bar hints for the current screen.
#[must_use]
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| !c.status_hint.is_empty())
.map(|c| c.status_hint)
.collect()
}
/// Total number of registered commands.
#[must_use]
pub fn len(&self) -> usize {
self.commands.len()
}
/// Whether the registry has no commands.
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
// ---------------------------------------------------------------------------
// build_registry
// ---------------------------------------------------------------------------
/// Build the command registry with all TUI commands.
///
/// This is the single source of truth — every keybinding, help text,
/// and palette entry originates here.
#[must_use]
pub fn build_registry() -> CommandRegistry {
let commands = vec![
// --- Global commands ---
CommandDef {
id: "quit",
label: "Quit",
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
cli_equivalent: None,
help_text: "Exit the TUI",
status_hint: "q:quit",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_back",
label: "Go Back",
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
cli_equivalent: None,
help_text: "Go back to previous screen",
status_hint: "esc:back",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "show_help",
label: "Help",
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
cli_equivalent: None,
help_text: "Show keybinding help overlay",
status_hint: "?:help",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "command_palette",
label: "Command Palette",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
cli_equivalent: None,
help_text: "Open command palette",
status_hint: "C-p:palette",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "open_in_browser",
label: "Open in Browser",
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Open current entity in browser",
status_hint: "o:browser",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "show_cli",
label: "Show CLI Equivalent",
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
cli_equivalent: None,
help_text: "Show equivalent lore CLI command",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "toggle_scope",
label: "Project Scope",
keybinding: Some(KeyCombo::key(KeyCode::Char('P'))),
cli_equivalent: None,
help_text: "Toggle project scope filter",
status_hint: "P:scope",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Navigation: g-prefix sequences ---
CommandDef {
id: "go_home",
label: "Go to Dashboard",
keybinding: Some(KeyCombo::g_then('h')),
cli_equivalent: None,
help_text: "Jump to dashboard",
status_hint: "gh:home",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_issues",
label: "Go to Issues",
keybinding: Some(KeyCombo::g_then('i')),
cli_equivalent: Some("lore issues"),
help_text: "Jump to issue list",
status_hint: "gi:issues",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_mrs",
label: "Go to Merge Requests",
keybinding: Some(KeyCombo::g_then('m')),
cli_equivalent: Some("lore mrs"),
help_text: "Jump to MR list",
status_hint: "gm:mrs",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_search",
label: "Go to Search",
keybinding: Some(KeyCombo::g_then('/')),
cli_equivalent: Some("lore search"),
help_text: "Jump to search",
status_hint: "g/:search",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_timeline",
label: "Go to Timeline",
keybinding: Some(KeyCombo::g_then('t')),
cli_equivalent: Some("lore timeline"),
help_text: "Jump to timeline",
status_hint: "gt:timeline",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_who",
label: "Go to Who",
keybinding: Some(KeyCombo::g_then('w')),
cli_equivalent: Some("lore who"),
help_text: "Jump to people intelligence",
status_hint: "gw:who",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_sync",
label: "Go to Sync",
keybinding: Some(KeyCombo::g_then('s')),
cli_equivalent: Some("lore sync"),
help_text: "Jump to sync status",
status_hint: "gs:sync",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_file_history",
label: "Go to File History",
keybinding: Some(KeyCombo::g_then('f')),
cli_equivalent: Some("lore file-history"),
help_text: "Jump to file history",
status_hint: "gf:files",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_trace",
label: "Go to Trace",
keybinding: Some(KeyCombo::g_then('r')),
cli_equivalent: Some("lore trace"),
help_text: "Jump to trace",
status_hint: "gr:trace",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_doctor",
label: "Go to Doctor",
keybinding: Some(KeyCombo::g_then('d')),
cli_equivalent: Some("lore doctor"),
help_text: "Jump to environment health checks",
status_hint: "gd:doctor",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_stats",
label: "Go to Stats",
keybinding: Some(KeyCombo::g_then('x')),
cli_equivalent: Some("lore stats"),
help_text: "Jump to database statistics",
status_hint: "gx:stats",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Vim-style jump list ---
CommandDef {
id: "jump_back",
label: "Jump Back",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Jump backward through visited detail views",
status_hint: "C-o:jump back",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "jump_forward",
label: "Jump Forward",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
cli_equivalent: None,
help_text: "Jump forward through visited detail views",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- List navigation ---
CommandDef {
id: "move_down",
label: "Move Down",
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
cli_equivalent: None,
help_text: "Move cursor down",
status_hint: "j:down",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "move_up",
label: "Move Up",
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
cli_equivalent: None,
help_text: "Move cursor up",
status_hint: "k:up",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "select_item",
label: "Select",
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
cli_equivalent: None,
help_text: "Open selected item",
status_hint: "enter:open",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
]),
available_in_text_mode: false,
},
// --- Filter ---
CommandDef {
id: "focus_filter",
label: "Filter",
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
cli_equivalent: None,
help_text: "Focus the filter input",
status_hint: "/:filter",
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
available_in_text_mode: false,
},
// --- Scroll ---
CommandDef {
id: "scroll_to_top",
label: "Scroll to Top",
keybinding: Some(KeyCombo::g_then('g')),
cli_equivalent: None,
help_text: "Scroll to the top of the current view",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
];
build_from_defs(commands)
}
/// Build index maps from a list of command definitions.
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
for (idx, cmd) in commands.iter().enumerate() {
if let Some(combo) = &cmd.keybinding {
match combo {
KeyCombo::Single { code, modifiers } => {
by_single_key
.entry((*code, *modifiers))
.or_default()
.push(idx);
}
KeyCombo::Sequence { .. } => {
by_sequence.insert(combo.clone(), idx);
// Also index the first key so is_sequence_starter works via by_single_key.
if let KeyCombo::Sequence {
first_code,
first_modifiers,
..
} = combo
{
by_single_key
.entry((*first_code, *first_modifiers))
.or_default()
.push(idx);
}
}
}
}
}
CommandRegistry {
commands,
by_single_key,
by_sequence,
}
}

View File

@@ -0,0 +1,450 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Ring buffer of recent app events for post-mortem crash diagnostics.
//!
//! The TUI pushes every key press, message dispatch, and state transition
//! into [`CrashContext`]. On panic the installed hook dumps the last 2000
//! events to `~/.local/share/lore/crash-<timestamp>.json` as NDJSON.
//!
//! Retention: only the 5 most recent crash files are kept.
use std::collections::VecDeque;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::Serialize;
/// Maximum number of events retained in the ring buffer.
const MAX_EVENTS: usize = 2000;
/// Maximum number of crash files to keep on disk.
const MAX_CRASH_FILES: usize = 5;
// ---------------------------------------------------------------------------
// CrashEvent
// ---------------------------------------------------------------------------
/// A single event recorded for crash diagnostics.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum CrashEvent {
/// A key was pressed.
KeyPress {
key: String,
mode: String,
screen: String,
},
/// A message was dispatched through update().
MsgDispatched { msg_name: String, screen: String },
/// Navigation changed screens.
StateTransition { from: String, to: String },
/// An error occurred.
Error { message: String },
/// Catch-all for ad-hoc diagnostic breadcrumbs.
Custom { tag: String, detail: String },
}
// ---------------------------------------------------------------------------
// CrashContext
// ---------------------------------------------------------------------------
/// Ring buffer of recent app events for panic diagnostics.
///
/// Holds at most [`MAX_EVENTS`] entries. When full, the oldest event
/// is evicted on each push.
pub struct CrashContext {
events: VecDeque<CrashEvent>,
}
impl CrashContext {
/// Create an empty crash context with pre-allocated capacity.
#[must_use]
pub fn new() -> Self {
Self {
events: VecDeque::with_capacity(MAX_EVENTS),
}
}
/// Record an event. Evicts the oldest when the buffer is full.
pub fn push(&mut self, event: CrashEvent) {
if self.events.len() == MAX_EVENTS {
self.events.pop_front();
}
self.events.push_back(event);
}
/// Number of events currently stored.
#[must_use]
pub fn len(&self) -> usize {
self.events.len()
}
/// Whether the buffer is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Iterate over stored events (oldest first).
pub fn iter(&self) -> impl Iterator<Item = &CrashEvent> {
self.events.iter()
}
/// Dump all events to a file as newline-delimited JSON.
///
/// Creates parent directories if they don't exist.
/// Returns `Ok(())` on success, `Err` on I/O failure.
pub fn dump_to_file(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let mut writer = BufWriter::new(file);
for event in &self.events {
match serde_json::to_string(event) {
Ok(json) => {
writeln!(writer, "{json}")?;
}
Err(_) => {
// Fallback to debug format if serialization fails.
writeln!(
writer,
"{{\"type\":\"SerializationError\",\"debug\":\"{event:?}\"}}"
)?;
}
}
}
writer.flush()?;
Ok(())
}
/// Default crash directory: `~/.local/share/lore/`.
#[must_use]
pub fn crash_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join("lore"))
}
/// Generate a timestamped crash file path.
#[must_use]
pub fn crash_file_path() -> Option<PathBuf> {
let dir = Self::crash_dir()?;
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
Some(dir.join(format!("crash-{timestamp}.json")))
}
/// Remove old crash files, keeping only the most recent [`MAX_CRASH_FILES`].
///
/// Best-effort: silently ignores I/O errors on individual deletions.
pub fn prune_crash_files() {
let Some(dir) = Self::crash_dir() else {
return;
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let mut crash_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
})
.collect();
// Sort ascending by filename (timestamps sort lexicographically).
crash_files.sort();
if crash_files.len() > MAX_CRASH_FILES {
let to_remove = crash_files.len() - MAX_CRASH_FILES;
for path in &crash_files[..to_remove] {
let _ = std::fs::remove_file(path);
}
}
}
/// Install a panic hook that dumps the crash context to disk.
///
/// Captures the current events via a snapshot. The hook chains with
/// the default panic handler so backtraces are still printed.
///
/// FIXME: This snapshots events at install time, which is typically
/// during init() when the buffer is empty. The crash dump will only
/// contain the panic itself, not the preceding key presses and state
/// transitions. Fix requires CrashContext to use interior mutability
/// (Arc<Mutex<VecDeque<CrashEvent>>>) so the panic hook reads live
/// state instead of a stale snapshot.
pub fn install_panic_hook(ctx: &Self) {
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Best-effort dump — never panic inside the panic hook.
if let Some(path) = Self::crash_file_path() {
let mut dump = CrashContext::new();
for event in &snapshot {
dump.push(event.clone());
}
// Add the panic info itself as the final event.
dump.push(CrashEvent::Error {
message: format!("{info}"),
});
let _ = dump.dump_to_file(&path);
}
// Chain to the previous hook (prints backtrace, etc.).
prev_hook(info);
}));
}
}
impl Default for CrashContext {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufRead;
/// Helper: create a numbered Custom event.
fn event(n: usize) -> CrashEvent {
CrashEvent::Custom {
tag: "test".into(),
detail: format!("event-{n}"),
}
}
#[test]
fn test_ring_buffer_evicts_oldest() {
let mut ctx = CrashContext::new();
for i in 0..2500 {
ctx.push(event(i));
}
assert_eq!(ctx.len(), MAX_EVENTS);
// First retained event should be #500 (0..499 evicted).
let first = ctx.iter().next().unwrap();
match first {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-500"),
other => panic!("unexpected variant: {other:?}"),
}
// Last retained event should be #2499.
let last = ctx.iter().last().unwrap();
match last {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-2499"),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn test_new_is_empty() {
let ctx = CrashContext::new();
assert!(ctx.is_empty());
assert_eq!(ctx.len(), 0);
}
#[test]
fn test_push_increments_len() {
let mut ctx = CrashContext::new();
ctx.push(event(1));
ctx.push(event(2));
assert_eq!(ctx.len(), 2);
}
#[test]
fn test_push_does_not_evict_below_capacity() {
let mut ctx = CrashContext::new();
for i in 0..MAX_EVENTS {
ctx.push(event(i));
}
assert_eq!(ctx.len(), MAX_EVENTS);
// First should still be event-0.
match ctx.iter().next().unwrap() {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-0"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn test_dump_to_file_writes_ndjson() {
let mut ctx = CrashContext::new();
ctx.push(CrashEvent::KeyPress {
key: "j".into(),
mode: "Normal".into(),
screen: "Dashboard".into(),
});
ctx.push(CrashEvent::MsgDispatched {
msg_name: "NavigateTo".into(),
screen: "Dashboard".into(),
});
ctx.push(CrashEvent::StateTransition {
from: "Dashboard".into(),
to: "IssueList".into(),
});
ctx.push(CrashEvent::Error {
message: "db busy".into(),
});
ctx.push(CrashEvent::Custom {
tag: "test".into(),
detail: "hello".into(),
});
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-crash.json");
ctx.dump_to_file(&path).unwrap();
// Verify: each line is valid JSON, total lines == 5.
let file = std::fs::File::open(&path).unwrap();
let reader = io::BufReader::new(file);
let lines: Vec<String> = reader.lines().map(Result::unwrap).collect();
assert_eq!(lines.len(), 5);
// Each line must parse as JSON.
for line in &lines {
let val: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(val.get("type").is_some(), "missing 'type' field: {line}");
}
// Spot check first line: KeyPress with correct fields.
let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(first["type"], "KeyPress");
assert_eq!(first["key"], "j");
assert_eq!(first["mode"], "Normal");
assert_eq!(first["screen"], "Dashboard");
}
#[test]
fn test_dump_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b").join("c").join("crash.json");
let mut ctx = CrashContext::new();
ctx.push(event(1));
ctx.dump_to_file(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_dump_empty_context_creates_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.json");
let ctx = CrashContext::new();
ctx.dump_to_file(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.is_empty());
}
#[test]
fn test_prune_keeps_newest_files() {
let dir = tempfile::tempdir().unwrap();
let crash_dir = dir.path();
// Create 8 crash files with ordered timestamps.
let filenames: Vec<String> = (0..8)
.map(|i| format!("crash-2026010{i}-120000.000.json"))
.collect();
for name in &filenames {
std::fs::write(crash_dir.join(name), "{}").unwrap();
}
// Prune, pointing at our temp dir.
prune_crash_files_in(crash_dir);
let remaining: Vec<String> = std::fs::read_dir(crash_dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("crash-") && n.ends_with(".json"))
.collect();
assert_eq!(remaining.len(), MAX_CRASH_FILES);
// Oldest 3 should be gone.
for name in filenames.iter().take(3) {
assert!(!remaining.contains(name));
}
// Newest 5 should remain.
for name in filenames.iter().skip(3) {
assert!(remaining.contains(name));
}
}
#[test]
fn test_all_event_variants_serialize() {
let events = vec![
CrashEvent::KeyPress {
key: "q".into(),
mode: "Normal".into(),
screen: "Dashboard".into(),
},
CrashEvent::MsgDispatched {
msg_name: "Quit".into(),
screen: "Dashboard".into(),
},
CrashEvent::StateTransition {
from: "Dashboard".into(),
to: "IssueList".into(),
},
CrashEvent::Error {
message: "oops".into(),
},
CrashEvent::Custom {
tag: "debug".into(),
detail: "trace".into(),
},
];
for event in events {
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("type").is_some());
}
}
#[test]
fn test_default_is_new() {
let ctx = CrashContext::default();
assert!(ctx.is_empty());
}
// -----------------------------------------------------------------------
// Test helper: prune files in a specific directory (not the real path).
// -----------------------------------------------------------------------
fn prune_crash_files_in(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut crash_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
})
.collect();
crash_files.sort();
if crash_files.len() > MAX_CRASH_FILES {
let to_remove = crash_files.len() - MAX_CRASH_FILES;
for path in &crash_files[..to_remove] {
let _ = std::fs::remove_file(path);
}
}
}
}

270
crates/lore-tui/src/db.rs Normal file
View File

@@ -0,0 +1,270 @@
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
//! Database access layer for the TUI.
//!
//! Provides a read pool (3 connections, round-robin) plus a dedicated writer
//! connection. All connections use WAL mode and busy_timeout for concurrency.
//!
//! The TUI operates read-heavy: parallel queries for dashboard, list views,
//! and prefetch. Writes are rare (TUI-local state: scroll positions, bookmarks).
use std::path::Path;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::{Context, Result};
use rusqlite::Connection;
/// Number of reader connections in the pool.
const READER_COUNT: usize = 3;
// ---------------------------------------------------------------------------
// DbManager
// ---------------------------------------------------------------------------
/// Manages a pool of read-only connections plus a dedicated writer.
///
/// Designed for `Arc<DbManager>` sharing across FrankenTUI's `Cmd::task`
/// background threads. Each reader is individually `Mutex`-protected so
/// concurrent tasks can query different readers without blocking.
pub struct DbManager {
readers: Vec<Mutex<Connection>>,
writer: Mutex<Connection>,
next_reader: AtomicUsize,
}
impl DbManager {
/// Open a database at `path` with 3 reader + 1 writer connections.
///
/// All connections get WAL mode, 5000ms busy_timeout, and foreign keys.
/// Reader connections additionally set `query_only = ON` as a safety guard.
pub fn open(path: &Path) -> Result<Self> {
let mut readers = Vec::with_capacity(READER_COUNT);
for i in 0..READER_COUNT {
let conn =
open_connection(path).with_context(|| format!("opening reader connection {i}"))?;
conn.pragma_update(None, "query_only", "ON")
.context("setting query_only on reader")?;
readers.push(Mutex::new(conn));
}
let writer = open_connection(path).context("opening writer connection")?;
Ok(Self {
readers,
writer: Mutex::new(writer),
next_reader: AtomicUsize::new(0),
})
}
/// Execute a read-only query against the pool.
///
/// Selects the next reader via round-robin. The connection is borrowed
/// for the duration of `f` and cannot leak outside.
pub fn with_reader<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&Connection) -> Result<T>,
{
let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % READER_COUNT;
let conn = self.readers[idx].lock().expect("reader mutex poisoned");
f(&conn)
}
/// Execute a write operation against the dedicated writer.
///
/// Serialized via a single `Mutex`. The TUI writes infrequently
/// (bookmarks, scroll state) so contention is negligible.
pub fn with_writer<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&Connection) -> Result<T>,
{
let conn = self.writer.lock().expect("writer mutex poisoned");
f(&conn)
}
}
// ---------------------------------------------------------------------------
// Connection setup
// ---------------------------------------------------------------------------
/// Open a single SQLite connection with TUI-appropriate pragmas.
///
/// Mirrors lore's `create_connection` pragmas (WAL, busy_timeout, etc.)
/// but skips the sqlite-vec extension registration — the TUI reads standard
/// tables only, never vec0 virtual tables.
fn open_connection(path: &Path) -> Result<Connection> {
let conn = Connection::open(path).context("opening SQLite database")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
Ok(conn)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Create a temporary database file for testing.
///
/// Uses an atomic counter + thread ID to guarantee unique paths even
/// when tests run in parallel.
fn test_db_path() -> std::path::PathBuf {
use std::sync::atomic::AtomicU64;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join("lore-tui-tests");
std::fs::create_dir_all(&dir).expect("create test dir");
dir.join(format!(
"test-{}-{:?}-{n}.db",
std::process::id(),
std::thread::current().id(),
))
}
fn create_test_table(conn: &Connection) {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS test_items (id INTEGER PRIMARY KEY, name TEXT);",
)
.expect("create test table");
}
#[test]
fn test_dbmanager_opens_successfully() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
// Writer creates the test table
db.with_writer(|conn| {
create_test_table(conn);
Ok(())
})
.expect("create table via writer");
}
#[test]
fn test_reader_is_query_only() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
// Create table via writer first
db.with_writer(|conn| {
create_test_table(conn);
Ok(())
})
.unwrap();
// Attempt INSERT via reader — should fail
let result = db.with_reader(|conn| {
conn.execute("INSERT INTO test_items (name) VALUES ('boom')", [])
.map_err(|e| anyhow::anyhow!(e))?;
Ok(())
});
assert!(result.is_err(), "reader should reject writes");
}
#[test]
fn test_writer_allows_mutations() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
db.with_writer(|conn| {
create_test_table(conn);
conn.execute("INSERT INTO test_items (name) VALUES ('hello')", [])?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
assert_eq!(count, 1);
Ok(())
})
.expect("writer should allow mutations");
}
#[test]
fn test_round_robin_rotates_readers() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
// Call with_reader 6 times — should cycle through readers 0,1,2,0,1,2
for expected_cycle in 0..2 {
for expected_idx in 0..READER_COUNT {
let current = db.next_reader.load(Ordering::Relaxed);
assert_eq!(
current % READER_COUNT,
(expected_cycle * READER_COUNT + expected_idx) % READER_COUNT,
);
db.with_reader(|_conn| Ok(())).unwrap();
}
}
}
#[test]
fn test_reader_can_read_writer_data() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
db.with_writer(|conn| {
create_test_table(conn);
conn.execute("INSERT INTO test_items (name) VALUES ('visible')", [])?;
Ok(())
})
.unwrap();
let name: String = db
.with_reader(|conn| {
let n: String =
conn.query_row("SELECT name FROM test_items WHERE id = 1", [], |r| r.get(0))?;
Ok(n)
})
.expect("reader should see writer's data");
assert_eq!(name, "visible");
}
#[test]
fn test_dbmanager_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<DbManager>();
}
#[test]
fn test_concurrent_reads() {
let path = test_db_path();
let db = Arc::new(DbManager::open(&path).expect("open"));
db.with_writer(|conn| {
create_test_table(conn);
for i in 0..10 {
conn.execute(
"INSERT INTO test_items (name) VALUES (?1)",
[format!("item-{i}")],
)?;
}
Ok(())
})
.unwrap();
let mut handles = Vec::new();
for _ in 0..6 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
db.with_reader(|conn| {
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
assert_eq!(count, 10);
Ok(())
})
.expect("concurrent read should succeed");
}));
}
for h in handles {
h.join().expect("thread should not panic");
}
}
}

View File

@@ -0,0 +1,338 @@
//! Bounded LRU entity cache for near-instant detail view reopens.
//!
//! Caches `IssueDetail` / `MrDetail` payloads keyed on [`EntityKey`].
//! Tick-based LRU eviction keeps the most-recently-accessed entries alive
//! while bounding memory usage. Selective invalidation removes only
//! stale entries after a sync, rather than flushing the whole cache.
//!
//! Single-threaded (TUI event loop) — no `Arc`/`Mutex` needed.
use std::collections::HashMap;
use crate::message::EntityKey;
/// Default entity cache capacity (sufficient for drill-in/out workflows).
const DEFAULT_CAPACITY: usize = 64;
/// Bounded LRU cache keyed on [`EntityKey`].
///
/// Each entry stores its value alongside a monotonic tick recording the
/// last access time. On capacity overflow, the entry with the lowest
/// tick (least recently used) is evicted.
pub struct EntityCache<V> {
entries: HashMap<EntityKey, (V, u64)>,
capacity: usize,
tick: u64,
}
impl<V> std::fmt::Debug for EntityCache<V> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EntityCache")
.field("len", &self.entries.len())
.field("capacity", &self.capacity)
.finish()
}
}
impl<V> EntityCache<V> {
/// Create a new cache with the default capacity (64).
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::with_capacity(DEFAULT_CAPACITY),
capacity: DEFAULT_CAPACITY,
tick: 0,
}
}
/// Create a new cache with the given capacity.
///
/// # Panics
/// Panics if `capacity` is zero.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
assert!(capacity > 0, "EntityCache capacity must be > 0");
Self {
entries: HashMap::with_capacity(capacity),
capacity,
tick: 0,
}
}
/// Look up an entry, bumping its access tick to keep it alive.
pub fn get(&mut self, key: &EntityKey) -> Option<&V> {
self.tick += 1;
let tick = self.tick;
self.entries.get_mut(key).map(|(val, t)| {
*t = tick;
&*val
})
}
/// Look up an entry mutably, bumping its access tick to keep it alive.
pub fn get_mut(&mut self, key: &EntityKey) -> Option<&mut V> {
self.tick += 1;
let tick = self.tick;
self.entries.get_mut(key).map(|(val, t)| {
*t = tick;
val
})
}
/// Insert an entry, evicting the least-recently-accessed entry if at capacity.
pub fn put(&mut self, key: EntityKey, value: V) {
self.tick += 1;
let tick = self.tick;
// If key already exists, just update in place.
if let Some(entry) = self.entries.get_mut(&key) {
*entry = (value, tick);
return;
}
// Evict LRU if at capacity.
if self.entries.len() >= self.capacity
&& let Some(lru_key) = self
.entries
.iter()
.min_by_key(|(_, (_, t))| *t)
.map(|(k, _)| k.clone())
{
self.entries.remove(&lru_key);
}
self.entries.insert(key, (value, tick));
}
/// Remove only the specified keys, leaving all other entries intact.
pub fn invalidate(&mut self, keys: &[EntityKey]) {
for key in keys {
self.entries.remove(key);
}
}
/// Number of entries currently cached.
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the cache is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Remove all entries from the cache.
pub fn clear(&mut self) {
self.entries.clear();
}
}
impl<V> Default for EntityCache<V> {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::EntityKey;
fn issue(iid: i64) -> EntityKey {
EntityKey::issue(1, iid)
}
fn mr(iid: i64) -> EntityKey {
EntityKey::mr(1, iid)
}
#[test]
fn test_get_returns_recently_put_item() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), "issue-1");
assert_eq!(cache.get(&issue(1)), Some(&"issue-1"));
}
#[test]
fn test_get_returns_none_for_missing_key() {
let mut cache: EntityCache<&str> = EntityCache::with_capacity(4);
assert_eq!(cache.get(&issue(99)), None);
}
#[test]
fn test_lru_eviction_removes_least_recently_used() {
let mut cache = EntityCache::with_capacity(3);
cache.put(issue(1), "a"); // tick 1
cache.put(issue(2), "b"); // tick 2
cache.put(issue(3), "c"); // tick 3
// Access issue(1) to bump its tick above issue(2).
cache.get(&issue(1)); // tick 4 -> issue(1) now most recent
// Insert a 4th item: should evict issue(2) (tick 2, lowest).
cache.put(issue(4), "d"); // tick 5
assert_eq!(
cache.get(&issue(1)),
Some(&"a"),
"issue(1) should survive (recently accessed)"
);
assert_eq!(
cache.get(&issue(2)),
None,
"issue(2) should be evicted (LRU)"
);
assert_eq!(cache.get(&issue(3)), Some(&"c"), "issue(3) should survive");
assert_eq!(cache.get(&issue(4)), Some(&"d"), "issue(4) just inserted");
}
#[test]
fn test_put_overwrites_existing_key() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), "v1");
cache.put(issue(1), "v2");
assert_eq!(cache.get(&issue(1)), Some(&"v2"));
assert_eq!(cache.len(), 1);
}
#[test]
fn test_invalidate_removes_only_specified_keys() {
let mut cache = EntityCache::with_capacity(8);
cache.put(issue(1), "a");
cache.put(issue(2), "b");
cache.put(mr(3), "c");
cache.put(mr(4), "d");
cache.invalidate(&[issue(2), mr(4)]);
assert_eq!(cache.get(&issue(1)), Some(&"a"), "issue(1) not invalidated");
assert_eq!(cache.get(&issue(2)), None, "issue(2) was invalidated");
assert_eq!(cache.get(&mr(3)), Some(&"c"), "mr(3) not invalidated");
assert_eq!(cache.get(&mr(4)), None, "mr(4) was invalidated");
}
#[test]
fn test_invalidate_with_nonexistent_keys_is_noop() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), "a");
cache.invalidate(&[issue(99), mr(99)]);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_default_capacity_is_64() {
let cache: EntityCache<String> = EntityCache::new();
assert_eq!(cache.capacity, DEFAULT_CAPACITY);
assert_eq!(cache.capacity, 64);
}
#[test]
fn test_len_and_is_empty() {
let mut cache = EntityCache::with_capacity(4);
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
cache.put(issue(1), "a");
assert!(!cache.is_empty());
assert_eq!(cache.len(), 1);
}
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn test_zero_capacity_panics() {
let _: EntityCache<String> = EntityCache::with_capacity(0);
}
#[test]
fn test_mixed_entity_kinds() {
let mut cache = EntityCache::with_capacity(4);
// Same iid, different kinds — should be separate entries.
cache.put(issue(42), "issue-42");
cache.put(mr(42), "mr-42");
assert_eq!(cache.get(&issue(42)), Some(&"issue-42"));
assert_eq!(cache.get(&mr(42)), Some(&"mr-42"));
assert_eq!(cache.len(), 2);
}
#[test]
fn test_get_mut_modifies_in_place() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), String::from("original"));
if let Some(val) = cache.get_mut(&issue(1)) {
val.push_str("-modified");
}
assert_eq!(
cache.get(&issue(1)),
Some(&String::from("original-modified"))
);
}
#[test]
fn test_get_mut_returns_none_for_missing() {
let mut cache: EntityCache<String> = EntityCache::with_capacity(4);
assert!(cache.get_mut(&issue(99)).is_none());
}
#[test]
fn test_get_mut_bumps_tick_keeps_alive() {
let mut cache = EntityCache::with_capacity(2);
cache.put(issue(1), "a"); // tick 1
cache.put(issue(2), "b"); // tick 2
// Bump issue(1) via get_mut so it survives eviction.
let _ = cache.get_mut(&issue(1)); // tick 3
// Insert a 3rd — should evict issue(2) (tick 2, LRU).
cache.put(issue(3), "c"); // tick 4
assert!(cache.get(&issue(1)).is_some(), "issue(1) should survive");
assert!(cache.get(&issue(2)).is_none(), "issue(2) should be evicted");
assert!(cache.get(&issue(3)).is_some(), "issue(3) just inserted");
}
#[test]
fn test_clear_removes_all_entries() {
let mut cache = EntityCache::with_capacity(8);
cache.put(issue(1), "a");
cache.put(issue(2), "b");
cache.put(mr(3), "c");
assert_eq!(cache.len(), 3);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(cache.get(&issue(1)), None);
assert_eq!(cache.get(&issue(2)), None);
assert_eq!(cache.get(&mr(3)), None);
}
#[test]
fn test_clear_on_empty_cache_is_noop() {
let mut cache: EntityCache<&str> = EntityCache::with_capacity(4);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_clear_resets_tick_and_allows_reuse() {
let mut cache = EntityCache::with_capacity(4);
cache.put(issue(1), "v1");
cache.put(issue(2), "v2");
cache.clear();
// Cache should work normally after clear.
cache.put(issue(3), "v3");
assert_eq!(cache.get(&issue(3)), Some(&"v3"));
assert_eq!(cache.len(), 1);
}
}

View File

@@ -0,0 +1,316 @@
#![allow(dead_code)] // Phase 2: consumed by filter_bar widget
//! Filter DSL parser for entity list screens.
//!
//! Parses a compact filter string into structured tokens:
//! - `field:value` — typed field filter (e.g., `state:opened`, `author:taylor`)
//! - `-field:value` — negation filter (exclude matches)
//! - `"quoted value"` — preserved as a single free-text token
//! - bare words — free-text search terms
//!
//! The DSL is intentionally simple: no boolean operators, no nesting.
//! Filters are AND-combined at the query layer.
// ---------------------------------------------------------------------------
// Token types
// ---------------------------------------------------------------------------
/// A single parsed filter token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterToken {
/// `field:value` — match entities where `field` equals `value`.
FieldValue { field: String, value: String },
/// `-field:value` — exclude entities where `field` equals `value`.
Negation { field: String, value: String },
/// Bare word(s) used as free-text search.
FreeText(String),
/// `"quoted value"` — preserved as a single search term.
QuotedValue(String),
}
// ---------------------------------------------------------------------------
// Known fields per entity type
// ---------------------------------------------------------------------------
/// Known filter fields for issues.
pub const ISSUE_FIELDS: &[&str] = &[
"state",
"author",
"assignee",
"label",
"milestone",
"status",
];
/// Known filter fields for merge requests.
pub const MR_FIELDS: &[&str] = &[
"state",
"author",
"reviewer",
"target_branch",
"source_branch",
"label",
"draft",
];
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
/// Parse a filter input string into a sequence of tokens.
///
/// Empty input returns an empty vec (no-op filter = show all).
pub fn parse_filter_tokens(input: &str) -> Vec<FilterToken> {
let input = input.trim();
if input.is_empty() {
return Vec::new();
}
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while chars.peek().is_some() {
// Skip whitespace between tokens.
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
match chars.peek() {
None => break,
Some('"') => {
// Quoted value — consume until closing quote or end.
chars.next(); // consume opening "
let value: String = consume_until(&mut chars, '"');
if chars.peek() == Some(&'"') {
chars.next(); // consume closing "
}
if !value.is_empty() {
tokens.push(FilterToken::QuotedValue(value));
}
}
Some('-') => {
// Could be negation prefix or just a free-text word starting with -.
chars.next(); // consume -
let word = consume_word(&mut chars);
if let Some((field, value)) = word.split_once(':') {
tokens.push(FilterToken::Negation {
field: field.to_string(),
value: value.to_string(),
});
} else if !word.is_empty() {
// Bare negation without field:value — treat as free text with -.
tokens.push(FilterToken::FreeText(format!("-{word}")));
}
}
Some(_) => {
let word = consume_word(&mut chars);
if let Some((field, value)) = word.split_once(':') {
tokens.push(FilterToken::FieldValue {
field: field.to_string(),
value: value.to_string(),
});
} else if !word.is_empty() {
tokens.push(FilterToken::FreeText(word));
}
}
}
}
tokens
}
/// Validate that a field name is known for the given entity type.
///
/// Returns `true` if the field is in the known set, `false` otherwise.
pub fn is_known_field(field: &str, known_fields: &[&str]) -> bool {
known_fields.contains(&field)
}
/// Extract all unknown fields from a token list.
pub fn unknown_fields<'a>(tokens: &'a [FilterToken], known_fields: &[&str]) -> Vec<&'a str> {
tokens
.iter()
.filter_map(|t| match t {
FilterToken::FieldValue { field, .. } | FilterToken::Negation { field, .. } => {
if is_known_field(field, known_fields) {
None
} else {
Some(field.as_str())
}
}
_ => None,
})
.collect()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Consume characters until `delim` is found (exclusive) or end of input.
fn consume_until(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, delim: char) -> String {
let mut buf = String::new();
while let Some(&c) = chars.peek() {
if c == delim {
break;
}
buf.push(c);
chars.next();
}
buf
}
/// Consume a non-whitespace word.
fn consume_word(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut buf = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
// Stop at quote boundaries so they're handled separately.
if c == '"' && !buf.is_empty() {
break;
}
buf.push(c);
chars.next();
}
buf
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- TDD Anchor: basic field:value parsing --
#[test]
fn test_parse_filter_basic() {
let tokens = parse_filter_tokens("state:opened author:taylor");
assert_eq!(tokens.len(), 2);
assert_eq!(
tokens[0],
FilterToken::FieldValue {
field: "state".into(),
value: "opened".into()
}
);
assert_eq!(
tokens[1],
FilterToken::FieldValue {
field: "author".into(),
value: "taylor".into()
}
);
}
#[test]
fn test_parse_quoted_value() {
let tokens = parse_filter_tokens("\"in progress\"");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], FilterToken::QuotedValue("in progress".into()));
}
#[test]
fn test_parse_negation() {
let tokens = parse_filter_tokens("-state:closed");
assert_eq!(tokens.len(), 1);
assert_eq!(
tokens[0],
FilterToken::Negation {
field: "state".into(),
value: "closed".into()
}
);
}
#[test]
fn test_parse_mixed() {
let tokens = parse_filter_tokens("state:opened \"bug fix\" -label:wontfix");
assert_eq!(tokens.len(), 3);
assert_eq!(
tokens[0],
FilterToken::FieldValue {
field: "state".into(),
value: "opened".into()
}
);
assert_eq!(tokens[1], FilterToken::QuotedValue("bug fix".into()));
assert_eq!(
tokens[2],
FilterToken::Negation {
field: "label".into(),
value: "wontfix".into()
}
);
}
#[test]
fn test_parse_empty_returns_empty() {
assert!(parse_filter_tokens("").is_empty());
assert!(parse_filter_tokens(" ").is_empty());
}
#[test]
fn test_parse_free_text() {
let tokens = parse_filter_tokens("authentication bug");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0], FilterToken::FreeText("authentication".into()));
assert_eq!(tokens[1], FilterToken::FreeText("bug".into()));
}
#[test]
fn test_parse_bare_negation_as_free_text() {
let tokens = parse_filter_tokens("-wontfix");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], FilterToken::FreeText("-wontfix".into()));
}
#[test]
fn test_parse_unicode() {
let tokens = parse_filter_tokens("author:田中 \"認証バグ\"");
assert_eq!(tokens.len(), 2);
assert_eq!(
tokens[0],
FilterToken::FieldValue {
field: "author".into(),
value: "田中".into()
}
);
assert_eq!(tokens[1], FilterToken::QuotedValue("認証バグ".into()));
}
#[test]
fn test_parse_unclosed_quote() {
let tokens = parse_filter_tokens("\"open ended");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], FilterToken::QuotedValue("open ended".into()));
}
// -- Field validation --
#[test]
fn test_known_field_issues() {
assert!(is_known_field("state", ISSUE_FIELDS));
assert!(is_known_field("author", ISSUE_FIELDS));
assert!(!is_known_field("reviewer", ISSUE_FIELDS));
assert!(!is_known_field("bogus", ISSUE_FIELDS));
}
#[test]
fn test_known_field_mrs() {
assert!(is_known_field("draft", MR_FIELDS));
assert!(is_known_field("reviewer", MR_FIELDS));
assert!(!is_known_field("assignee", MR_FIELDS));
}
#[test]
fn test_unknown_fields_detection() {
let tokens = parse_filter_tokens("state:opened bogus:val author:taylor unknown:x");
let unknown = unknown_fields(&tokens, ISSUE_FIELDS);
assert_eq!(unknown, vec!["bogus", "unknown"]);
}
}

View File

@@ -0,0 +1,202 @@
//! Single-instance advisory lock for the TUI.
//!
//! Prevents concurrent `lore-tui` launches from corrupting state.
//! Uses an advisory lock file with PID. Stale locks (dead PID) are
//! automatically recovered.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// Advisory lock preventing concurrent TUI launches.
///
/// On `acquire()`, writes the current PID to the lock file.
/// On `Drop`, removes the lock file (best-effort).
#[derive(Debug)]
pub struct InstanceLock {
path: PathBuf,
}
/// Error returned when another instance is already running.
#[derive(Debug)]
pub struct LockConflict {
pub pid: u32,
pub path: PathBuf,
}
impl std::fmt::Display for LockConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Another lore-tui instance is running (PID {}). Lock file: {}",
self.pid,
self.path.display()
)
}
}
impl std::error::Error for LockConflict {}
impl InstanceLock {
/// Try to acquire the instance lock.
///
/// - If the lock file doesn't exist, creates it with our PID.
/// - If the lock file exists with a live PID, returns `LockConflict`.
/// - If the lock file exists with a dead PID, removes the stale lock and acquires.
pub fn acquire(lock_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
// Ensure lock directory exists.
fs::create_dir_all(lock_dir)?;
let path = lock_dir.join("tui.lock");
// Check for existing lock.
if path.exists() {
let contents = fs::read_to_string(&path).unwrap_or_default();
if let Ok(pid) = contents.trim().parse::<u32>()
&& is_process_alive(pid)
{
return Err(Box::new(LockConflict {
pid,
path: path.clone(),
}));
}
// Stale lock — PID is dead, or corrupt file. Remove and re-acquire.
fs::remove_file(&path)?;
}
// Write our PID.
let mut file = fs::File::create(&path)?;
write!(file, "{}", std::process::id())?;
file.sync_all()?;
Ok(Self { path })
}
/// Path to the lock file.
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for InstanceLock {
fn drop(&mut self) {
// Best-effort cleanup. If it fails, the stale lock will be
// recovered on next launch via the dead-PID check.
let _ = fs::remove_file(&self.path);
}
}
/// Check whether a process with the given PID is alive.
///
/// Uses `kill -0 <pid>` on Unix (exit 0 = alive, non-zero = dead).
/// On non-Unix, conservatively assumes alive.
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(not(unix))]
fn is_process_alive(_pid: u32) -> bool {
// Conservative fallback: assume alive.
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_acquire_and_release() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("tui.lock");
{
let _lock = InstanceLock::acquire(dir.path()).unwrap();
assert!(lock_path.exists());
// Lock file should contain our PID.
let contents = fs::read_to_string(&lock_path).unwrap();
assert_eq!(contents, format!("{}", std::process::id()));
}
// After drop, lock file should be removed.
assert!(!lock_path.exists());
}
#[test]
fn test_double_acquire_fails() {
let dir = tempfile::tempdir().unwrap();
let _lock = InstanceLock::acquire(dir.path()).unwrap();
// Second acquire should fail because our PID is still alive.
let result = InstanceLock::acquire(dir.path());
assert!(result.is_err());
let err = result.unwrap_err();
let conflict = err.downcast_ref::<LockConflict>().unwrap();
assert_eq!(conflict.pid, std::process::id());
}
#[test]
fn test_stale_lock_recovery() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("tui.lock");
// Write a lock file with a dead PID (PID 1 is init, but PID 99999999
// almost certainly doesn't exist).
let dead_pid = 99_999_999u32;
fs::write(&lock_path, dead_pid.to_string()).unwrap();
// Should succeed — stale lock is recovered.
let _lock = InstanceLock::acquire(dir.path()).unwrap();
assert!(lock_path.exists());
// Lock file now contains our PID, not the dead one.
let contents = fs::read_to_string(&lock_path).unwrap();
assert_eq!(contents, format!("{}", std::process::id()));
}
#[test]
fn test_corrupt_lock_file_recovered() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("tui.lock");
// Write garbage to the lock file.
fs::write(&lock_path, "not-a-pid").unwrap();
// Should succeed — corrupt lock is treated as stale.
let lock = InstanceLock::acquire(dir.path()).unwrap();
let contents = fs::read_to_string(lock.path()).unwrap();
assert_eq!(contents, format!("{}", std::process::id()));
}
#[test]
fn test_creates_lock_directory() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b").join("c");
let lock = InstanceLock::acquire(&nested).unwrap();
assert!(nested.join("tui.lock").exists());
drop(lock);
}
#[test]
fn test_lock_conflict_display() {
let conflict = LockConflict {
pid: 12345,
path: PathBuf::from("/tmp/tui.lock"),
};
let msg = format!("{conflict}");
assert!(msg.contains("12345"));
assert!(msg.contains("/tmp/tui.lock"));
}
}

View File

@@ -0,0 +1,236 @@
#![allow(clippy::module_name_repetitions)]
//! Responsive layout helpers for the Lore TUI.
//!
//! Wraps [`ftui::layout::Breakpoint`] and [`ftui::layout::Breakpoints`] with
//! Lore-specific configuration: breakpoint thresholds, column counts per
//! breakpoint, and preview-pane visibility rules.
use ftui::layout::{Breakpoint, Breakpoints};
/// Lore-specific breakpoint thresholds.
///
/// Uses the ftui defaults: Sm=60, Md=90, Lg=120, Xl=160 columns.
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::DEFAULT;
/// Classify a terminal width into a [`Breakpoint`].
#[inline]
pub fn classify_width(width: u16) -> Breakpoint {
LORE_BREAKPOINTS.classify_width(width)
}
/// Number of dashboard columns for a given breakpoint.
///
/// - `Xs` / `Sm`: 1 column (narrow terminals)
/// - `Md`: 2 columns (standard width)
/// - `Lg` / `Xl`: 3 columns (wide terminals)
#[inline]
pub const fn dashboard_columns(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 1,
Breakpoint::Md => 2,
Breakpoint::Lg | Breakpoint::Xl => 3,
}
}
/// Whether the preview pane should be visible at a given breakpoint.
///
/// Preview requires at least `Md` width to avoid cramping the main list.
#[inline]
pub const fn show_preview_pane(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
Breakpoint::Xs | Breakpoint::Sm => false,
}
}
// ---------------------------------------------------------------------------
// Per-screen responsive helpers
// ---------------------------------------------------------------------------
/// Whether detail views (issue/MR) should show a side panel for discussions.
///
/// At `Lg`+ widths, enough room exists for a 60/40 or 50/50 split with
/// description on the left and discussions/cross-refs on the right.
#[inline]
pub const fn detail_side_panel(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Lg | Breakpoint::Xl => true,
Breakpoint::Xs | Breakpoint::Sm | Breakpoint::Md => false,
}
}
/// Number of stat columns for the Stats/Doctor screens.
///
/// - `Xs` / `Sm`: 1 column (full-width stacked)
/// - `Md`+: 2 columns (side-by-side sections)
#[inline]
pub const fn info_screen_columns(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 1,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 2,
}
}
/// Whether to show the project path column in search results.
///
/// On narrow terminals, the project path is dropped to give the title
/// more room.
#[inline]
pub const fn search_show_project(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Xs | Breakpoint::Sm => false,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
}
}
/// Width allocated for the relative-time column in timeline events.
///
/// Narrow terminals get a compact time (e.g., "2h"), wider terminals
/// get the full relative time (e.g., "2 hours ago").
#[inline]
pub const fn timeline_time_width(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs => 5,
Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
/// Whether to use abbreviated mode-tab labels in the Who screen.
///
/// On narrow terminals, tabs are shortened to 3-char abbreviations
/// (e.g., "Exp" instead of "Expert") to fit all 5 modes.
#[inline]
pub const fn who_abbreviated_tabs(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Xs | Breakpoint::Sm => true,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => false,
}
}
/// Width of the progress bar in the Sync screen.
///
/// Scales with terminal width to use available space effectively.
#[inline]
pub const fn sync_progress_bar_width(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 25,
Breakpoint::Md => 35,
Breakpoint::Lg | Breakpoint::Xl => 50,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_width_boundaries() {
// Xs: 0..59
assert_eq!(classify_width(59), Breakpoint::Xs);
// Sm: 60..89
assert_eq!(classify_width(60), Breakpoint::Sm);
assert_eq!(classify_width(89), Breakpoint::Sm);
// Md: 90..119
assert_eq!(classify_width(90), Breakpoint::Md);
assert_eq!(classify_width(119), Breakpoint::Md);
// Lg: 120..159
assert_eq!(classify_width(120), Breakpoint::Lg);
assert_eq!(classify_width(159), Breakpoint::Lg);
// Xl: 160+
assert_eq!(classify_width(160), Breakpoint::Xl);
}
#[test]
fn test_dashboard_columns_per_breakpoint() {
assert_eq!(dashboard_columns(Breakpoint::Xs), 1);
assert_eq!(dashboard_columns(Breakpoint::Sm), 1);
assert_eq!(dashboard_columns(Breakpoint::Md), 2);
assert_eq!(dashboard_columns(Breakpoint::Lg), 3);
assert_eq!(dashboard_columns(Breakpoint::Xl), 3);
}
#[test]
fn test_show_preview_pane_per_breakpoint() {
assert!(!show_preview_pane(Breakpoint::Xs));
assert!(!show_preview_pane(Breakpoint::Sm));
assert!(show_preview_pane(Breakpoint::Md));
assert!(show_preview_pane(Breakpoint::Lg));
assert!(show_preview_pane(Breakpoint::Xl));
}
#[test]
fn test_edge_cases() {
// Width 0 must not panic, should classify as Xs
assert_eq!(classify_width(0), Breakpoint::Xs);
// Very wide terminal
assert_eq!(classify_width(300), Breakpoint::Xl);
}
#[test]
fn test_lore_breakpoints_matches_defaults() {
assert_eq!(LORE_BREAKPOINTS, Breakpoints::DEFAULT);
}
// -- Per-screen responsive helpers ----------------------------------------
#[test]
fn test_detail_side_panel() {
assert!(!detail_side_panel(Breakpoint::Xs));
assert!(!detail_side_panel(Breakpoint::Sm));
assert!(!detail_side_panel(Breakpoint::Md));
assert!(detail_side_panel(Breakpoint::Lg));
assert!(detail_side_panel(Breakpoint::Xl));
}
#[test]
fn test_info_screen_columns() {
assert_eq!(info_screen_columns(Breakpoint::Xs), 1);
assert_eq!(info_screen_columns(Breakpoint::Sm), 1);
assert_eq!(info_screen_columns(Breakpoint::Md), 2);
assert_eq!(info_screen_columns(Breakpoint::Lg), 2);
assert_eq!(info_screen_columns(Breakpoint::Xl), 2);
}
#[test]
fn test_search_show_project() {
assert!(!search_show_project(Breakpoint::Xs));
assert!(!search_show_project(Breakpoint::Sm));
assert!(search_show_project(Breakpoint::Md));
assert!(search_show_project(Breakpoint::Lg));
assert!(search_show_project(Breakpoint::Xl));
}
#[test]
fn test_timeline_time_width() {
assert_eq!(timeline_time_width(Breakpoint::Xs), 5);
assert_eq!(timeline_time_width(Breakpoint::Sm), 8);
assert_eq!(timeline_time_width(Breakpoint::Md), 12);
assert_eq!(timeline_time_width(Breakpoint::Lg), 12);
assert_eq!(timeline_time_width(Breakpoint::Xl), 12);
}
#[test]
fn test_who_abbreviated_tabs() {
assert!(who_abbreviated_tabs(Breakpoint::Xs));
assert!(who_abbreviated_tabs(Breakpoint::Sm));
assert!(!who_abbreviated_tabs(Breakpoint::Md));
assert!(!who_abbreviated_tabs(Breakpoint::Lg));
assert!(!who_abbreviated_tabs(Breakpoint::Xl));
}
#[test]
fn test_sync_progress_bar_width() {
assert_eq!(sync_progress_bar_width(Breakpoint::Xs), 15);
assert_eq!(sync_progress_bar_width(Breakpoint::Sm), 25);
assert_eq!(sync_progress_bar_width(Breakpoint::Md), 35);
assert_eq!(sync_progress_bar_width(Breakpoint::Lg), 50);
assert_eq!(sync_progress_bar_width(Breakpoint::Xl), 50);
}
}

146
crates/lore-tui/src/lib.rs Normal file
View File

@@ -0,0 +1,146 @@
#![forbid(unsafe_code)]
//! Gitlore TUI — terminal interface for exploring GitLab data locally.
//!
//! Built on FrankenTUI (Elm architecture): Model, update, view.
//! The `lore` CLI spawns `lore-tui` via PATH lookup at runtime.
use anyhow::{Context, Result};
// Phase 0 modules.
pub mod clock; // Clock trait: SystemClock + FakeClock (bd-2lg6)
pub mod message; // Msg, Screen, EntityKey, AppError, InputMode (bd-c9gk)
pub mod safety; // Terminal safety: sanitize + URL policy + redact (bd-3ir1)
pub mod db; // DbManager: read pool + dedicated writer (bd-2kop)
pub mod theme; // Flexoki theme: build_theme, state_color, label_style (bd-5ofk)
pub mod app; // LoreApp Model trait impl (Phase 0 proof: bd-2emv, full: bd-6pmy)
// Phase 1 modules.
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
pub mod crash_context; // CrashContext ring buffer + panic hook (bd-2fr7)
pub mod layout; // Responsive layout: breakpoints, columns, preview pane (bd-1pzj)
pub mod navigation; // NavigationStack: back/forward/jump list (bd-1qpp)
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
pub mod view; // View layer: render_screen + common widgets (bd-26f2)
// Phase 2 modules.
pub mod action; // Data-fetching actions for TUI screens (bd-35g5+)
pub mod filter_dsl; // Filter DSL tokenizer for list screen filter bars (bd-18qs)
// Phase 4 modules.
pub mod entity_cache; // Bounded LRU entity cache for detail view reopens (bd-2og9)
pub mod render_cache; // Bounded render cache for expensive per-frame computations (bd-2og9)
pub mod scope; // Global scope context: SQL helpers + project listing (bd-1ser)
// Phase 5 modules.
pub mod instance_lock; // Single-instance advisory lock for TUI (bd-3h00)
pub mod session; // Session state persistence: save/load/quarantine (bd-3h00)
pub mod text_width; // Unicode-aware text width measurement + truncation (bd-3h00)
/// Options controlling how the TUI launches.
#[derive(Debug, Clone)]
pub struct LaunchOptions {
/// Path to lore config file.
pub config_path: Option<String>,
/// Run a background sync before displaying data.
pub sync_on_start: bool,
/// Clear cached TUI state and start fresh.
pub fresh: bool,
/// Render backend: "crossterm" or "native".
pub render_mode: String,
/// Use ASCII-only box drawing characters.
pub ascii: bool,
/// Disable alternate screen (render inline).
pub no_alt_screen: bool,
}
/// Launch the TUI in browse mode (no sync).
///
/// Loads config from `options.config_path` (or default location),
/// opens the database read-only, and enters the FrankenTUI event loop.
///
/// ## Preflight sequence
///
/// 1. **Schema preflight** — validate the database schema version before
/// creating the app. If incompatible, print an actionable error and exit
/// with a non-zero code.
/// 2. **Data readiness** — check whether the database has any entity data.
/// If empty, start on the Bootstrap screen; otherwise start on Dashboard.
pub fn launch_tui(options: LaunchOptions) -> Result<()> {
let _options = options; // remaining fields (fresh, ascii, etc.) consumed in later phases
// 1. Resolve database path.
let db_path = lore::core::paths::get_db_path(None);
if !db_path.exists() {
anyhow::bail!(
"No lore database found at {}.\n\
Run 'lore init' to create a config, then 'lore sync' to fetch data.",
db_path.display()
);
}
// 2. Open DB and run schema preflight.
let db = db::DbManager::open(&db_path)
.with_context(|| format!("opening database at {}", db_path.display()))?;
db.with_reader(schema_preflight)?;
// 3. Check data readiness — bootstrap screen if empty.
let start_on_bootstrap = db.with_reader(|conn| {
let readiness = action::check_data_readiness(conn)?;
Ok(!readiness.has_any_data())
})?;
// 4. Build the app model.
let mut app = app::LoreApp::new();
app.db = Some(db);
if start_on_bootstrap {
app.navigation.reset_to(message::Screen::Bootstrap);
}
// 5. Enter the FrankenTUI event loop.
ftui::App::fullscreen(app)
.with_mouse()
.run()
.context("running TUI event loop")?;
Ok(())
}
/// Run the schema preflight check.
///
/// Returns `Ok(())` if the schema is compatible, or an error with an
/// actionable message if it's not. The caller should exit non-zero on error.
pub fn schema_preflight(conn: &rusqlite::Connection) -> Result<()> {
use state::bootstrap::SchemaCheck;
match action::check_schema_version(conn, action::MINIMUM_SCHEMA_VERSION) {
SchemaCheck::Compatible { .. } => Ok(()),
SchemaCheck::NoDB => {
anyhow::bail!(
"No lore database found.\n\
Run 'lore init' to create a config, then 'lore sync' to fetch data."
);
}
SchemaCheck::Incompatible { found, minimum } => {
anyhow::bail!(
"Database schema version {found} is too old (minimum: {minimum}).\n\
Run 'lore migrate' to upgrade, or 'lore sync' to rebuild."
);
}
}
}
/// Launch the TUI with an initial sync pass.
///
/// Runs `lore sync` in the background while displaying a progress screen,
/// then transitions to browse mode once sync completes.
pub fn launch_sync_tui(options: LaunchOptions) -> Result<()> {
let _options = options;
// Phase 2 will implement the sync progress screen
eprintln!("lore-tui: sync mode not yet implemented (Phase 2)");
Ok(())
}

View File

@@ -0,0 +1,53 @@
#![forbid(unsafe_code)]
use anyhow::Result;
use clap::Parser;
use lore_tui::LaunchOptions;
/// Terminal UI for Gitlore — explore GitLab issues, MRs, and search locally.
#[derive(Parser, Debug)]
#[command(name = "lore-tui", version, about)]
struct TuiCli {
/// Path to lore config file (default: ~/.config/lore/config.json).
#[arg(short, long, env = "LORE_CONFIG_PATH")]
config: Option<String>,
/// Run a sync before launching the TUI.
#[arg(long)]
sync: bool,
/// Clear cached state and start fresh.
#[arg(long)]
fresh: bool,
/// Render mode: "crossterm" (default) or "native".
#[arg(long, default_value = "crossterm")]
render_mode: String,
/// Use ASCII-only drawing characters (no Unicode box drawing).
#[arg(long)]
ascii: bool,
/// Disable alternate screen (render inline).
#[arg(long)]
no_alt_screen: bool,
}
fn main() -> Result<()> {
let cli = TuiCli::parse();
let options = LaunchOptions {
config_path: cli.config,
sync_on_start: cli.sync,
fresh: cli.fresh,
render_mode: cli.render_mode,
ascii: cli.ascii,
no_alt_screen: cli.no_alt_screen,
};
if options.sync_on_start {
lore_tui::launch_sync_tui(options)
} else {
lore_tui::launch_tui(options)
}
}

View File

@@ -0,0 +1,759 @@
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
//! Core types for the lore-tui Elm architecture.
//!
//! - [`Msg`] — every user action and async result flows through this enum.
//! - [`Screen`] — navigation targets.
//! - [`EntityKey`] — safe cross-project entity identity.
//! - [`AppError`] — structured error display in the TUI.
//! - [`InputMode`] — controls key dispatch routing.
use std::fmt;
use chrono::{DateTime, Utc};
use ftui::Event;
// ---------------------------------------------------------------------------
// EntityKind
// ---------------------------------------------------------------------------
/// Distinguishes issue vs merge request in an [`EntityKey`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum EntityKind {
Issue,
MergeRequest,
}
// ---------------------------------------------------------------------------
// EntityKey
// ---------------------------------------------------------------------------
/// Uniquely identifies an entity (issue or MR) across projects.
///
/// Bare `iid` is unsafe in multi-project datasets — equality requires
/// project_id + iid + kind.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EntityKey {
pub project_id: i64,
pub iid: i64,
pub kind: EntityKind,
}
impl EntityKey {
#[must_use]
pub fn issue(project_id: i64, iid: i64) -> Self {
Self {
project_id,
iid,
kind: EntityKind::Issue,
}
}
#[must_use]
pub fn mr(project_id: i64, iid: i64) -> Self {
Self {
project_id,
iid,
kind: EntityKind::MergeRequest,
}
}
}
impl fmt::Display for EntityKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = match self.kind {
EntityKind::Issue => "#",
EntityKind::MergeRequest => "!",
};
write!(f, "p{}:{}{}", self.project_id, prefix, self.iid)
}
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
/// Navigation targets within the TUI.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Screen {
Dashboard,
IssueList,
IssueDetail(EntityKey),
MrList,
MrDetail(EntityKey),
Search,
Timeline,
Who,
Trace,
FileHistory,
Sync,
Stats,
Doctor,
Bootstrap,
}
impl Screen {
/// Human-readable label for breadcrumbs and status bar.
#[must_use]
pub fn label(&self) -> &str {
match self {
Self::Dashboard => "Dashboard",
Self::IssueList => "Issues",
Self::IssueDetail(_) => "Issue",
Self::MrList => "Merge Requests",
Self::MrDetail(_) => "Merge Request",
Self::Search => "Search",
Self::Timeline => "Timeline",
Self::Who => "Who",
Self::Trace => "Trace",
Self::FileHistory => "File History",
Self::Sync => "Sync",
Self::Stats => "Stats",
Self::Doctor => "Doctor",
Self::Bootstrap => "Bootstrap",
}
}
/// Whether this screen shows a specific entity detail view.
#[must_use]
pub fn is_detail_or_entity(&self) -> bool {
matches!(self, Self::IssueDetail(_) | Self::MrDetail(_))
}
}
// ---------------------------------------------------------------------------
// AppError
// ---------------------------------------------------------------------------
/// Structured error types for user-facing display in the TUI.
#[derive(Debug, Clone)]
pub enum AppError {
/// Database is busy (WAL contention).
DbBusy,
/// Database corruption detected.
DbCorruption(String),
/// GitLab rate-limited; retry after N seconds (if header present).
NetworkRateLimited { retry_after_secs: Option<u64> },
/// Network unavailable.
NetworkUnavailable,
/// GitLab authentication failed.
AuthFailed,
/// Data parsing error.
ParseError(String),
/// Internal / unexpected error.
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DbBusy => write!(f, "Database is busy — another process holds the lock"),
Self::DbCorruption(detail) => write!(f, "Database corruption: {detail}"),
Self::NetworkRateLimited {
retry_after_secs: Some(secs),
} => write!(f, "Rate limited by GitLab — retry in {secs}s"),
Self::NetworkRateLimited {
retry_after_secs: None,
} => write!(f, "Rate limited by GitLab — try again shortly"),
Self::NetworkUnavailable => write!(f, "Network unavailable — working offline"),
Self::AuthFailed => write!(f, "GitLab authentication failed — check your token"),
Self::ParseError(detail) => write!(f, "Parse error: {detail}"),
Self::Internal(detail) => write!(f, "Internal error: {detail}"),
}
}
}
// ---------------------------------------------------------------------------
// InputMode
// ---------------------------------------------------------------------------
/// Controls how keystrokes are routed through the key dispatch pipeline.
#[derive(Debug, Clone, Default)]
pub enum InputMode {
/// Standard navigation mode — keys dispatch to screen-specific handlers.
#[default]
Normal,
/// Text input focused (filter bar, search box).
Text,
/// Command palette is open.
Palette,
/// "g" prefix pressed — waiting for second key (500ms timeout).
GoPrefix { started_at: DateTime<Utc> },
}
// ---------------------------------------------------------------------------
// Msg
// ---------------------------------------------------------------------------
/// Every user action and async result flows through this enum.
///
/// Generation fields (`generation: u64`) on async result variants enable
/// stale-response detection: if the generation doesn't match the current
/// request generation, the result is silently dropped.
#[derive(Debug)]
pub enum Msg {
// --- Terminal events ---
/// Raw terminal event (key, mouse, paste, focus, clipboard).
RawEvent(Event),
/// Periodic tick from runtime subscription.
Tick,
/// Terminal resized.
Resize {
width: u16,
height: u16,
},
// --- Navigation ---
/// Navigate to a specific screen.
NavigateTo(Screen),
/// Go back in navigation history.
GoBack,
/// Go forward in navigation history.
GoForward,
/// Jump to the dashboard.
GoHome,
/// Jump back N screens in history.
JumpBack(usize),
/// Jump forward N screens in history.
JumpForward(usize),
// --- Command palette ---
OpenCommandPalette,
CloseCommandPalette,
CommandPaletteInput(String),
CommandPaletteSelect(String),
// --- Issue list ---
IssueListLoaded {
generation: u64,
page: crate::state::issue_list::IssueListPage,
},
IssueListFilterChanged(String),
IssueListSortChanged,
IssueSelected(EntityKey),
// --- MR list ---
MrListLoaded {
generation: u64,
page: crate::state::mr_list::MrListPage,
},
MrListFilterChanged(String),
MrSelected(EntityKey),
// --- Issue detail ---
IssueDetailLoaded {
generation: u64,
key: EntityKey,
data: Box<crate::state::issue_detail::IssueDetailData>,
},
// --- MR detail ---
MrDetailLoaded {
generation: u64,
key: EntityKey,
data: Box<crate::state::mr_detail::MrDetailData>,
},
// --- Discussions (shared by issue + MR detail) ---
DiscussionsLoaded {
generation: u64,
key: EntityKey,
discussions: Vec<crate::view::common::discussion_tree::DiscussionNode>,
},
// --- Search ---
SearchQueryChanged(String),
SearchRequestStarted {
generation: u64,
query: String,
},
SearchExecuted {
generation: u64,
results: Vec<SearchResult>,
},
SearchResultSelected(EntityKey),
SearchModeChanged,
SearchCapabilitiesLoaded,
// --- Timeline ---
TimelineLoaded {
generation: u64,
events: Vec<TimelineEvent>,
},
TimelineEntitySelected(EntityKey),
// --- Who (people) ---
WhoResultLoaded {
generation: u64,
result: Box<WhoResult>,
},
WhoModeChanged,
// --- Trace ---
TraceResultLoaded {
generation: u64,
result: Box<lore::core::trace::TraceResult>,
},
TraceKnownPathsLoaded {
paths: Vec<String>,
},
// --- File History ---
FileHistoryLoaded {
generation: u64,
result: Box<crate::state::file_history::FileHistoryResult>,
},
FileHistoryKnownPathsLoaded {
paths: Vec<String>,
},
// --- Scope ---
/// Projects loaded for the scope picker.
ScopeProjectsLoaded {
projects: Vec<crate::scope::ProjectInfo>,
},
// --- Doctor ---
DoctorLoaded {
checks: Vec<crate::state::doctor::HealthCheck>,
},
// --- Stats ---
StatsLoaded {
data: crate::state::stats::StatsData,
},
// --- Sync ---
SyncStarted,
SyncProgress {
stage: String,
current: u64,
total: u64,
},
SyncProgressBatch {
stage: String,
batch_size: u64,
},
SyncLogLine(String),
SyncBackpressureDrop,
SyncCompleted {
elapsed_ms: u64,
},
SyncCancelled,
SyncFailed(String),
SyncStreamStats {
bytes: u64,
items: u64,
},
// --- Search debounce ---
SearchDebounceArmed {
generation: u64,
},
SearchDebounceFired {
generation: u64,
},
// --- Dashboard ---
DashboardLoaded {
generation: u64,
data: Box<crate::state::dashboard::DashboardData>,
},
// --- Global actions ---
Error(AppError),
ShowHelp,
ShowCliEquivalent,
OpenInBrowser,
BlurTextInput,
ScrollToTopCurrentScreen,
Quit,
}
impl Msg {
/// Return the variant name as a static string without formatting payload.
///
/// Used by crash context to cheaply record which message was dispatched.
pub fn variant_name(&self) -> &'static str {
match self {
Self::RawEvent(_) => "RawEvent",
Self::Tick => "Tick",
Self::Resize { .. } => "Resize",
Self::NavigateTo(_) => "NavigateTo",
Self::GoBack => "GoBack",
Self::GoForward => "GoForward",
Self::GoHome => "GoHome",
Self::JumpBack(_) => "JumpBack",
Self::JumpForward(_) => "JumpForward",
Self::OpenCommandPalette => "OpenCommandPalette",
Self::CloseCommandPalette => "CloseCommandPalette",
Self::CommandPaletteInput(_) => "CommandPaletteInput",
Self::CommandPaletteSelect(_) => "CommandPaletteSelect",
Self::IssueListLoaded { .. } => "IssueListLoaded",
Self::IssueListFilterChanged(_) => "IssueListFilterChanged",
Self::IssueListSortChanged => "IssueListSortChanged",
Self::IssueSelected(_) => "IssueSelected",
Self::MrListLoaded { .. } => "MrListLoaded",
Self::MrListFilterChanged(_) => "MrListFilterChanged",
Self::MrSelected(_) => "MrSelected",
Self::IssueDetailLoaded { .. } => "IssueDetailLoaded",
Self::MrDetailLoaded { .. } => "MrDetailLoaded",
Self::DiscussionsLoaded { .. } => "DiscussionsLoaded",
Self::SearchQueryChanged(_) => "SearchQueryChanged",
Self::SearchRequestStarted { .. } => "SearchRequestStarted",
Self::SearchExecuted { .. } => "SearchExecuted",
Self::SearchResultSelected(_) => "SearchResultSelected",
Self::SearchModeChanged => "SearchModeChanged",
Self::SearchCapabilitiesLoaded => "SearchCapabilitiesLoaded",
Self::TimelineLoaded { .. } => "TimelineLoaded",
Self::TimelineEntitySelected(_) => "TimelineEntitySelected",
Self::WhoResultLoaded { .. } => "WhoResultLoaded",
Self::WhoModeChanged => "WhoModeChanged",
Self::TraceResultLoaded { .. } => "TraceResultLoaded",
Self::TraceKnownPathsLoaded { .. } => "TraceKnownPathsLoaded",
Self::FileHistoryLoaded { .. } => "FileHistoryLoaded",
Self::FileHistoryKnownPathsLoaded { .. } => "FileHistoryKnownPathsLoaded",
Self::ScopeProjectsLoaded { .. } => "ScopeProjectsLoaded",
Self::DoctorLoaded { .. } => "DoctorLoaded",
Self::StatsLoaded { .. } => "StatsLoaded",
Self::SyncStarted => "SyncStarted",
Self::SyncProgress { .. } => "SyncProgress",
Self::SyncProgressBatch { .. } => "SyncProgressBatch",
Self::SyncLogLine(_) => "SyncLogLine",
Self::SyncBackpressureDrop => "SyncBackpressureDrop",
Self::SyncCompleted { .. } => "SyncCompleted",
Self::SyncCancelled => "SyncCancelled",
Self::SyncFailed(_) => "SyncFailed",
Self::SyncStreamStats { .. } => "SyncStreamStats",
Self::SearchDebounceArmed { .. } => "SearchDebounceArmed",
Self::SearchDebounceFired { .. } => "SearchDebounceFired",
Self::DashboardLoaded { .. } => "DashboardLoaded",
Self::Error(_) => "Error",
Self::ShowHelp => "ShowHelp",
Self::ShowCliEquivalent => "ShowCliEquivalent",
Self::OpenInBrowser => "OpenInBrowser",
Self::BlurTextInput => "BlurTextInput",
Self::ScrollToTopCurrentScreen => "ScrollToTopCurrentScreen",
Self::Quit => "Quit",
}
}
}
/// Convert terminal events into messages.
///
/// FrankenTUI requires `From<Event>` on the message type so the runtime
/// can inject terminal events into the model's update loop.
impl From<Event> for Msg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => Self::Resize { width, height },
Event::Tick => Self::Tick,
other => Self::RawEvent(other),
}
}
}
// ---------------------------------------------------------------------------
// Placeholder data types (will be fleshed out in Phase 1+)
// ---------------------------------------------------------------------------
/// Placeholder for issue detail payload.
#[derive(Debug, Clone)]
pub struct IssueDetail {
pub key: EntityKey,
pub title: String,
pub description: String,
}
/// Placeholder for MR detail payload.
#[derive(Debug, Clone)]
pub struct MrDetail {
pub key: EntityKey,
pub title: String,
pub description: String,
}
/// Placeholder for a discussion thread.
#[derive(Debug, Clone)]
pub struct Discussion {
pub id: String,
pub notes: Vec<String>,
}
// ---------------------------------------------------------------------------
// SearchMode
// ---------------------------------------------------------------------------
/// Search mode determines which backend index is used.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SearchMode {
/// FTS5 only — fast, always available if documents are indexed.
#[default]
Lexical,
/// FTS5 + vector RRF merge — best quality when embeddings exist.
Hybrid,
/// Vector-only cosine similarity — requires Ollama embeddings.
Semantic,
}
impl SearchMode {
/// Short label for the mode indicator in the query bar.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Lexical => "FTS",
Self::Hybrid => "Hybrid",
Self::Semantic => "Vec",
}
}
/// Cycle to the next mode, wrapping around.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Lexical => Self::Hybrid,
Self::Hybrid => Self::Semantic,
Self::Semantic => Self::Lexical,
}
}
}
impl std::fmt::Display for SearchMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// A search result from the local database.
#[derive(Debug, Clone)]
pub struct SearchResult {
pub key: EntityKey,
pub title: String,
pub score: f64,
pub snippet: String,
pub project_path: String,
}
// ---------------------------------------------------------------------------
// TimelineEventKind
// ---------------------------------------------------------------------------
/// Event kind for color coding in the TUI timeline.
///
/// Derived from raw resource event tables in the local database.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TimelineEventKind {
/// Entity was created.
Created,
/// State changed (opened/closed/reopened/locked).
StateChanged,
/// Label added to entity.
LabelAdded,
/// Label removed from entity.
LabelRemoved,
/// Milestone set on entity.
MilestoneSet,
/// Milestone removed from entity.
MilestoneRemoved,
/// Merge request was merged.
Merged,
}
impl TimelineEventKind {
/// Short display label for the event kind badge.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Created => "Created",
Self::StateChanged => "State",
Self::LabelAdded => "+Label",
Self::LabelRemoved => "-Label",
Self::MilestoneSet => "+Mile",
Self::MilestoneRemoved => "-Mile",
Self::Merged => "Merged",
}
}
}
// ---------------------------------------------------------------------------
// TimelineEvent
// ---------------------------------------------------------------------------
/// A timeline event for TUI display.
///
/// Produced by [`crate::action::fetch_timeline_events`] from raw
/// resource event tables. Contains enough data for the view to
/// render color-coded events with navigable entity references.
#[derive(Debug, Clone)]
pub struct TimelineEvent {
/// Epoch milliseconds (UTC).
pub timestamp_ms: i64,
/// Entity this event belongs to (for navigation).
pub entity_key: EntityKey,
/// Event kind for color coding.
pub event_kind: TimelineEventKind,
/// Human-readable summary (e.g., "State changed to closed").
pub summary: String,
/// Optional detail text (e.g., label name, new state value).
pub detail: Option<String>,
/// Who performed the action.
pub actor: Option<String>,
/// Project path for display (e.g., "group/project").
pub project_path: String,
}
// WhoResult is re-exported from the lore core crate.
pub use lore::core::who_types::WhoResult;
// DashboardData moved to crate::state::dashboard (enriched with
// EntityCounts, ProjectSyncInfo, RecentActivityItem, LastSyncInfo).
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entity_key_equality() {
assert_eq!(EntityKey::issue(1, 42), EntityKey::issue(1, 42));
assert_ne!(EntityKey::issue(1, 42), EntityKey::mr(1, 42));
}
#[test]
fn test_entity_key_different_projects() {
assert_ne!(EntityKey::issue(1, 42), EntityKey::issue(2, 42));
}
#[test]
fn test_entity_key_display() {
assert_eq!(EntityKey::issue(5, 123).to_string(), "p5:#123");
assert_eq!(EntityKey::mr(5, 456).to_string(), "p5:!456");
}
#[test]
fn test_entity_key_hash_is_usable_in_collections() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(EntityKey::issue(1, 1));
set.insert(EntityKey::issue(1, 1)); // duplicate
set.insert(EntityKey::mr(1, 1));
assert_eq!(set.len(), 2);
}
#[test]
fn test_screen_labels() {
assert_eq!(Screen::Dashboard.label(), "Dashboard");
assert_eq!(Screen::IssueList.label(), "Issues");
assert_eq!(Screen::MrList.label(), "Merge Requests");
assert_eq!(Screen::Search.label(), "Search");
}
#[test]
fn test_screen_is_detail_or_entity() {
assert!(Screen::IssueDetail(EntityKey::issue(1, 1)).is_detail_or_entity());
assert!(Screen::MrDetail(EntityKey::mr(1, 1)).is_detail_or_entity());
assert!(!Screen::Dashboard.is_detail_or_entity());
assert!(!Screen::IssueList.is_detail_or_entity());
assert!(!Screen::Search.is_detail_or_entity());
}
#[test]
fn test_app_error_display() {
let err = AppError::DbBusy;
assert!(err.to_string().contains("busy"));
let err = AppError::NetworkRateLimited {
retry_after_secs: Some(30),
};
assert!(err.to_string().contains("30s"));
let err = AppError::NetworkRateLimited {
retry_after_secs: None,
};
assert!(err.to_string().contains("shortly"));
let err = AppError::AuthFailed;
assert!(err.to_string().contains("token"));
}
#[test]
fn test_input_mode_default_is_normal() {
assert!(matches!(InputMode::default(), InputMode::Normal));
}
#[test]
fn test_msg_from_event_resize() {
let event = Event::Resize {
width: 80,
height: 24,
};
let msg = Msg::from(event);
assert!(matches!(
msg,
Msg::Resize {
width: 80,
height: 24
}
));
}
#[test]
fn test_msg_from_event_tick() {
let msg = Msg::from(Event::Tick);
assert!(matches!(msg, Msg::Tick));
}
#[test]
fn test_msg_from_event_focus_wraps_raw() {
let msg = Msg::from(Event::Focus(true));
assert!(matches!(msg, Msg::RawEvent(Event::Focus(true))));
}
#[test]
fn test_search_mode_labels() {
assert_eq!(SearchMode::Lexical.label(), "FTS");
assert_eq!(SearchMode::Hybrid.label(), "Hybrid");
assert_eq!(SearchMode::Semantic.label(), "Vec");
}
#[test]
fn test_search_mode_next_cycles() {
assert_eq!(SearchMode::Lexical.next(), SearchMode::Hybrid);
assert_eq!(SearchMode::Hybrid.next(), SearchMode::Semantic);
assert_eq!(SearchMode::Semantic.next(), SearchMode::Lexical);
}
#[test]
fn test_search_mode_display() {
assert_eq!(format!("{}", SearchMode::Lexical), "FTS");
assert_eq!(format!("{}", SearchMode::Hybrid), "Hybrid");
assert_eq!(format!("{}", SearchMode::Semantic), "Vec");
}
#[test]
fn test_search_mode_default_is_lexical() {
assert_eq!(SearchMode::default(), SearchMode::Lexical);
}
// -- TimelineEventKind tests --
#[test]
fn test_timeline_event_kind_labels() {
assert_eq!(TimelineEventKind::Created.label(), "Created");
assert_eq!(TimelineEventKind::StateChanged.label(), "State");
assert_eq!(TimelineEventKind::LabelAdded.label(), "+Label");
assert_eq!(TimelineEventKind::LabelRemoved.label(), "-Label");
assert_eq!(TimelineEventKind::MilestoneSet.label(), "+Mile");
assert_eq!(TimelineEventKind::MilestoneRemoved.label(), "-Mile");
assert_eq!(TimelineEventKind::Merged.label(), "Merged");
}
#[test]
fn test_timeline_event_kind_equality() {
assert_eq!(TimelineEventKind::Created, TimelineEventKind::Created);
assert_ne!(TimelineEventKind::Created, TimelineEventKind::Merged);
}
}

View File

@@ -0,0 +1,350 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Browser-like navigation stack with vim-style jump list.
//!
//! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I),
//! and breadcrumb generation. State is preserved when navigating away —
//! screens are never cleared on pop.
use crate::message::Screen;
// ---------------------------------------------------------------------------
// NavigationStack
// ---------------------------------------------------------------------------
/// Browser-like navigation with back/forward stacks and a vim jump list.
///
/// The jump list only records "significant" hops — detail views and
/// cross-references — skipping list/dashboard screens that users
/// visit briefly during drilling.
pub struct NavigationStack {
back_stack: Vec<Screen>,
current: Screen,
forward_stack: Vec<Screen>,
jump_list: Vec<Screen>,
jump_index: usize,
}
impl NavigationStack {
/// Create a new stack starting at the Dashboard.
#[must_use]
pub fn new() -> Self {
Self {
back_stack: Vec::new(),
current: Screen::Dashboard,
forward_stack: Vec::new(),
jump_list: Vec::new(),
jump_index: 0,
}
}
/// The currently displayed screen.
#[must_use]
pub fn current(&self) -> &Screen {
&self.current
}
/// Whether the current screen matches the given screen.
#[must_use]
pub fn is_at(&self, screen: &Screen) -> bool {
&self.current == screen
}
/// Navigate to a new screen.
///
/// Pushes current to back_stack, clears forward_stack (browser behavior),
/// and records detail hops in the jump list.
pub fn push(&mut self, screen: Screen) {
let old = std::mem::replace(&mut self.current, screen);
self.back_stack.push(old);
self.forward_stack.clear();
// Record significant hops in jump list (vim behavior):
// Keep entries up to and including the current position, discard
// any forward entries beyond it, then append the new destination.
if self.current.is_detail_or_entity() {
self.jump_list.truncate(self.jump_index.saturating_add(1));
self.jump_list.push(self.current.clone());
self.jump_index = self.jump_list.len();
}
}
/// Go back to the previous screen.
///
/// Returns `None` at root (can't pop past the initial screen).
pub fn pop(&mut self) -> Option<&Screen> {
let prev = self.back_stack.pop()?;
let old = std::mem::replace(&mut self.current, prev);
self.forward_stack.push(old);
Some(&self.current)
}
/// Go forward (redo a pop).
///
/// Returns `None` if there's nothing to go forward to.
pub fn go_forward(&mut self) -> Option<&Screen> {
let next = self.forward_stack.pop()?;
let old = std::mem::replace(&mut self.current, next);
self.back_stack.push(old);
Some(&self.current)
}
/// Jump backward through the jump list (vim Ctrl+O).
///
/// Only visits detail/entity screens. Skips entries matching the
/// current screen so the first press always produces a visible change.
pub fn jump_back(&mut self) -> Option<&Screen> {
while self.jump_index > 0 {
self.jump_index -= 1;
if let Some(target) = self.jump_list.get(self.jump_index).cloned()
&& target != self.current
{
self.current = target;
return Some(&self.current);
}
}
None
}
/// Jump forward through the jump list (vim Ctrl+I).
///
/// Skips entries matching the current screen.
pub fn jump_forward(&mut self) -> Option<&Screen> {
while self.jump_index < self.jump_list.len() {
if let Some(target) = self.jump_list.get(self.jump_index).cloned() {
self.jump_index += 1;
if target != self.current {
self.current = target;
return Some(&self.current);
}
} else {
break;
}
}
None
}
/// Reset to a single screen, clearing all history.
pub fn reset_to(&mut self, screen: Screen) {
self.current = screen;
self.back_stack.clear();
self.forward_stack.clear();
self.jump_list.clear();
self.jump_index = 0;
}
/// Breadcrumb labels for the current navigation path.
///
/// Returns the back stack labels plus the current screen label.
#[must_use]
pub fn breadcrumbs(&self) -> Vec<&str> {
self.back_stack
.iter()
.chain(std::iter::once(&self.current))
.map(Screen::label)
.collect()
}
/// Navigation depth (1 = at root, 2 = one push deep, etc.).
#[must_use]
pub fn depth(&self) -> usize {
self.back_stack.len() + 1
}
/// Whether there's anything to go back to.
#[must_use]
pub fn can_go_back(&self) -> bool {
!self.back_stack.is_empty()
}
/// Whether there's anything to go forward to.
#[must_use]
pub fn can_go_forward(&self) -> bool {
!self.forward_stack.is_empty()
}
}
impl Default for NavigationStack {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::EntityKey;
#[test]
fn test_new_starts_at_dashboard() {
let nav = NavigationStack::new();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_push_pop_preserves_order() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42))));
assert_eq!(nav.depth(), 3);
nav.pop();
assert!(nav.is_at(&Screen::IssueList));
nav.pop();
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_pop_at_root_returns_none() {
let mut nav = NavigationStack::new();
assert!(nav.pop().is_none());
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_forward_stack_cleared_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList, Search in forward
assert!(nav.can_go_forward());
nav.push(Screen::Timeline); // new push clears forward
assert!(!nav.can_go_forward());
}
#[test]
fn test_go_forward_restores() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList
let screen = nav.go_forward();
assert!(screen.is_some());
assert!(nav.is_at(&Screen::Search));
}
#[test]
fn test_go_forward_returns_none_when_empty() {
let mut nav = NavigationStack::new();
assert!(nav.go_forward().is_none());
}
#[test]
fn test_jump_list_skips_list_screens() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList); // not a detail — skip
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record
nav.push(Screen::MrList); // not a detail — skip
nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record
assert_eq!(nav.jump_list.len(), 2);
}
#[test]
fn test_jump_back_and_forward() {
let mut nav = NavigationStack::new();
let issue = Screen::IssueDetail(EntityKey::issue(1, 1));
let mr = Screen::MrDetail(EntityKey::mr(1, 2));
nav.push(Screen::IssueList);
nav.push(issue.clone());
nav.push(Screen::MrList);
nav.push(mr.clone());
// Current is MrDetail. jump_list = [IssueDetail, MrDetail], index = 2.
// First jump_back skips MrDetail (== current) and lands on IssueDetail.
let prev = nav.jump_back();
assert_eq!(prev, Some(&issue));
assert!(nav.is_at(&issue));
// Already at beginning of jump list.
assert!(nav.jump_back().is_none());
// jump_forward skips IssueDetail (== current) and lands on MrDetail.
let next = nav.jump_forward();
assert_eq!(next, Some(&mr));
assert!(nav.is_at(&mr));
// At end of jump list.
assert!(nav.jump_forward().is_none());
}
#[test]
fn test_jump_list_truncates_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
// jump back twice — lands on issue(1,1), jump_index = 0
nav.jump_back();
nav.jump_back();
// new detail push truncates forward entries
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
// should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3)
assert_eq!(nav.jump_list.len(), 2);
assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99)));
}
#[test]
fn test_reset_clears_all_history() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.reset_to(Screen::Dashboard);
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
assert!(nav.jump_list.is_empty());
}
#[test]
fn test_breadcrumbs_reflect_stack() {
let mut nav = NavigationStack::new();
assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]);
nav.push(Screen::IssueList);
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]);
}
#[test]
fn test_default_is_new() {
let nav = NavigationStack::default();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_can_go_back_and_forward() {
let mut nav = NavigationStack::new();
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
nav.push(Screen::IssueList);
assert!(nav.can_go_back());
assert!(!nav.can_go_forward());
nav.pop();
assert!(!nav.can_go_back());
assert!(nav.can_go_forward());
}
}

View File

@@ -0,0 +1,250 @@
//! Bounded render cache for expensive per-frame computations.
//!
//! Caches pre-computed render artifacts (markdown to styled text, discussion
//! tree layout, issue body rendering) keyed on `(content_hash, terminal_width)`.
//! Width is part of the key because line wrapping changes with terminal size.
//!
//! Invalidation strategies:
//! - **Width change** (`invalidate_width`): purge entries not matching current width
//! - **Theme change** (`invalidate_all`): full clear (colors changed)
//!
//! Single-threaded (TUI event loop) — no `Arc`/`Mutex` needed.
use std::collections::HashMap;
/// Default render cache capacity.
const DEFAULT_CAPACITY: usize = 256;
/// Cache key: content identity + terminal width that produced the render.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RenderCacheKey {
/// Hash of the source content (e.g., `DefaultHasher` or FxHash of text).
pub content_hash: u64,
/// Terminal width at the time of rendering.
pub terminal_width: u16,
}
impl RenderCacheKey {
/// Create a new render cache key.
#[must_use]
pub fn new(content_hash: u64, terminal_width: u16) -> Self {
Self {
content_hash,
terminal_width,
}
}
}
/// Bounded cache for pre-computed render artifacts.
///
/// Uses simple capacity-bounded insertion. When at capacity, the oldest
/// entry (lowest insertion order) is evicted. This is simpler than full
/// LRU because render cache hits tend to be ephemeral — the current
/// frame's renders are the most important.
pub struct RenderCache<V> {
entries: HashMap<RenderCacheKey, (V, u64)>,
capacity: usize,
tick: u64,
}
impl<V> RenderCache<V> {
/// Create a new cache with the default capacity (256).
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::with_capacity(DEFAULT_CAPACITY),
capacity: DEFAULT_CAPACITY,
tick: 0,
}
}
/// Create a new cache with the given capacity.
///
/// # Panics
/// Panics if `capacity` is zero.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
assert!(capacity > 0, "RenderCache capacity must be > 0");
Self {
entries: HashMap::with_capacity(capacity),
capacity,
tick: 0,
}
}
/// Look up a cached render artifact.
pub fn get(&self, key: &RenderCacheKey) -> Option<&V> {
self.entries.get(key).map(|(v, _)| v)
}
/// Insert a render artifact, evicting the oldest entry if at capacity.
pub fn put(&mut self, key: RenderCacheKey, value: V) {
self.tick += 1;
let tick = self.tick;
if let Some(entry) = self.entries.get_mut(&key) {
*entry = (value, tick);
return;
}
if self.entries.len() >= self.capacity
&& let Some(oldest_key) = self
.entries
.iter()
.min_by_key(|(_, (_, t))| *t)
.map(|(k, _)| *k)
{
self.entries.remove(&oldest_key);
}
self.entries.insert(key, (value, tick));
}
/// Remove entries NOT matching the given width (terminal resize).
///
/// After a resize, only entries rendered at the new width are still valid.
pub fn invalidate_width(&mut self, keep_width: u16) {
self.entries.retain(|k, _| k.terminal_width == keep_width);
}
/// Clear the entire cache (theme change — all colors invalidated).
pub fn invalidate_all(&mut self) {
self.entries.clear();
}
/// Number of entries currently cached.
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the cache is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl<V> Default for RenderCache<V> {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn key(hash: u64, width: u16) -> RenderCacheKey {
RenderCacheKey::new(hash, width)
}
#[test]
fn test_get_returns_recently_put_item() {
let mut cache = RenderCache::with_capacity(4);
cache.put(key(100, 80), "rendered-a");
assert_eq!(cache.get(&key(100, 80)), Some(&"rendered-a"));
}
#[test]
fn test_get_returns_none_for_missing_key() {
let cache: RenderCache<&str> = RenderCache::with_capacity(4);
assert_eq!(cache.get(&key(100, 80)), None);
}
#[test]
fn test_same_hash_different_width_are_separate() {
let mut cache = RenderCache::with_capacity(4);
cache.put(key(100, 80), "wide");
cache.put(key(100, 40), "narrow");
assert_eq!(cache.get(&key(100, 80)), Some(&"wide"));
assert_eq!(cache.get(&key(100, 40)), Some(&"narrow"));
assert_eq!(cache.len(), 2);
}
#[test]
fn test_put_overwrites_existing_key() {
let mut cache = RenderCache::with_capacity(4);
cache.put(key(100, 80), "v1");
cache.put(key(100, 80), "v2");
assert_eq!(cache.get(&key(100, 80)), Some(&"v2"));
assert_eq!(cache.len(), 1);
}
#[test]
fn test_eviction_at_capacity() {
let mut cache = RenderCache::with_capacity(2);
cache.put(key(1, 80), "a"); // tick 1
cache.put(key(2, 80), "b"); // tick 2
cache.put(key(3, 80), "c"); // tick 3 -> evicts key(1) (tick 1, oldest)
assert_eq!(cache.get(&key(1, 80)), None, "oldest should be evicted");
assert_eq!(cache.get(&key(2, 80)), Some(&"b"));
assert_eq!(cache.get(&key(3, 80)), Some(&"c"));
}
#[test]
fn test_invalidate_width_removes_non_matching() {
let mut cache = RenderCache::with_capacity(8);
cache.put(key(1, 80), "a");
cache.put(key(2, 80), "b");
cache.put(key(3, 120), "c");
cache.put(key(4, 40), "d");
cache.invalidate_width(80);
assert_eq!(cache.get(&key(1, 80)), Some(&"a"), "width=80 kept");
assert_eq!(cache.get(&key(2, 80)), Some(&"b"), "width=80 kept");
assert_eq!(cache.get(&key(3, 120)), None, "width=120 removed");
assert_eq!(cache.get(&key(4, 40)), None, "width=40 removed");
assert_eq!(cache.len(), 2);
}
#[test]
fn test_invalidate_all_clears_everything() {
let mut cache = RenderCache::with_capacity(8);
cache.put(key(1, 80), "a");
cache.put(key(2, 120), "b");
cache.put(key(3, 40), "c");
cache.invalidate_all();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[test]
fn test_default_capacity_is_256() {
let cache: RenderCache<String> = RenderCache::new();
assert_eq!(cache.capacity, DEFAULT_CAPACITY);
assert_eq!(cache.capacity, 256);
}
#[test]
fn test_len_and_is_empty() {
let mut cache = RenderCache::with_capacity(4);
assert!(cache.is_empty());
cache.put(key(1, 80), "a");
assert!(!cache.is_empty());
assert_eq!(cache.len(), 1);
}
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn test_zero_capacity_panics() {
let _: RenderCache<String> = RenderCache::with_capacity(0);
}
#[test]
fn test_invalidate_width_on_empty_cache_is_noop() {
let mut cache: RenderCache<&str> = RenderCache::with_capacity(4);
cache.invalidate_width(80);
assert!(cache.is_empty());
}
}

View File

@@ -0,0 +1,587 @@
//! Terminal safety: sanitize untrusted text, URL policy, credential redaction.
//!
//! GitLab content can contain ANSI escapes, bidi overrides, OSC hyperlinks,
//! and C1 control codes that could corrupt terminal rendering. This module
//! strips dangerous sequences while preserving a safe SGR subset for readability.
use std::fmt::Write;
// ---------------------------------------------------------------------------
// UrlPolicy
// ---------------------------------------------------------------------------
/// Controls how OSC 8 hyperlinks in input are handled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UrlPolicy {
/// Remove OSC 8 hyperlinks entirely, keeping only the link text.
#[default]
Strip,
/// Convert hyperlinks to numbered footnotes: `text [1]` with URL list appended.
Footnote,
/// Pass hyperlinks through unchanged (only for trusted content).
Passthrough,
}
// ---------------------------------------------------------------------------
// RedactPattern
// ---------------------------------------------------------------------------
/// Common patterns for PII/secret redaction.
#[derive(Debug, Clone)]
pub struct RedactPattern {
patterns: Vec<regex::Regex>,
}
impl RedactPattern {
/// Create a default set of redaction patterns (tokens, emails, etc.).
#[must_use]
pub fn defaults() -> Self {
let patterns = vec![
// GitLab personal access tokens
regex::Regex::new(r"glpat-[A-Za-z0-9_\-]{20,}").expect("valid regex"),
// Generic bearer/API tokens (long hex or base64-ish strings after common prefixes)
regex::Regex::new(r"(?i)(token|bearer|api[_-]?key)[\s:=]+\S{8,}").expect("valid regex"),
// Email addresses
regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
.expect("valid regex"),
];
Self { patterns }
}
/// Apply all redaction patterns to the input string.
#[must_use]
pub fn redact(&self, input: &str) -> String {
let mut result = input.to_string();
for pattern in &self.patterns {
result = pattern.replace_all(&result, "[REDACTED]").into_owned();
}
result
}
}
// ---------------------------------------------------------------------------
// sanitize_for_terminal
// ---------------------------------------------------------------------------
/// Sanitize untrusted text for safe terminal display.
///
/// - Strips C1 control codes (0x80-0x9F)
/// - Strips OSC sequences (ESC ] ... ST)
/// - Strips cursor movement CSI sequences (CSI n A/B/C/D/E/F/G/H/J/K)
/// - Strips bidi overrides (U+202A-U+202E, U+2066-U+2069)
/// - Preserves safe SGR subset (bold, italic, underline, reset, standard colors)
///
/// `url_policy` controls handling of OSC 8 hyperlinks.
#[must_use]
pub fn sanitize_for_terminal(input: &str, url_policy: UrlPolicy) -> String {
let mut output = String::with_capacity(input.len());
let mut footnotes: Vec<String> = Vec::new();
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
let ch = chars[i];
// --- Bidi overrides ---
if is_bidi_override(ch) {
i += 1;
continue;
}
// --- C1 control codes (U+0080-U+009F) ---
if ('\u{0080}'..='\u{009F}').contains(&ch) {
i += 1;
continue;
}
// --- C0 control codes except tab, newline, carriage return ---
if ch.is_ascii_control() && ch != '\t' && ch != '\n' && ch != '\r' && ch != '\x1B' {
i += 1;
continue;
}
// --- ESC sequences ---
if ch == '\x1B' {
if i + 1 < len {
match chars[i + 1] {
// CSI sequence: ESC [
'[' => {
let (consumed, safe_seq) = parse_csi(&chars, i);
if let Some(seq) = safe_seq {
output.push_str(&seq);
}
i += consumed;
continue;
}
// OSC sequence: ESC ]
']' => {
let (consumed, link_text, link_url) = parse_osc(&chars, i);
match url_policy {
UrlPolicy::Strip => {
if let Some(text) = link_text {
output.push_str(&text);
}
}
UrlPolicy::Footnote => {
if let (Some(text), Some(url)) = (link_text, link_url) {
footnotes.push(url);
let _ = write!(output, "{text} [{n}]", n = footnotes.len());
}
}
UrlPolicy::Passthrough => {
// Reproduce the raw OSC sequence
for &ch_raw in &chars[i..len.min(i + consumed)] {
output.push(ch_raw);
}
}
}
i += consumed;
continue;
}
_ => {
// Unknown ESC sequence — skip ESC + next char
i += 2;
continue;
}
}
} else {
// Trailing ESC at end of input
i += 1;
continue;
}
}
// --- Normal character ---
output.push(ch);
i += 1;
}
// Append footnotes if any
if !footnotes.is_empty() {
output.push('\n');
for (idx, url) in footnotes.iter().enumerate() {
let _ = write!(output, "\n[{}] {url}", idx + 1);
}
}
output
}
// ---------------------------------------------------------------------------
// Bidi check
// ---------------------------------------------------------------------------
fn is_bidi_override(ch: char) -> bool {
matches!(
ch,
'\u{202A}' // LRE
| '\u{202B}' // RLE
| '\u{202C}' // PDF
| '\u{202D}' // LRO
| '\u{202E}' // RLO
| '\u{2066}' // LRI
| '\u{2067}' // RLI
| '\u{2068}' // FSI
| '\u{2069}' // PDI
)
}
// ---------------------------------------------------------------------------
// CSI parser
// ---------------------------------------------------------------------------
/// Parse a CSI sequence starting at `chars[start]` (which should be ESC).
///
/// Returns `(chars_consumed, Option<safe_sequence_string>)`.
/// If the CSI is a safe SGR, returns the full sequence string to preserve.
/// Otherwise returns None (strip it).
fn parse_csi(chars: &[char], start: usize) -> (usize, Option<String>) {
// Minimum: ESC [ <final_byte>
debug_assert!(chars[start] == '\x1B');
debug_assert!(start + 1 < chars.len() && chars[start + 1] == '[');
let mut i = start + 2; // skip ESC [
let len = chars.len();
// Collect parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F)
let param_start = i;
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x3F {
i += 1;
}
// Collect intermediate bytes
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x2F {
i += 1;
}
// Final byte (0x40-0x7E)
if i >= len || (chars[i] as u32) < 0x40 || (chars[i] as u32) > 0x7E {
// Malformed — consume what we've seen and strip
return (i.saturating_sub(start).max(2), None);
}
let final_byte = chars[i];
let consumed = i + 1 - start;
// Only preserve SGR sequences (final byte 'm')
if final_byte == 'm' {
let param_str: String = chars[param_start..i].iter().collect();
if is_safe_sgr(&param_str) {
let full_seq: String = chars[start..start + consumed].iter().collect();
return (consumed, Some(full_seq));
}
}
// Anything else (cursor movement A-H, erase J/K, etc.) is stripped
(consumed, None)
}
/// Check if all SGR parameters in a sequence are in the safe subset.
///
/// Safe: 0 (reset), 1 (bold), 3 (italic), 4 (underline), 22 (normal intensity),
/// 23 (not italic), 24 (not underline), 39 (default fg), 49 (default bg),
/// 30-37 (standard fg), 40-47 (standard bg), 90-97 (bright fg), 100-107 (bright bg).
fn is_safe_sgr(params: &str) -> bool {
if params.is_empty() {
return true; // ESC[m is reset
}
for param in params.split(';') {
let param = param.trim();
if param.is_empty() {
continue; // treat empty as 0
}
let Ok(n) = param.parse::<u32>() else {
return false;
};
if !is_safe_sgr_code(n) {
return false;
}
}
true
}
fn is_safe_sgr_code(n: u32) -> bool {
matches!(
n,
0 // reset
| 1 // bold
| 3 // italic
| 4 // underline
| 22 // normal intensity (turn off bold)
| 23 // not italic
| 24 // not underline
| 39 // default foreground
| 49 // default background
| 30..=37 // standard foreground colors
| 40..=47 // standard background colors
| 90..=97 // bright foreground colors
| 100..=107 // bright background colors
)
}
// ---------------------------------------------------------------------------
// OSC parser
// ---------------------------------------------------------------------------
/// Parse an OSC sequence starting at `chars[start]` (ESC ]).
///
/// Returns `(chars_consumed, link_text, link_url)`.
/// For OSC 8 hyperlinks: `ESC ] 8 ; params ; url ST text ESC ] 8 ; ; ST`
/// For other OSC: consumed without extracting link data.
fn parse_osc(chars: &[char], start: usize) -> (usize, Option<String>, Option<String>) {
debug_assert!(chars[start] == '\x1B');
debug_assert!(start + 1 < chars.len() && chars[start + 1] == ']');
let len = chars.len();
let i = start + 2; // skip ESC ]
// Find ST (String Terminator): ESC \ or BEL (0x07)
let osc_end = find_st(chars, i);
// Check if this is OSC 8 (hyperlink)
if i < len && chars[i] == '8' && i + 1 < len && chars[i + 1] == ';' {
// OSC 8 hyperlink: ESC ] 8 ; params ; url ST ... ESC ] 8 ; ; ST
let osc_content: String = chars[i..osc_end.0].iter().collect();
let first_consumed = osc_end.1;
// Extract URL from "8;params;url"
let url = extract_osc8_url(&osc_content);
// Now find the link text (between first ST and second OSC 8)
let after_first_st = start + 2 + first_consumed;
let mut text = String::new();
let mut j = after_first_st;
// Collect text until we hit the closing OSC 8 or end of input
while j < len {
if j + 1 < len && chars[j] == '\x1B' && chars[j + 1] == ']' {
// Found another OSC — this should be the closing OSC 8
let close_end = find_st(chars, j + 2);
return (
j + close_end.1 - start + 2,
Some(text),
url.map(String::from),
);
}
text.push(chars[j]);
j += 1;
}
// Reached end without closing OSC 8
return (j - start, Some(text), url.map(String::from));
}
// Non-OSC-8: just consume and strip
(osc_end.1 + (start + 2 - start), None, None)
}
/// Find the String Terminator (ST) for an OSC sequence.
/// ST is either ESC \ (two chars) or BEL (0x07).
/// Returns (content_end_index, total_consumed_from_content_start).
fn find_st(chars: &[char], from: usize) -> (usize, usize) {
let len = chars.len();
let mut i = from;
while i < len {
if chars[i] == '\x07' {
return (i, i - from + 1);
}
if i + 1 < len && chars[i] == '\x1B' && chars[i + 1] == '\\' {
return (i, i - from + 2);
}
i += 1;
}
// Unterminated — consume everything
(len, len - from)
}
/// Extract URL from OSC 8 content "8;params;url".
fn extract_osc8_url(content: &str) -> Option<&str> {
// Format: "8;params;url"
let rest = content.strip_prefix("8;")?;
// Skip params (up to next ;)
let url_start = rest.find(';')? + 1;
let url = &rest[url_start..];
if url.is_empty() { None } else { Some(url) }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- CSI / cursor movement ---
#[test]
fn test_strips_cursor_movement() {
// CSI 5A = cursor up 5
let input = "before\x1B[5Aafter";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "beforeafter");
}
#[test]
fn test_strips_cursor_movement_all_directions() {
for dir in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] {
let input = format!("x\x1B[3{dir}y");
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "xy", "failed for direction {dir}");
}
}
#[test]
fn test_strips_erase_sequences() {
// CSI 2J = erase display
let input = "before\x1B[2Jafter";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "beforeafter");
}
// --- SGR preservation ---
#[test]
fn test_preserves_bold_italic_underline_reset() {
let input = "\x1B[1mbold\x1B[0m \x1B[3mitalic\x1B[0m \x1B[4munderline\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_preserves_standard_colors() {
// Red foreground, green background
let input = "\x1B[31mred\x1B[42m on green\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_preserves_bright_colors() {
let input = "\x1B[91mbright red\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_preserves_combined_safe_sgr() {
// Bold + red foreground in one sequence
let input = "\x1B[1;31mbold red\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_strips_unsafe_sgr() {
// SGR 8 = hidden text (not in safe list)
let input = "\x1B[8mhidden\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
// SGR 8 stripped, SGR 0 preserved
assert_eq!(result, "hidden\x1B[0m");
}
// --- C1 control codes ---
#[test]
fn test_strips_c1_control_codes() {
// U+008D = Reverse Index, U+009B = CSI (8-bit)
let input = format!("before{}middle{}after", '\u{008D}', '\u{009B}');
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "beforemiddleafter");
}
// --- Bidi overrides ---
#[test]
fn test_strips_bidi_overrides() {
let input = format!(
"normal{}reversed{}end",
'\u{202E}', // RLO
'\u{202C}' // PDF
);
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "normalreversedend");
}
#[test]
fn test_strips_all_bidi_chars() {
let bidi_chars = [
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}',
'\u{2068}', '\u{2069}',
];
for ch in bidi_chars {
let input = format!("a{ch}b");
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "ab", "failed for U+{:04X}", ch as u32);
}
}
// --- OSC sequences ---
#[test]
fn test_strips_osc_sequences() {
// OSC 0 (set title): ESC ] 0 ; title BEL
let input = "before\x1B]0;My Title\x07after";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "beforeafter");
}
// --- OSC 8 hyperlinks ---
#[test]
fn test_url_policy_strip() {
// OSC 8 hyperlink: ESC]8;;url ST text ESC]8;; ST
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "click here done");
}
#[test]
fn test_url_policy_footnote() {
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
let result = sanitize_for_terminal(input, UrlPolicy::Footnote);
assert!(result.contains("here [1]"));
assert!(result.contains("[1] https://example.com"));
}
// --- Redaction ---
#[test]
fn test_redact_gitlab_token() {
let redactor = RedactPattern::defaults();
let input = "My token is glpat-AbCdEfGhIjKlMnOpQrStUvWx";
let result = redactor.redact(input);
assert_eq!(result, "My token is [REDACTED]");
}
#[test]
fn test_redact_email() {
let redactor = RedactPattern::defaults();
let input = "Contact user@example.com for details";
let result = redactor.redact(input);
assert_eq!(result, "Contact [REDACTED] for details");
}
#[test]
fn test_redact_bearer_token() {
let redactor = RedactPattern::defaults();
let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI";
let result = redactor.redact(input);
assert!(result.contains("[REDACTED]"));
assert!(!result.contains("eyJ"));
}
// --- Edge cases ---
#[test]
fn test_empty_input() {
assert_eq!(sanitize_for_terminal("", UrlPolicy::Strip), "");
}
#[test]
fn test_safe_content_passthrough() {
let input = "Hello, world! This is normal text.\nWith newlines\tand tabs.";
assert_eq!(sanitize_for_terminal(input, UrlPolicy::Strip), input);
}
#[test]
fn test_trailing_esc() {
let input = "text\x1B";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "text");
}
#[test]
fn test_malformed_csi_does_not_eat_text() {
// ESC [ without a valid final byte before next printable
let input = "a\x1B[b";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
// The malformed CSI is consumed but shouldn't eat "b" as text
// ESC[ is start, 'b' is final byte (0x62 is in 0x40-0x7E range)
// So this is CSI with final byte 'b' (cursor back) — gets stripped
assert_eq!(result, "a");
}
#[test]
fn test_utf8_adjacent_to_escapes() {
let input = "\x1B[1m日本語\x1B[0m text";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "\x1B[1m日本語\x1B[0m text");
}
#[test]
fn test_fuzz_no_panic() {
// 1000 random-ish byte sequences — must not panic
for seed in 0u16..1000 {
let mut bytes = Vec::new();
for j in 0..50 {
bytes.push(((seed.wrapping_mul(31).wrapping_add(j)) & 0xFF) as u8);
}
// Best-effort UTF-8
let input = String::from_utf8_lossy(&bytes);
let _ = sanitize_for_terminal(&input, UrlPolicy::Strip);
}
}
}

View File

@@ -0,0 +1,162 @@
//! Global scope context helpers: SQL fragment generation and project listing.
//!
//! The [`ScopeContext`] struct lives in [`state::mod`] and holds the active
//! project filter. This module provides:
//!
//! - [`scope_filter_sql`] — generates a SQL WHERE clause fragment
//! - [`fetch_projects`] — lists available projects for the scope picker
//!
//! Action functions already accept `project_id: Option<i64>` — callers pass
//! `scope.project_id` directly. The helpers here are for screens that build
//! custom SQL or need the project list for UI.
use anyhow::{Context, Result};
use rusqlite::Connection;
/// Project metadata for the scope picker overlay.
#[derive(Debug, Clone)]
pub struct ProjectInfo {
/// Internal database ID (projects.id).
pub id: i64,
/// GitLab path (e.g., "group/repo").
pub path: String,
}
/// Generate a SQL WHERE clause fragment that filters by project_id.
///
/// Returns an empty string for `None` (all projects), or
/// `" AND {table_alias}.project_id = {id}"` for `Some(id)`.
///
/// The leading `AND` makes it safe to append to an existing WHERE clause.
///
/// # Examples
///
/// ```ignore
/// let filter = scope_filter_sql(Some(42), "mr");
/// assert_eq!(filter, " AND mr.project_id = 42");
///
/// let filter = scope_filter_sql(None, "mr");
/// assert_eq!(filter, "");
/// ```
#[must_use]
pub fn scope_filter_sql(project_id: Option<i64>, table_alias: &str) -> String {
debug_assert!(
!table_alias.is_empty()
&& table_alias
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_'),
"table_alias must be a valid SQL identifier, got: {table_alias:?}"
);
match project_id {
Some(id) => format!(" AND {table_alias}.project_id = {id}"),
None => String::new(),
}
}
/// Fetch all projects from the database for the scope picker.
///
/// Returns projects sorted by path. Used to populate the scope picker
/// overlay when the user presses `P`.
pub fn fetch_projects(conn: &Connection) -> Result<Vec<ProjectInfo>> {
let mut stmt = conn
.prepare("SELECT id, path_with_namespace FROM projects ORDER BY path_with_namespace")
.context("preparing projects query")?;
let projects = stmt
.query_map([], |row| {
Ok(ProjectInfo {
id: row.get(0)?,
path: row.get(1)?,
})
})
.context("querying projects")?
.filter_map(std::result::Result::ok)
.collect();
Ok(projects)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scope_filter_sql_none_returns_empty() {
let sql = scope_filter_sql(None, "mr");
assert_eq!(sql, "");
}
#[test]
fn test_scope_filter_sql_some_returns_and_clause() {
let sql = scope_filter_sql(Some(42), "mr");
assert_eq!(sql, " AND mr.project_id = 42");
}
#[test]
fn test_scope_filter_sql_different_alias() {
let sql = scope_filter_sql(Some(7), "mfc");
assert_eq!(sql, " AND mfc.project_id = 7");
}
#[test]
fn test_fetch_projects_empty_db() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
)",
)
.unwrap();
let projects = fetch_projects(&conn).unwrap();
assert!(projects.is_empty());
}
#[test]
fn test_fetch_projects_returns_sorted() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE projects (
id INTEGER PRIMARY KEY,
gitlab_project_id INTEGER UNIQUE NOT NULL,
path_with_namespace TEXT NOT NULL
)",
)
.unwrap();
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'z-group/repo')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (2, 200, 'a-group/repo')",
[],
)
.unwrap();
let projects = fetch_projects(&conn).unwrap();
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].path, "a-group/repo");
assert_eq!(projects[0].id, 2);
assert_eq!(projects[1].path, "z-group/repo");
assert_eq!(projects[1].id, 1);
}
#[test]
fn test_scope_filter_sql_composable_in_query() {
// Verify the fragment works when embedded in a full SQL statement.
let project_id = Some(5);
let filter = scope_filter_sql(project_id, "mr");
let sql = format!(
"SELECT * FROM merge_requests mr WHERE mr.state = 'merged'{filter} ORDER BY mr.updated_at"
);
assert!(sql.contains("AND mr.project_id = 5"));
}
}

View File

@@ -0,0 +1,402 @@
//! Session state persistence — save on quit, restore on launch.
//!
//! Enables the TUI to resume where the user left off: current screen,
//! navigation history, filter state, scroll positions.
//!
//! ## File format
//!
//! `session.json` is a versioned JSON blob with a CRC32 checksum appended
//! as the last 8 hex characters. Writes are atomic (tmp → fsync → rename).
//! Corrupt files are quarantined, not deleted.
use std::fs;
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
/// Maximum session file size (1 MB). Files larger than this are rejected.
const MAX_SESSION_SIZE: u64 = 1_024 * 1_024;
/// Current session format version. Bump when the schema changes.
const SESSION_VERSION: u32 = 1;
// ---------------------------------------------------------------------------
// Persisted screen (decoupled from message::Screen)
// ---------------------------------------------------------------------------
/// Lightweight screen identifier for serialization.
///
/// Decoupled from `message::Screen` so session persistence doesn't require
/// `Serialize`/`Deserialize` on core types.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind")]
pub enum PersistedScreen {
Dashboard,
IssueList,
IssueDetail { project_id: i64, iid: i64 },
MrList,
MrDetail { project_id: i64, iid: i64 },
Search,
Timeline,
Who,
Trace,
FileHistory,
Sync,
Stats,
Doctor,
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
/// Versioned session state persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionState {
/// Format version for migration.
pub version: u32,
/// Screen to restore on launch.
pub current_screen: PersistedScreen,
/// Navigation history (back stack).
pub nav_history: Vec<PersistedScreen>,
/// Per-screen filter text (screen name -> filter string).
pub filters: Vec<(String, String)>,
/// Per-screen scroll offset (screen name -> offset).
pub scroll_offsets: Vec<(String, u16)>,
/// Global scope project path filter (if set).
pub global_scope: Option<String>,
}
impl Default for SessionState {
fn default() -> Self {
Self {
version: SESSION_VERSION,
current_screen: PersistedScreen::Dashboard,
nav_history: Vec::new(),
filters: Vec::new(),
scroll_offsets: Vec::new(),
global_scope: None,
}
}
}
// ---------------------------------------------------------------------------
// Save / Load
// ---------------------------------------------------------------------------
/// Save session state atomically.
///
/// Writes to a temp file, fsyncs, appends CRC32 checksum, then renames
/// over the target path. This prevents partial writes on crash.
pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionError> {
// Ensure parent directory exists.
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?;
}
let json =
serde_json::to_string_pretty(state).map_err(|e| SessionError::Serialize(e.to_string()))?;
// Check size before writing.
if json.len() as u64 > MAX_SESSION_SIZE {
return Err(SessionError::TooLarge {
size: json.len() as u64,
max: MAX_SESSION_SIZE,
});
}
// Compute CRC32 over the JSON payload.
let checksum = crc32fast::hash(json.as_bytes());
let payload = format!("{json}\n{checksum:08x}");
// Write to temp file, fsync, rename.
let tmp_path = path.with_extension("tmp");
let mut file = fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
file.write_all(payload.as_bytes())
.map_err(|e| SessionError::Io(e.to_string()))?;
file.sync_all()
.map_err(|e| SessionError::Io(e.to_string()))?;
drop(file);
fs::rename(&tmp_path, path).map_err(|e| SessionError::Io(e.to_string()))?;
Ok(())
}
/// Load session state from disk.
///
/// Validates CRC32 checksum. On corruption, quarantines the file and
/// returns `SessionError::Corrupt`.
pub fn load_session(path: &Path) -> Result<SessionState, SessionError> {
if !path.exists() {
return Err(SessionError::NotFound);
}
// Check file size before reading.
let metadata = fs::metadata(path).map_err(|e| SessionError::Io(e.to_string()))?;
if metadata.len() > MAX_SESSION_SIZE {
quarantine(path)?;
return Err(SessionError::TooLarge {
size: metadata.len(),
max: MAX_SESSION_SIZE,
});
}
let raw = fs::read_to_string(path).map_err(|e| SessionError::Io(e.to_string()))?;
// Split: everything before the last newline is JSON, after is the checksum.
let (json, checksum_hex) = raw
.rsplit_once('\n')
.ok_or_else(|| SessionError::Corrupt("no checksum separator".into()))?;
// Validate checksum.
let expected = u32::from_str_radix(checksum_hex.trim(), 16)
.map_err(|_| SessionError::Corrupt("invalid checksum hex".into()))?;
let actual = crc32fast::hash(json.as_bytes());
if actual != expected {
quarantine(path)?;
return Err(SessionError::Corrupt(format!(
"CRC32 mismatch: expected {expected:08x}, got {actual:08x}"
)));
}
// Deserialize.
let state: SessionState = serde_json::from_str(json)
.map_err(|e| SessionError::Corrupt(format!("JSON parse error: {e}")))?;
// Version check — future-proof: reject newer versions, accept current.
if state.version > SESSION_VERSION {
return Err(SessionError::Corrupt(format!(
"session version {} is newer than supported ({})",
state.version, SESSION_VERSION
)));
}
Ok(state)
}
/// Move a corrupt session file to `.quarantine/` instead of deleting it.
fn quarantine(path: &Path) -> Result<(), SessionError> {
let quarantine_dir = path.parent().unwrap_or(Path::new(".")).join(".quarantine");
fs::create_dir_all(&quarantine_dir).map_err(|e| SessionError::Io(e.to_string()))?;
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let quarantine_path = quarantine_dir.join(format!("{filename}.{ts}"));
fs::rename(path, &quarantine_path).map_err(|e| SessionError::Io(e.to_string()))?;
Ok(())
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Session persistence errors.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionError {
/// Session file not found (first launch).
NotFound,
/// File is corrupt (bad checksum, invalid JSON, etc.).
Corrupt(String),
/// File exceeds size limit.
TooLarge { size: u64, max: u64 },
/// I/O error.
Io(String),
/// Serialization error.
Serialize(String),
}
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "session file not found"),
Self::Corrupt(msg) => write!(f, "corrupt session: {msg}"),
Self::TooLarge { size, max } => {
write!(f, "session file too large ({size} bytes, max {max})")
}
Self::Io(msg) => write!(f, "session I/O error: {msg}"),
Self::Serialize(msg) => write!(f, "session serialization error: {msg}"),
}
}
}
impl std::error::Error for SessionError {}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_state() -> SessionState {
SessionState {
version: SESSION_VERSION,
current_screen: PersistedScreen::IssueList,
nav_history: vec![PersistedScreen::Dashboard],
filters: vec![("IssueList".into(), "bug".into())],
scroll_offsets: vec![("IssueList".into(), 5)],
global_scope: Some("group/project".into()),
}
}
#[test]
fn test_session_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let state = sample_state();
save_session(&state, &path).unwrap();
let loaded = load_session(&path).unwrap();
assert_eq!(state, loaded);
}
#[test]
fn test_session_default_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let state = SessionState::default();
save_session(&state, &path).unwrap();
let loaded = load_session(&path).unwrap();
assert_eq!(state, loaded);
}
#[test]
fn test_session_not_found() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.json");
let result = load_session(&path);
assert_eq!(result.unwrap_err(), SessionError::NotFound);
}
#[test]
fn test_session_corruption_detected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let state = sample_state();
save_session(&state, &path).unwrap();
// Tamper with the file — modify a byte in the JSON section.
let raw = fs::read_to_string(&path).unwrap();
let tampered = raw.replacen("IssueList", "MrList___", 1);
fs::write(&path, tampered).unwrap();
let result = load_session(&path);
assert!(matches!(result, Err(SessionError::Corrupt(_))));
}
#[test]
fn test_session_corruption_quarantines_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let state = sample_state();
save_session(&state, &path).unwrap();
// Tamper with the checksum line.
let raw = fs::read_to_string(&path).unwrap();
let tampered = format!("{}\ndeadbeef", raw.rsplit_once('\n').unwrap().0);
fs::write(&path, tampered).unwrap();
let _ = load_session(&path);
// Original file should be gone.
assert!(!path.exists());
// Quarantine directory should contain the file.
let quarantine_dir = dir.path().join(".quarantine");
assert!(quarantine_dir.exists());
let entries: Vec<_> = fs::read_dir(&quarantine_dir).unwrap().collect();
assert_eq!(entries.len(), 1);
}
#[test]
fn test_session_creates_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b").join("session.json");
let state = SessionState::default();
save_session(&state, &nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_session_persisted_screen_variants() {
let screens = vec![
PersistedScreen::Dashboard,
PersistedScreen::IssueList,
PersistedScreen::IssueDetail {
project_id: 1,
iid: 42,
},
PersistedScreen::MrList,
PersistedScreen::MrDetail {
project_id: 2,
iid: 99,
},
PersistedScreen::Search,
PersistedScreen::Timeline,
PersistedScreen::Who,
PersistedScreen::Trace,
PersistedScreen::FileHistory,
PersistedScreen::Sync,
PersistedScreen::Stats,
PersistedScreen::Doctor,
];
for screen in screens {
let state = SessionState {
current_screen: screen.clone(),
..SessionState::default()
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
save_session(&state, &path).unwrap();
let loaded = load_session(&path).unwrap();
assert_eq!(state.current_screen, loaded.current_screen);
}
}
#[test]
fn test_session_max_size_enforced() {
let state = SessionState {
filters: (0..100_000)
.map(|i| (format!("key_{i}"), "x".repeat(100)))
.collect(),
..SessionState::default()
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let result = save_session(&state, &path);
assert!(matches!(result, Err(SessionError::TooLarge { .. })));
}
#[test]
fn test_session_atomic_write_no_partial() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let tmp_path = path.with_extension("tmp");
let state = sample_state();
save_session(&state, &path).unwrap();
// After save, no tmp file should remain.
assert!(!tmp_path.exists());
assert!(path.exists());
}
}

View File

@@ -0,0 +1,160 @@
#![allow(dead_code)] // Phase 2.5: consumed by Bootstrap screen
//! Bootstrap screen state.
//!
//! Handles first-launch and empty-database scenarios. The schema
//! preflight runs before the TUI event loop; the bootstrap screen
//! guides users to sync when no data is available.
// ---------------------------------------------------------------------------
// DataReadiness
// ---------------------------------------------------------------------------
/// Result of checking whether the database has enough data to show the TUI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataReadiness {
/// Database has at least one issue.
pub has_issues: bool,
/// Database has at least one merge request.
pub has_mrs: bool,
/// Database has at least one search document.
pub has_documents: bool,
/// Current schema version from the schema_version table.
pub schema_version: i32,
}
impl DataReadiness {
/// Whether the database has any entity data at all.
#[must_use]
pub fn has_any_data(&self) -> bool {
self.has_issues || self.has_mrs
}
}
// ---------------------------------------------------------------------------
// SchemaCheck
// ---------------------------------------------------------------------------
/// Result of schema version validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaCheck {
/// Schema is at or above the minimum required version.
Compatible { version: i32 },
/// No database or no schema_version table found.
NoDB,
/// Schema exists but is too old for this TUI version.
Incompatible { found: i32, minimum: i32 },
}
// ---------------------------------------------------------------------------
// BootstrapState
// ---------------------------------------------------------------------------
/// State for the Bootstrap screen.
#[derive(Debug, Default)]
pub struct BootstrapState {
/// Whether a data readiness check has completed.
pub readiness: Option<DataReadiness>,
/// Whether the user has initiated a sync from the bootstrap screen.
pub sync_started: bool,
}
impl BootstrapState {
/// Apply a data readiness result.
pub fn apply_readiness(&mut self, readiness: DataReadiness) {
self.readiness = Some(readiness);
}
/// Whether we have data (and should auto-transition to Dashboard).
#[must_use]
pub fn should_transition_to_dashboard(&self) -> bool {
self.readiness
.as_ref()
.is_some_and(DataReadiness::has_any_data)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_data_readiness_has_any_data() {
let empty = DataReadiness {
has_issues: false,
has_mrs: false,
has_documents: false,
schema_version: 26,
};
assert!(!empty.has_any_data());
let with_issues = DataReadiness {
has_issues: true,
..empty.clone()
};
assert!(with_issues.has_any_data());
let with_mrs = DataReadiness {
has_mrs: true,
..empty
};
assert!(with_mrs.has_any_data());
}
#[test]
fn test_schema_check_variants() {
let compat = SchemaCheck::Compatible { version: 26 };
assert!(matches!(compat, SchemaCheck::Compatible { version: 26 }));
let no_db = SchemaCheck::NoDB;
assert!(matches!(no_db, SchemaCheck::NoDB));
let incompat = SchemaCheck::Incompatible {
found: 10,
minimum: 20,
};
assert!(matches!(
incompat,
SchemaCheck::Incompatible {
found: 10,
minimum: 20
}
));
}
#[test]
fn test_bootstrap_state_default() {
let state = BootstrapState::default();
assert!(state.readiness.is_none());
assert!(!state.sync_started);
assert!(!state.should_transition_to_dashboard());
}
#[test]
fn test_bootstrap_state_apply_readiness_empty() {
let mut state = BootstrapState::default();
state.apply_readiness(DataReadiness {
has_issues: false,
has_mrs: false,
has_documents: false,
schema_version: 26,
});
assert!(!state.should_transition_to_dashboard());
}
#[test]
fn test_bootstrap_state_apply_readiness_with_data() {
let mut state = BootstrapState::default();
state.apply_readiness(DataReadiness {
has_issues: true,
has_mrs: false,
has_documents: false,
schema_version: 26,
});
assert!(state.should_transition_to_dashboard());
}
}

View File

@@ -0,0 +1,304 @@
//! Command palette state and fuzzy matching.
//!
//! The command palette is a modal overlay (Ctrl+P) that provides fuzzy-match
//! access to all commands. Populated from [`CommandRegistry::palette_entries`].
use crate::commands::{CommandId, CommandRegistry};
use crate::message::Screen;
// ---------------------------------------------------------------------------
// PaletteEntry
// ---------------------------------------------------------------------------
/// A single entry in the filtered palette list.
#[derive(Debug, Clone)]
pub struct PaletteEntry {
/// Command ID for execution.
pub id: CommandId,
/// Human-readable label.
pub label: &'static str,
/// Keybinding display string (e.g., "g i").
pub keybinding: Option<String>,
/// Help text / description.
pub help_text: &'static str,
}
// ---------------------------------------------------------------------------
// CommandPaletteState
// ---------------------------------------------------------------------------
/// State for the command palette overlay.
#[derive(Debug, Default)]
pub struct CommandPaletteState {
/// Current query text.
pub query: String,
/// Whether the query input is focused.
pub query_focused: bool,
/// Cursor position within the query string (byte offset).
pub cursor: usize,
/// Index of the currently selected entry in `filtered`.
pub selected_index: usize,
/// Filtered and scored palette entries.
pub filtered: Vec<PaletteEntry>,
}
impl CommandPaletteState {
/// Open the palette: reset query, focus input, populate with all commands.
pub fn open(&mut self, registry: &CommandRegistry, screen: &Screen) {
self.query.clear();
self.cursor = 0;
self.query_focused = true;
self.selected_index = 0;
self.refilter(registry, screen);
}
/// Close the palette: unfocus and clear state.
pub fn close(&mut self) {
self.query_focused = false;
self.query.clear();
self.cursor = 0;
self.selected_index = 0;
self.filtered.clear();
}
/// Insert a character at the cursor position.
pub fn insert_char(&mut self, c: char, registry: &CommandRegistry, screen: &Screen) {
self.query.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.selected_index = 0;
self.refilter(registry, screen);
}
/// Delete the character before the cursor.
pub fn delete_back(&mut self, registry: &CommandRegistry, screen: &Screen) {
if self.cursor > 0 {
// Find the previous character boundary.
let prev = self.query[..self.cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
self.query.drain(prev..self.cursor);
self.cursor = prev;
self.selected_index = 0;
self.refilter(registry, screen);
}
}
/// Move selection up by one.
pub fn select_prev(&mut self) {
self.selected_index = self.selected_index.saturating_sub(1);
}
/// Move selection down by one.
pub fn select_next(&mut self) {
if !self.filtered.is_empty() {
self.selected_index = (self.selected_index + 1).min(self.filtered.len() - 1);
}
}
/// Get the currently selected entry's command ID.
#[must_use]
pub fn selected_command_id(&self) -> Option<CommandId> {
self.filtered.get(self.selected_index).map(|e| e.id)
}
/// Whether the palette is visible/active.
#[must_use]
pub fn is_open(&self) -> bool {
self.query_focused
}
/// Recompute the filtered list from the registry.
fn refilter(&mut self, registry: &CommandRegistry, screen: &Screen) {
let entries = registry.palette_entries(screen);
let query_lower = self.query.to_lowercase();
self.filtered = entries
.into_iter()
.filter(|cmd| {
if query_lower.is_empty() {
return true;
}
fuzzy_match(&query_lower, cmd.label) || fuzzy_match(&query_lower, cmd.help_text)
})
.map(|cmd| PaletteEntry {
id: cmd.id,
label: cmd.label,
keybinding: cmd.keybinding.as_ref().map(|kb| kb.display()),
help_text: cmd.help_text,
})
.collect();
// Clamp selection.
if !self.filtered.is_empty() {
self.selected_index = self.selected_index.min(self.filtered.len() - 1);
} else {
self.selected_index = 0;
}
}
}
// ---------------------------------------------------------------------------
// Fuzzy matching
// ---------------------------------------------------------------------------
/// Subsequence fuzzy match: every character in `query` must appear in `text`
/// in order, case-insensitive.
fn fuzzy_match(query: &str, text: &str) -> bool {
let text_lower = text.to_lowercase();
let mut text_chars = text_lower.chars();
for qc in query.chars() {
if !text_chars.any(|tc| tc == qc) {
return false;
}
}
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
#[test]
fn test_fuzzy_match_exact() {
assert!(fuzzy_match("quit", "Quit"));
}
#[test]
fn test_fuzzy_match_subsequence() {
assert!(fuzzy_match("gi", "Go to Issues"));
assert!(fuzzy_match("iss", "Go to Issues"));
}
#[test]
fn test_fuzzy_match_case_insensitive() {
assert!(fuzzy_match("help", "Show keybinding help overlay"));
}
#[test]
fn test_fuzzy_match_no_match() {
assert!(!fuzzy_match("xyz", "Quit"));
}
#[test]
fn test_fuzzy_match_empty_query() {
assert!(fuzzy_match("", "anything"));
}
#[test]
fn test_palette_open_populates_all() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
assert!(state.query_focused);
assert!(state.query.is_empty());
assert!(!state.filtered.is_empty());
// All palette-eligible commands for Dashboard should be present.
let palette_count = registry.palette_entries(&Screen::Dashboard).len();
assert_eq!(state.filtered.len(), palette_count);
}
#[test]
fn test_palette_filter_narrows_results() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
let all_count = state.filtered.len();
state.insert_char('i', &registry, &Screen::Dashboard);
state.insert_char('s', &registry, &Screen::Dashboard);
state.insert_char('s', &registry, &Screen::Dashboard);
// "iss" should match "Go to Issues" but not most other commands.
assert!(state.filtered.len() < all_count);
assert!(state.filtered.iter().any(|e| e.label == "Go to Issues"));
}
#[test]
fn test_palette_delete_back_widens_results() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.insert_char('q', &registry, &Screen::Dashboard);
let narrow_count = state.filtered.len();
state.delete_back(&registry, &Screen::Dashboard);
// After deleting, query is empty — should show all commands again.
assert!(state.filtered.len() > narrow_count);
}
#[test]
fn test_palette_select_navigation() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
assert_eq!(state.selected_index, 0);
state.select_next();
assert_eq!(state.selected_index, 1);
state.select_next();
assert_eq!(state.selected_index, 2);
state.select_prev();
assert_eq!(state.selected_index, 1);
state.select_prev();
assert_eq!(state.selected_index, 0);
state.select_prev(); // Should not go below 0.
assert_eq!(state.selected_index, 0);
}
#[test]
fn test_palette_selected_command_id() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
assert!(state.selected_command_id().is_some());
}
#[test]
fn test_palette_close_resets() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.insert_char('q', &registry, &Screen::Dashboard);
state.select_next();
state.close();
assert!(!state.query_focused);
assert!(state.query.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.filtered.is_empty());
}
#[test]
fn test_palette_empty_query_no_match_returns_empty() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
// Type something that matches nothing.
for c in "zzzzzz".chars() {
state.insert_char(c, &registry, &Screen::Dashboard);
}
assert!(state.filtered.is_empty());
assert!(state.selected_command_id().is_none());
}
#[test]
fn test_palette_keybinding_display() {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
// "Quit" should have keybinding "q".
let quit_entry = state.filtered.iter().find(|e| e.id == "quit");
assert!(quit_entry.is_some());
assert_eq!(quit_entry.unwrap().keybinding.as_deref(), Some("q"));
}
}

View File

@@ -0,0 +1,255 @@
#![allow(dead_code)]
//! Dashboard screen state.
//!
//! The dashboard is the home screen — entity counts, per-project sync
//! status, recent activity, and the last sync summary.
// ---------------------------------------------------------------------------
// EntityCounts
// ---------------------------------------------------------------------------
/// Aggregated entity counts from the local database.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntityCounts {
pub issues_open: u64,
pub issues_total: u64,
pub mrs_open: u64,
pub mrs_total: u64,
pub discussions: u64,
pub notes_total: u64,
/// Percentage of notes that are system-generated (0-100).
pub notes_system_pct: u8,
pub documents: u64,
pub embeddings: u64,
}
// ---------------------------------------------------------------------------
// ProjectSyncInfo
// ---------------------------------------------------------------------------
/// Per-project sync freshness.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSyncInfo {
pub path: String,
pub minutes_since_sync: u64,
}
// ---------------------------------------------------------------------------
// RecentActivityItem
// ---------------------------------------------------------------------------
/// A recently-updated entity for the activity feed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentActivityItem {
/// "issue" or "mr".
pub entity_type: String,
pub iid: u64,
pub title: String,
pub state: String,
pub minutes_ago: u64,
}
// ---------------------------------------------------------------------------
// LastSyncInfo
// ---------------------------------------------------------------------------
/// Summary of the most recent sync run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LastSyncInfo {
pub status: String,
/// Milliseconds epoch UTC.
pub finished_at: Option<i64>,
pub command: String,
pub error: Option<String>,
}
// ---------------------------------------------------------------------------
// DashboardData
// ---------------------------------------------------------------------------
/// Data returned by the `fetch_dashboard` action.
///
/// Pure data transfer — no rendering or display logic.
#[derive(Debug, Clone, Default)]
pub struct DashboardData {
pub counts: EntityCounts,
pub projects: Vec<ProjectSyncInfo>,
pub recent: Vec<RecentActivityItem>,
pub last_sync: Option<LastSyncInfo>,
}
// ---------------------------------------------------------------------------
// DashboardState
// ---------------------------------------------------------------------------
/// State for the dashboard summary screen.
#[derive(Debug, Default)]
pub struct DashboardState {
pub counts: EntityCounts,
pub projects: Vec<ProjectSyncInfo>,
pub recent: Vec<RecentActivityItem>,
pub last_sync: Option<LastSyncInfo>,
/// Scroll offset for the recent activity list.
pub scroll_offset: usize,
}
impl DashboardState {
/// Apply fresh data from a `fetch_dashboard` result.
///
/// Preserves scroll offset (clamped to new data bounds).
pub fn update(&mut self, data: DashboardData) {
self.counts = data.counts;
self.projects = data.projects;
self.last_sync = data.last_sync;
self.recent = data.recent;
// Clamp scroll offset if the list shrunk.
if !self.recent.is_empty() {
self.scroll_offset = self.scroll_offset.min(self.recent.len() - 1);
} else {
self.scroll_offset = 0;
}
}
/// Scroll the recent activity list down by one.
pub fn scroll_down(&mut self) {
if !self.recent.is_empty() {
self.scroll_offset = (self.scroll_offset + 1).min(self.recent.len() - 1);
}
}
/// Scroll the recent activity list up by one.
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dashboard_state_default() {
let state = DashboardState::default();
assert_eq!(state.counts.issues_total, 0);
assert_eq!(state.scroll_offset, 0);
assert!(state.recent.is_empty());
}
#[test]
fn test_dashboard_state_update_applies_data() {
let mut state = DashboardState::default();
let data = DashboardData {
counts: EntityCounts {
issues_open: 3,
issues_total: 5,
..Default::default()
},
projects: vec![ProjectSyncInfo {
path: "group/project".into(),
minutes_since_sync: 42,
}],
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 1,
title: "Fix bug".into(),
state: "opened".into(),
minutes_ago: 10,
}],
last_sync: None,
};
state.update(data);
assert_eq!(state.counts.issues_open, 3);
assert_eq!(state.counts.issues_total, 5);
assert_eq!(state.projects.len(), 1);
assert_eq!(state.recent.len(), 1);
}
#[test]
fn test_dashboard_state_update_clamps_scroll() {
let mut state = DashboardState {
scroll_offset: 10,
..Default::default()
};
let data = DashboardData {
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 1,
title: "Only item".into(),
state: "opened".into(),
minutes_ago: 5,
}],
..Default::default()
};
state.update(data);
assert_eq!(state.scroll_offset, 0); // Clamped to len-1 = 0
}
#[test]
fn test_dashboard_state_update_empty_resets_scroll() {
let mut state = DashboardState {
scroll_offset: 5,
..Default::default()
};
state.update(DashboardData::default());
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_down_and_up() {
let mut state = DashboardState::default();
state.recent = (0..5)
.map(|i| RecentActivityItem {
entity_type: "issue".into(),
iid: i,
title: format!("Item {i}"),
state: "opened".into(),
minutes_ago: i,
})
.collect();
assert_eq!(state.scroll_offset, 0);
state.scroll_down();
assert_eq!(state.scroll_offset, 1);
state.scroll_down();
assert_eq!(state.scroll_offset, 2);
state.scroll_up();
assert_eq!(state.scroll_offset, 1);
state.scroll_up();
assert_eq!(state.scroll_offset, 0);
state.scroll_up(); // Can't go below 0
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_down_stops_at_end() {
let mut state = DashboardState::default();
state.recent = vec![RecentActivityItem {
entity_type: "mr".into(),
iid: 1,
title: "Only".into(),
state: "merged".into(),
minutes_ago: 0,
}];
state.scroll_down();
assert_eq!(state.scroll_offset, 0); // Can't scroll past single item
}
#[test]
fn test_scroll_on_empty_is_noop() {
let mut state = DashboardState::default();
state.scroll_down();
assert_eq!(state.scroll_offset, 0);
state.scroll_up();
assert_eq!(state.scroll_offset, 0);
}
}

View File

@@ -0,0 +1,199 @@
#![allow(dead_code)]
//! Doctor screen state — health check results.
//!
//! Displays a list of environment health checks with pass/warn/fail
//! indicators. Checks are synchronous (config, DB, projects, FTS) —
//! network checks (GitLab auth, Ollama) are not run from the TUI.
// ---------------------------------------------------------------------------
// HealthStatus
// ---------------------------------------------------------------------------
/// Status of a single health check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthStatus {
Pass,
Warn,
Fail,
}
impl HealthStatus {
/// Human-readable label for display.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Pass => "PASS",
Self::Warn => "WARN",
Self::Fail => "FAIL",
}
}
}
// ---------------------------------------------------------------------------
// HealthCheck
// ---------------------------------------------------------------------------
/// A single health check result for display.
#[derive(Debug, Clone)]
pub struct HealthCheck {
/// Check category name (e.g., "Config", "Database").
pub name: String,
/// Pass/warn/fail status.
pub status: HealthStatus,
/// Human-readable detail (e.g., path, version, count).
pub detail: String,
}
// ---------------------------------------------------------------------------
// DoctorState
// ---------------------------------------------------------------------------
/// State for the Doctor screen.
#[derive(Debug, Default)]
pub struct DoctorState {
/// Health check results (empty until loaded).
pub checks: Vec<HealthCheck>,
/// Whether checks have been loaded at least once.
pub loaded: bool,
}
impl DoctorState {
/// Apply loaded health check results.
pub fn apply_checks(&mut self, checks: Vec<HealthCheck>) {
self.checks = checks;
self.loaded = true;
}
/// Overall status — worst status across all checks.
#[must_use]
pub fn overall_status(&self) -> HealthStatus {
if self.checks.iter().any(|c| c.status == HealthStatus::Fail) {
HealthStatus::Fail
} else if self.checks.iter().any(|c| c.status == HealthStatus::Warn) {
HealthStatus::Warn
} else {
HealthStatus::Pass
}
}
/// Count of checks by status.
#[must_use]
pub fn count_by_status(&self, status: HealthStatus) -> usize {
self.checks.iter().filter(|c| c.status == status).count()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_checks() -> Vec<HealthCheck> {
vec![
HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "/home/user/.config/lore/config.json".into(),
},
HealthCheck {
name: "Database".into(),
status: HealthStatus::Pass,
detail: "schema v12".into(),
},
HealthCheck {
name: "Projects".into(),
status: HealthStatus::Warn,
detail: "0 projects configured".into(),
},
HealthCheck {
name: "FTS Index".into(),
status: HealthStatus::Fail,
detail: "No documents indexed".into(),
},
]
}
#[test]
fn test_default_state() {
let state = DoctorState::default();
assert!(state.checks.is_empty());
assert!(!state.loaded);
}
#[test]
fn test_apply_checks() {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
assert!(state.loaded);
assert_eq!(state.checks.len(), 4);
}
#[test]
fn test_overall_status_fail_wins() {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
assert_eq!(state.overall_status(), HealthStatus::Fail);
}
#[test]
fn test_overall_status_all_pass() {
let mut state = DoctorState::default();
state.apply_checks(vec![
HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "ok".into(),
},
HealthCheck {
name: "Database".into(),
status: HealthStatus::Pass,
detail: "ok".into(),
},
]);
assert_eq!(state.overall_status(), HealthStatus::Pass);
}
#[test]
fn test_overall_status_warn_without_fail() {
let mut state = DoctorState::default();
state.apply_checks(vec![
HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "ok".into(),
},
HealthCheck {
name: "Ollama".into(),
status: HealthStatus::Warn,
detail: "not running".into(),
},
]);
assert_eq!(state.overall_status(), HealthStatus::Warn);
}
#[test]
fn test_overall_status_empty_is_pass() {
let state = DoctorState::default();
assert_eq!(state.overall_status(), HealthStatus::Pass);
}
#[test]
fn test_count_by_status() {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
assert_eq!(state.count_by_status(HealthStatus::Pass), 2);
assert_eq!(state.count_by_status(HealthStatus::Warn), 1);
assert_eq!(state.count_by_status(HealthStatus::Fail), 1);
}
#[test]
fn test_health_status_labels() {
assert_eq!(HealthStatus::Pass.label(), "PASS");
assert_eq!(HealthStatus::Warn.label(), "WARN");
assert_eq!(HealthStatus::Fail.label(), "FAIL");
}
}

View File

@@ -0,0 +1,348 @@
//! File History screen state — per-file MR timeline with rename tracking.
//!
//! Shows which MRs touched a file over time, resolving renames via BFS.
//! Users enter a file path, toggle options (follow renames, merged only,
//! show discussions), and browse a chronological MR list.
use crate::text_width::{next_char_boundary, prev_char_boundary};
// ---------------------------------------------------------------------------
// FileHistoryState
// ---------------------------------------------------------------------------
/// State for the File History screen.
#[derive(Debug, Default)]
pub struct FileHistoryState {
/// User-entered file path.
pub path_input: String,
/// Cursor position within `path_input` (byte offset).
pub path_cursor: usize,
/// Whether the path input field has keyboard focus.
pub path_focused: bool,
/// The most recent result (None until first query).
pub result: Option<FileHistoryResult>,
/// Index of the currently selected MR in the result list.
pub selected_mr_index: usize,
/// Vertical scroll offset for the MR list.
pub scroll_offset: u16,
/// Whether to follow rename chains (default true).
pub follow_renames: bool,
/// Whether to show only merged MRs (default false).
pub merged_only: bool,
/// Whether to show inline discussion snippets (default false).
pub show_discussions: bool,
/// Cached list of known file paths for autocomplete.
pub known_paths: Vec<String>,
/// Filtered autocomplete matches for current input.
pub autocomplete_matches: Vec<String>,
/// Currently highlighted autocomplete suggestion index.
pub autocomplete_index: usize,
/// Monotonic generation counter for stale-response detection.
pub generation: u64,
/// Whether a query is currently in-flight.
pub loading: bool,
}
// ---------------------------------------------------------------------------
// Result types (local to TUI — avoids coupling to CLI command structs)
// ---------------------------------------------------------------------------
/// Full result of a file-history query.
#[derive(Debug)]
pub struct FileHistoryResult {
/// The queried file path.
pub path: String,
/// Resolved rename chain (may be just the original path).
pub rename_chain: Vec<String>,
/// Whether renames were actually followed.
pub renames_followed: bool,
/// MRs that touched any path in the rename chain.
pub merge_requests: Vec<FileHistoryMr>,
/// DiffNote discussion snippets on the file (when requested).
pub discussions: Vec<FileDiscussion>,
/// Total MR count (may exceed displayed count if limited).
pub total_mrs: usize,
/// Number of distinct file paths searched.
pub paths_searched: usize,
}
/// A single MR that touched the file.
#[derive(Debug)]
pub struct FileHistoryMr {
pub iid: i64,
pub title: String,
/// "merged", "opened", or "closed".
pub state: String,
pub author_username: String,
/// "added", "modified", "deleted", or "renamed".
pub change_type: String,
pub merged_at_ms: Option<i64>,
pub updated_at_ms: i64,
pub merge_commit_sha: Option<String>,
}
/// A DiffNote discussion snippet on the file.
#[derive(Debug)]
pub struct FileDiscussion {
pub discussion_id: String,
pub author_username: String,
pub body_snippet: String,
pub path: String,
pub created_at_ms: i64,
}
// ---------------------------------------------------------------------------
// State methods
// ---------------------------------------------------------------------------
impl FileHistoryState {
/// Enter the screen: focus the path input.
pub fn enter(&mut self) {
self.path_focused = true;
self.path_cursor = self.path_input.len();
}
/// Leave the screen: blur all inputs.
pub fn leave(&mut self) {
self.path_focused = false;
}
/// Whether any text input has focus.
#[must_use]
pub fn has_text_focus(&self) -> bool {
self.path_focused
}
/// Blur all inputs.
pub fn blur(&mut self) {
self.path_focused = false;
}
/// Submit the current path (trigger a query).
/// Returns the generation for stale detection.
pub fn submit(&mut self) -> u64 {
self.loading = true;
self.bump_generation()
}
/// Apply query results if generation matches.
pub fn apply_results(&mut self, generation: u64, result: FileHistoryResult) {
if generation != self.generation {
return; // Stale response — discard.
}
self.result = Some(result);
self.loading = false;
self.selected_mr_index = 0;
self.scroll_offset = 0;
}
/// Toggle follow_renames. Returns new generation for re-query.
pub fn toggle_follow_renames(&mut self) -> u64 {
self.follow_renames = !self.follow_renames;
self.bump_generation()
}
/// Toggle merged_only. Returns new generation for re-query.
pub fn toggle_merged_only(&mut self) -> u64 {
self.merged_only = !self.merged_only;
self.bump_generation()
}
/// Toggle show_discussions. Returns new generation for re-query.
pub fn toggle_show_discussions(&mut self) -> u64 {
self.show_discussions = !self.show_discussions;
self.bump_generation()
}
// --- Input field operations ---
/// Insert a char at cursor.
pub fn insert_char(&mut self, c: char) {
if self.path_focused {
self.path_input.insert(self.path_cursor, c);
self.path_cursor += c.len_utf8();
}
}
/// Delete the char before cursor.
pub fn delete_char_before_cursor(&mut self) {
if self.path_focused && self.path_cursor > 0 {
let prev = prev_char_boundary(&self.path_input, self.path_cursor);
self.path_input.drain(prev..self.path_cursor);
self.path_cursor = prev;
}
}
/// Move cursor left.
pub fn cursor_left(&mut self) {
if self.path_focused && self.path_cursor > 0 {
self.path_cursor = prev_char_boundary(&self.path_input, self.path_cursor);
}
}
/// Move cursor right.
pub fn cursor_right(&mut self) {
if self.path_focused && self.path_cursor < self.path_input.len() {
self.path_cursor = next_char_boundary(&self.path_input, self.path_cursor);
}
}
// --- Selection navigation ---
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected_mr_index = self.selected_mr_index.saturating_sub(1);
}
/// Move selection down (bounded by result count).
pub fn select_next(&mut self, result_count: usize) {
if result_count > 0 {
self.selected_mr_index = (self.selected_mr_index + 1).min(result_count - 1);
}
}
/// Ensure the selected row is visible within the viewport.
pub fn ensure_visible(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
let offset = self.scroll_offset as usize;
if self.selected_mr_index < offset {
self.scroll_offset = self.selected_mr_index as u16;
} else if self.selected_mr_index >= offset + viewport_height {
self.scroll_offset = (self.selected_mr_index - viewport_height + 1) as u16;
}
}
// --- Internal ---
fn bump_generation(&mut self) -> u64 {
self.generation += 1;
self.generation
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_state() {
let state = FileHistoryState::default();
assert!(state.path_input.is_empty());
assert!(!state.path_focused);
assert!(state.result.is_none());
assert!(!state.follow_renames); // Default false, toggled on by user
assert!(!state.merged_only);
assert!(!state.show_discussions);
assert_eq!(state.generation, 0);
}
#[test]
fn test_enter_focuses_path() {
let mut state = FileHistoryState {
path_input: "src/lib.rs".into(),
..FileHistoryState::default()
};
state.enter();
assert!(state.path_focused);
assert_eq!(state.path_cursor, 10);
}
#[test]
fn test_submit_bumps_generation() {
let mut state = FileHistoryState::default();
let generation = state.submit();
assert_eq!(generation, 1);
assert!(state.loading);
}
#[test]
fn test_stale_response_discarded() {
let mut state = FileHistoryState::default();
let stale_gen = state.submit();
// Bump again (user toggled an option).
let _new_gen = state.toggle_merged_only();
// Stale result arrives.
state.apply_results(
stale_gen,
FileHistoryResult {
path: "src/lib.rs".into(),
rename_chain: vec!["src/lib.rs".into()],
renames_followed: false,
merge_requests: vec![],
discussions: vec![],
total_mrs: 0,
paths_searched: 1,
},
);
assert!(state.result.is_none()); // Discarded.
}
#[test]
fn test_toggle_options_bump_generation() {
let mut state = FileHistoryState::default();
let g1 = state.toggle_follow_renames();
assert_eq!(g1, 1);
assert!(state.follow_renames);
let g2 = state.toggle_merged_only();
assert_eq!(g2, 2);
assert!(state.merged_only);
let g3 = state.toggle_show_discussions();
assert_eq!(g3, 3);
assert!(state.show_discussions);
}
#[test]
fn test_insert_and_delete_char() {
let mut state = FileHistoryState {
path_focused: true,
..FileHistoryState::default()
};
state.insert_char('s');
state.insert_char('r');
state.insert_char('c');
assert_eq!(state.path_input, "src");
assert_eq!(state.path_cursor, 3);
state.delete_char_before_cursor();
assert_eq!(state.path_input, "sr");
assert_eq!(state.path_cursor, 2);
}
#[test]
fn test_select_prev_next() {
let mut state = FileHistoryState::default();
state.select_next(5);
assert_eq!(state.selected_mr_index, 1);
state.select_next(5);
assert_eq!(state.selected_mr_index, 2);
state.select_prev();
assert_eq!(state.selected_mr_index, 1);
state.select_prev();
assert_eq!(state.selected_mr_index, 0);
state.select_prev(); // Should not underflow.
assert_eq!(state.selected_mr_index, 0);
}
#[test]
fn test_ensure_visible() {
let mut state = FileHistoryState {
selected_mr_index: 15,
..FileHistoryState::default()
};
state.ensure_visible(5);
assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1
}
}

View File

@@ -0,0 +1,284 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail screen
//! Issue detail screen state.
//!
//! Holds metadata, discussions, cross-references, and UI state for
//! viewing a single issue. Supports progressive hydration: metadata
//! loads first, discussions load async in a second phase.
use crate::message::EntityKey;
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
// ---------------------------------------------------------------------------
// IssueMetadata
// ---------------------------------------------------------------------------
/// Full metadata for a single issue, fetched from the local DB.
#[derive(Debug, Clone)]
pub struct IssueMetadata {
/// Issue IID (project-scoped).
pub iid: i64,
/// Project path (e.g., "group/project").
pub project_path: String,
/// Issue title.
pub title: String,
/// Issue description (markdown).
pub description: String,
/// Current state: "opened" or "closed".
pub state: String,
/// Author username.
pub author: String,
/// Assigned usernames.
pub assignees: Vec<String>,
/// Label names.
pub labels: Vec<String>,
/// Milestone title (if set).
pub milestone: Option<String>,
/// Due date (if set, "YYYY-MM-DD").
pub due_date: Option<String>,
/// Created timestamp (ms epoch).
pub created_at: i64,
/// Updated timestamp (ms epoch).
pub updated_at: i64,
/// GitLab web URL for "open in browser".
pub web_url: String,
/// Discussion count (for display before discussions load).
pub discussion_count: usize,
}
// ---------------------------------------------------------------------------
// IssueDetailData
// ---------------------------------------------------------------------------
/// Bundle returned by the metadata fetch action.
///
/// Metadata + cross-refs load in Phase 1 (fast). Discussions load
/// separately in Phase 2.
#[derive(Debug, Clone)]
pub struct IssueDetailData {
pub metadata: IssueMetadata,
pub cross_refs: Vec<CrossRef>,
}
// ---------------------------------------------------------------------------
// DetailSection
// ---------------------------------------------------------------------------
/// Which section of the detail view has keyboard focus.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetailSection {
/// Description area (scrollable text).
#[default]
Description,
/// Discussion tree.
Discussions,
/// Cross-references list.
CrossRefs,
}
impl DetailSection {
/// Cycle to the next section.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Description => Self::Discussions,
Self::Discussions => Self::CrossRefs,
Self::CrossRefs => Self::Description,
}
}
/// Cycle to the previous section.
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::Description => Self::CrossRefs,
Self::Discussions => Self::Description,
Self::CrossRefs => Self::Discussions,
}
}
}
// ---------------------------------------------------------------------------
// IssueDetailState
// ---------------------------------------------------------------------------
/// State for the issue detail screen.
#[derive(Debug, Default)]
pub struct IssueDetailState {
/// Entity key for the currently displayed issue.
pub current_key: Option<EntityKey>,
/// Issue metadata (Phase 1 load).
pub metadata: Option<IssueMetadata>,
/// Discussion nodes (Phase 2 async load).
pub discussions: Vec<DiscussionNode>,
/// Whether discussions have finished loading.
pub discussions_loaded: bool,
/// Cross-references (loaded with metadata in Phase 1).
pub cross_refs: Vec<CrossRef>,
/// Discussion tree UI state (expand/collapse, selection).
pub tree_state: DiscussionTreeState,
/// Cross-reference list UI state.
pub cross_ref_state: CrossRefState,
/// Description scroll offset.
pub description_scroll: usize,
/// Active section for keyboard focus.
pub active_section: DetailSection,
}
impl IssueDetailState {
/// Reset state for a new issue.
pub fn load_new(&mut self, key: EntityKey) {
self.current_key = Some(key);
self.metadata = None;
self.discussions.clear();
self.discussions_loaded = false;
self.cross_refs.clear();
self.tree_state = DiscussionTreeState::default();
self.cross_ref_state = CrossRefState::default();
self.description_scroll = 0;
self.active_section = DetailSection::Description;
}
/// Apply Phase 1 data (metadata + cross-refs).
pub fn apply_metadata(&mut self, data: IssueDetailData) {
self.metadata = Some(data.metadata);
self.cross_refs = data.cross_refs;
}
/// Apply Phase 2 data (discussions).
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
self.discussions = discussions;
self.discussions_loaded = true;
}
/// Whether we have metadata loaded for the current key.
#[must_use]
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
/// Cycle to the next section.
pub fn next_section(&mut self) {
self.active_section = self.active_section.next();
}
/// Cycle to the previous section.
pub fn prev_section(&mut self) {
self.active_section = self.active_section.prev();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::common::cross_ref::CrossRefKind;
#[test]
fn test_issue_detail_state_default() {
let state = IssueDetailState::default();
assert!(state.current_key.is_none());
assert!(state.metadata.is_none());
assert!(state.discussions.is_empty());
assert!(!state.discussions_loaded);
assert!(state.cross_refs.is_empty());
assert_eq!(state.active_section, DetailSection::Description);
}
#[test]
fn test_load_new_resets_state() {
let mut state = IssueDetailState {
discussions_loaded: true,
description_scroll: 10,
active_section: DetailSection::CrossRefs,
..IssueDetailState::default()
};
state.load_new(EntityKey::issue(1, 42));
assert_eq!(state.current_key, Some(EntityKey::issue(1, 42)));
assert!(state.metadata.is_none());
assert!(!state.discussions_loaded);
assert_eq!(state.description_scroll, 0);
assert_eq!(state.active_section, DetailSection::Description);
}
#[test]
fn test_apply_metadata() {
let mut state = IssueDetailState::default();
state.load_new(EntityKey::issue(1, 42));
let data = IssueDetailData {
metadata: IssueMetadata {
iid: 42,
project_path: "group/proj".into(),
title: "Fix auth".into(),
description: "Description here".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into()],
milestone: Some("v1.0".into()),
due_date: None,
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/proj/-/issues/42".into(),
discussion_count: 3,
},
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 10),
label: "Fix auth MR".into(),
navigable: true,
}],
};
state.apply_metadata(data);
assert!(state.has_metadata());
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
assert_eq!(state.cross_refs.len(), 1);
}
#[test]
fn test_apply_discussions() {
let mut state = IssueDetailState::default();
assert!(!state.discussions_loaded);
let discussions = vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![],
resolvable: false,
resolved: false,
}];
state.apply_discussions(discussions);
assert!(state.discussions_loaded);
assert_eq!(state.discussions.len(), 1);
}
#[test]
fn test_detail_section_cycling() {
let section = DetailSection::Description;
assert_eq!(section.next(), DetailSection::Discussions);
assert_eq!(section.next().next(), DetailSection::CrossRefs);
assert_eq!(section.next().next().next(), DetailSection::Description);
assert_eq!(section.prev(), DetailSection::CrossRefs);
assert_eq!(section.prev().prev(), DetailSection::Discussions);
}
#[test]
fn test_section_next_prev_round_trip() {
let mut state = IssueDetailState::default();
assert_eq!(state.active_section, DetailSection::Description);
state.next_section();
assert_eq!(state.active_section, DetailSection::Discussions);
state.prev_section();
assert_eq!(state.active_section, DetailSection::Description);
}
}

View File

@@ -0,0 +1,376 @@
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/issue_list
//! Issue list screen state.
//!
//! Uses keyset pagination with a snapshot fence for stable ordering
//! under concurrent sync writes. Filter changes reset the pagination
//! cursor and snapshot fence.
use std::hash::{Hash, Hasher};
// ---------------------------------------------------------------------------
// Cursor (keyset pagination boundary)
// ---------------------------------------------------------------------------
/// Keyset pagination cursor — (updated_at, iid) boundary.
///
/// The next page query uses `WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid)`
/// to avoid OFFSET instability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueCursor {
pub updated_at: i64,
pub iid: i64,
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
/// Structured filter for issue list queries.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueFilter {
pub state: Option<String>,
pub author: Option<String>,
pub assignee: Option<String>,
pub label: Option<String>,
pub milestone: Option<String>,
pub status: Option<String>,
pub free_text: Option<String>,
pub project_id: Option<i64>,
}
impl IssueFilter {
/// Compute a hash for change detection.
pub fn hash_value(&self) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.state.hash(&mut hasher);
self.author.hash(&mut hasher);
self.assignee.hash(&mut hasher);
self.label.hash(&mut hasher);
self.milestone.hash(&mut hasher);
self.status.hash(&mut hasher);
self.free_text.hash(&mut hasher);
self.project_id.hash(&mut hasher);
hasher.finish()
}
/// Whether any filter is active.
pub fn is_active(&self) -> bool {
self.state.is_some()
|| self.author.is_some()
|| self.assignee.is_some()
|| self.label.is_some()
|| self.milestone.is_some()
|| self.status.is_some()
|| self.free_text.is_some()
|| self.project_id.is_some()
}
}
// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------
/// A single row in the issue list.
#[derive(Debug, Clone)]
pub struct IssueListRow {
pub project_path: String,
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub labels: Vec<String>,
pub updated_at: i64,
}
// ---------------------------------------------------------------------------
// Page result
// ---------------------------------------------------------------------------
/// Result from a paginated issue list query.
#[derive(Debug, Clone)]
pub struct IssueListPage {
pub rows: Vec<IssueListRow>,
pub next_cursor: Option<IssueCursor>,
pub total_count: u64,
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
/// Fields available for sorting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortField {
#[default]
UpdatedAt,
Iid,
Title,
State,
Author,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Desc,
Asc,
}
// ---------------------------------------------------------------------------
// IssueListState
// ---------------------------------------------------------------------------
/// State for the issue list screen.
#[derive(Debug, Default)]
pub struct IssueListState {
/// Current page of issue rows.
pub rows: Vec<IssueListRow>,
/// Total count of matching issues.
pub total_count: u64,
/// Selected row index (within current window).
pub selected_index: usize,
/// Scroll offset for the entity table.
pub scroll_offset: usize,
/// Cursor for the next page.
pub next_cursor: Option<IssueCursor>,
/// Whether a prefetch is in flight.
pub prefetch_in_flight: bool,
/// Current filter.
pub filter: IssueFilter,
/// Raw filter input text.
pub filter_input: String,
/// Whether the filter bar has focus.
pub filter_focused: bool,
/// Sort field.
pub sort_field: SortField,
/// Sort direction.
pub sort_order: SortOrder,
/// Snapshot fence: max updated_at from initial load.
pub snapshot_fence: Option<i64>,
/// Hash of the current filter for change detection.
pub filter_hash: u64,
/// Whether Quick Peek is visible.
pub peek_visible: bool,
}
impl IssueListState {
/// Reset pagination state (called when filter changes or on refresh).
pub fn reset_pagination(&mut self) {
self.rows.clear();
self.next_cursor = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.snapshot_fence = None;
self.total_count = 0;
self.prefetch_in_flight = false;
}
/// Apply a new page of results.
pub fn apply_page(&mut self, page: IssueListPage) {
// Set snapshot fence on first page load.
if self.snapshot_fence.is_none() {
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
}
self.rows.extend(page.rows);
self.next_cursor = page.next_cursor;
self.total_count = page.total_count;
self.prefetch_in_flight = false;
}
/// Check if filter changed and reset if needed.
pub fn check_filter_change(&mut self) -> bool {
let new_hash = self.filter.hash_value();
if new_hash != self.filter_hash {
self.filter_hash = new_hash;
self.reset_pagination();
true
} else {
false
}
}
/// Whether the user has scrolled near the end of current data (80% threshold).
pub fn should_prefetch(&self) -> bool {
if self.prefetch_in_flight || self.next_cursor.is_none() {
return false;
}
if self.rows.is_empty() {
return false;
}
let threshold = (self.rows.len() * 4) / 5; // 80%
self.selected_index >= threshold
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(count: usize, has_next: bool) -> IssueListPage {
let rows: Vec<IssueListRow> = (0..count)
.map(|i| IssueListRow {
project_path: "group/project".into(),
iid: (count - i) as i64,
title: format!("Issue {}", count - i),
state: "opened".into(),
author: "taylor".into(),
labels: vec![],
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
})
.collect();
let next_cursor = if has_next {
rows.last().map(|r| IssueCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
IssueListPage {
rows,
next_cursor,
total_count: if has_next {
(count * 2) as u64
} else {
count as u64
},
}
}
#[test]
fn test_apply_page_sets_snapshot_fence() {
let mut state = IssueListState::default();
let page = sample_page(5, false);
state.apply_page(page);
assert_eq!(state.rows.len(), 5);
assert!(state.snapshot_fence.is_some());
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
}
#[test]
fn test_apply_page_appends() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
assert_eq!(state.rows.len(), 5);
state.apply_page(sample_page(3, false));
assert_eq!(state.rows.len(), 8);
}
#[test]
fn test_reset_pagination_clears_state() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
state.selected_index = 3;
state.reset_pagination();
assert!(state.rows.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.next_cursor.is_none());
assert!(state.snapshot_fence.is_none());
}
#[test]
fn test_check_filter_change_detects_change() {
let mut state = IssueListState::default();
state.filter_hash = state.filter.hash_value();
state.filter.state = Some("opened".into());
assert!(state.check_filter_change());
}
#[test]
fn test_check_filter_change_no_change() {
let mut state = IssueListState::default();
state.filter_hash = state.filter.hash_value();
assert!(!state.check_filter_change());
}
#[test]
fn test_should_prefetch() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 4; // 40% — no prefetch
assert!(!state.should_prefetch());
state.selected_index = 8; // 80% — prefetch
assert!(state.should_prefetch());
}
#[test]
fn test_should_prefetch_no_next_page() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, false));
state.selected_index = 9;
assert!(!state.should_prefetch());
}
#[test]
fn test_should_prefetch_already_in_flight() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 9;
state.prefetch_in_flight = true;
assert!(!state.should_prefetch());
}
#[test]
fn test_issue_filter_is_active() {
let empty = IssueFilter::default();
assert!(!empty.is_active());
let active = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
assert!(active.is_active());
}
#[test]
fn test_issue_filter_hash_deterministic() {
let f1 = IssueFilter {
state: Some("opened".into()),
author: Some("taylor".into()),
..Default::default()
};
let f2 = f1.clone();
assert_eq!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_issue_filter_hash_differs() {
let f1 = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
let f2 = IssueFilter {
state: Some("closed".into()),
..Default::default()
};
assert_ne!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_snapshot_fence_not_overwritten_on_second_page() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
let fence = state.snapshot_fence;
state.apply_page(sample_page(3, false));
assert_eq!(
state.snapshot_fence, fence,
"Fence should not change on second page"
);
}
}

View File

@@ -0,0 +1,393 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Top-level state composition for the TUI.
//!
//! Each screen has its own state struct. State is preserved when
//! navigating away — screens are never cleared on pop.
//!
//! [`LoadState`] enables stale-while-revalidate: screens show the last
//! available data during a refresh, with a spinner indicating the load.
//!
//! [`ScreenIntent`] is the pure return type from state handlers — they
//! never spawn async tasks directly. The intent is interpreted by
//! [`LoreApp`](crate::app::LoreApp) which dispatches through the
//! [`TaskSupervisor`](crate::task_supervisor::TaskSupervisor).
pub mod bootstrap;
pub mod command_palette;
pub mod dashboard;
pub mod doctor;
pub mod file_history;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
pub mod scope_picker;
pub mod search;
pub mod stats;
pub mod sync;
pub mod sync_delta_ledger;
pub mod timeline;
pub mod trace;
pub mod who;
use std::collections::{HashMap, HashSet};
use crate::entity_cache::EntityCache;
use crate::message::Screen;
use crate::view::common::discussion_tree::DiscussionNode;
// Re-export screen states for convenience.
pub use bootstrap::BootstrapState;
pub use command_palette::CommandPaletteState;
pub use dashboard::DashboardState;
pub use doctor::DoctorState;
pub use file_history::FileHistoryState;
pub use issue_detail::IssueDetailState;
pub use issue_list::IssueListState;
pub use mr_detail::MrDetailState;
pub use mr_list::MrListState;
pub use scope_picker::ScopePickerState;
pub use search::SearchState;
pub use stats::StatsState;
pub use sync::SyncState;
pub use timeline::TimelineState;
pub use trace::TraceState;
pub use who::WhoState;
// ---------------------------------------------------------------------------
// LoadState
// ---------------------------------------------------------------------------
/// Loading state for a screen's data.
///
/// Enables stale-while-revalidate: screens render their last data while
/// showing a spinner when `Refreshing`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LoadState {
/// No load in progress, data is current (or screen was never loaded).
#[default]
Idle,
/// First load — no data to show yet, display a full-screen spinner.
LoadingInitial,
/// Background refresh — show existing data with a spinner indicator.
Refreshing,
/// Load failed — display the error alongside any stale data.
Error(String),
}
impl LoadState {
/// Whether data is currently being loaded.
#[must_use]
pub fn is_loading(&self) -> bool {
matches!(self, Self::LoadingInitial | Self::Refreshing)
}
}
// ---------------------------------------------------------------------------
// ScreenLoadStateMap
// ---------------------------------------------------------------------------
/// Tracks per-screen load state.
///
/// Returns [`LoadState::Idle`] for screens that haven't been tracked.
/// Automatically removes entries set to `Idle` to prevent unbounded growth.
#[derive(Debug, Default)]
pub struct ScreenLoadStateMap {
map: HashMap<Screen, LoadState>,
/// Screens that have had a load state set at least once.
visited: HashSet<Screen>,
}
impl ScreenLoadStateMap {
/// Get the load state for a screen (defaults to `Idle`).
#[must_use]
pub fn get(&self, screen: &Screen) -> &LoadState {
static IDLE: LoadState = LoadState::Idle;
self.map.get(screen).unwrap_or(&IDLE)
}
/// Set the load state for a screen.
///
/// Setting to `Idle` removes the entry to prevent map growth.
pub fn set(&mut self, screen: Screen, state: LoadState) {
self.visited.insert(screen.clone());
if state == LoadState::Idle {
self.map.remove(&screen);
} else {
self.map.insert(screen, state);
}
}
/// Whether this screen has ever had a load initiated.
#[must_use]
pub fn was_visited(&self, screen: &Screen) -> bool {
self.visited.contains(screen)
}
/// Whether any screen is currently loading.
#[must_use]
pub fn any_loading(&self) -> bool {
self.map.values().any(LoadState::is_loading)
}
}
// ---------------------------------------------------------------------------
// ScreenIntent
// ---------------------------------------------------------------------------
/// Pure return type from screen state handlers.
///
/// State handlers must never spawn async work directly — they return
/// an intent that [`LoreApp`] interprets and dispatches through the
/// [`TaskSupervisor`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenIntent {
/// No action needed.
None,
/// Navigate to a new screen.
Navigate(Screen),
/// Screen data needs re-querying (e.g., filter changed).
RequeryNeeded(Screen),
}
// ---------------------------------------------------------------------------
// ScopeContext
// ---------------------------------------------------------------------------
/// Global scope filters applied across all screens.
///
/// When a project filter is active, all data queries scope to that
/// project. The TUI shows the active scope in the status bar.
#[derive(Debug, Default)]
pub struct ScopeContext {
/// Active project filter (project_id).
pub project_id: Option<i64>,
/// Human-readable project name for display.
pub project_name: Option<String>,
}
// ---------------------------------------------------------------------------
// Cached detail payloads
// ---------------------------------------------------------------------------
/// Cached issue detail payload (metadata + discussions).
#[derive(Debug, Clone)]
pub struct CachedIssuePayload {
pub data: issue_detail::IssueDetailData,
pub discussions: Vec<DiscussionNode>,
}
/// Cached MR detail payload (metadata + discussions).
#[derive(Debug, Clone)]
pub struct CachedMrPayload {
pub data: mr_detail::MrDetailData,
pub discussions: Vec<DiscussionNode>,
}
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
/// Top-level state composition for the TUI.
///
/// Each field holds one screen's state. State is preserved when
/// navigating away and restored on return.
#[derive(Debug, Default)]
pub struct AppState {
// Per-screen states.
pub bootstrap: BootstrapState,
pub dashboard: DashboardState,
pub doctor: DoctorState,
pub issue_list: IssueListState,
pub issue_detail: IssueDetailState,
pub mr_list: MrListState,
pub mr_detail: MrDetailState,
pub search: SearchState,
pub stats: StatsState,
pub timeline: TimelineState,
pub who: WhoState,
pub trace: TraceState,
pub file_history: FileHistoryState,
pub sync: SyncState,
pub command_palette: CommandPaletteState,
pub scope_picker: ScopePickerState,
// Cross-cutting state.
pub global_scope: ScopeContext,
pub load_state: ScreenLoadStateMap,
pub error_toast: Option<String>,
pub show_help: bool,
pub terminal_size: (u16, u16),
// Entity caches for near-instant detail view reopens.
pub issue_cache: EntityCache<CachedIssuePayload>,
pub mr_cache: EntityCache<CachedMrPayload>,
}
impl AppState {
/// Set a screen's load state.
pub fn set_loading(&mut self, screen: Screen, state: LoadState) {
self.load_state.set(screen, state);
}
/// Set the global error toast.
pub fn set_error(&mut self, msg: String) {
self.error_toast = Some(msg);
}
/// Clear the global error toast.
pub fn clear_error(&mut self) {
self.error_toast = None;
}
/// Whether any text input is currently focused.
#[must_use]
pub fn has_text_focus(&self) -> bool {
self.issue_list.filter_focused
|| self.mr_list.filter_focused
|| self.search.query_focused
|| self.command_palette.query_focused
|| self.who.has_text_focus()
|| self.trace.has_text_focus()
|| self.file_history.has_text_focus()
}
/// Remove focus from all text inputs.
pub fn blur_text_focus(&mut self) {
self.issue_list.filter_focused = false;
self.mr_list.filter_focused = false;
self.search.query_focused = false;
self.command_palette.query_focused = false;
self.who.blur();
self.trace.blur();
self.file_history.blur();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_state_default_idle() {
let map = ScreenLoadStateMap::default();
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
}
#[test]
fn test_load_state_set_and_get() {
let mut map = ScreenLoadStateMap::default();
map.set(Screen::Dashboard, LoadState::LoadingInitial);
assert_eq!(*map.get(&Screen::Dashboard), LoadState::LoadingInitial);
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
}
#[test]
fn test_load_state_set_idle_removes_entry() {
let mut map = ScreenLoadStateMap::default();
map.set(Screen::Dashboard, LoadState::Refreshing);
assert_eq!(map.map.len(), 1);
map.set(Screen::Dashboard, LoadState::Idle);
assert_eq!(map.map.len(), 0);
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
}
#[test]
fn test_any_loading() {
let mut map = ScreenLoadStateMap::default();
assert!(!map.any_loading());
map.set(Screen::Dashboard, LoadState::LoadingInitial);
assert!(map.any_loading());
map.set(Screen::Dashboard, LoadState::Error("oops".into()));
assert!(!map.any_loading());
}
#[test]
fn test_load_state_is_loading() {
assert!(!LoadState::Idle.is_loading());
assert!(LoadState::LoadingInitial.is_loading());
assert!(LoadState::Refreshing.is_loading());
assert!(!LoadState::Error("x".into()).is_loading());
}
#[test]
fn test_app_state_default_compiles() {
let state = AppState::default();
assert!(!state.show_help);
assert!(state.error_toast.is_none());
assert_eq!(state.terminal_size, (0, 0));
}
#[test]
fn test_app_state_set_error_and_clear() {
let mut state = AppState::default();
state.set_error("db busy".into());
assert_eq!(state.error_toast.as_deref(), Some("db busy"));
state.clear_error();
assert!(state.error_toast.is_none());
}
#[test]
fn test_app_state_has_text_focus() {
let mut state = AppState::default();
assert!(!state.has_text_focus());
state.search.query_focused = true;
assert!(state.has_text_focus());
}
#[test]
fn test_app_state_blur_text_focus() {
let mut state = AppState::default();
state.issue_list.filter_focused = true;
state.mr_list.filter_focused = true;
state.search.query_focused = true;
state.command_palette.query_focused = true;
state.blur_text_focus();
assert!(!state.has_text_focus());
assert!(!state.issue_list.filter_focused);
assert!(!state.mr_list.filter_focused);
assert!(!state.search.query_focused);
assert!(!state.command_palette.query_focused);
}
#[test]
fn test_app_state_set_loading() {
let mut state = AppState::default();
state.set_loading(Screen::IssueList, LoadState::Refreshing);
assert_eq!(
*state.load_state.get(&Screen::IssueList),
LoadState::Refreshing
);
}
#[test]
fn test_screen_intent_variants() {
let none = ScreenIntent::None;
let nav = ScreenIntent::Navigate(Screen::IssueList);
let requery = ScreenIntent::RequeryNeeded(Screen::Search);
assert_eq!(none, ScreenIntent::None);
assert_eq!(nav, ScreenIntent::Navigate(Screen::IssueList));
assert_eq!(requery, ScreenIntent::RequeryNeeded(Screen::Search));
}
#[test]
fn test_scope_context_default() {
let scope = ScopeContext::default();
assert!(scope.project_id.is_none());
assert!(scope.project_name.is_none());
}
}

View File

@@ -0,0 +1,387 @@
#![allow(dead_code)] // Phase 2: consumed by MR Detail screen
//! Merge request detail screen state.
//!
//! Holds MR metadata, file changes, discussions, cross-references,
//! and UI state. Supports progressive hydration identical to
//! Issue Detail: metadata loads first, discussions load async.
use crate::message::EntityKey;
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
// ---------------------------------------------------------------------------
// MrMetadata
// ---------------------------------------------------------------------------
/// Full metadata for a single merge request, fetched from the local DB.
#[derive(Debug, Clone)]
pub struct MrMetadata {
/// MR IID (project-scoped).
pub iid: i64,
/// Project path (e.g., "group/project").
pub project_path: String,
/// MR title.
pub title: String,
/// MR description (markdown).
pub description: String,
/// Current state: "opened", "merged", "closed", "locked".
pub state: String,
/// Whether this is a draft/WIP MR.
pub draft: bool,
/// Author username.
pub author: String,
/// Assigned usernames.
pub assignees: Vec<String>,
/// Reviewer usernames.
pub reviewers: Vec<String>,
/// Label names.
pub labels: Vec<String>,
/// Source branch name.
pub source_branch: String,
/// Target branch name.
pub target_branch: String,
/// Detailed merge status (e.g., "mergeable", "checking").
pub merge_status: String,
/// Created timestamp (ms epoch).
pub created_at: i64,
/// Updated timestamp (ms epoch).
pub updated_at: i64,
/// Merged timestamp (ms epoch), if merged.
pub merged_at: Option<i64>,
/// GitLab web URL.
pub web_url: String,
/// Discussion count (for display before discussions load).
pub discussion_count: usize,
/// File change count.
pub file_change_count: usize,
}
// ---------------------------------------------------------------------------
// FileChange
// ---------------------------------------------------------------------------
/// A file changed in the merge request.
#[derive(Debug, Clone)]
pub struct FileChange {
/// Previous file path (if renamed).
pub old_path: Option<String>,
/// New/current file path.
pub new_path: String,
/// Type of change.
pub change_type: FileChangeType,
}
/// The type of file change in an MR.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChangeType {
Added,
Modified,
Deleted,
Renamed,
}
impl FileChangeType {
/// Short icon for display.
#[must_use]
pub const fn icon(&self) -> &str {
match self {
Self::Added => "+",
Self::Modified => "~",
Self::Deleted => "-",
Self::Renamed => "R",
}
}
/// Parse from DB string.
#[must_use]
pub fn parse_db(s: &str) -> Self {
match s {
"added" => Self::Added,
"deleted" => Self::Deleted,
"renamed" => Self::Renamed,
_ => Self::Modified,
}
}
}
// ---------------------------------------------------------------------------
// MrDetailData
// ---------------------------------------------------------------------------
/// Bundle returned by the metadata fetch action.
///
/// Metadata + cross-refs + file changes load in Phase 1 (fast).
/// Discussions load separately in Phase 2.
#[derive(Debug, Clone)]
pub struct MrDetailData {
pub metadata: MrMetadata,
pub cross_refs: Vec<CrossRef>,
pub file_changes: Vec<FileChange>,
}
// ---------------------------------------------------------------------------
// MrTab
// ---------------------------------------------------------------------------
/// Active tab in the MR detail view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrTab {
/// Overview: description + cross-refs.
#[default]
Overview,
/// File changes list.
Files,
/// Discussions (general + diff).
Discussions,
}
impl MrTab {
/// Cycle to the next tab.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Overview => Self::Files,
Self::Files => Self::Discussions,
Self::Discussions => Self::Overview,
}
}
/// Cycle to the previous tab.
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::Overview => Self::Discussions,
Self::Files => Self::Overview,
Self::Discussions => Self::Files,
}
}
/// Human-readable label.
#[must_use]
pub const fn label(&self) -> &str {
match self {
Self::Overview => "Overview",
Self::Files => "Files",
Self::Discussions => "Discussions",
}
}
}
// ---------------------------------------------------------------------------
// MrDetailState
// ---------------------------------------------------------------------------
/// State for the MR detail screen.
#[derive(Debug, Default)]
pub struct MrDetailState {
/// Entity key for the currently displayed MR.
pub current_key: Option<EntityKey>,
/// MR metadata (Phase 1 load).
pub metadata: Option<MrMetadata>,
/// File changes (loaded with metadata in Phase 1).
pub file_changes: Vec<FileChange>,
/// Discussion nodes (Phase 2 async load).
pub discussions: Vec<DiscussionNode>,
/// Whether discussions have finished loading.
pub discussions_loaded: bool,
/// Cross-references (loaded with metadata in Phase 1).
pub cross_refs: Vec<CrossRef>,
/// Discussion tree UI state.
pub tree_state: DiscussionTreeState,
/// Cross-reference list UI state.
pub cross_ref_state: CrossRefState,
/// Description scroll offset.
pub description_scroll: usize,
/// File list selected index.
pub file_selected: usize,
/// File list scroll offset.
pub file_scroll: usize,
/// Active tab.
pub active_tab: MrTab,
}
impl MrDetailState {
/// Reset state for a new MR.
pub fn load_new(&mut self, key: EntityKey) {
self.current_key = Some(key);
self.metadata = None;
self.file_changes.clear();
self.discussions.clear();
self.discussions_loaded = false;
self.cross_refs.clear();
self.tree_state = DiscussionTreeState::default();
self.cross_ref_state = CrossRefState::default();
self.description_scroll = 0;
self.file_selected = 0;
self.file_scroll = 0;
self.active_tab = MrTab::Overview;
}
/// Apply Phase 1 data (metadata + cross-refs + file changes).
pub fn apply_metadata(&mut self, data: MrDetailData) {
self.metadata = Some(data.metadata);
self.cross_refs = data.cross_refs;
self.file_changes = data.file_changes;
}
/// Apply Phase 2 data (discussions).
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
self.discussions = discussions;
self.discussions_loaded = true;
}
/// Whether we have metadata loaded.
#[must_use]
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
/// Switch to the next tab.
pub fn next_tab(&mut self) {
self.active_tab = self.active_tab.next();
}
/// Switch to the previous tab.
pub fn prev_tab(&mut self) {
self.active_tab = self.active_tab.prev();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::common::cross_ref::CrossRefKind;
#[test]
fn test_mr_detail_state_default() {
let state = MrDetailState::default();
assert!(state.current_key.is_none());
assert!(state.metadata.is_none());
assert!(state.discussions.is_empty());
assert!(!state.discussions_loaded);
assert!(state.file_changes.is_empty());
assert_eq!(state.active_tab, MrTab::Overview);
}
#[test]
fn test_load_new_resets_state() {
let mut state = MrDetailState {
discussions_loaded: true,
description_scroll: 10,
active_tab: MrTab::Files,
..MrDetailState::default()
};
state.load_new(EntityKey::mr(1, 42));
assert_eq!(state.current_key, Some(EntityKey::mr(1, 42)));
assert!(state.metadata.is_none());
assert!(!state.discussions_loaded);
assert_eq!(state.description_scroll, 0);
assert_eq!(state.active_tab, MrTab::Overview);
}
#[test]
fn test_apply_metadata() {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 42));
let data = MrDetailData {
metadata: MrMetadata {
iid: 42,
project_path: "group/proj".into(),
title: "Fix auth".into(),
description: "MR description".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec!["backend".into()],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/proj/-/merge_requests/42".into(),
discussion_count: 2,
file_change_count: 3,
},
cross_refs: vec![CrossRef {
kind: CrossRefKind::RelatedIssue,
entity_key: EntityKey::issue(1, 10),
label: "Related issue".into(),
navigable: true,
}],
file_changes: vec![FileChange {
old_path: None,
new_path: "src/auth.rs".into(),
change_type: FileChangeType::Modified,
}],
};
state.apply_metadata(data);
assert!(state.has_metadata());
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
assert_eq!(state.cross_refs.len(), 1);
assert_eq!(state.file_changes.len(), 1);
}
#[test]
fn test_tab_cycling() {
let tab = MrTab::Overview;
assert_eq!(tab.next(), MrTab::Files);
assert_eq!(tab.next().next(), MrTab::Discussions);
assert_eq!(tab.next().next().next(), MrTab::Overview);
assert_eq!(tab.prev(), MrTab::Discussions);
assert_eq!(tab.prev().prev(), MrTab::Files);
}
#[test]
fn test_tab_labels() {
assert_eq!(MrTab::Overview.label(), "Overview");
assert_eq!(MrTab::Files.label(), "Files");
assert_eq!(MrTab::Discussions.label(), "Discussions");
}
#[test]
fn test_file_change_type_icon() {
assert_eq!(FileChangeType::Added.icon(), "+");
assert_eq!(FileChangeType::Modified.icon(), "~");
assert_eq!(FileChangeType::Deleted.icon(), "-");
assert_eq!(FileChangeType::Renamed.icon(), "R");
}
#[test]
fn test_file_change_type_parse_db() {
assert_eq!(FileChangeType::parse_db("added"), FileChangeType::Added);
assert_eq!(FileChangeType::parse_db("deleted"), FileChangeType::Deleted);
assert_eq!(FileChangeType::parse_db("renamed"), FileChangeType::Renamed);
assert_eq!(
FileChangeType::parse_db("modified"),
FileChangeType::Modified
);
assert_eq!(
FileChangeType::parse_db("unknown"),
FileChangeType::Modified
);
}
#[test]
fn test_next_prev_tab_on_state() {
let mut state = MrDetailState::default();
assert_eq!(state.active_tab, MrTab::Overview);
state.next_tab();
assert_eq!(state.active_tab, MrTab::Files);
state.prev_tab();
assert_eq!(state.active_tab, MrTab::Overview);
}
}

View File

@@ -0,0 +1,422 @@
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/mr_list
//! Merge request list screen state.
//!
//! Mirrors the issue list pattern with MR-specific filter fields
//! (draft, reviewer, target/source branch). Uses the same keyset
//! pagination with snapshot fence for stable ordering.
use std::hash::{Hash, Hasher};
// ---------------------------------------------------------------------------
// Cursor (keyset pagination boundary)
// ---------------------------------------------------------------------------
/// Keyset pagination cursor — (updated_at, iid) boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrCursor {
pub updated_at: i64,
pub iid: i64,
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
/// Structured filter for MR list queries.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MrFilter {
pub state: Option<String>,
pub author: Option<String>,
pub reviewer: Option<String>,
pub target_branch: Option<String>,
pub source_branch: Option<String>,
pub label: Option<String>,
pub draft: Option<bool>,
pub free_text: Option<String>,
pub project_id: Option<i64>,
}
impl MrFilter {
/// Compute a hash for change detection.
pub fn hash_value(&self) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.state.hash(&mut hasher);
self.author.hash(&mut hasher);
self.reviewer.hash(&mut hasher);
self.target_branch.hash(&mut hasher);
self.source_branch.hash(&mut hasher);
self.label.hash(&mut hasher);
self.draft.hash(&mut hasher);
self.free_text.hash(&mut hasher);
self.project_id.hash(&mut hasher);
hasher.finish()
}
/// Whether any filter is active.
pub fn is_active(&self) -> bool {
self.state.is_some()
|| self.author.is_some()
|| self.reviewer.is_some()
|| self.target_branch.is_some()
|| self.source_branch.is_some()
|| self.label.is_some()
|| self.draft.is_some()
|| self.free_text.is_some()
|| self.project_id.is_some()
}
}
// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------
/// A single row in the MR list.
#[derive(Debug, Clone)]
pub struct MrListRow {
pub project_path: String,
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub target_branch: String,
pub labels: Vec<String>,
pub updated_at: i64,
pub draft: bool,
}
// ---------------------------------------------------------------------------
// Page result
// ---------------------------------------------------------------------------
/// Result from a paginated MR list query.
#[derive(Debug, Clone)]
pub struct MrListPage {
pub rows: Vec<MrListRow>,
pub next_cursor: Option<MrCursor>,
pub total_count: u64,
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
/// Fields available for sorting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrSortField {
#[default]
UpdatedAt,
Iid,
Title,
State,
Author,
TargetBranch,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrSortOrder {
#[default]
Desc,
Asc,
}
// ---------------------------------------------------------------------------
// MrListState
// ---------------------------------------------------------------------------
/// State for the MR list screen.
#[derive(Debug, Default)]
pub struct MrListState {
/// Current page of MR rows.
pub rows: Vec<MrListRow>,
/// Total count of matching MRs.
pub total_count: u64,
/// Selected row index (within current window).
pub selected_index: usize,
/// Scroll offset for the entity table.
pub scroll_offset: usize,
/// Cursor for the next page.
pub next_cursor: Option<MrCursor>,
/// Whether a prefetch is in flight.
pub prefetch_in_flight: bool,
/// Current filter.
pub filter: MrFilter,
/// Raw filter input text.
pub filter_input: String,
/// Whether the filter bar has focus.
pub filter_focused: bool,
/// Sort field.
pub sort_field: MrSortField,
/// Sort direction.
pub sort_order: MrSortOrder,
/// Snapshot fence: max updated_at from initial load.
pub snapshot_fence: Option<i64>,
/// Hash of the current filter for change detection.
pub filter_hash: u64,
/// Whether Quick Peek is visible.
pub peek_visible: bool,
}
impl MrListState {
/// Reset pagination state (called when filter changes or on refresh).
pub fn reset_pagination(&mut self) {
self.rows.clear();
self.next_cursor = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.snapshot_fence = None;
self.total_count = 0;
self.prefetch_in_flight = false;
}
/// Apply a new page of results.
pub fn apply_page(&mut self, page: MrListPage) {
// Set snapshot fence on first page load.
if self.snapshot_fence.is_none() {
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
}
self.rows.extend(page.rows);
self.next_cursor = page.next_cursor;
self.total_count = page.total_count;
self.prefetch_in_flight = false;
}
/// Check if filter changed and reset if needed.
pub fn check_filter_change(&mut self) -> bool {
let new_hash = self.filter.hash_value();
if new_hash != self.filter_hash {
self.filter_hash = new_hash;
self.reset_pagination();
true
} else {
false
}
}
/// Whether the user has scrolled near the end of current data (80% threshold).
pub fn should_prefetch(&self) -> bool {
if self.prefetch_in_flight || self.next_cursor.is_none() {
return false;
}
if self.rows.is_empty() {
return false;
}
let threshold = (self.rows.len() * 4) / 5; // 80%
self.selected_index >= threshold
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(count: usize, has_next: bool) -> MrListPage {
let rows: Vec<MrListRow> = (0..count)
.map(|i| MrListRow {
project_path: "group/project".into(),
iid: (count - i) as i64,
title: format!("MR {}", count - i),
state: "opened".into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
draft: i % 3 == 0,
})
.collect();
let next_cursor = if has_next {
rows.last().map(|r| MrCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
MrListPage {
rows,
next_cursor,
total_count: if has_next {
(count * 2) as u64
} else {
count as u64
},
}
}
#[test]
fn test_apply_page_sets_snapshot_fence() {
let mut state = MrListState::default();
let page = sample_page(5, false);
state.apply_page(page);
assert_eq!(state.rows.len(), 5);
assert!(state.snapshot_fence.is_some());
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
}
#[test]
fn test_apply_page_appends() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
assert_eq!(state.rows.len(), 5);
state.apply_page(sample_page(3, false));
assert_eq!(state.rows.len(), 8);
}
#[test]
fn test_reset_pagination_clears_state() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
state.selected_index = 3;
state.reset_pagination();
assert!(state.rows.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.next_cursor.is_none());
assert!(state.snapshot_fence.is_none());
}
#[test]
fn test_check_filter_change_detects_change() {
let mut state = MrListState::default();
state.filter_hash = state.filter.hash_value();
state.filter.state = Some("opened".into());
assert!(state.check_filter_change());
}
#[test]
fn test_check_filter_change_no_change() {
let mut state = MrListState::default();
state.filter_hash = state.filter.hash_value();
assert!(!state.check_filter_change());
}
#[test]
fn test_should_prefetch() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 4; // 40% -- no prefetch
assert!(!state.should_prefetch());
state.selected_index = 8; // 80% -- prefetch
assert!(state.should_prefetch());
}
#[test]
fn test_should_prefetch_no_next_page() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, false));
state.selected_index = 9;
assert!(!state.should_prefetch());
}
#[test]
fn test_should_prefetch_already_in_flight() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 9;
state.prefetch_in_flight = true;
assert!(!state.should_prefetch());
}
#[test]
fn test_mr_filter_is_active() {
let empty = MrFilter::default();
assert!(!empty.is_active());
let active = MrFilter {
state: Some("opened".into()),
..Default::default()
};
assert!(active.is_active());
let draft_active = MrFilter {
draft: Some(true),
..Default::default()
};
assert!(draft_active.is_active());
}
#[test]
fn test_mr_filter_hash_deterministic() {
let f1 = MrFilter {
state: Some("opened".into()),
author: Some("taylor".into()),
..Default::default()
};
let f2 = f1.clone();
assert_eq!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_mr_filter_hash_differs() {
let f1 = MrFilter {
state: Some("opened".into()),
..Default::default()
};
let f2 = MrFilter {
state: Some("merged".into()),
..Default::default()
};
assert_ne!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_snapshot_fence_not_overwritten_on_second_page() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
let fence = state.snapshot_fence;
state.apply_page(sample_page(3, false));
assert_eq!(
state.snapshot_fence, fence,
"Fence should not change on second page"
);
}
#[test]
fn test_mr_filter_reviewer_field() {
let f = MrFilter {
reviewer: Some("alice".into()),
..Default::default()
};
assert!(f.is_active());
assert_ne!(f.hash_value(), MrFilter::default().hash_value());
}
#[test]
fn test_mr_filter_target_branch_field() {
let f = MrFilter {
target_branch: Some("main".into()),
..Default::default()
};
assert!(f.is_active());
}
#[test]
fn test_mr_list_row_draft_field() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 1,
title: "Draft MR".into(),
state: "opened".into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 0,
draft: true,
};
assert!(row.draft);
}
}

View File

@@ -0,0 +1,239 @@
//! Scope picker overlay state.
//!
//! The scope picker lets users filter all screens to a specific project.
//! It appears as a modal overlay when the user presses `P`.
use crate::scope::ProjectInfo;
use crate::state::ScopeContext;
/// State for the scope picker overlay.
#[derive(Debug, Default)]
pub struct ScopePickerState {
/// Available projects (populated on open).
pub projects: Vec<ProjectInfo>,
/// Currently highlighted index (0 = "All Projects", 1..N = specific projects).
pub selected_index: usize,
/// Whether the picker overlay is visible.
pub visible: bool,
/// Scroll offset for long project lists.
pub scroll_offset: usize,
}
/// Max visible rows in the picker before scrolling kicks in.
const MAX_VISIBLE_ROWS: usize = 15;
impl ScopePickerState {
/// Open the picker with the given project list.
///
/// Pre-selects the row matching the current scope, or "All Projects" (index 0)
/// if no project filter is active.
pub fn open(&mut self, projects: Vec<ProjectInfo>, current_scope: &ScopeContext) {
self.projects = projects;
self.visible = true;
self.scroll_offset = 0;
// Pre-select the currently active scope.
self.selected_index = match current_scope.project_id {
None => 0, // "All Projects" row
Some(id) => self
.projects
.iter()
.position(|p| p.id == id)
.map_or(0, |i| i + 1), // +1 because index 0 is "All Projects"
};
self.ensure_visible();
}
/// Close the picker without changing scope.
pub fn close(&mut self) {
self.visible = false;
}
/// Move selection up.
pub fn select_prev(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
self.ensure_visible();
}
}
/// Move selection down.
pub fn select_next(&mut self) {
let max_index = self.projects.len(); // 0="All" + N projects
if self.selected_index < max_index {
self.selected_index += 1;
self.ensure_visible();
}
}
/// Confirm the current selection and return the new scope.
#[must_use]
pub fn confirm(&self) -> ScopeContext {
if self.selected_index == 0 {
// "All Projects"
ScopeContext {
project_id: None,
project_name: None,
}
} else if let Some(project) = self.projects.get(self.selected_index - 1) {
ScopeContext {
project_id: Some(project.id),
project_name: Some(project.path.clone()),
}
} else {
// Out-of-bounds — fall back to "All Projects".
ScopeContext {
project_id: None,
project_name: None,
}
}
}
/// Total number of rows (1 for "All" + project count).
#[must_use]
pub fn row_count(&self) -> usize {
1 + self.projects.len()
}
/// Ensure the selected index is within the visible scroll window.
fn ensure_visible(&mut self) {
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + MAX_VISIBLE_ROWS {
self.scroll_offset = self.selected_index.saturating_sub(MAX_VISIBLE_ROWS - 1);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_projects() -> Vec<ProjectInfo> {
vec![
ProjectInfo {
id: 1,
path: "alpha/repo".into(),
},
ProjectInfo {
id: 2,
path: "beta/repo".into(),
},
ProjectInfo {
id: 3,
path: "gamma/repo".into(),
},
]
}
#[test]
fn test_open_no_scope_selects_all() {
let mut picker = ScopePickerState::default();
let scope = ScopeContext::default();
picker.open(sample_projects(), &scope);
assert!(picker.visible);
assert_eq!(picker.selected_index, 0); // "All Projects"
assert_eq!(picker.projects.len(), 3);
}
#[test]
fn test_open_with_scope_preselects_project() {
let mut picker = ScopePickerState::default();
let scope = ScopeContext {
project_id: Some(2),
project_name: Some("beta/repo".into()),
};
picker.open(sample_projects(), &scope);
assert_eq!(picker.selected_index, 2); // index 1 in projects = index 2 in picker
}
#[test]
fn test_select_prev_and_next() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
picker.select_next();
assert_eq!(picker.selected_index, 1);
picker.select_next();
assert_eq!(picker.selected_index, 2);
picker.select_prev();
assert_eq!(picker.selected_index, 1);
}
#[test]
fn test_select_prev_at_zero_stays() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
picker.select_prev();
assert_eq!(picker.selected_index, 0);
}
#[test]
fn test_select_next_at_max_stays() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
// 4 total rows (All + 3 projects), max index = 3
for _ in 0..10 {
picker.select_next();
}
assert_eq!(picker.selected_index, 3);
}
#[test]
fn test_confirm_all_projects() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
let scope = picker.confirm();
assert!(scope.project_id.is_none());
assert!(scope.project_name.is_none());
}
#[test]
fn test_confirm_specific_project() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
picker.select_next(); // index 1 = first project (alpha/repo, id=1)
let scope = picker.confirm();
assert_eq!(scope.project_id, Some(1));
assert_eq!(scope.project_name.as_deref(), Some("alpha/repo"));
}
#[test]
fn test_close_hides_picker() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
assert!(picker.visible);
picker.close();
assert!(!picker.visible);
}
#[test]
fn test_row_count() {
let mut picker = ScopePickerState::default();
picker.open(sample_projects(), &ScopeContext::default());
assert_eq!(picker.row_count(), 4); // "All" + 3 projects
}
#[test]
fn test_open_with_unknown_project_selects_all() {
let mut picker = ScopePickerState::default();
let scope = ScopeContext {
project_id: Some(999), // Not in list
project_name: Some("unknown".into()),
};
picker.open(sample_projects(), &scope);
assert_eq!(picker.selected_index, 0); // Falls back to "All"
}
}

View File

@@ -0,0 +1,569 @@
#![allow(dead_code)]
//! Search screen state — query input, mode selection, capability detection.
//!
//! The search screen supports three modes ([`SearchMode`]): Lexical (FTS5),
//! Hybrid (FTS+vector RRF), and Semantic (vector-only). Available modes are
//! gated by [`SearchCapabilities`], which probes the database on screen entry.
use crate::message::{SearchMode, SearchResult};
// ---------------------------------------------------------------------------
// SearchCapabilities
// ---------------------------------------------------------------------------
/// What search indexes are available in the local database.
///
/// Detected once on screen entry by probing FTS and embedding tables.
/// Used to gate which [`SearchMode`] values are selectable.
#[derive(Debug, Clone, PartialEq)]
pub struct SearchCapabilities {
/// FTS5 `documents_fts` table has rows.
pub has_fts: bool,
/// `embedding_metadata` table has rows.
pub has_embeddings: bool,
/// Percentage of documents that have embeddings (0.0100.0).
pub embedding_coverage_pct: f32,
}
impl Default for SearchCapabilities {
fn default() -> Self {
Self {
has_fts: false,
has_embeddings: false,
embedding_coverage_pct: 0.0,
}
}
}
impl SearchCapabilities {
/// Whether the given mode is usable with these capabilities.
#[must_use]
pub fn supports_mode(&self, mode: SearchMode) -> bool {
match mode {
SearchMode::Lexical => self.has_fts,
SearchMode::Hybrid => self.has_fts && self.has_embeddings,
SearchMode::Semantic => self.has_embeddings,
}
}
/// The best default mode given current capabilities.
#[must_use]
pub fn best_default_mode(&self) -> SearchMode {
if self.has_fts && self.has_embeddings {
SearchMode::Hybrid
} else if self.has_fts {
SearchMode::Lexical
} else if self.has_embeddings {
SearchMode::Semantic
} else {
SearchMode::Lexical // Fallback; UI will show "no indexes" message
}
}
/// Whether any search index is available at all.
#[must_use]
pub fn has_any_index(&self) -> bool {
self.has_fts || self.has_embeddings
}
}
// ---------------------------------------------------------------------------
// SearchState
// ---------------------------------------------------------------------------
/// State for the search screen.
#[derive(Debug, Default)]
pub struct SearchState {
/// Current query text.
pub query: String,
/// Whether the query input has keyboard focus.
pub query_focused: bool,
/// Cursor position within the query string (byte offset).
pub cursor: usize,
/// Active search mode.
pub mode: SearchMode,
/// Available search capabilities (detected on screen entry).
pub capabilities: SearchCapabilities,
/// Current result set.
pub results: Vec<SearchResult>,
/// Index of the selected result in the list.
pub selected_index: usize,
/// Monotonic generation counter for stale-response detection.
pub generation: u64,
/// Whether a search request is in-flight.
pub loading: bool,
}
impl SearchState {
/// Enter the search screen: focus query, detect capabilities.
pub fn enter(&mut self, capabilities: SearchCapabilities) {
self.query_focused = true;
self.cursor = self.query.len();
self.capabilities = capabilities;
// Pick the best mode for detected capabilities.
if !self.capabilities.supports_mode(self.mode) {
self.mode = self.capabilities.best_default_mode();
}
}
/// Leave the search screen: blur focus.
pub fn leave(&mut self) {
self.query_focused = false;
}
/// Focus the query input.
pub fn focus_query(&mut self) {
self.query_focused = true;
self.cursor = self.query.len();
}
/// Blur the query input.
pub fn blur_query(&mut self) {
self.query_focused = false;
}
/// Insert a character at the cursor position.
///
/// Returns the new generation (caller should arm debounce timer).
pub fn insert_char(&mut self, c: char) -> u64 {
self.query.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.generation += 1;
self.generation
}
/// Delete the character before the cursor (backspace).
///
/// Returns the new generation if changed, or `None` if cursor was at start.
pub fn delete_back(&mut self) -> Option<u64> {
if self.cursor == 0 {
return None;
}
let prev = self.query[..self.cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
self.query.drain(prev..self.cursor);
self.cursor = prev;
self.generation += 1;
Some(self.generation)
}
/// Move cursor left by one character.
pub fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.query[..self.cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
}
}
/// Move cursor right by one character.
pub fn cursor_right(&mut self) {
if self.cursor < self.query.len() {
self.cursor = self.query[self.cursor..]
.chars()
.next()
.map_or(self.query.len(), |ch| self.cursor + ch.len_utf8());
}
}
/// Move cursor to the start of the query.
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
/// Move cursor to the end of the query.
pub fn cursor_end(&mut self) {
self.cursor = self.query.len();
}
/// Cycle to the next available search mode (skip unsupported modes).
pub fn cycle_mode(&mut self) {
let start = self.mode;
let mut candidate = start.next();
// Cycle through at most 3 modes to find a supported one.
for _ in 0..3 {
if self.capabilities.supports_mode(candidate) {
self.mode = candidate;
return;
}
candidate = candidate.next();
}
// No supported mode found (shouldn't happen if has_any_index is true).
}
/// Apply search results from an async response.
///
/// Only applies if the generation matches (stale guard).
pub fn apply_results(&mut self, generation: u64, results: Vec<SearchResult>) {
if generation != self.generation {
return; // Stale response — discard.
}
self.results = results;
self.selected_index = 0;
self.loading = false;
}
/// Move selection up in the results list.
pub fn select_prev(&mut self) {
self.selected_index = self.selected_index.saturating_sub(1);
}
/// Move selection down in the results list.
pub fn select_next(&mut self) {
if !self.results.is_empty() {
self.selected_index = (self.selected_index + 1).min(self.results.len() - 1);
}
}
/// Get the currently selected result, if any.
#[must_use]
pub fn selected_result(&self) -> Option<&SearchResult> {
self.results.get(self.selected_index)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{EntityKey, SearchMode};
fn fts_only() -> SearchCapabilities {
SearchCapabilities {
has_fts: true,
has_embeddings: false,
embedding_coverage_pct: 0.0,
}
}
fn full_caps() -> SearchCapabilities {
SearchCapabilities {
has_fts: true,
has_embeddings: true,
embedding_coverage_pct: 85.0,
}
}
fn embeddings_only() -> SearchCapabilities {
SearchCapabilities {
has_fts: false,
has_embeddings: true,
embedding_coverage_pct: 100.0,
}
}
fn no_indexes() -> SearchCapabilities {
SearchCapabilities::default()
}
fn sample_result(iid: i64) -> SearchResult {
SearchResult {
key: EntityKey::issue(1, iid),
title: format!("Issue #{iid}"),
score: 0.95,
snippet: "matched text here".into(),
project_path: "group/project".into(),
}
}
// -- SearchCapabilities tests --
#[test]
fn test_capabilities_supports_mode_fts_only() {
let caps = fts_only();
assert!(caps.supports_mode(SearchMode::Lexical));
assert!(!caps.supports_mode(SearchMode::Hybrid));
assert!(!caps.supports_mode(SearchMode::Semantic));
}
#[test]
fn test_capabilities_supports_mode_full() {
let caps = full_caps();
assert!(caps.supports_mode(SearchMode::Lexical));
assert!(caps.supports_mode(SearchMode::Hybrid));
assert!(caps.supports_mode(SearchMode::Semantic));
}
#[test]
fn test_capabilities_supports_mode_embeddings_only() {
let caps = embeddings_only();
assert!(!caps.supports_mode(SearchMode::Lexical));
assert!(!caps.supports_mode(SearchMode::Hybrid));
assert!(caps.supports_mode(SearchMode::Semantic));
}
#[test]
fn test_capabilities_best_default_hybrid_when_both() {
assert_eq!(full_caps().best_default_mode(), SearchMode::Hybrid);
}
#[test]
fn test_capabilities_best_default_lexical_when_fts_only() {
assert_eq!(fts_only().best_default_mode(), SearchMode::Lexical);
}
#[test]
fn test_capabilities_best_default_semantic_when_embeddings_only() {
assert_eq!(embeddings_only().best_default_mode(), SearchMode::Semantic);
}
#[test]
fn test_capabilities_best_default_lexical_when_none() {
assert_eq!(no_indexes().best_default_mode(), SearchMode::Lexical);
}
#[test]
fn test_capabilities_has_any_index() {
assert!(fts_only().has_any_index());
assert!(full_caps().has_any_index());
assert!(embeddings_only().has_any_index());
assert!(!no_indexes().has_any_index());
}
// -- SearchState tests --
#[test]
fn test_enter_focuses_and_preserves_supported_mode() {
let mut state = SearchState::default();
// Default mode is Lexical, which full_caps supports — preserved.
state.enter(full_caps());
assert!(state.query_focused);
assert_eq!(state.mode, SearchMode::Lexical);
}
#[test]
fn test_enter_preserves_mode_if_supported() {
let mut state = SearchState {
mode: SearchMode::Lexical,
..SearchState::default()
};
state.enter(full_caps());
// Lexical is supported by full_caps, so it stays.
assert_eq!(state.mode, SearchMode::Lexical);
}
#[test]
fn test_enter_overrides_unsupported_mode() {
let mut state = SearchState {
mode: SearchMode::Hybrid,
..SearchState::default()
};
state.enter(fts_only());
// Hybrid requires embeddings, so fallback to Lexical.
assert_eq!(state.mode, SearchMode::Lexical);
}
#[test]
fn test_insert_char_and_cursor() {
let mut state = SearchState::default();
let generation1 = state.insert_char('h');
let generation2 = state.insert_char('i');
assert_eq!(state.query, "hi");
assert_eq!(state.cursor, 2);
assert_eq!(generation1, 1);
assert_eq!(generation2, 2);
}
#[test]
fn test_delete_back() {
let mut state = SearchState::default();
state.insert_char('a');
state.insert_char('b');
state.insert_char('c');
let generation = state.delete_back();
assert!(generation.is_some());
assert_eq!(state.query, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_delete_back_at_start_returns_none() {
let mut state = SearchState::default();
state.insert_char('a');
state.cursor = 0;
assert!(state.delete_back().is_none());
assert_eq!(state.query, "a");
}
#[test]
fn test_cursor_movement() {
let mut state = SearchState::default();
state.insert_char('a');
state.insert_char('b');
state.insert_char('c');
assert_eq!(state.cursor, 3);
state.cursor_left();
assert_eq!(state.cursor, 2);
state.cursor_left();
assert_eq!(state.cursor, 1);
state.cursor_right();
assert_eq!(state.cursor, 2);
state.cursor_home();
assert_eq!(state.cursor, 0);
state.cursor_end();
assert_eq!(state.cursor, 3);
}
#[test]
fn test_cursor_left_at_start_is_noop() {
let mut state = SearchState::default();
state.cursor_left();
assert_eq!(state.cursor, 0);
}
#[test]
fn test_cursor_right_at_end_is_noop() {
let mut state = SearchState::default();
state.insert_char('x');
state.cursor_right();
assert_eq!(state.cursor, 1);
}
#[test]
fn test_cycle_mode_full_caps() {
let mut state = SearchState {
capabilities: full_caps(),
mode: SearchMode::Lexical,
..SearchState::default()
};
state.cycle_mode();
assert_eq!(state.mode, SearchMode::Hybrid);
state.cycle_mode();
assert_eq!(state.mode, SearchMode::Semantic);
state.cycle_mode();
assert_eq!(state.mode, SearchMode::Lexical);
}
#[test]
fn test_cycle_mode_fts_only_stays_lexical() {
let mut state = SearchState {
capabilities: fts_only(),
mode: SearchMode::Lexical,
..SearchState::default()
};
state.cycle_mode();
// Hybrid and Semantic unsupported, wraps back to Lexical.
assert_eq!(state.mode, SearchMode::Lexical);
}
#[test]
fn test_cycle_mode_embeddings_only() {
let mut state = SearchState {
capabilities: embeddings_only(),
mode: SearchMode::Semantic,
..SearchState::default()
};
state.cycle_mode();
// Lexical and Hybrid unsupported, wraps back to Semantic.
assert_eq!(state.mode, SearchMode::Semantic);
}
#[test]
fn test_apply_results_matching_generation() {
let mut state = SearchState::default();
let generation = state.insert_char('q');
let results = vec![sample_result(1), sample_result(2)];
state.apply_results(generation, results);
assert_eq!(state.results.len(), 2);
assert_eq!(state.selected_index, 0);
assert!(!state.loading);
}
#[test]
fn test_apply_results_stale_generation_discarded() {
let mut state = SearchState::default();
state.insert_char('q'); // gen=1
state.insert_char('u'); // gen=2
let stale_results = vec![sample_result(99)];
state.apply_results(1, stale_results); // gen 1 is stale
assert!(state.results.is_empty());
}
#[test]
fn test_select_prev_next() {
let mut state = SearchState {
results: vec![sample_result(1), sample_result(2), sample_result(3)],
..SearchState::default()
};
assert_eq!(state.selected_index, 0);
state.select_next();
assert_eq!(state.selected_index, 1);
state.select_next();
assert_eq!(state.selected_index, 2);
state.select_next(); // Clamps at end.
assert_eq!(state.selected_index, 2);
state.select_prev();
assert_eq!(state.selected_index, 1);
state.select_prev();
assert_eq!(state.selected_index, 0);
state.select_prev(); // Clamps at start.
assert_eq!(state.selected_index, 0);
}
#[test]
fn test_selected_result() {
let mut state = SearchState::default();
assert!(state.selected_result().is_none());
state.results = vec![sample_result(42)];
let result = state.selected_result().unwrap();
assert_eq!(result.key.iid, 42);
}
#[test]
fn test_leave_blurs_focus() {
let mut state = SearchState::default();
state.enter(fts_only());
assert!(state.query_focused);
state.leave();
assert!(!state.query_focused);
}
#[test]
fn test_focus_query_moves_cursor_to_end() {
let mut state = SearchState {
query: "hello".into(),
cursor: 0,
..SearchState::default()
};
state.focus_query();
assert!(state.query_focused);
assert_eq!(state.cursor, 5);
}
#[test]
fn test_unicode_cursor_handling() {
let mut state = SearchState::default();
// Insert a multi-byte character.
state.insert_char('田');
assert_eq!(state.cursor, 3); // 田 is 3 bytes in UTF-8
state.insert_char('中');
assert_eq!(state.cursor, 6);
state.cursor_left();
assert_eq!(state.cursor, 3);
state.cursor_right();
assert_eq!(state.cursor, 6);
state.delete_back();
assert_eq!(state.query, "");
assert_eq!(state.cursor, 3);
}
}

View File

@@ -0,0 +1,153 @@
#![allow(dead_code)]
//! Stats screen state — database and index statistics.
//!
//! Shows entity counts, FTS coverage, embedding coverage, and queue
//! health. Data is produced by synchronous DB queries.
// ---------------------------------------------------------------------------
// StatsData
// ---------------------------------------------------------------------------
/// Database statistics for TUI display.
#[derive(Debug, Clone, Default)]
pub struct StatsData {
/// Total documents in the database.
pub total_documents: i64,
/// Issues stored.
pub issues: i64,
/// Merge requests stored.
pub merge_requests: i64,
/// Discussions stored.
pub discussions: i64,
/// Notes stored.
pub notes: i64,
/// Documents indexed in FTS.
pub fts_indexed: i64,
/// Documents with embeddings.
pub embedded_documents: i64,
/// Total embedding chunks.
pub total_chunks: i64,
/// Embedding coverage percentage (0.0100.0).
pub coverage_pct: f64,
/// Pending queue items (dirty sources).
pub queue_pending: i64,
/// Failed queue items.
pub queue_failed: i64,
}
impl StatsData {
/// FTS coverage percentage relative to total documents.
#[must_use]
pub fn fts_coverage_pct(&self) -> f64 {
if self.total_documents == 0 {
0.0
} else {
(self.fts_indexed as f64 / self.total_documents as f64) * 100.0
}
}
/// Whether there are pending queue items that need processing.
#[must_use]
pub fn has_queue_work(&self) -> bool {
self.queue_pending > 0 || self.queue_failed > 0
}
}
// ---------------------------------------------------------------------------
// StatsState
// ---------------------------------------------------------------------------
/// State for the Stats screen.
#[derive(Debug, Default)]
pub struct StatsState {
/// Statistics data (None until loaded).
pub data: Option<StatsData>,
/// Whether data has been loaded at least once.
pub loaded: bool,
}
impl StatsState {
/// Apply loaded stats data.
pub fn apply_data(&mut self, data: StatsData) {
self.data = Some(data);
self.loaded = true;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_stats() -> StatsData {
StatsData {
total_documents: 500,
issues: 200,
merge_requests: 150,
discussions: 100,
notes: 50,
fts_indexed: 450,
embedded_documents: 300,
total_chunks: 1200,
coverage_pct: 60.0,
queue_pending: 5,
queue_failed: 1,
}
}
#[test]
fn test_default_state() {
let state = StatsState::default();
assert!(state.data.is_none());
assert!(!state.loaded);
}
#[test]
fn test_apply_data() {
let mut state = StatsState::default();
state.apply_data(sample_stats());
assert!(state.loaded);
assert!(state.data.is_some());
}
#[test]
fn test_fts_coverage_pct() {
let stats = sample_stats();
let pct = stats.fts_coverage_pct();
assert!((pct - 90.0).abs() < 0.01); // 450/500 = 90%
}
#[test]
fn test_fts_coverage_pct_zero_documents() {
let stats = StatsData::default();
assert_eq!(stats.fts_coverage_pct(), 0.0);
}
#[test]
fn test_has_queue_work() {
let stats = sample_stats();
assert!(stats.has_queue_work());
}
#[test]
fn test_no_queue_work() {
let stats = StatsData {
queue_pending: 0,
queue_failed: 0,
..sample_stats()
};
assert!(!stats.has_queue_work());
}
#[test]
fn test_stats_data_default() {
let stats = StatsData::default();
assert_eq!(stats.total_documents, 0);
assert_eq!(stats.issues, 0);
assert_eq!(stats.coverage_pct, 0.0);
}
}

View File

@@ -0,0 +1,593 @@
#![allow(dead_code)]
//! Sync screen state: progress tracking, coalescing, and summary.
//!
//! The sync screen shows real-time progress during data synchronization
//! and transitions to a summary view when complete. A progress coalescer
//! prevents render thrashing from rapid progress updates.
use std::time::Instant;
// ---------------------------------------------------------------------------
// Sync lanes (entity types being synced)
// ---------------------------------------------------------------------------
/// Sync entity types that progress is tracked for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SyncLane {
Issues,
MergeRequests,
Discussions,
Notes,
Events,
Statuses,
}
impl SyncLane {
/// Human-readable label for this lane.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Issues => "Issues",
Self::MergeRequests => "MRs",
Self::Discussions => "Discussions",
Self::Notes => "Notes",
Self::Events => "Events",
Self::Statuses => "Statuses",
}
}
/// All lanes in display order.
pub const ALL: &'static [SyncLane] = &[
Self::Issues,
Self::MergeRequests,
Self::Discussions,
Self::Notes,
Self::Events,
Self::Statuses,
];
}
// ---------------------------------------------------------------------------
// Per-lane progress
// ---------------------------------------------------------------------------
/// Progress for a single sync lane.
#[derive(Debug, Clone, Default)]
pub struct LaneProgress {
/// Current items processed.
pub current: u64,
/// Total items expected (0 = unknown).
pub total: u64,
/// Whether this lane has completed.
pub done: bool,
}
impl LaneProgress {
/// Fraction complete (0.0..=1.0). Returns 0.0 if total is unknown.
#[must_use]
pub fn fraction(&self) -> f64 {
if self.total == 0 {
return 0.0;
}
(self.current as f64 / self.total as f64).clamp(0.0, 1.0)
}
}
// ---------------------------------------------------------------------------
// Sync summary
// ---------------------------------------------------------------------------
/// Per-entity-type change counts after sync completes.
#[derive(Debug, Clone, Default)]
pub struct EntityChangeCounts {
pub new: u64,
pub updated: u64,
}
/// Summary of a completed sync run.
#[derive(Debug, Clone, Default)]
pub struct SyncSummary {
pub issues: EntityChangeCounts,
pub merge_requests: EntityChangeCounts,
pub discussions: EntityChangeCounts,
pub notes: EntityChangeCounts,
pub elapsed_ms: u64,
/// Per-project errors (project path -> error message).
pub project_errors: Vec<(String, String)>,
}
impl SyncSummary {
/// Total number of changes across all entity types.
#[must_use]
pub fn total_changes(&self) -> u64 {
self.issues.new
+ self.issues.updated
+ self.merge_requests.new
+ self.merge_requests.updated
+ self.discussions.new
+ self.discussions.updated
+ self.notes.new
+ self.notes.updated
}
/// Whether any errors occurred during sync.
#[must_use]
pub fn has_errors(&self) -> bool {
!self.project_errors.is_empty()
}
}
// ---------------------------------------------------------------------------
// Sync screen mode
// ---------------------------------------------------------------------------
/// Display mode for the sync screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyncScreenMode {
/// Full-screen sync progress with per-lane bars.
#[default]
FullScreen,
/// Compact single-line progress for embedding in Bootstrap screen.
Inline,
}
// ---------------------------------------------------------------------------
// Sync phase
// ---------------------------------------------------------------------------
/// Current phase of the sync operation.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SyncPhase {
/// Sync hasn't started yet.
#[default]
Idle,
/// Sync is running.
Running,
/// Sync completed successfully.
Complete,
/// Sync was cancelled by user.
Cancelled,
/// Sync failed with an error.
Failed(String),
}
// ---------------------------------------------------------------------------
// Progress coalescer
// ---------------------------------------------------------------------------
/// Batches rapid progress updates to prevent render thrashing.
///
/// At most one update is emitted per `floor_ms`. Updates arriving faster
/// are coalesced — only the latest value survives.
#[derive(Debug)]
pub struct ProgressCoalescer {
/// Minimum interval between emitted updates.
floor_ms: u64,
/// Timestamp of the last emitted update.
last_emit: Option<Instant>,
/// Number of updates coalesced (dropped) since last emit.
coalesced_count: u64,
}
impl ProgressCoalescer {
/// Create a new coalescer with the given floor interval in milliseconds.
#[must_use]
pub fn new(floor_ms: u64) -> Self {
Self {
floor_ms,
last_emit: None,
coalesced_count: 0,
}
}
/// Default coalescer with 100ms floor (10 updates/second max).
#[must_use]
pub fn default_floor() -> Self {
Self::new(100)
}
/// Should this update be emitted?
///
/// Returns `true` if enough time has elapsed since the last emit.
/// The caller should only render/process the update when this returns true.
pub fn should_emit(&mut self) -> bool {
let now = Instant::now();
match self.last_emit {
None => {
self.last_emit = Some(now);
self.coalesced_count = 0;
true
}
Some(last) => {
let elapsed_ms = now.duration_since(last).as_millis() as u64;
if elapsed_ms >= self.floor_ms {
self.last_emit = Some(now);
self.coalesced_count = 0;
true
} else {
self.coalesced_count += 1;
false
}
}
}
}
/// Number of updates that have been coalesced since the last emit.
#[must_use]
pub fn coalesced_count(&self) -> u64 {
self.coalesced_count
}
/// Reset the coalescer (e.g., when sync restarts).
pub fn reset(&mut self) {
self.last_emit = None;
self.coalesced_count = 0;
}
}
// ---------------------------------------------------------------------------
// SyncState
// ---------------------------------------------------------------------------
/// State for the sync progress/summary screen.
#[derive(Debug)]
pub struct SyncState {
/// Current sync phase.
pub phase: SyncPhase,
/// Display mode (full screen vs inline).
pub mode: SyncScreenMode,
/// Per-lane progress (updated during Running phase).
pub lanes: [LaneProgress; 6],
/// Current stage label (e.g., "Fetching issues...").
pub stage: String,
/// Log lines from the sync process.
pub log_lines: Vec<String>,
/// Stream throughput stats (items per second).
pub items_per_sec: f64,
/// Bytes synced.
pub bytes_synced: u64,
/// Total items synced.
pub items_synced: u64,
/// When the current sync run started (for throughput calculation).
pub started_at: Option<Instant>,
/// Progress coalescer for render throttling.
pub coalescer: ProgressCoalescer,
/// Summary (populated after sync completes).
pub summary: Option<SyncSummary>,
/// Scroll offset for log lines view.
pub log_scroll_offset: usize,
}
impl Default for SyncState {
fn default() -> Self {
Self {
phase: SyncPhase::Idle,
mode: SyncScreenMode::FullScreen,
lanes: Default::default(),
stage: String::new(),
log_lines: Vec::new(),
items_per_sec: 0.0,
bytes_synced: 0,
items_synced: 0,
started_at: None,
coalescer: ProgressCoalescer::default_floor(),
summary: None,
log_scroll_offset: 0,
}
}
}
impl SyncState {
/// Reset state for a new sync run.
pub fn start(&mut self) {
self.phase = SyncPhase::Running;
self.lanes = Default::default();
self.stage.clear();
self.log_lines.clear();
self.items_per_sec = 0.0;
self.bytes_synced = 0;
self.items_synced = 0;
self.started_at = Some(Instant::now());
self.coalescer.reset();
self.summary = None;
self.log_scroll_offset = 0;
}
/// Apply a progress update for a specific lane.
pub fn update_progress(&mut self, stage: &str, current: u64, total: u64) {
self.stage = stage.to_string();
// Map stage name to lane index.
if let Some(lane) = self.lane_for_stage(stage) {
lane.current = current;
lane.total = total;
}
}
/// Apply a batch progress increment.
pub fn update_batch(&mut self, stage: &str, batch_size: u64) {
self.stage = stage.to_string();
if let Some(lane) = self.lane_for_stage(stage) {
lane.current += batch_size;
}
}
/// Mark sync as completed with summary.
pub fn complete(&mut self, elapsed_ms: u64) {
self.phase = SyncPhase::Complete;
// Mark all lanes as done.
for lane in &mut self.lanes {
lane.done = true;
}
// Build summary from lane data if not already set.
if self.summary.is_none() {
self.summary = Some(SyncSummary {
elapsed_ms,
..Default::default()
});
} else if let Some(ref mut summary) = self.summary {
summary.elapsed_ms = elapsed_ms;
}
}
/// Mark sync as cancelled.
pub fn cancel(&mut self) {
self.phase = SyncPhase::Cancelled;
}
/// Mark sync as failed.
pub fn fail(&mut self, error: String) {
self.phase = SyncPhase::Failed(error);
}
/// Add a log line.
pub fn add_log_line(&mut self, line: String) {
self.log_lines.push(line);
// Auto-scroll to bottom.
if self.log_lines.len() > 1 {
self.log_scroll_offset = self.log_lines.len().saturating_sub(20);
}
}
/// Update stream stats.
pub fn update_stream_stats(&mut self, bytes: u64, items: u64) {
self.bytes_synced = bytes;
self.items_synced = items;
// Compute actual throughput from elapsed time since sync start.
if items > 0
&& let Some(started) = self.started_at
{
let elapsed_secs = started.elapsed().as_secs_f64();
if elapsed_secs > 0.0 {
self.items_per_sec = items as f64 / elapsed_secs;
}
}
}
/// Whether sync is currently running.
#[must_use]
pub fn is_running(&self) -> bool {
self.phase == SyncPhase::Running
}
/// Overall progress fraction (average of all lanes).
#[must_use]
pub fn overall_progress(&self) -> f64 {
let active_lanes: Vec<&LaneProgress> = self.lanes.iter().filter(|l| l.total > 0).collect();
if active_lanes.is_empty() {
return 0.0;
}
let sum: f64 = active_lanes.iter().map(|l| l.fraction()).sum();
sum / active_lanes.len() as f64
}
/// Map a stage name to the corresponding lane.
fn lane_for_stage(&mut self, stage: &str) -> Option<&mut LaneProgress> {
let lower = stage.to_lowercase();
let idx = if lower.contains("issue") {
Some(0)
} else if lower.contains("merge") || lower.contains("mr") {
Some(1)
} else if lower.contains("discussion") {
Some(2)
} else if lower.contains("note") {
Some(3)
} else if lower.contains("event") {
Some(4)
} else if lower.contains("status") {
Some(5)
} else {
None
};
idx.map(|i| &mut self.lanes[i])
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_lane_progress_fraction() {
let lane = LaneProgress {
current: 50,
total: 100,
done: false,
};
assert!((lane.fraction() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_lane_progress_fraction_zero_total() {
let lane = LaneProgress::default();
assert!((lane.fraction()).abs() < f64::EPSILON);
}
#[test]
fn test_sync_summary_total_changes() {
let summary = SyncSummary {
issues: EntityChangeCounts { new: 5, updated: 3 },
merge_requests: EntityChangeCounts { new: 2, updated: 1 },
..Default::default()
};
assert_eq!(summary.total_changes(), 11);
}
#[test]
fn test_sync_summary_has_errors() {
let mut summary = SyncSummary::default();
assert!(!summary.has_errors());
summary
.project_errors
.push(("grp/repo".into(), "timeout".into()));
assert!(summary.has_errors());
}
#[test]
fn test_sync_state_start_resets() {
let mut state = SyncState {
stage: "old".into(),
phase: SyncPhase::Complete,
..SyncState::default()
};
state.log_lines.push("old log".into());
state.start();
assert_eq!(state.phase, SyncPhase::Running);
assert!(state.stage.is_empty());
assert!(state.log_lines.is_empty());
}
#[test]
fn test_sync_state_update_progress() {
let mut state = SyncState::default();
state.start();
state.update_progress("Fetching issues", 10, 50);
assert_eq!(state.lanes[0].current, 10);
assert_eq!(state.lanes[0].total, 50);
assert_eq!(state.stage, "Fetching issues");
}
#[test]
fn test_sync_state_update_batch() {
let mut state = SyncState::default();
state.start();
state.update_batch("MR processing", 5);
state.update_batch("MR processing", 3);
assert_eq!(state.lanes[1].current, 8); // MR lane
}
#[test]
fn test_sync_state_complete() {
let mut state = SyncState::default();
state.start();
state.complete(5000);
assert_eq!(state.phase, SyncPhase::Complete);
assert!(state.summary.is_some());
assert_eq!(state.summary.as_ref().unwrap().elapsed_ms, 5000);
}
#[test]
fn test_sync_state_overall_progress() {
let mut state = SyncState::default();
state.start();
state.update_progress("issues", 50, 100);
state.update_progress("merge requests", 25, 100);
// Two active lanes: 0.5 and 0.25, average = 0.375
assert!((state.overall_progress() - 0.375).abs() < 0.01);
}
#[test]
fn test_sync_state_overall_progress_no_active_lanes() {
let state = SyncState::default();
assert!((state.overall_progress()).abs() < f64::EPSILON);
}
#[test]
fn test_progress_coalescer_first_always_emits() {
let mut coalescer = ProgressCoalescer::new(100);
assert!(coalescer.should_emit());
}
#[test]
fn test_progress_coalescer_rapid_updates_coalesced() {
let mut coalescer = ProgressCoalescer::new(100);
assert!(coalescer.should_emit()); // First always emits.
// Rapid-fire updates within 100ms should be coalesced.
let mut emitted = 0;
for _ in 0..50 {
if coalescer.should_emit() {
emitted += 1;
}
}
// With ~0ms between calls, at most 0-1 additional emits expected.
assert!(emitted <= 1, "Expected at most 1 emit, got {emitted}");
}
#[test]
fn test_progress_coalescer_emits_after_floor() {
let mut coalescer = ProgressCoalescer::new(50);
assert!(coalescer.should_emit());
// Wait longer than floor.
thread::sleep(Duration::from_millis(60));
assert!(coalescer.should_emit());
}
#[test]
fn test_progress_coalescer_reset() {
let mut coalescer = ProgressCoalescer::new(100);
coalescer.should_emit();
coalescer.should_emit(); // Coalesced.
coalescer.reset();
assert!(coalescer.should_emit()); // Fresh start.
}
#[test]
fn test_sync_lane_labels() {
assert_eq!(SyncLane::Issues.label(), "Issues");
assert_eq!(SyncLane::MergeRequests.label(), "MRs");
assert_eq!(SyncLane::Notes.label(), "Notes");
}
#[test]
fn test_sync_state_add_log_line() {
let mut state = SyncState::default();
state.add_log_line("line 1".into());
state.add_log_line("line 2".into());
assert_eq!(state.log_lines.len(), 2);
assert_eq!(state.log_lines[0], "line 1");
}
#[test]
fn test_sync_state_cancel() {
let mut state = SyncState::default();
state.start();
state.cancel();
assert_eq!(state.phase, SyncPhase::Cancelled);
}
#[test]
fn test_sync_state_fail() {
let mut state = SyncState::default();
state.start();
state.fail("network timeout".into());
assert!(matches!(state.phase, SyncPhase::Failed(_)));
}
}

View File

@@ -0,0 +1,222 @@
#![allow(dead_code)]
//! Sync delta ledger — records entity changes during a sync run.
//!
//! After sync completes, the dashboard and list screens can query the
//! ledger to highlight "new since last sync" items. The ledger is
//! ephemeral (per-run, not persisted to disk).
use std::collections::HashSet;
/// Kind of change that occurred to an entity during sync.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
New,
Updated,
}
/// Entity type for the ledger.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LedgerEntityType {
Issue,
MergeRequest,
Discussion,
Note,
}
/// Per-run record of changed entity IDs during sync.
///
/// Used to highlight newly synced items in list/dashboard views.
#[derive(Debug, Default)]
pub struct SyncDeltaLedger {
pub new_issue_iids: HashSet<i64>,
pub updated_issue_iids: HashSet<i64>,
pub new_mr_iids: HashSet<i64>,
pub updated_mr_iids: HashSet<i64>,
pub new_discussion_count: u64,
pub updated_discussion_count: u64,
pub new_note_count: u64,
}
impl SyncDeltaLedger {
/// Record a change to an entity.
pub fn record_change(&mut self, entity_type: LedgerEntityType, iid: i64, kind: ChangeKind) {
match (entity_type, kind) {
(LedgerEntityType::Issue, ChangeKind::New) => {
self.new_issue_iids.insert(iid);
}
(LedgerEntityType::Issue, ChangeKind::Updated) => {
self.updated_issue_iids.insert(iid);
}
(LedgerEntityType::MergeRequest, ChangeKind::New) => {
self.new_mr_iids.insert(iid);
}
(LedgerEntityType::MergeRequest, ChangeKind::Updated) => {
self.updated_mr_iids.insert(iid);
}
(LedgerEntityType::Discussion, ChangeKind::New) => {
self.new_discussion_count += 1;
}
(LedgerEntityType::Discussion, ChangeKind::Updated) => {
self.updated_discussion_count += 1;
}
(LedgerEntityType::Note, ChangeKind::New) => {
self.new_note_count += 1;
}
(LedgerEntityType::Note, ChangeKind::Updated) => {
// Notes don't have a meaningful "updated" count.
}
}
}
/// Produce a summary of changes from this sync run.
#[must_use]
pub fn summary(&self) -> super::sync::SyncSummary {
use super::sync::{EntityChangeCounts, SyncSummary};
SyncSummary {
issues: EntityChangeCounts {
new: self.new_issue_iids.len() as u64,
updated: self.updated_issue_iids.len() as u64,
},
merge_requests: EntityChangeCounts {
new: self.new_mr_iids.len() as u64,
updated: self.updated_mr_iids.len() as u64,
},
discussions: EntityChangeCounts {
new: self.new_discussion_count,
updated: self.updated_discussion_count,
},
notes: EntityChangeCounts {
new: self.new_note_count,
updated: 0,
},
..Default::default()
}
}
/// Whether any entity was an issue IID that was newly added in this sync.
#[must_use]
pub fn is_new_issue(&self, iid: i64) -> bool {
self.new_issue_iids.contains(&iid)
}
/// Whether any entity was an MR IID that was newly added in this sync.
#[must_use]
pub fn is_new_mr(&self, iid: i64) -> bool {
self.new_mr_iids.contains(&iid)
}
/// Total changes recorded.
#[must_use]
pub fn total_changes(&self) -> u64 {
self.new_issue_iids.len() as u64
+ self.updated_issue_iids.len() as u64
+ self.new_mr_iids.len() as u64
+ self.updated_mr_iids.len() as u64
+ self.new_discussion_count
+ self.updated_discussion_count
+ self.new_note_count
}
/// Clear the ledger for a new sync run.
pub fn clear(&mut self) {
self.new_issue_iids.clear();
self.updated_issue_iids.clear();
self.new_mr_iids.clear();
self.updated_mr_iids.clear();
self.new_discussion_count = 0;
self.updated_discussion_count = 0;
self.new_note_count = 0;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_record_new_issues() {
let mut ledger = SyncDeltaLedger::default();
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
ledger.record_change(LedgerEntityType::Issue, 2, ChangeKind::New);
ledger.record_change(LedgerEntityType::Issue, 3, ChangeKind::Updated);
assert_eq!(ledger.new_issue_iids.len(), 2);
assert_eq!(ledger.updated_issue_iids.len(), 1);
assert!(ledger.is_new_issue(1));
assert!(ledger.is_new_issue(2));
assert!(!ledger.is_new_issue(3));
}
#[test]
fn test_record_new_mrs() {
let mut ledger = SyncDeltaLedger::default();
ledger.record_change(LedgerEntityType::MergeRequest, 10, ChangeKind::New);
ledger.record_change(LedgerEntityType::MergeRequest, 20, ChangeKind::Updated);
assert!(ledger.is_new_mr(10));
assert!(!ledger.is_new_mr(20));
}
#[test]
fn test_summary_counts() {
let mut ledger = SyncDeltaLedger::default();
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
ledger.record_change(LedgerEntityType::Issue, 2, ChangeKind::New);
ledger.record_change(LedgerEntityType::Issue, 3, ChangeKind::Updated);
ledger.record_change(LedgerEntityType::MergeRequest, 10, ChangeKind::New);
ledger.record_change(LedgerEntityType::Discussion, 0, ChangeKind::New);
ledger.record_change(LedgerEntityType::Note, 0, ChangeKind::New);
let summary = ledger.summary();
assert_eq!(summary.issues.new, 2);
assert_eq!(summary.issues.updated, 1);
assert_eq!(summary.merge_requests.new, 1);
assert_eq!(summary.discussions.new, 1);
assert_eq!(summary.notes.new, 1);
}
#[test]
fn test_total_changes() {
let mut ledger = SyncDeltaLedger::default();
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
ledger.record_change(LedgerEntityType::MergeRequest, 10, ChangeKind::Updated);
ledger.record_change(LedgerEntityType::Note, 0, ChangeKind::New);
assert_eq!(ledger.total_changes(), 3);
}
#[test]
fn test_dedup_same_iid() {
let mut ledger = SyncDeltaLedger::default();
// Recording same IID twice should deduplicate.
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
assert_eq!(ledger.new_issue_iids.len(), 1);
}
#[test]
fn test_clear() {
let mut ledger = SyncDeltaLedger::default();
ledger.record_change(LedgerEntityType::Issue, 1, ChangeKind::New);
ledger.record_change(LedgerEntityType::Note, 0, ChangeKind::New);
ledger.clear();
assert_eq!(ledger.total_changes(), 0);
assert!(ledger.new_issue_iids.is_empty());
}
#[test]
fn test_empty_ledger_summary() {
let ledger = SyncDeltaLedger::default();
let summary = ledger.summary();
assert_eq!(summary.total_changes(), 0);
assert!(!summary.has_errors());
}
}

View File

@@ -0,0 +1,271 @@
#![allow(dead_code)]
//! Timeline screen state — event stream, scope filtering, navigation.
//!
//! The timeline displays a chronological event stream from resource event
//! tables. Events can be scoped to a specific entity, author, or shown
//! globally. [`TimelineScope`] gates the query; [`TimelineState`] manages
//! the scroll position, selected event, and generation counter for
//! stale-response detection.
use crate::message::{EntityKey, TimelineEvent};
// ---------------------------------------------------------------------------
// TimelineScope
// ---------------------------------------------------------------------------
/// Scope filter for the timeline event query.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum TimelineScope {
/// All events across all entities.
#[default]
All,
/// Events for a specific entity (issue or MR).
Entity(EntityKey),
/// Events by a specific actor.
Author(String),
}
// ---------------------------------------------------------------------------
// TimelineState
// ---------------------------------------------------------------------------
/// State for the timeline screen.
#[derive(Debug, Default)]
pub struct TimelineState {
/// Loaded timeline events (sorted by timestamp, most recent first).
pub events: Vec<TimelineEvent>,
/// Active scope filter.
pub scope: TimelineScope,
/// Index of the selected event in the list.
pub selected_index: usize,
/// Scroll offset for the visible window.
pub scroll_offset: usize,
/// Monotonic generation counter for stale-response detection.
pub generation: u64,
/// Whether a fetch is in-flight.
pub loading: bool,
}
impl TimelineState {
/// Enter the timeline screen. Bumps generation for fresh data.
pub fn enter(&mut self) -> u64 {
self.generation += 1;
self.loading = true;
self.generation
}
/// Set the scope filter and bump generation.
///
/// Returns the new generation (caller should trigger a re-fetch).
pub fn set_scope(&mut self, scope: TimelineScope) -> u64 {
self.scope = scope;
self.generation += 1;
self.loading = true;
self.generation
}
/// Apply timeline events from an async response.
///
/// Only applies if the generation matches (stale guard).
pub fn apply_results(&mut self, generation: u64, events: Vec<TimelineEvent>) {
if generation != self.generation {
return; // Stale response — discard.
}
self.events = events;
self.selected_index = 0;
self.scroll_offset = 0;
self.loading = false;
}
/// Move selection up in the event list.
pub fn select_prev(&mut self) {
self.selected_index = self.selected_index.saturating_sub(1);
}
/// Move selection down in the event list.
pub fn select_next(&mut self) {
if !self.events.is_empty() {
self.selected_index = (self.selected_index + 1).min(self.events.len() - 1);
}
}
/// Get the currently selected event, if any.
#[must_use]
pub fn selected_event(&self) -> Option<&TimelineEvent> {
self.events.get(self.selected_index)
}
/// Ensure the selected index is visible given the viewport height.
///
/// Adjusts `scroll_offset` so the selected item is within the
/// visible window.
pub fn ensure_visible(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + viewport_height {
self.scroll_offset = self.selected_index - viewport_height + 1;
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::TimelineEventKind;
fn sample_event(timestamp_ms: i64, iid: i64) -> TimelineEvent {
TimelineEvent {
timestamp_ms,
entity_key: EntityKey::issue(1, iid),
event_kind: TimelineEventKind::Created,
summary: format!("Issue #{iid} created"),
detail: None,
actor: Some("alice".into()),
project_path: "group/project".into(),
}
}
#[test]
fn test_timeline_scope_default_is_all() {
assert_eq!(TimelineScope::default(), TimelineScope::All);
}
#[test]
fn test_enter_bumps_generation() {
let mut state = TimelineState::default();
let generation = state.enter();
assert_eq!(generation, 1);
assert!(state.loading);
}
#[test]
fn test_set_scope_bumps_generation() {
let mut state = TimelineState::default();
let gen1 = state.set_scope(TimelineScope::Author("bob".into()));
assert_eq!(gen1, 1);
assert_eq!(state.scope, TimelineScope::Author("bob".into()));
let gen2 = state.set_scope(TimelineScope::All);
assert_eq!(gen2, 2);
}
#[test]
fn test_apply_results_matching_generation() {
let mut state = TimelineState::default();
let generation = state.enter();
let events = vec![sample_event(3000, 1), sample_event(2000, 2)];
state.apply_results(generation, events);
assert_eq!(state.events.len(), 2);
assert_eq!(state.selected_index, 0);
assert!(!state.loading);
}
#[test]
fn test_apply_results_stale_generation_discarded() {
let mut state = TimelineState::default();
state.enter(); // gen=1
let _gen2 = state.enter(); // gen=2
let stale_events = vec![sample_event(1000, 99)];
state.apply_results(1, stale_events); // gen 1 is stale
assert!(state.events.is_empty());
}
#[test]
fn test_select_prev_next() {
let mut state = TimelineState {
events: vec![
sample_event(3000, 1),
sample_event(2000, 2),
sample_event(1000, 3),
],
..TimelineState::default()
};
assert_eq!(state.selected_index, 0);
state.select_next();
assert_eq!(state.selected_index, 1);
state.select_next();
assert_eq!(state.selected_index, 2);
state.select_next(); // Clamps at end.
assert_eq!(state.selected_index, 2);
state.select_prev();
assert_eq!(state.selected_index, 1);
state.select_prev();
assert_eq!(state.selected_index, 0);
state.select_prev(); // Clamps at start.
assert_eq!(state.selected_index, 0);
}
#[test]
fn test_selected_event() {
let mut state = TimelineState::default();
assert!(state.selected_event().is_none());
state.events = vec![sample_event(3000, 42)];
let event = state.selected_event().unwrap();
assert_eq!(event.entity_key.iid, 42);
}
#[test]
fn test_ensure_visible_scrolls_down() {
let mut state = TimelineState {
events: vec![
sample_event(5000, 1),
sample_event(4000, 2),
sample_event(3000, 3),
sample_event(2000, 4),
sample_event(1000, 5),
],
selected_index: 4,
scroll_offset: 0,
..TimelineState::default()
};
state.ensure_visible(3);
assert_eq!(state.scroll_offset, 2); // 4 - 3 + 1 = 2
}
#[test]
fn test_ensure_visible_scrolls_up() {
let mut state = TimelineState {
events: vec![
sample_event(5000, 1),
sample_event(4000, 2),
sample_event(3000, 3),
],
selected_index: 0,
scroll_offset: 2,
..TimelineState::default()
};
state.ensure_visible(3);
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_ensure_visible_zero_viewport() {
let mut state = TimelineState {
scroll_offset: 5,
..TimelineState::default()
};
state.ensure_visible(0);
assert_eq!(state.scroll_offset, 5); // Unchanged.
}
#[test]
fn test_select_next_on_empty_is_noop() {
let mut state = TimelineState::default();
state.select_next();
assert_eq!(state.selected_index, 0);
}
}

View File

@@ -0,0 +1,545 @@
//! Trace screen state — file → MR → issue chain drill-down.
//!
//! Users enter a file path, and the trace query resolves rename chains,
//! finds MRs that touched the file, links issues via entity_references,
//! and extracts DiffNote discussions. Each result chain can be
//! expanded/collapsed independently.
use std::collections::HashSet;
use lore::core::trace::TraceResult;
use crate::text_width::{next_char_boundary, prev_char_boundary};
// ---------------------------------------------------------------------------
// TraceState
// ---------------------------------------------------------------------------
/// State for the Trace screen.
#[derive(Debug, Default)]
pub struct TraceState {
/// User-entered file path (with optional :line suffix).
pub path_input: String,
/// Cursor position within `path_input` (byte offset).
pub path_cursor: usize,
/// Whether the path input field has keyboard focus.
pub path_focused: bool,
/// Parsed line filter from `:N` suffix (stored but not yet used for highlighting).
pub line_filter: Option<u32>,
/// The most recent trace result (None until first query).
pub result: Option<TraceResult>,
/// Index of the currently selected chain in the trace result.
pub selected_chain_index: usize,
/// Set of chain indices that are currently expanded.
pub expanded_chains: HashSet<usize>,
/// Whether to follow rename chains in the query (default true).
pub follow_renames: bool,
/// Whether to include DiffNote discussions (default true).
pub include_discussions: bool,
/// Vertical scroll offset for the chain list.
pub scroll_offset: u16,
/// Cached list of known file paths for autocomplete.
pub known_paths: Vec<String>,
/// Filtered autocomplete matches for current input.
pub autocomplete_matches: Vec<String>,
/// Currently highlighted autocomplete suggestion index.
pub autocomplete_index: usize,
/// Generation counter for stale response guard.
pub generation: u64,
/// Whether a query is in flight.
pub loading: bool,
}
impl TraceState {
/// Initialize defaults for a fresh Trace screen entry.
pub fn enter(&mut self) {
self.path_focused = true;
self.follow_renames = true;
self.include_discussions = true;
}
/// Clean up when leaving the Trace screen.
pub fn leave(&mut self) {
self.path_focused = false;
}
/// Submit the current path input as a trace query.
///
/// Bumps generation, parses the :line suffix, and returns the
/// new generation if the path is non-empty.
pub fn submit(&mut self) -> Option<u64> {
let trimmed = self.path_input.trim();
if trimmed.is_empty() {
return None;
}
let (path, line) = lore::cli::commands::trace::parse_trace_path(trimmed);
self.path_input = path;
self.path_cursor = self.path_input.len();
self.line_filter = line;
self.generation += 1;
self.loading = true;
self.selected_chain_index = 0;
self.expanded_chains.clear();
self.scroll_offset = 0;
self.path_focused = false;
self.autocomplete_matches.clear();
Some(self.generation)
}
/// Apply a trace result, guarded by generation counter.
pub fn apply_result(&mut self, generation: u64, result: TraceResult) {
if generation != self.generation {
return; // Stale response — discard.
}
self.result = Some(result);
self.loading = false;
}
/// Toggle the expand/collapse state of the selected chain.
pub fn toggle_expand(&mut self) {
if self.expanded_chains.contains(&self.selected_chain_index) {
self.expanded_chains.remove(&self.selected_chain_index);
} else {
self.expanded_chains.insert(self.selected_chain_index);
}
}
/// Toggle follow_renames and bump generation (triggers re-fetch).
pub fn toggle_follow_renames(&mut self) -> Option<u64> {
self.follow_renames = !self.follow_renames;
self.requery()
}
/// Toggle include_discussions and bump generation (triggers re-fetch).
pub fn toggle_include_discussions(&mut self) -> Option<u64> {
self.include_discussions = !self.include_discussions;
self.requery()
}
/// Re-query with current settings if path is non-empty.
fn requery(&mut self) -> Option<u64> {
if self.path_input.trim().is_empty() {
return None;
}
self.generation += 1;
self.loading = true;
self.selected_chain_index = 0;
self.expanded_chains.clear();
self.scroll_offset = 0;
Some(self.generation)
}
/// Select the previous chain.
pub fn select_prev(&mut self) {
if self.selected_chain_index > 0 {
self.selected_chain_index -= 1;
self.ensure_visible();
}
}
/// Select the next chain.
pub fn select_next(&mut self) {
let max = self.chain_count().saturating_sub(1);
if self.selected_chain_index < max {
self.selected_chain_index += 1;
self.ensure_visible();
}
}
/// Number of trace chains in the current result.
fn chain_count(&self) -> usize {
self.result.as_ref().map_or(0, |r| r.trace_chains.len())
}
/// Ensure the selected chain is visible within the scroll viewport.
fn ensure_visible(&mut self) {
let idx = self.selected_chain_index as u16;
if idx < self.scroll_offset {
self.scroll_offset = idx;
}
// Rough viewport — exact height adjusted in render.
}
/// Whether the text input has focus.
#[must_use]
pub fn has_text_focus(&self) -> bool {
self.path_focused
}
/// Remove focus from all text inputs.
pub fn blur(&mut self) {
self.path_focused = false;
self.autocomplete_matches.clear();
}
/// Focus the path input.
pub fn focus_input(&mut self) {
self.path_focused = true;
self.update_autocomplete();
}
// --- Text editing helpers ---
/// Insert a character at the cursor position (byte offset).
pub fn insert_char(&mut self, ch: char) {
self.path_input.insert(self.path_cursor, ch);
self.path_cursor += ch.len_utf8();
self.update_autocomplete();
}
/// Delete the character before the cursor (byte offset).
pub fn delete_char_before_cursor(&mut self) {
if self.path_cursor == 0 {
return;
}
let prev = prev_char_boundary(&self.path_input, self.path_cursor);
self.path_input.drain(prev..self.path_cursor);
self.path_cursor = prev;
self.update_autocomplete();
}
/// Move cursor left (byte offset).
pub fn cursor_left(&mut self) {
if self.path_cursor > 0 {
self.path_cursor = prev_char_boundary(&self.path_input, self.path_cursor);
}
}
/// Move cursor right (byte offset).
pub fn cursor_right(&mut self) {
if self.path_cursor < self.path_input.len() {
self.path_cursor = next_char_boundary(&self.path_input, self.path_cursor);
}
}
// --- Autocomplete ---
/// Update autocomplete matches based on current input.
pub fn update_autocomplete(&mut self) {
let input_lower = self.path_input.to_lowercase();
if input_lower.is_empty() {
self.autocomplete_matches.clear();
self.autocomplete_index = 0;
return;
}
self.autocomplete_matches = self
.known_paths
.iter()
.filter(|p| p.to_lowercase().contains(&input_lower))
.take(10) // Limit visible suggestions.
.cloned()
.collect();
self.autocomplete_index = 0;
}
/// Cycle to the next autocomplete suggestion.
pub fn autocomplete_next(&mut self) {
if self.autocomplete_matches.is_empty() {
return;
}
self.autocomplete_index = (self.autocomplete_index + 1) % self.autocomplete_matches.len();
}
/// Accept the current autocomplete suggestion into the path input.
pub fn accept_autocomplete(&mut self) {
if let Some(match_) = self.autocomplete_matches.get(self.autocomplete_index) {
self.path_input = match_.clone();
self.path_cursor = self.path_input.len();
self.autocomplete_matches.clear();
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trace_state_default() {
let state = TraceState::default();
assert!(state.path_input.is_empty());
assert!(!state.path_focused);
assert!(!state.follow_renames); // Default false, enter() sets true.
assert!(state.result.is_none());
assert_eq!(state.generation, 0);
}
#[test]
fn test_trace_state_enter_sets_defaults() {
let mut state = TraceState::default();
state.enter();
assert!(state.path_focused);
assert!(state.follow_renames);
assert!(state.include_discussions);
}
#[test]
fn test_submit_empty_returns_none() {
let mut state = TraceState::default();
assert!(state.submit().is_none());
assert_eq!(state.generation, 0);
}
#[test]
fn test_submit_with_path_bumps_generation() {
let mut state = TraceState {
path_input: "src/main.rs".into(),
..TraceState::default()
};
let generation = state.submit();
assert_eq!(generation, Some(1));
assert_eq!(state.generation, 1);
assert!(state.loading);
assert!(!state.path_focused);
}
#[test]
fn test_submit_parses_line_suffix() {
let mut state = TraceState {
path_input: "src/main.rs:42".into(),
..TraceState::default()
};
state.submit();
assert_eq!(state.path_input, "src/main.rs");
assert_eq!(state.line_filter, Some(42));
}
#[test]
fn test_apply_result_matching_generation() {
let mut state = TraceState {
path_input: "src/lib.rs".into(),
..TraceState::default()
};
state.submit(); // generation = 1
let result = TraceResult {
path: "src/lib.rs".into(),
resolved_paths: vec![],
renames_followed: false,
trace_chains: vec![],
total_chains: 0,
};
state.apply_result(1, result);
assert!(state.result.is_some());
assert!(!state.loading);
}
#[test]
fn test_apply_result_stale_generation_discarded() {
let mut state = TraceState {
path_input: "src/lib.rs".into(),
..TraceState::default()
};
state.submit(); // generation = 1
state.path_input = "src/other.rs".into();
state.submit(); // generation = 2
let stale_result = TraceResult {
path: "src/lib.rs".into(),
resolved_paths: vec![],
renames_followed: false,
trace_chains: vec![],
total_chains: 0,
};
state.apply_result(1, stale_result); // Stale — should be discarded.
assert!(state.result.is_none());
assert!(state.loading); // Still loading.
}
#[test]
fn test_toggle_expand() {
let mut state = TraceState {
selected_chain_index: 2,
..TraceState::default()
};
state.toggle_expand();
assert!(state.expanded_chains.contains(&2));
state.toggle_expand();
assert!(!state.expanded_chains.contains(&2));
}
#[test]
fn test_toggle_follow_renames_requeues() {
let mut state = TraceState {
path_input: "src/main.rs".into(),
path_focused: true,
follow_renames: true,
include_discussions: true,
..TraceState::default()
};
assert!(state.follow_renames);
let generation = state.toggle_follow_renames();
assert!(!state.follow_renames);
assert_eq!(generation, Some(1));
assert!(state.loading);
}
#[test]
fn test_toggle_include_discussions_requeues() {
let mut state = TraceState {
path_input: "src/main.rs".into(),
path_focused: true,
follow_renames: true,
include_discussions: true,
..TraceState::default()
};
assert!(state.include_discussions);
let generation = state.toggle_include_discussions();
assert!(!state.include_discussions);
assert_eq!(generation, Some(1));
}
#[test]
fn test_select_prev_next() {
let mut state = TraceState {
result: Some(TraceResult {
path: "x".into(),
resolved_paths: vec![],
renames_followed: false,
trace_chains: vec![
lore::core::trace::TraceChain {
mr_iid: 1,
mr_title: "a".into(),
mr_state: "merged".into(),
mr_author: "x".into(),
change_type: "modified".into(),
merged_at_iso: None,
updated_at_iso: "2024-01-01".into(),
web_url: None,
issues: vec![],
discussions: vec![],
},
lore::core::trace::TraceChain {
mr_iid: 2,
mr_title: "b".into(),
mr_state: "merged".into(),
mr_author: "y".into(),
change_type: "added".into(),
merged_at_iso: None,
updated_at_iso: "2024-01-02".into(),
web_url: None,
issues: vec![],
discussions: vec![],
},
],
total_chains: 2,
}),
..TraceState::default()
};
assert_eq!(state.selected_chain_index, 0);
state.select_next();
assert_eq!(state.selected_chain_index, 1);
state.select_next(); // Clamped at max.
assert_eq!(state.selected_chain_index, 1);
state.select_prev();
assert_eq!(state.selected_chain_index, 0);
state.select_prev(); // Clamped at 0.
assert_eq!(state.selected_chain_index, 0);
}
#[test]
fn test_insert_char_and_delete() {
let mut state = TraceState::default();
state.insert_char('a');
state.insert_char('b');
state.insert_char('c');
assert_eq!(state.path_input, "abc");
assert_eq!(state.path_cursor, 3);
state.delete_char_before_cursor();
assert_eq!(state.path_input, "ab");
assert_eq!(state.path_cursor, 2);
}
#[test]
fn test_autocomplete_filters() {
let mut state = TraceState {
known_paths: vec!["src/a.rs".into(), "src/b.rs".into(), "lib/c.rs".into()],
path_input: "src/".into(),
..TraceState::default()
};
state.update_autocomplete();
assert_eq!(state.autocomplete_matches.len(), 2);
assert!(state.autocomplete_matches.contains(&"src/a.rs".to_string()));
assert!(state.autocomplete_matches.contains(&"src/b.rs".to_string()));
}
#[test]
fn test_autocomplete_next_cycles() {
let mut state = TraceState {
known_paths: vec!["a.rs".into(), "ab.rs".into()],
path_input: "a".into(),
..TraceState::default()
};
state.update_autocomplete();
assert_eq!(state.autocomplete_matches.len(), 2);
assert_eq!(state.autocomplete_index, 0);
state.autocomplete_next();
assert_eq!(state.autocomplete_index, 1);
state.autocomplete_next();
assert_eq!(state.autocomplete_index, 0); // Wrapped.
}
#[test]
fn test_accept_autocomplete() {
let mut state = TraceState {
known_paths: vec!["src/main.rs".into()],
path_input: "src/".into(),
..TraceState::default()
};
state.update_autocomplete();
assert_eq!(state.autocomplete_matches.len(), 1);
state.accept_autocomplete();
assert_eq!(state.path_input, "src/main.rs");
assert!(state.autocomplete_matches.is_empty());
}
#[test]
fn test_has_text_focus() {
let state = TraceState::default();
assert!(!state.has_text_focus());
let state = TraceState {
path_focused: true,
..TraceState::default()
};
assert!(state.has_text_focus());
}
#[test]
fn test_blur_clears_focus_and_autocomplete() {
let mut state = TraceState {
path_focused: true,
autocomplete_matches: vec!["a".into()],
..TraceState::default()
};
state.blur();
assert!(!state.path_focused);
assert!(state.autocomplete_matches.is_empty());
}
}

View File

@@ -0,0 +1,512 @@
//! Who (people intelligence) screen state.
//!
//! Manages 5 query modes (Expert, Workload, Reviews, Active, Overlap),
//! input fields (path or username depending on mode), and result display.
use lore::core::who_types::WhoResult;
use crate::text_width::{next_char_boundary, prev_char_boundary};
// ---------------------------------------------------------------------------
// WhoMode
// ---------------------------------------------------------------------------
/// The 5 query modes for the who screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WhoMode {
/// File-path expertise scores.
#[default]
Expert,
/// Issue/MR assignment workload for a username.
Workload,
/// Review activity breakdown for a username.
Reviews,
/// Recent unresolved discussions (no input needed).
Active,
/// Shared file knowledge between contributors.
Overlap,
}
impl WhoMode {
/// Short label for mode tab rendering.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Expert => "Expert",
Self::Workload => "Workload",
Self::Reviews => "Reviews",
Self::Active => "Active",
Self::Overlap => "Overlap",
}
}
/// Abbreviated 3-char label for narrow terminals.
#[must_use]
pub fn short_label(self) -> &'static str {
match self {
Self::Expert => "Exp",
Self::Workload => "Wkl",
Self::Reviews => "Rev",
Self::Active => "Act",
Self::Overlap => "Ovl",
}
}
/// Whether this mode requires a path input.
#[must_use]
pub fn needs_path(self) -> bool {
matches!(self, Self::Expert | Self::Overlap)
}
/// Whether this mode requires a username input.
#[must_use]
pub fn needs_username(self) -> bool {
matches!(self, Self::Workload | Self::Reviews)
}
/// Whether include_closed affects this mode's query.
#[must_use]
pub fn affected_by_include_closed(self) -> bool {
matches!(self, Self::Workload | Self::Active)
}
/// Cycle to the next mode (wraps around).
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Expert => Self::Workload,
Self::Workload => Self::Reviews,
Self::Reviews => Self::Active,
Self::Active => Self::Overlap,
Self::Overlap => Self::Expert,
}
}
/// All modes in order.
pub const ALL: [Self; 5] = [
Self::Expert,
Self::Workload,
Self::Reviews,
Self::Active,
Self::Overlap,
];
/// Mode from 1-based number key (1=Expert, 2=Workload, ..., 5=Overlap).
#[must_use]
pub fn from_number(n: u8) -> Option<Self> {
match n {
1 => Some(Self::Expert),
2 => Some(Self::Workload),
3 => Some(Self::Reviews),
4 => Some(Self::Active),
5 => Some(Self::Overlap),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// WhoState
// ---------------------------------------------------------------------------
/// State for the who/people screen.
#[derive(Debug, Default)]
pub struct WhoState {
/// Active query mode.
pub mode: WhoMode,
/// Current result (if any).
pub result: Option<WhoResult>,
// Input fields.
/// Path input text (used by Expert and Overlap modes).
pub path: String,
/// Cursor position within path string (byte offset).
pub path_cursor: usize,
/// Whether the path input has focus.
pub path_focused: bool,
/// Username input text (used by Workload and Reviews modes).
pub username: String,
/// Cursor position within username string (byte offset).
pub username_cursor: usize,
/// Whether the username input has focus.
pub username_focused: bool,
/// Toggle: include closed entities in Workload/Active queries.
pub include_closed: bool,
// Result navigation.
/// Index of the selected row in the result list.
pub selected_index: usize,
/// Vertical scroll offset for the result area.
pub scroll_offset: usize,
// Async coordination.
/// Monotonic generation counter for stale-response detection.
pub generation: u64,
/// Whether a query is in-flight.
pub loading: bool,
}
impl WhoState {
/// Enter the who screen: focus the appropriate input.
pub fn enter(&mut self) {
self.focus_input_for_mode();
}
/// Leave the who screen: blur all inputs.
pub fn leave(&mut self) {
self.path_focused = false;
self.username_focused = false;
}
/// Switch to a different mode. Clears result and resets selection.
/// Returns the new generation for stale detection.
pub fn set_mode(&mut self, mode: WhoMode) -> u64 {
if self.mode == mode {
return self.generation;
}
self.mode = mode;
self.result = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.focus_input_for_mode();
self.bump_generation()
}
/// Toggle include_closed. Returns new generation if the mode is affected.
pub fn toggle_include_closed(&mut self) -> Option<u64> {
self.include_closed = !self.include_closed;
if self.mode.affected_by_include_closed() {
Some(self.bump_generation())
} else {
None
}
}
/// Apply query results if generation matches.
pub fn apply_results(&mut self, generation: u64, result: WhoResult) {
if generation != self.generation {
return; // Stale response — discard.
}
self.result = Some(result);
self.loading = false;
self.selected_index = 0;
self.scroll_offset = 0;
}
/// Submit the current input (trigger a query).
/// Returns the generation for the new query.
pub fn submit(&mut self) -> u64 {
self.loading = true;
self.bump_generation()
}
// --- Input field operations ---
/// Insert a char at cursor in the active input field.
pub fn insert_char(&mut self, c: char) {
if self.path_focused {
self.path.insert(self.path_cursor, c);
self.path_cursor += c.len_utf8();
} else if self.username_focused {
self.username.insert(self.username_cursor, c);
self.username_cursor += c.len_utf8();
}
}
/// Delete the char before cursor in the active input field.
pub fn delete_char_before_cursor(&mut self) {
if self.path_focused && self.path_cursor > 0 {
let prev = prev_char_boundary(&self.path, self.path_cursor);
self.path.drain(prev..self.path_cursor);
self.path_cursor = prev;
} else if self.username_focused && self.username_cursor > 0 {
let prev = prev_char_boundary(&self.username, self.username_cursor);
self.username.drain(prev..self.username_cursor);
self.username_cursor = prev;
}
}
/// Move cursor left in the active input.
pub fn cursor_left(&mut self) {
if self.path_focused && self.path_cursor > 0 {
self.path_cursor = prev_char_boundary(&self.path, self.path_cursor);
} else if self.username_focused && self.username_cursor > 0 {
self.username_cursor = prev_char_boundary(&self.username, self.username_cursor);
}
}
/// Move cursor right in the active input.
pub fn cursor_right(&mut self) {
if self.path_focused && self.path_cursor < self.path.len() {
self.path_cursor = next_char_boundary(&self.path, self.path_cursor);
} else if self.username_focused && self.username_cursor < self.username.len() {
self.username_cursor = next_char_boundary(&self.username, self.username_cursor);
}
}
/// Whether any input field has focus.
#[must_use]
pub fn has_text_focus(&self) -> bool {
self.path_focused || self.username_focused
}
/// Blur all inputs.
pub fn blur(&mut self) {
self.path_focused = false;
self.username_focused = false;
}
/// Focus the appropriate input for the current mode.
pub fn focus_input_for_mode(&mut self) {
self.path_focused = self.mode.needs_path();
self.username_focused = self.mode.needs_username();
// Place cursor at end of text.
if self.path_focused {
self.path_cursor = self.path.len();
}
if self.username_focused {
self.username_cursor = self.username.len();
}
}
// --- Selection navigation ---
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected_index = self.selected_index.saturating_sub(1);
}
/// Move selection down (bounded by result count).
pub fn select_next(&mut self, result_count: usize) {
if result_count > 0 {
self.selected_index = (self.selected_index + 1).min(result_count - 1);
}
}
/// Ensure the selected row is visible within the viewport.
pub fn ensure_visible(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + viewport_height {
self.scroll_offset = self.selected_index - viewport_height + 1;
}
}
// --- Internal ---
fn bump_generation(&mut self) -> u64 {
self.generation += 1;
self.generation
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_who_mode_defaults_to_expert() {
assert_eq!(WhoMode::default(), WhoMode::Expert);
}
#[test]
fn test_who_mode_labels() {
assert_eq!(WhoMode::Expert.label(), "Expert");
assert_eq!(WhoMode::Active.label(), "Active");
assert_eq!(WhoMode::Overlap.label(), "Overlap");
}
#[test]
fn test_who_mode_needs_path() {
assert!(WhoMode::Expert.needs_path());
assert!(WhoMode::Overlap.needs_path());
assert!(!WhoMode::Workload.needs_path());
assert!(!WhoMode::Reviews.needs_path());
assert!(!WhoMode::Active.needs_path());
}
#[test]
fn test_who_mode_needs_username() {
assert!(WhoMode::Workload.needs_username());
assert!(WhoMode::Reviews.needs_username());
assert!(!WhoMode::Expert.needs_username());
assert!(!WhoMode::Active.needs_username());
}
#[test]
fn test_who_mode_next_cycles() {
let start = WhoMode::Expert;
let m = start.next().next().next().next().next();
assert_eq!(m, start);
}
#[test]
fn test_who_mode_from_number() {
assert_eq!(WhoMode::from_number(1), Some(WhoMode::Expert));
assert_eq!(WhoMode::from_number(5), Some(WhoMode::Overlap));
assert_eq!(WhoMode::from_number(0), None);
assert_eq!(WhoMode::from_number(6), None);
}
#[test]
fn test_who_state_default() {
let state = WhoState::default();
assert_eq!(state.mode, WhoMode::Expert);
assert!(state.result.is_none());
assert!(!state.include_closed);
assert_eq!(state.generation, 0);
}
#[test]
fn test_set_mode_bumps_generation() {
let mut state = WhoState::default();
let generation = state.set_mode(WhoMode::Workload);
assert_eq!(generation, 1);
assert_eq!(state.mode, WhoMode::Workload);
assert!(state.result.is_none());
assert!(state.username_focused);
assert!(!state.path_focused);
}
#[test]
fn test_set_mode_same_does_not_bump() {
let mut state = WhoState::default();
let generation = state.set_mode(WhoMode::Expert);
assert_eq!(generation, 0); // No bump for same mode.
}
#[test]
fn test_toggle_include_closed_returns_gen_for_affected_modes() {
let state = &mut WhoState {
mode: WhoMode::Workload,
..WhoState::default()
};
let generation = state.toggle_include_closed();
assert!(generation.is_some());
assert!(state.include_closed);
}
#[test]
fn test_toggle_include_closed_returns_none_for_unaffected_modes() {
let state = &mut WhoState {
mode: WhoMode::Expert,
..WhoState::default()
};
let generation = state.toggle_include_closed();
assert!(generation.is_none());
assert!(state.include_closed);
}
#[test]
fn test_stale_response_guard() {
let mut state = WhoState::default();
let stale_gen = state.submit();
// Bump generation again (simulating user changed mode).
let _new_gen = state.set_mode(WhoMode::Active);
// Old response arrives — should be discarded.
state.apply_results(
stale_gen,
WhoResult::Active(lore::core::who_types::ActiveResult {
discussions: vec![],
total_unresolved_in_window: 0,
truncated: false,
}),
);
assert!(state.result.is_none()); // Stale, discarded.
}
#[test]
fn test_insert_and_delete_char() {
let mut state = WhoState {
path_focused: true,
..WhoState::default()
};
state.insert_char('s');
state.insert_char('r');
state.insert_char('c');
assert_eq!(state.path, "src");
assert_eq!(state.path_cursor, 3);
state.delete_char_before_cursor();
assert_eq!(state.path, "sr");
assert_eq!(state.path_cursor, 2);
}
#[test]
fn test_cursor_movement() {
let mut state = WhoState {
username_focused: true,
username: "alice".into(),
username_cursor: 5,
..WhoState::default()
};
state.cursor_left();
assert_eq!(state.username_cursor, 4);
state.cursor_right();
assert_eq!(state.username_cursor, 5);
// Right at end is clamped.
state.cursor_right();
assert_eq!(state.username_cursor, 5);
}
#[test]
fn test_select_prev_next() {
let mut state = WhoState::default();
state.select_next(5);
assert_eq!(state.selected_index, 1);
state.select_next(5);
assert_eq!(state.selected_index, 2);
state.select_prev();
assert_eq!(state.selected_index, 1);
state.select_prev();
assert_eq!(state.selected_index, 0);
state.select_prev(); // Should not underflow.
assert_eq!(state.selected_index, 0);
}
#[test]
fn test_ensure_visible() {
let mut state = WhoState {
selected_index: 15,
..WhoState::default()
};
state.ensure_visible(5);
assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1
}
#[test]
fn test_enter_focuses_correct_input() {
let mut state = WhoState {
mode: WhoMode::Expert,
..WhoState::default()
};
state.enter();
assert!(state.path_focused);
assert!(!state.username_focused);
state.mode = WhoMode::Reviews;
state.enter();
assert!(!state.path_focused);
// Reviews needs username.
// focus_input_for_mode is called in enter().
}
#[test]
fn test_affected_by_include_closed() {
assert!(WhoMode::Workload.affected_by_include_closed());
assert!(WhoMode::Active.affected_by_include_closed());
assert!(!WhoMode::Expert.affected_by_include_closed());
assert!(!WhoMode::Reviews.affected_by_include_closed());
assert!(!WhoMode::Overlap.affected_by_include_closed());
}
}

View File

@@ -0,0 +1,388 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Centralized background task management with dedup and cancellation.
//!
//! All background work (DB queries, sync, search) flows through
//! [`TaskSupervisor`]. Submitting a task with a key that already has an
//! active handle cancels the previous task via its [`CancelToken`] and
//! bumps the generation counter.
//!
//! Generation IDs enable stale-result detection: when an async result
//! arrives, [`is_current`] checks whether the result's generation
//! matches the latest submission for that key.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::message::Screen;
// ---------------------------------------------------------------------------
// TaskKey
// ---------------------------------------------------------------------------
/// Deduplication key for background tasks.
///
/// Two tasks with the same key cannot run concurrently — submitting a
/// new task with an existing key cancels the previous one.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TaskKey {
/// Load data for a specific screen.
LoadScreen(Screen),
/// Global search query.
Search,
/// Sync stream (only one at a time).
SyncStream,
/// Re-query after filter change on a specific screen.
FilterRequery(Screen),
}
// ---------------------------------------------------------------------------
// TaskPriority
// ---------------------------------------------------------------------------
/// Priority levels for task scheduling.
///
/// Lower numeric value = higher priority.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskPriority {
/// User-initiated input (highest priority).
Input = 0,
/// Navigation-triggered data load.
Navigation = 1,
/// Background refresh / prefetch (lowest priority).
Background = 2,
}
// ---------------------------------------------------------------------------
// CancelToken
// ---------------------------------------------------------------------------
/// Thread-safe cooperative cancellation flag.
///
/// Background tasks poll [`is_cancelled`] periodically and exit early
/// when it returns `true`.
#[derive(Debug)]
pub struct CancelToken {
cancelled: AtomicBool,
}
impl CancelToken {
/// Create a new, non-cancelled token.
#[must_use]
pub fn new() -> Self {
Self {
cancelled: AtomicBool::new(false),
}
}
/// Signal cancellation.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
/// Check whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
}
impl Default for CancelToken {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// InterruptHandle
// ---------------------------------------------------------------------------
/// Opaque handle for interrupting a rusqlite operation.
///
/// Wraps the rusqlite `InterruptHandle` so the supervisor can cancel
/// long-running queries. This is only set for tasks that lease a reader
/// connection from [`DbManager`](crate::db::DbManager).
pub struct InterruptHandle {
handle: rusqlite::InterruptHandle,
}
impl std::fmt::Debug for InterruptHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InterruptHandle").finish_non_exhaustive()
}
}
impl InterruptHandle {
/// Wrap a rusqlite interrupt handle.
#[must_use]
pub fn new(handle: rusqlite::InterruptHandle) -> Self {
Self { handle }
}
/// Interrupt the associated SQLite operation.
pub fn interrupt(&self) {
self.handle.interrupt();
}
}
// ---------------------------------------------------------------------------
// TaskHandle
// ---------------------------------------------------------------------------
/// Handle returned when a task is submitted.
///
/// Callers use this to pass the generation ID into async work so
/// results can be tagged and checked for staleness.
#[derive(Debug)]
pub struct TaskHandle {
/// Dedup key for this task.
pub key: TaskKey,
/// Monotonically increasing generation for stale detection.
pub generation: u64,
/// Cooperative cancellation token (shared with the supervisor).
pub cancel: Arc<CancelToken>,
/// Optional SQLite interrupt handle for long queries.
pub interrupt: Option<InterruptHandle>,
}
// ---------------------------------------------------------------------------
// TaskSupervisor
// ---------------------------------------------------------------------------
/// Manages background tasks with deduplication and cancellation.
///
/// Only one task per [`TaskKey`] can be active. Submitting a new task
/// with an existing key cancels the previous one (via its cancel token
/// and optional interrupt handle) before registering the new handle.
pub struct TaskSupervisor {
active: HashMap<TaskKey, TaskHandle>,
next_generation: AtomicU64,
}
impl TaskSupervisor {
/// Create a new supervisor with no active tasks.
#[must_use]
pub fn new() -> Self {
Self {
active: HashMap::new(),
next_generation: AtomicU64::new(1),
}
}
/// Submit a new task, cancelling any existing task with the same key.
///
/// Returns a [`TaskHandle`] with a fresh generation ID and a shared
/// cancel token. The caller clones the `Arc<CancelToken>` and passes
/// it into the async work.
pub fn submit(&mut self, key: TaskKey) -> &TaskHandle {
// Cancel existing task with this key, if any.
if let Some(old) = self.active.remove(&key) {
old.cancel.cancel();
if let Some(interrupt) = &old.interrupt {
interrupt.interrupt();
}
}
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let cancel = Arc::new(CancelToken::new());
let handle = TaskHandle {
key: key.clone(),
generation,
cancel,
interrupt: None,
};
self.active.insert(key.clone(), handle);
self.active.get(&key).expect("just inserted")
}
/// Check whether a generation is current for a given key.
///
/// Returns `true` only if the key has an active handle with the
/// specified generation.
#[must_use]
pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool {
self.active
.get(key)
.is_some_and(|h| h.generation == generation)
}
/// Mark a task as complete, removing its handle.
///
/// Only removes the handle if the generation matches the active one.
/// This prevents a late-arriving completion from removing a newer
/// task's handle.
pub fn complete(&mut self, key: &TaskKey, generation: u64) {
if self.is_current(key, generation) {
self.active.remove(key);
}
}
/// Cancel all active tasks.
///
/// Used during shutdown to ensure background work stops promptly.
pub fn cancel_all(&mut self) {
for (_, handle) in self.active.drain() {
handle.cancel.cancel();
if let Some(interrupt) = &handle.interrupt {
interrupt.interrupt();
}
}
}
/// Get the cancel token for an active task, if any.
///
/// Used in tests to verify cooperative cancellation behavior.
#[must_use]
pub fn active_cancel_token(&self, key: &TaskKey) -> Option<Arc<CancelToken>> {
self.active.get(key).map(|h| h.cancel.clone())
}
/// Number of currently active tasks.
#[must_use]
pub fn active_count(&self) -> usize {
self.active.len()
}
}
impl Default for TaskSupervisor {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_submit_cancels_previous() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let cancel1 = sup.active.get(&TaskKey::Search).unwrap().cancel.clone();
let gen2 = sup.submit(TaskKey::Search).generation;
// First task's token should be cancelled.
assert!(cancel1.is_cancelled());
// Second task should have a different (higher) generation.
assert!(gen2 > gen1);
// Only one active task for this key.
assert_eq!(sup.active_count(), 1);
}
#[test]
fn test_is_current_after_supersede() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let gen2 = sup.submit(TaskKey::Search).generation;
assert!(!sup.is_current(&TaskKey::Search, gen1));
assert!(sup.is_current(&TaskKey::Search, gen2));
}
#[test]
fn test_complete_removes_handle() {
let mut sup = TaskSupervisor::new();
let generation = sup.submit(TaskKey::Search).generation;
assert_eq!(sup.active_count(), 1);
sup.complete(&TaskKey::Search, generation);
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_complete_ignores_stale() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let gen2 = sup.submit(TaskKey::Search).generation;
// Completing with old generation should NOT remove the newer handle.
sup.complete(&TaskKey::Search, gen1);
assert_eq!(sup.active_count(), 1);
assert!(sup.is_current(&TaskKey::Search, gen2));
}
#[test]
fn test_generation_monotonic() {
let mut sup = TaskSupervisor::new();
let g1 = sup.submit(TaskKey::Search).generation;
let g2 = sup.submit(TaskKey::SyncStream).generation;
let g3 = sup.submit(TaskKey::Search).generation;
assert!(g1 < g2);
assert!(g2 < g3);
}
#[test]
fn test_different_keys_coexist() {
let mut sup = TaskSupervisor::new();
sup.submit(TaskKey::Search);
sup.submit(TaskKey::SyncStream);
sup.submit(TaskKey::LoadScreen(Screen::Dashboard));
assert_eq!(sup.active_count(), 3);
}
#[test]
fn test_cancel_all() {
let mut sup = TaskSupervisor::new();
let cancel_search = {
sup.submit(TaskKey::Search);
sup.active.get(&TaskKey::Search).unwrap().cancel.clone()
};
let cancel_sync = {
sup.submit(TaskKey::SyncStream);
sup.active.get(&TaskKey::SyncStream).unwrap().cancel.clone()
};
sup.cancel_all();
assert!(cancel_search.is_cancelled());
assert!(cancel_sync.is_cancelled());
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_cancel_token_default_is_not_cancelled() {
let token = CancelToken::new();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled());
}
#[test]
fn test_cancel_token_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CancelToken>();
assert_send_sync::<Arc<CancelToken>>();
}
#[test]
fn test_task_supervisor_default() {
let sup = TaskSupervisor::default();
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_filter_requery_key_distinct_per_screen() {
let mut sup = TaskSupervisor::new();
sup.submit(TaskKey::FilterRequery(Screen::IssueList));
sup.submit(TaskKey::FilterRequery(Screen::MrList));
assert_eq!(sup.active_count(), 2);
}
}

View File

@@ -0,0 +1,300 @@
//! Unicode-aware text width measurement and truncation.
//!
//! Terminal cells aren't 1:1 with bytes or even chars. CJK characters
//! occupy 2 cells, emoji ZWJ sequences are single grapheme clusters,
//! and combining marks have zero width. This module provides correct
//! measurement and truncation that never splits a grapheme cluster.
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// Measure the display width of a string in terminal cells.
///
/// - ASCII characters: 1 cell each
/// - CJK characters: 2 cells each
/// - Emoji: varies (ZWJ sequences treated as grapheme clusters)
/// - Combining marks: 0 cells
#[must_use]
pub fn measure_display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
/// Truncate a string to fit within `max_width` terminal cells.
///
/// Appends an ellipsis character if truncation occurs. Never splits
/// a grapheme cluster — if appending the next cluster would exceed
/// the limit, it stops before that cluster.
///
/// The ellipsis itself occupies 1 cell of the budget.
#[must_use]
pub fn truncate_display_width(s: &str, max_width: usize) -> String {
let full_width = measure_display_width(s);
if full_width <= max_width {
return s.to_string();
}
if max_width == 0 {
return String::new();
}
// Reserve 1 cell for the ellipsis.
let budget = max_width.saturating_sub(1);
let mut result = String::new();
let mut used = 0;
for grapheme in s.graphemes(true) {
let gw = UnicodeWidthStr::width(grapheme);
if used + gw > budget {
break;
}
result.push_str(grapheme);
used += gw;
}
result.push('\u{2026}'); // ellipsis
result
}
/// Pad a string with trailing spaces to reach `width` terminal cells.
///
/// If the string is already wider than `width`, returns it unchanged.
#[must_use]
pub fn pad_display_width(s: &str, width: usize) -> String {
let current = measure_display_width(s);
if current >= width {
return s.to_string();
}
let padding = width - current;
let mut result = s.to_string();
for _ in 0..padding {
result.push(' ');
}
result
}
// ---------------------------------------------------------------------------
// Cursor / char-boundary helpers
// ---------------------------------------------------------------------------
/// Find the byte offset of the previous char boundary before `pos`.
///
/// Walks backwards from `pos - 1` until a valid char boundary is found.
/// Returns 0 if `pos` is 0 or 1.
pub(crate) fn prev_char_boundary(s: &str, pos: usize) -> usize {
let mut i = pos.saturating_sub(1);
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Find the byte offset of the next char boundary after `pos`.
///
/// Walks forward from `pos + 1` until a valid char boundary is found.
/// Returns `s.len()` if already at or past the end.
pub(crate) fn next_char_boundary(s: &str, pos: usize) -> usize {
let mut i = pos + 1;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Convert a byte-offset cursor position to a display-column offset.
///
/// Snaps to the nearest char boundary at or before `cursor`, then counts
/// the number of characters from the start of the string to that point.
/// This gives the correct terminal column offset for cursor rendering.
pub(crate) fn cursor_cell_offset(text: &str, cursor: usize) -> u16 {
let mut idx = cursor.min(text.len());
while idx > 0 && !text.is_char_boundary(idx) {
idx -= 1;
}
text[..idx].chars().count().min(u16::MAX as usize) as u16
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- measure_display_width ---
#[test]
fn test_measure_ascii() {
assert_eq!(measure_display_width("Hello"), 5);
}
#[test]
fn test_measure_empty() {
assert_eq!(measure_display_width(""), 0);
}
#[test]
fn test_measure_cjk_width() {
// TDD anchor from the bead spec
assert_eq!(measure_display_width("Hello"), 5);
assert_eq!(measure_display_width("\u{65E5}\u{672C}\u{8A9E}"), 6); // 日本語 = 3 chars * 2 cells
}
#[test]
fn test_measure_mixed_ascii_cjk() {
// "Hi日本" = 2 + 2 + 2 = 6
assert_eq!(measure_display_width("Hi\u{65E5}\u{672C}"), 6);
}
#[test]
fn test_measure_combining_marks() {
// e + combining acute accent = 1 cell (combining mark is 0-width)
assert_eq!(measure_display_width("e\u{0301}"), 1);
}
// --- truncate_display_width ---
#[test]
fn test_truncate_no_truncation_needed() {
assert_eq!(truncate_display_width("Hello", 10), "Hello");
}
#[test]
fn test_truncate_exact_fit() {
assert_eq!(truncate_display_width("Hello", 5), "Hello");
}
#[test]
fn test_truncate_ascii() {
// "Hello World" is 11 cells. Truncate to 8: budget=7 for text + 1 for ellipsis
let result = truncate_display_width("Hello World", 8);
assert_eq!(measure_display_width(&result), 8); // 7 chars + ellipsis
assert!(result.ends_with('\u{2026}'));
}
#[test]
fn test_truncate_cjk_no_split() {
// 日本語テスト = 6 chars * 2 cells = 12 cells
// Truncate to 5: budget=4 for text + 1 for ellipsis
// Can fit 2 CJK chars (4 cells), then ellipsis
let result = truncate_display_width("\u{65E5}\u{672C}\u{8A9E}\u{30C6}\u{30B9}\u{30C8}", 5);
assert!(result.ends_with('\u{2026}'));
assert!(measure_display_width(&result) <= 5);
}
#[test]
fn test_truncate_zero_width() {
assert_eq!(truncate_display_width("Hello", 0), "");
}
#[test]
fn test_truncate_width_one() {
// Only room for the ellipsis itself
let result = truncate_display_width("Hello", 1);
assert_eq!(result, "\u{2026}");
}
#[test]
fn test_truncate_emoji() {
// Family emoji (ZWJ sequence) — should not be split
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; // 👨‍👩‍👧
let result = truncate_display_width(&format!("{family}Hello"), 3);
// The emoji grapheme cluster is > 1 cell; if it doesn't fit in budget,
// it should be skipped entirely, leaving just the ellipsis or less.
assert!(measure_display_width(&result) <= 3);
}
// --- pad_display_width ---
#[test]
fn test_pad_basic() {
let result = pad_display_width("Hi", 5);
assert_eq!(result, "Hi ");
assert_eq!(measure_display_width(&result), 5);
}
#[test]
fn test_pad_already_wide_enough() {
assert_eq!(pad_display_width("Hello", 3), "Hello");
}
#[test]
fn test_pad_exact_width() {
assert_eq!(pad_display_width("Hello", 5), "Hello");
}
#[test]
fn test_pad_cjk() {
// 日本 = 4 cells, pad to 6 = 2 spaces
let result = pad_display_width("\u{65E5}\u{672C}", 6);
assert_eq!(measure_display_width(&result), 6);
assert!(result.ends_with(" "));
}
// --- prev_char_boundary / next_char_boundary ---
#[test]
fn test_prev_char_boundary_ascii() {
assert_eq!(prev_char_boundary("hello", 3), 2);
assert_eq!(prev_char_boundary("hello", 1), 0);
}
#[test]
fn test_prev_char_boundary_at_zero() {
assert_eq!(prev_char_boundary("hello", 0), 0);
}
#[test]
fn test_prev_char_boundary_multibyte() {
// "aé" = 'a' (1 byte) + 'é' (2 bytes) = 3 bytes total
let s = "a\u{00E9}b";
// Position 3 = start of 'b', prev boundary = 1 (start of 'é')
assert_eq!(prev_char_boundary(s, 3), 1);
// Position 2 = mid-'é' byte, should snap to 1
assert_eq!(prev_char_boundary(s, 2), 1);
}
#[test]
fn test_next_char_boundary_ascii() {
assert_eq!(next_char_boundary("hello", 0), 1);
assert_eq!(next_char_boundary("hello", 3), 4);
}
#[test]
fn test_next_char_boundary_multibyte() {
// "aé" = 'a' (1 byte) + 'é' (2 bytes)
let s = "a\u{00E9}b";
// Position 1 = start of 'é', next boundary = 3 (start of 'b')
assert_eq!(next_char_boundary(s, 1), 3);
}
#[test]
fn test_next_char_boundary_at_end() {
assert_eq!(next_char_boundary("hi", 2), 3);
}
// --- cursor_cell_offset ---
#[test]
fn test_cursor_cell_offset_ascii() {
assert_eq!(cursor_cell_offset("hello", 0), 0);
assert_eq!(cursor_cell_offset("hello", 3), 3);
assert_eq!(cursor_cell_offset("hello", 5), 5);
}
#[test]
fn test_cursor_cell_offset_multibyte() {
// "aéb" = byte offsets: a=0, é=1..3, b=3
let s = "a\u{00E9}b";
assert_eq!(cursor_cell_offset(s, 0), 0); // before 'a'
assert_eq!(cursor_cell_offset(s, 1), 1); // after 'a', before 'é'
assert_eq!(cursor_cell_offset(s, 2), 1); // mid-'é', snaps back to 1
assert_eq!(cursor_cell_offset(s, 3), 2); // after 'é', before 'b'
assert_eq!(cursor_cell_offset(s, 4), 3); // after 'b'
}
#[test]
fn test_cursor_cell_offset_beyond_end() {
assert_eq!(cursor_cell_offset("hi", 99), 2);
}
}

View File

@@ -0,0 +1,251 @@
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
//! Flexoki-based theme for the lore TUI.
//!
//! Uses FrankenTUI's `AdaptiveColor::adaptive(light, dark)` for automatic
//! light/dark mode switching. The palette is [Flexoki](https://stephango.com/flexoki)
//! by Steph Ango, designed in Oklab perceptual color space for balanced contrast.
use ftui::{AdaptiveColor, Color, PackedRgba, Style, Theme};
// ---------------------------------------------------------------------------
// Flexoki palette constants
// ---------------------------------------------------------------------------
// Base tones
const PAPER: Color = Color::rgb(0xFF, 0xFC, 0xF0);
const BASE_50: Color = Color::rgb(0xF2, 0xF0, 0xE5);
const BASE_100: Color = Color::rgb(0xE6, 0xE4, 0xD9);
const BASE_200: Color = Color::rgb(0xCE, 0xCD, 0xC3);
const BASE_300: Color = Color::rgb(0xB7, 0xB5, 0xAC);
const BASE_400: Color = Color::rgb(0x9F, 0x9D, 0x96);
const BASE_500: Color = Color::rgb(0x87, 0x85, 0x80);
const BASE_600: Color = Color::rgb(0x6F, 0x6E, 0x69);
const BASE_700: Color = Color::rgb(0x57, 0x56, 0x53);
const BASE_800: Color = Color::rgb(0x40, 0x3E, 0x3C);
const BASE_850: Color = Color::rgb(0x34, 0x33, 0x31);
const BASE_900: Color = Color::rgb(0x28, 0x27, 0x26);
const BLACK: Color = Color::rgb(0x10, 0x0F, 0x0F);
// Accent colors — light-600 (for light mode)
const RED_600: Color = Color::rgb(0xAF, 0x30, 0x29);
const ORANGE_600: Color = Color::rgb(0xBC, 0x52, 0x15);
const YELLOW_600: Color = Color::rgb(0xAD, 0x83, 0x01);
const GREEN_600: Color = Color::rgb(0x66, 0x80, 0x0B);
const CYAN_600: Color = Color::rgb(0x24, 0x83, 0x7B);
const BLUE_600: Color = Color::rgb(0x20, 0x5E, 0xA6);
const PURPLE_600: Color = Color::rgb(0x5E, 0x40, 0x9D);
// Accent colors — dark-400 (for dark mode)
const RED_400: Color = Color::rgb(0xD1, 0x4D, 0x41);
const ORANGE_400: Color = Color::rgb(0xDA, 0x70, 0x2C);
const YELLOW_400: Color = Color::rgb(0xD0, 0xA2, 0x15);
const GREEN_400: Color = Color::rgb(0x87, 0x9A, 0x39);
const CYAN_400: Color = Color::rgb(0x3A, 0xA9, 0x9F);
const BLUE_400: Color = Color::rgb(0x43, 0x85, 0xBE);
const PURPLE_400: Color = Color::rgb(0x8B, 0x7E, 0xC8);
const MAGENTA_400: Color = Color::rgb(0xCE, 0x5D, 0x97);
// Muted fallback as PackedRgba (for Style::fg)
const MUTED_PACKED: PackedRgba = PackedRgba::rgb(0x87, 0x85, 0x80);
// ---------------------------------------------------------------------------
// build_theme
// ---------------------------------------------------------------------------
/// Build the lore TUI theme with Flexoki adaptive colors.
///
/// Each of the 19 semantic slots gets an `AdaptiveColor::adaptive(light, dark)`
/// pair. FrankenTUI detects the terminal background and resolves accordingly.
#[must_use]
pub fn build_theme() -> Theme {
Theme::builder()
.primary(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
.secondary(AdaptiveColor::adaptive(CYAN_600, CYAN_400))
.accent(AdaptiveColor::adaptive(PURPLE_600, PURPLE_400))
.background(AdaptiveColor::adaptive(PAPER, BLACK))
.surface(AdaptiveColor::adaptive(BASE_50, BASE_900))
.overlay(AdaptiveColor::adaptive(BASE_100, BASE_850))
.text(AdaptiveColor::adaptive(BASE_700, BASE_200))
.text_muted(AdaptiveColor::adaptive(BASE_500, BASE_500))
.text_subtle(AdaptiveColor::adaptive(BASE_400, BASE_600))
.success(AdaptiveColor::adaptive(GREEN_600, GREEN_400))
.warning(AdaptiveColor::adaptive(YELLOW_600, YELLOW_400))
.error(AdaptiveColor::adaptive(RED_600, RED_400))
.info(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
.border(AdaptiveColor::adaptive(BASE_300, BASE_700))
.border_focused(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
.selection_bg(AdaptiveColor::adaptive(BASE_100, BASE_800))
.selection_fg(AdaptiveColor::adaptive(BASE_700, BASE_100))
.scrollbar_track(AdaptiveColor::adaptive(BASE_50, BASE_900))
.scrollbar_thumb(AdaptiveColor::adaptive(BASE_300, BASE_700))
.build()
}
// ---------------------------------------------------------------------------
// State colors
// ---------------------------------------------------------------------------
/// Map a GitLab entity state to a display color.
///
/// Returns fixed (non-adaptive) colors — state indicators should be
/// consistent regardless of light/dark mode.
#[must_use]
pub fn state_color(state: &str) -> Color {
match state {
"opened" => GREEN_400,
"closed" => RED_400,
"merged" => PURPLE_400,
"locked" => YELLOW_400,
_ => BASE_500,
}
}
// ---------------------------------------------------------------------------
// Event type colors
// ---------------------------------------------------------------------------
/// Map a timeline event type to a display color.
#[must_use]
pub fn event_color(event_type: &str) -> Color {
match event_type {
"created" => GREEN_400,
"updated" => BLUE_400,
"closed" => RED_400,
"merged" => PURPLE_400,
"commented" => CYAN_400,
"labeled" => ORANGE_400,
"milestoned" => YELLOW_400,
_ => BASE_500,
}
}
// ---------------------------------------------------------------------------
// Label styling
// ---------------------------------------------------------------------------
/// Convert a GitLab label hex color (e.g., "#FF0000" or "FF0000") to a Style.
///
/// Falls back to muted text color if the hex string is invalid.
#[must_use]
pub fn label_style(hex_color: &str) -> Style {
let packed = parse_hex_to_packed(hex_color).unwrap_or(MUTED_PACKED);
Style::default().fg(packed)
}
/// Parse a hex color string like "#RRGGBB" or "RRGGBB" into a `PackedRgba`.
fn parse_hex_to_packed(s: &str) -> Option<PackedRgba> {
let hex = s.strip_prefix('#').unwrap_or(s);
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(PackedRgba::rgb(r, g, b))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_theme_compiles() {
let theme = build_theme();
// Resolve for dark mode — primary should be Blue-400
let resolved = theme.resolve(true);
assert_eq!(resolved.primary, BLUE_400);
}
#[test]
fn test_build_theme_light_mode() {
let theme = build_theme();
let resolved = theme.resolve(false);
assert_eq!(resolved.primary, BLUE_600);
}
#[test]
fn test_build_theme_all_slots_differ_between_modes() {
let theme = build_theme();
let dark = theme.resolve(true);
let light = theme.resolve(false);
// Background should differ (Paper vs Black)
assert_ne!(dark.background, light.background);
// Text should differ
assert_ne!(dark.text, light.text);
}
#[test]
fn test_state_color_opened_is_green() {
assert_eq!(state_color("opened"), GREEN_400);
}
#[test]
fn test_state_color_closed_is_red() {
assert_eq!(state_color("closed"), RED_400);
}
#[test]
fn test_state_color_merged_is_purple() {
assert_eq!(state_color("merged"), PURPLE_400);
}
#[test]
fn test_state_color_unknown_returns_muted() {
assert_eq!(state_color("unknown"), BASE_500);
}
#[test]
fn test_event_color_created_is_green() {
assert_eq!(event_color("created"), GREEN_400);
}
#[test]
fn test_event_color_unknown_returns_muted() {
assert_eq!(event_color("whatever"), BASE_500);
}
#[test]
fn test_label_style_valid_hex_with_hash() {
let style = label_style("#FF0000");
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
}
#[test]
fn test_label_style_valid_hex_without_hash() {
let style = label_style("00FF00");
assert_eq!(style.fg, Some(PackedRgba::rgb(0x00, 0xFF, 0x00)));
}
#[test]
fn test_label_style_lowercase_hex() {
let style = label_style("#ff0000");
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
}
#[test]
fn test_label_style_invalid_hex_fallback() {
let style = label_style("invalid");
assert_eq!(style.fg, Some(MUTED_PACKED));
}
#[test]
fn test_label_style_empty_fallback() {
let style = label_style("");
assert_eq!(style.fg, Some(MUTED_PACKED));
}
#[test]
fn test_parse_hex_short_string() {
assert!(parse_hex_to_packed("#FFF").is_none());
}
#[test]
fn test_parse_hex_non_hex_chars() {
assert!(parse_hex_to_packed("#GGHHII").is_none());
}
}

View File

@@ -0,0 +1,134 @@
#![allow(dead_code)] // Phase 2.5: consumed by render_screen dispatch
//! Bootstrap screen view.
//!
//! Shown when the database has no entity data. Guides users to run
//! a sync to populate the database.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::bootstrap::BootstrapState;
// Colors (Flexoki palette).
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
/// Render the bootstrap screen.
///
/// Centers a message in the content area, guiding the user to start a sync.
/// When a sync is in progress, shows a "syncing" message instead.
pub fn render_bootstrap(frame: &mut Frame<'_>, state: &BootstrapState, area: Rect) {
if area.width < 10 || area.height < 5 {
return;
}
let center_y = area.y + area.height / 2;
let max_x = area.x.saturating_add(area.width);
// Title.
let title = "No data found";
let title_x = area.x + area.width.saturating_sub(title.len() as u16) / 2;
frame.print_text_clipped(
title_x,
center_y.saturating_sub(2),
title,
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
if state.sync_started {
// Sync in progress.
let msg = "Syncing data from GitLab...";
let msg_x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
frame.print_text_clipped(
msg_x,
center_y,
msg,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
} else {
// Prompt user to start sync.
let msg = "Run sync to get started.";
let msg_x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
frame.print_text_clipped(
msg_x,
center_y,
msg,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
let hint = "Press 'g' then 's' to start sync, or 'q' to quit.";
let hint_x = area.x + area.width.saturating_sub(hint.len() as u16) / 2;
frame.print_text_clipped(
hint_x,
center_y + 2,
hint,
Cell {
fg: MUTED,
..Cell::default()
},
max_x,
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
#[test]
fn test_render_bootstrap_no_panic() {
with_frame!(80, 24, |frame| {
let state = BootstrapState::default();
render_bootstrap(&mut frame, &state, Rect::new(0, 1, 80, 22));
});
}
#[test]
fn test_render_bootstrap_sync_started() {
with_frame!(80, 24, |frame| {
let state = BootstrapState {
sync_started: true,
..Default::default()
};
render_bootstrap(&mut frame, &state, Rect::new(0, 1, 80, 22));
});
}
#[test]
fn test_render_bootstrap_tiny_area_noop() {
with_frame!(8, 3, |frame| {
let state = BootstrapState::default();
render_bootstrap(&mut frame, &state, Rect::new(0, 0, 8, 3));
// Should not panic — early return for tiny areas.
});
}
}

View File

@@ -0,0 +1,382 @@
//! Command palette overlay — modal fuzzy-match command picker.
//!
//! Renders a centered modal with a query input at the top and a scrollable
//! list of matching commands below. Keybinding hints are right-aligned.
use ftui::core::geometry::Rect;
use ftui::render::cell::Cell;
use ftui::render::drawing::{BorderChars, Draw};
use ftui::render::frame::Frame;
use crate::state::command_palette::CommandPaletteState;
use crate::text_width::cursor_cell_offset;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
fn text_cell_width(text: &str) -> u16 {
text.chars().count().min(u16::MAX as usize) as u16
}
// ---------------------------------------------------------------------------
// render_command_palette
// ---------------------------------------------------------------------------
/// Render the command palette overlay centered on the screen.
///
/// Only renders if `state.is_open()`. The modal is 60% width, 50% height,
/// capped at 60x20.
pub fn render_command_palette(frame: &mut Frame<'_>, state: &CommandPaletteState, area: Rect) {
if !state.is_open() {
return;
}
if area.height < 5 || area.width < 20 {
return;
}
// Modal dimensions: 60% of screen, capped.
let modal_width = (area.width * 3 / 5).clamp(30, 60);
let modal_height = (area.height / 2).clamp(6, 20);
let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_rect = Rect::new(modal_x, modal_y, modal_width, modal_height);
// Clear background.
let bg_cell = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
for y in modal_rect.y..modal_rect.bottom() {
for x in modal_rect.x..modal_rect.right() {
frame.buffer.set(x, y, bg_cell);
}
}
// Border.
let border_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
frame.draw_border(modal_rect, BorderChars::ROUNDED, border_cell);
// Title.
let title = " Command Palette ";
let title_x = modal_x + (modal_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(title_x, modal_y, title, title_cell, modal_rect.right());
// Inner content area (inside border).
let inner = Rect::new(
modal_x + 2,
modal_y + 1,
modal_width.saturating_sub(4),
modal_height.saturating_sub(2),
);
if inner.width < 4 || inner.height < 2 {
return;
}
// --- Query input line ---
let query_y = inner.y;
let prompt = "> ";
let prompt_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
let query_start =
frame.print_text_clipped(inner.x, query_y, prompt, prompt_cell, inner.right());
let query_display = if state.query.is_empty() {
"Type to filter..."
} else {
&state.query
};
let query_fg = if state.query.is_empty() {
TEXT_MUTED
} else {
TEXT
};
let q_cell = Cell {
fg: query_fg,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(query_start, query_y, query_display, q_cell, inner.right());
// Cursor indicator (if query focused and not showing placeholder).
if !state.query.is_empty() {
let cursor_x = query_start.saturating_add(cursor_cell_offset(&state.query, state.cursor));
if cursor_x < inner.right() {
let cursor_cell = Cell {
fg: BG_SURFACE,
bg: TEXT,
..Cell::default()
};
// Draw cursor block. If at end of text, draw a space.
let cursor_char = state
.query
.get(state.cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(
cursor_x,
query_y,
&cursor_char.to_string(),
cursor_cell,
inner.right(),
);
}
}
// --- Separator ---
let sep_y = query_y + 1;
if sep_y >= inner.bottom() {
return;
}
let sep_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
let sep_line = "".repeat(inner.width as usize);
frame.print_text_clipped(inner.x, sep_y, &sep_line, sep_cell, inner.right());
// --- Results list ---
let list_y = sep_y + 1;
let list_height = inner.bottom().saturating_sub(list_y) as usize;
if list_height == 0 {
return;
}
if state.filtered.is_empty() {
let msg = if state.query.is_empty() {
"No commands available"
} else {
"No matching commands"
};
let msg_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(inner.x, list_y, msg, msg_cell, inner.right());
return;
}
// Scroll so the selected item is always visible.
let scroll_offset = if state.selected_index >= list_height {
state.selected_index - list_height + 1
} else {
0
};
let normal_cell = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
let selected_cell = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
let key_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let key_selected_cell = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
for (i, entry) in state
.filtered
.iter()
.skip(scroll_offset)
.enumerate()
.take(list_height)
{
let y = list_y + i as u16;
let is_selected = i + scroll_offset == state.selected_index;
let (label_style, kb_style) = if is_selected {
(selected_cell, key_selected_cell)
} else {
(normal_cell, key_cell)
};
// Fill row background for selected item.
if is_selected {
for x in inner.x..inner.right() {
frame.buffer.set(x, y, selected_cell);
}
}
// Label (left-aligned).
frame.print_text_clipped(inner.x, y, entry.label, label_style, inner.right());
// Keybinding (right-aligned).
if let Some(ref kb) = entry.keybinding {
let kb_width = text_cell_width(kb);
let kb_x = inner.right().saturating_sub(kb_width);
if kb_x > inner.x + text_cell_width(entry.label).saturating_add(1) {
frame.print_text_clipped(kb_x, y, kb, kb_style, inner.right());
}
}
}
// Scroll indicator.
if state.filtered.len() > list_height {
let indicator = format!(
" {}/{} ",
(scroll_offset + list_height).min(state.filtered.len()),
state.filtered.len()
);
let ind_x = modal_rect
.right()
.saturating_sub(indicator.len() as u16 + 1);
let ind_y = modal_rect.bottom().saturating_sub(1);
let ind_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, modal_rect.right());
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use crate::state::command_palette::CommandPaletteState;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
#[test]
fn test_render_palette_closed_is_noop() {
with_frame!(80, 24, |frame| {
let state = CommandPaletteState::default();
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
// No content rendered when palette is closed.
});
}
#[test]
fn test_render_palette_open_no_panic() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
// Should have rendered content in center area.
let has_content = (25..55u16).any(|x| {
(8..16u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(has_content, "Expected palette overlay in center area");
});
}
#[test]
fn test_render_palette_with_query() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.insert_char('q', &registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_palette_unicode_cursor_uses_char_offset() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.insert_char('é', &registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
let area = Rect::new(0, 0, 80, 24);
let modal_width = (area.width * 3 / 5).clamp(30, 60);
let modal_height = (area.height / 2).clamp(6, 20);
let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let inner = Rect::new(
modal_x + 2,
modal_y + 1,
modal_width.saturating_sub(4),
modal_height.saturating_sub(2),
);
// Prompt "> " is two cells; one unicode scalar should place cursor at +1.
let query_y = inner.y;
let cursor_x = inner.x + 3;
let cell = frame
.buffer
.get(cursor_x, query_y)
.expect("cursor position must be in bounds");
assert_eq!(cell.bg, TEXT);
});
}
#[test]
fn test_render_palette_with_selection() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.select_next();
state.select_next();
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_palette_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 15, 4));
});
}
#[test]
fn test_render_palette_no_results() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
for c in "zzzzzz".chars() {
state.insert_char(c, &registry, &Screen::Dashboard);
}
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
}

View File

@@ -0,0 +1,208 @@
//! Navigation breadcrumb trail ("Dashboard > Issues > #42").
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::navigation::NavigationStack;
/// Render the navigation breadcrumb trail.
///
/// Shows "Dashboard > Issues > Issue" with " > " separators. When the
/// trail exceeds the available width, entries are truncated from the left
/// with a leading "...".
pub fn render_breadcrumb(
frame: &mut Frame<'_>,
area: Rect,
nav: &NavigationStack,
text_color: PackedRgba,
muted_color: PackedRgba,
) {
if area.height == 0 || area.width < 3 {
return;
}
let crumbs = nav.breadcrumbs();
let separator = " > ";
// Build the full breadcrumb string and calculate width.
let full: String = crumbs.join(separator);
let max_width = area.width as usize;
let display = if full.len() <= max_width {
full
} else {
// Truncate from the left: show "... > last_crumbs"
truncate_breadcrumb_left(&crumbs, separator, max_width)
};
let base = Cell {
fg: text_color,
..Cell::default()
};
let muted = Cell {
fg: muted_color,
..Cell::default()
};
// Render each segment with separators in muted color.
let mut x = area.x;
let max_x = area.x.saturating_add(area.width);
if let Some(rest) = display.strip_prefix("...") {
// Render ellipsis in muted, then the rest
x = frame.print_text_clipped(x, area.y, "...", muted, max_x);
if !rest.is_empty() {
render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x);
}
} else {
render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x);
}
}
/// Render breadcrumb text with separators in muted color.
#[allow(clippy::too_many_arguments)]
fn render_crumb_segments(
frame: &mut Frame<'_>,
start_x: u16,
y: u16,
text: &str,
separator: &str,
base: Cell,
muted: Cell,
max_x: u16,
) {
let mut x = start_x;
let parts: Vec<&str> = text.split(separator).collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
x = frame.print_text_clipped(x, y, separator, muted, max_x);
}
x = frame.print_text_clipped(x, y, part, base, max_x);
if x >= max_x {
break;
}
}
}
/// Truncate breadcrumb from the left to fit within max_width.
fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String {
let ellipsis = "...";
// Try showing progressively fewer crumbs from the right.
for skip in 1..crumbs.len() {
let tail = &crumbs[skip..];
let tail_str: String = tail.join(separator);
let candidate = format!("{ellipsis}{separator}{tail_str}");
if candidate.len() <= max_width {
return candidate;
}
}
// Last resort: just the current screen truncated.
let last = crumbs.last().unwrap_or(&"");
if last.len() + ellipsis.len() <= max_width {
return format!("{ellipsis}{last}");
}
// Truly tiny terminal: just ellipsis.
ellipsis.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Screen;
use crate::navigation::NavigationStack;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_breadcrumb_single_screen() {
with_frame!(80, 1, |frame| {
let nav = NavigationStack::new();
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
cell.content.as_char() == Some('D'),
"Expected 'D' at (0,0), got {:?}",
cell.content.as_char()
);
});
}
#[test]
fn test_breadcrumb_multi_screen() {
with_frame!(80, 1, |frame| {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
let d = frame.buffer.get(0, 0).unwrap();
assert_eq!(d.content.as_char(), Some('D'));
// "Dashboard > Issues" = 'I' at 12
let i_cell = frame.buffer.get(12, 0).unwrap();
assert_eq!(i_cell.content.as_char(), Some('I'));
});
}
#[test]
fn test_breadcrumb_truncation() {
let crumbs = vec!["Dashboard", "Issues", "Issue"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 20);
assert!(
result.starts_with("..."),
"Expected ellipsis prefix, got: {result}"
);
assert!(result.len() <= 20, "Result too long: {result}");
}
#[test]
fn test_breadcrumb_zero_height_noop() {
with_frame!(80, 1, |frame| {
let nav = NavigationStack::new();
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray());
});
}
#[test]
fn test_truncate_breadcrumb_fits() {
let crumbs = vec!["A", "B"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 100);
assert!(result.contains("..."), "Should always add ellipsis");
}
#[test]
fn test_truncate_breadcrumb_single_entry() {
let crumbs = vec!["Dashboard"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 5);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_breadcrumb_shows_last_entries() {
let crumbs = vec!["Dashboard", "Issues", "Issue Detail"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 30);
assert!(result.starts_with("..."));
assert!(result.contains("Issue Detail"));
}
}

View File

@@ -0,0 +1,413 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
//! Cross-reference widget for entity detail screens.
//!
//! Renders a list of linked entities (closing MRs, related issues, mentions)
//! as navigable items. Used in both Issue Detail and MR Detail views.
use std::fmt;
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::message::EntityKey;
// ---------------------------------------------------------------------------
// CrossRefKind
// ---------------------------------------------------------------------------
/// The relationship type between two entities.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossRefKind {
/// MR that closes this issue when merged.
ClosingMr,
/// Issue related via GitLab link.
RelatedIssue,
/// Entity mentioned in a note or description.
MentionedIn,
}
impl CrossRefKind {
/// Short icon/prefix for display.
#[must_use]
pub const fn icon(&self) -> &str {
match self {
Self::ClosingMr => "MR",
Self::RelatedIssue => "REL",
Self::MentionedIn => "REF",
}
}
}
impl fmt::Display for CrossRefKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClosingMr => write!(f, "Closing MR"),
Self::RelatedIssue => write!(f, "Related Issue"),
Self::MentionedIn => write!(f, "Mentioned In"),
}
}
}
// ---------------------------------------------------------------------------
// CrossRef
// ---------------------------------------------------------------------------
/// A single cross-reference to another entity.
#[derive(Debug, Clone)]
pub struct CrossRef {
/// Relationship type.
pub kind: CrossRefKind,
/// Target entity identity.
pub entity_key: EntityKey,
/// Human-readable label (e.g., "Fix authentication flow").
pub label: String,
/// Whether this ref points to an entity in the local DB (navigable).
pub navigable: bool,
}
// ---------------------------------------------------------------------------
// CrossRefState
// ---------------------------------------------------------------------------
/// Rendering state for the cross-reference list.
#[derive(Debug, Clone, Default)]
pub struct CrossRefState {
/// Index of the selected cross-reference.
pub selected: usize,
/// First visible item index.
pub scroll_offset: usize,
}
impl CrossRefState {
/// Move selection down.
pub fn select_next(&mut self, total: usize) {
if total > 0 && self.selected < total - 1 {
self.selected += 1;
}
}
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Color scheme for cross-reference rendering.
pub struct CrossRefColors {
/// Foreground for the kind icon/badge.
pub kind_fg: PackedRgba,
/// Foreground for the label text.
pub label_fg: PackedRgba,
/// Muted foreground for non-navigable refs.
pub muted_fg: PackedRgba,
/// Selected item foreground.
pub selected_fg: PackedRgba,
/// Selected item background.
pub selected_bg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render a list of cross-references within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout per row:
/// ```text
/// [MR] !42 Fix authentication flow
/// [REL] #15 Related auth issue
/// [REF] !99 Mentioned in pipeline MR
/// ```
pub fn render_cross_refs(
frame: &mut Frame<'_>,
refs: &[CrossRef],
state: &CrossRefState,
area: Rect,
colors: &CrossRefColors,
) -> u16 {
if refs.is_empty() || area.height == 0 || area.width < 10 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let visible_count = (area.height as usize).min(refs.len().saturating_sub(state.scroll_offset));
for i in 0..visible_count {
let idx = state.scroll_offset + i;
let Some(cr) = refs.get(idx) else { break };
let y = area.y + i as u16;
let is_selected = idx == state.selected;
// Background fill for selected row.
if is_selected {
frame.draw_rect_filled(
Rect::new(area.x, y, area.width, 1),
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
},
);
}
let mut x = area.x;
// Kind badge: [MR], [REL], [REF]
let badge = format!("[{}]", cr.kind.icon());
let badge_style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else {
Cell {
fg: colors.kind_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, &badge, badge_style, max_x);
// Spacing
x = frame.print_text_clipped(x, y, " ", badge_style, max_x);
// Entity prefix + label — derive sigil from entity kind, not ref kind.
let prefix = match cr.kind {
CrossRefKind::ClosingMr => format!("!{} ", cr.entity_key.iid),
CrossRefKind::RelatedIssue => format!("#{} ", cr.entity_key.iid),
CrossRefKind::MentionedIn => {
let sigil = match cr.entity_key.kind {
crate::message::EntityKind::MergeRequest => "!",
crate::message::EntityKind::Issue => "#",
};
format!("{sigil}{} ", cr.entity_key.iid)
}
};
let label_style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else if cr.navigable {
Cell {
fg: colors.label_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.muted_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, &prefix, label_style, max_x);
let _ = frame.print_text_clipped(x, y, &cr.label, label_style, max_x);
}
visible_count as u16
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_refs() -> Vec<CrossRef> {
vec![
CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 42),
label: "Fix authentication flow".into(),
navigable: true,
},
CrossRef {
kind: CrossRefKind::RelatedIssue,
entity_key: EntityKey::issue(1, 15),
label: "Related auth issue".into(),
navigable: true,
},
CrossRef {
kind: CrossRefKind::MentionedIn,
entity_key: EntityKey::mr(2, 99),
label: "Pipeline improvements".into(),
navigable: false,
},
]
}
fn test_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
label_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
muted_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
}
}
// TDD anchor test from bead spec.
#[test]
fn test_cross_ref_entity_key() {
let cr = CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 42),
label: "Fix auth".into(),
navigable: true,
};
assert_eq!(cr.kind, CrossRefKind::ClosingMr);
assert_eq!(cr.entity_key, EntityKey::mr(1, 42));
}
#[test]
fn test_cross_ref_kind_display() {
assert_eq!(CrossRefKind::ClosingMr.to_string(), "Closing MR");
assert_eq!(CrossRefKind::RelatedIssue.to_string(), "Related Issue");
assert_eq!(CrossRefKind::MentionedIn.to_string(), "Mentioned In");
}
#[test]
fn test_cross_ref_kind_icon() {
assert_eq!(CrossRefKind::ClosingMr.icon(), "MR");
assert_eq!(CrossRefKind::RelatedIssue.icon(), "REL");
assert_eq!(CrossRefKind::MentionedIn.icon(), "REF");
}
#[test]
fn test_cross_ref_state_navigation() {
let mut state = CrossRefState::default();
assert_eq!(state.selected, 0);
state.select_next(3);
assert_eq!(state.selected, 1);
state.select_next(3);
assert_eq!(state.selected, 2);
// Can't go past end.
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
state.select_prev();
assert_eq!(state.selected, 0);
// Can't go before start.
state.select_prev();
assert_eq!(state.selected, 0);
}
#[test]
fn test_render_cross_refs_no_panic() {
with_frame!(80, 10, |frame| {
let refs = sample_refs();
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 10),
&test_colors(),
);
assert_eq!(rows, 3);
});
}
#[test]
fn test_render_cross_refs_empty() {
with_frame!(80, 10, |frame| {
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&[],
&state,
Rect::new(0, 0, 80, 10),
&test_colors(),
);
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_cross_refs_tiny_area() {
with_frame!(5, 1, |frame| {
let refs = sample_refs();
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 5, 1),
&test_colors(),
);
// Too narrow (< 10), should bail.
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_cross_refs_with_scroll() {
with_frame!(80, 2, |frame| {
let refs = sample_refs();
let state = CrossRefState {
selected: 2,
scroll_offset: 1,
};
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 2),
&test_colors(),
);
// 2 visible (indices 1 and 2).
assert_eq!(rows, 2);
});
}
#[test]
fn test_render_cross_refs_non_navigable() {
with_frame!(80, 5, |frame| {
let refs = vec![CrossRef {
kind: CrossRefKind::MentionedIn,
entity_key: EntityKey::mr(2, 99),
label: "Non-local entity".into(),
navigable: false,
}];
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 5),
&test_colors(),
);
assert_eq!(rows, 1);
});
}
}

View File

@@ -0,0 +1,979 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
//! Discussion tree widget for entity detail screens.
//!
//! Renders threaded conversations from GitLab issues/MRs. Discussions are
//! top-level expandable nodes, with notes as children. Supports expand/collapse
//! persistence, system note styling, and diff note file path rendering.
use std::collections::HashSet;
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
/// A single discussion thread (top-level node).
#[derive(Debug, Clone)]
pub struct DiscussionNode {
/// GitLab discussion ID (used as expand/collapse key).
pub discussion_id: String,
/// Notes within this discussion, ordered by position.
pub notes: Vec<NoteNode>,
/// Whether this discussion is resolvable (MR discussions only).
pub resolvable: bool,
/// Whether this discussion has been resolved.
pub resolved: bool,
}
impl DiscussionNode {
/// Summary line for collapsed display.
fn summary(&self) -> String {
let first = self.notes.first();
let author = first.map_or("unknown", |n| n.author.as_str());
let note_count = self.notes.len();
let resolved_tag = if self.resolved { " [resolved]" } else { "" };
if note_count == 1 {
format!("{author}{resolved_tag}")
} else {
format!("{author} ({note_count} notes){resolved_tag}")
}
}
/// First line of the first note body, sanitized and truncated.
fn preview(&self, max_chars: usize) -> String {
self.notes
.first()
.and_then(|n| n.body.lines().next())
.map(|line| {
let sanitized = sanitize_for_terminal(line, UrlPolicy::Strip);
if sanitized.len() > max_chars {
let trunc = max_chars.saturating_sub(3);
// Find the last valid char boundary at or before `trunc`
// to avoid panicking on multi-byte UTF-8 (emoji, CJK).
let safe_end = sanitized
.char_indices()
.take_while(|&(i, _)| i <= trunc)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
format!("{}...", &sanitized[..safe_end])
} else {
sanitized
}
})
.unwrap_or_default()
}
}
/// A single note within a discussion.
#[derive(Debug, Clone)]
pub struct NoteNode {
/// Author username.
pub author: String,
/// Note body (markdown text from GitLab).
pub body: String,
/// Creation timestamp in milliseconds since epoch.
pub created_at: i64,
/// Whether this is a system-generated note.
pub is_system: bool,
/// Whether this is a diff/code review note.
pub is_diff_note: bool,
/// File path for diff notes.
pub diff_file_path: Option<String>,
/// New line number for diff notes.
pub diff_new_line: Option<i64>,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Rendering state for the discussion tree.
#[derive(Debug, Clone, Default)]
pub struct DiscussionTreeState {
/// Index of the selected discussion (0-based).
pub selected: usize,
/// First visible row index for scrolling.
pub scroll_offset: usize,
/// Set of expanded discussion IDs.
pub expanded: HashSet<String>,
}
impl DiscussionTreeState {
/// Move selection down.
pub fn select_next(&mut self, total: usize) {
if total > 0 && self.selected < total - 1 {
self.selected += 1;
}
}
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
/// Toggle expand/collapse for the selected discussion.
pub fn toggle_selected(&mut self, discussions: &[DiscussionNode]) {
if let Some(d) = discussions.get(self.selected) {
let id = &d.discussion_id;
if self.expanded.contains(id) {
self.expanded.remove(id);
} else {
self.expanded.insert(id.clone());
}
}
}
/// Whether a discussion is expanded.
#[must_use]
pub fn is_expanded(&self, discussion_id: &str) -> bool {
self.expanded.contains(discussion_id)
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Color scheme for discussion tree rendering.
pub struct DiscussionTreeColors {
/// Author name foreground.
pub author_fg: PackedRgba,
/// Timestamp foreground.
pub timestamp_fg: PackedRgba,
/// Note body foreground.
pub body_fg: PackedRgba,
/// System note foreground (muted).
pub system_fg: PackedRgba,
/// Diff file path foreground.
pub diff_path_fg: PackedRgba,
/// Resolved indicator foreground.
pub resolved_fg: PackedRgba,
/// Tree guide characters.
pub guide_fg: PackedRgba,
/// Selected discussion background.
pub selected_fg: PackedRgba,
/// Selected discussion background.
pub selected_bg: PackedRgba,
/// Expand/collapse indicator.
pub expand_fg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Relative time formatting
// ---------------------------------------------------------------------------
/// Format a timestamp as a human-readable relative time string.
///
/// Uses the provided `Clock` for deterministic rendering in tests.
#[must_use]
pub fn format_relative_time(epoch_ms: i64, clock: &dyn Clock) -> String {
let now_ms = clock.now_ms();
let diff_ms = now_ms.saturating_sub(epoch_ms);
if diff_ms < 0 {
return "just now".to_string();
}
let seconds = diff_ms / 1_000;
let minutes = seconds / 60;
let hours = minutes / 60;
let days = hours / 24;
let weeks = days / 7;
let months = days / 30;
if seconds < 60 {
"just now".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if hours < 24 {
format!("{hours}h ago")
} else if days < 7 {
format!("{days}d ago")
} else if weeks < 4 {
format!("{weeks}w ago")
} else {
format!("{months}mo ago")
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Maximum indent depth for nested content (notes within discussions).
const INDENT: u16 = 4;
/// Render a discussion tree within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout:
/// ```text
/// > alice (3 notes) [resolved] <- collapsed discussion
/// First line of note body preview...
///
/// v bob (2 notes) <- expanded discussion
/// | bob · 3h ago
/// | This is the first note body...
/// |
/// | alice · 1h ago <- diff note
/// | diff src/auth.rs:42
/// | Code review comment about...
/// ```
pub fn render_discussion_tree(
frame: &mut Frame<'_>,
discussions: &[DiscussionNode],
state: &DiscussionTreeState,
area: Rect,
colors: &DiscussionTreeColors,
clock: &dyn Clock,
) -> u16 {
if discussions.is_empty() || area.height == 0 || area.width < 15 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let y_max = area.y.saturating_add(area.height);
// Pre-compute all visual rows to support scroll offset.
let rows = compute_visual_rows_with_clock(
discussions,
state,
max_x.saturating_sub(area.x) as usize,
clock,
);
// Apply scroll offset.
let visible_rows = rows
.iter()
.skip(state.scroll_offset)
.take(area.height as usize);
for row in visible_rows {
if y >= y_max {
break;
}
match row {
VisualRow::DiscussionHeader {
disc_idx,
expanded,
summary,
preview,
} => {
let is_selected = *disc_idx == state.selected;
// Background fill for selected.
if is_selected {
frame.draw_rect_filled(
Rect::new(area.x, y, area.width, 1),
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
},
);
}
let style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else {
Cell {
fg: colors.author_fg,
..Cell::default()
}
};
let indicator = if *expanded { "v " } else { "> " };
let mut x = frame.print_text_clipped(area.x, y, indicator, style, max_x);
x = frame.print_text_clipped(x, y, summary, style, max_x);
// Show preview on same line for collapsed.
if !expanded && !preview.is_empty() {
let preview_style = if is_selected {
style
} else {
Cell {
fg: colors.timestamp_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, " - ", preview_style, max_x);
let _ = frame.print_text_clipped(x, y, preview, preview_style, max_x);
}
y += 1;
}
VisualRow::NoteHeader {
author,
relative_time,
is_system,
..
} => {
let style = if *is_system {
Cell {
fg: colors.system_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.author_fg,
..Cell::default()
}
};
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let indent_x = area.x.saturating_add(INDENT);
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
x = frame.print_text_clipped(x.max(indent_x), y, author, style, max_x);
let time_style = Cell {
fg: colors.timestamp_fg,
..Cell::default()
};
x = frame.print_text_clipped(x, y, " · ", time_style, max_x);
let _ = frame.print_text_clipped(x, y, relative_time, time_style, max_x);
y += 1;
}
VisualRow::DiffPath { file_path, line } => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let path_style = Cell {
fg: colors.diff_path_fg,
..Cell::default()
};
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
let indent_x = area.x.saturating_add(INDENT);
x = x.max(indent_x);
let location = match line {
Some(l) => format!("diff {file_path}:{l}"),
None => format!("diff {file_path}"),
};
let _ = frame.print_text_clipped(x, y, &location, path_style, max_x);
y += 1;
}
VisualRow::BodyLine { text, is_system } => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let body_style = if *is_system {
Cell {
fg: colors.system_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.body_fg,
..Cell::default()
}
};
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
let indent_x = area.x.saturating_add(INDENT);
x = x.max(indent_x);
let _ = frame.print_text_clipped(x, y, text, body_style, max_x);
y += 1;
}
VisualRow::Separator => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x, y, " |", guide_style, max_x);
y += 1;
}
}
}
y.saturating_sub(area.y)
}
// ---------------------------------------------------------------------------
// Visual row computation
// ---------------------------------------------------------------------------
/// Pre-computed visual row for the discussion tree.
///
/// We flatten the tree into rows to support scroll offset correctly.
#[derive(Debug)]
enum VisualRow {
/// Discussion header (collapsed or expanded).
DiscussionHeader {
disc_idx: usize,
expanded: bool,
summary: String,
preview: String,
},
/// Note author + timestamp line.
NoteHeader {
author: String,
relative_time: String,
is_system: bool,
},
/// Diff note file path line.
DiffPath {
file_path: String,
line: Option<i64>,
},
/// Note body text line.
BodyLine { text: String, is_system: bool },
/// Blank separator between notes.
Separator,
}
/// Maximum body lines shown per note to prevent one huge note from
/// consuming the entire viewport.
const MAX_BODY_LINES: usize = 10;
/// Compute visual rows with relative timestamps from the clock.
fn compute_visual_rows_with_clock(
discussions: &[DiscussionNode],
state: &DiscussionTreeState,
available_width: usize,
clock: &dyn Clock,
) -> Vec<VisualRow> {
let mut rows = Vec::new();
let preview_max = available_width.saturating_sub(40).max(20);
for (idx, disc) in discussions.iter().enumerate() {
let expanded = state.is_expanded(&disc.discussion_id);
rows.push(VisualRow::DiscussionHeader {
disc_idx: idx,
expanded,
summary: disc.summary(),
preview: if expanded {
String::new()
} else {
disc.preview(preview_max)
},
});
if expanded {
for (note_idx, note) in disc.notes.iter().enumerate() {
if note_idx > 0 {
rows.push(VisualRow::Separator);
}
rows.push(VisualRow::NoteHeader {
author: note.author.clone(),
relative_time: format_relative_time(note.created_at, clock),
is_system: note.is_system,
});
if note.is_diff_note
&& let Some(ref path) = note.diff_file_path
{
rows.push(VisualRow::DiffPath {
file_path: path.clone(),
line: note.diff_new_line,
});
}
let sanitized = sanitize_for_terminal(&note.body, UrlPolicy::Strip);
for (line_idx, line) in sanitized.lines().enumerate() {
if line_idx >= MAX_BODY_LINES {
rows.push(VisualRow::BodyLine {
text: "...".to_string(),
is_system: note.is_system,
});
break;
}
rows.push(VisualRow::BodyLine {
text: line.to_string(),
is_system: note.is_system,
});
}
}
}
}
rows
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_note(author: &str, body: &str, created_at: i64) -> NoteNode {
NoteNode {
author: author.into(),
body: body.into(),
created_at,
is_system: false,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}
}
fn system_note(body: &str, created_at: i64) -> NoteNode {
NoteNode {
author: "system".into(),
body: body.into(),
created_at,
is_system: true,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}
}
fn diff_note(author: &str, body: &str, path: &str, line: i64, created_at: i64) -> NoteNode {
NoteNode {
author: author.into(),
body: body.into(),
created_at,
is_system: false,
is_diff_note: true,
diff_file_path: Some(path.into()),
diff_new_line: Some(line),
}
}
fn sample_discussions() -> Vec<DiscussionNode> {
vec![
DiscussionNode {
discussion_id: "disc-1".into(),
notes: vec![
sample_note("alice", "This looks good overall", 1_700_000_000_000),
sample_note("bob", "Agreed, but one concern", 1_700_000_060_000),
],
resolvable: false,
resolved: false,
},
DiscussionNode {
discussion_id: "disc-2".into(),
notes: vec![diff_note(
"charlie",
"This function needs error handling",
"src/auth.rs",
42,
1_700_000_120_000,
)],
resolvable: true,
resolved: true,
},
DiscussionNode {
discussion_id: "disc-3".into(),
notes: vec![system_note("changed the description", 1_700_000_180_000)],
resolvable: false,
resolved: false,
},
]
}
fn test_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
timestamp_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
body_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
system_fg: PackedRgba::rgb(0x6F, 0x6E, 0x69),
diff_path_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
resolved_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
guide_fg: PackedRgba::rgb(0x40, 0x40, 0x3C),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
expand_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
}
}
// Clock set to 1h after the last sample note.
fn test_clock() -> FakeClock {
FakeClock::from_ms(1_700_000_180_000 + 3_600_000)
}
#[test]
fn test_format_relative_time_just_now() {
let clock = FakeClock::from_ms(1_000_000);
assert_eq!(format_relative_time(1_000_000, &clock), "just now");
assert_eq!(format_relative_time(999_990, &clock), "just now");
}
#[test]
fn test_format_relative_time_minutes() {
let clock = FakeClock::from_ms(1_000_000 + 5 * 60 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "5m ago");
}
#[test]
fn test_format_relative_time_hours() {
let clock = FakeClock::from_ms(1_000_000 + 3 * 3_600 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "3h ago");
}
#[test]
fn test_format_relative_time_days() {
let clock = FakeClock::from_ms(1_000_000 + 2 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2d ago");
}
#[test]
fn test_format_relative_time_weeks() {
let clock = FakeClock::from_ms(1_000_000 + 14 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2w ago");
}
#[test]
fn test_format_relative_time_months() {
let clock = FakeClock::from_ms(1_000_000 + 60 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2mo ago");
}
#[test]
fn test_discussion_node_summary() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![
sample_note("alice", "body", 0),
sample_note("bob", "reply", 1000),
],
resolvable: false,
resolved: false,
};
assert_eq!(disc.summary(), "alice (2 notes)");
}
#[test]
fn test_discussion_node_summary_single() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "body", 0)],
resolvable: false,
resolved: false,
};
assert_eq!(disc.summary(), "alice");
}
#[test]
fn test_discussion_node_summary_resolved() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "body", 0)],
resolvable: true,
resolved: true,
};
assert_eq!(disc.summary(), "alice [resolved]");
}
#[test]
fn test_discussion_node_preview() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "First line\nSecond line", 0)],
resolvable: false,
resolved: false,
};
assert_eq!(disc.preview(50), "First line");
}
#[test]
fn test_discussion_tree_state_navigation() {
let mut state = DiscussionTreeState::default();
assert_eq!(state.selected, 0);
state.select_next(3);
assert_eq!(state.selected, 1);
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
state.select_prev();
assert_eq!(state.selected, 0);
state.select_prev();
assert_eq!(state.selected, 0);
}
#[test]
fn test_discussion_tree_state_toggle() {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
assert!(!state.is_expanded("disc-1"));
state.toggle_selected(&discussions);
assert!(state.is_expanded("disc-1"));
state.toggle_selected(&discussions);
assert!(!state.is_expanded("disc-1"));
}
#[test]
fn test_render_discussion_tree_collapsed_no_panic() {
with_frame!(80, 20, |frame| {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
// 3 discussions, all collapsed = 3 rows.
assert_eq!(rows, 3);
});
}
#[test]
fn test_render_discussion_tree_expanded_no_panic() {
with_frame!(80, 30, |frame| {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
state.expanded.insert("disc-1".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 30),
&test_colors(),
&clock,
);
// disc-1 expanded: header + 2 notes (each: header + body line) + separator between
// = 1 + (1+1) + 1 + (1+1) = 6 rows from disc-1
// disc-2 collapsed: 1 row
// disc-3 collapsed: 1 row
// Total: 8
assert!(rows >= 6); // At least disc-1 content + 2 collapsed.
});
}
#[test]
fn test_render_discussion_tree_empty() {
with_frame!(80, 20, |frame| {
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&[],
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_discussion_tree_tiny_area() {
with_frame!(10, 2, |frame| {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 10, 2),
&test_colors(),
&clock,
);
// Too narrow (< 15), should bail.
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_discussion_tree_with_diff_note() {
with_frame!(80, 30, |frame| {
let discussions = vec![DiscussionNode {
discussion_id: "diff-disc".into(),
notes: vec![diff_note(
"reviewer",
"Add error handling here",
"src/main.rs",
42,
1_700_000_000_000,
)],
resolvable: true,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("diff-disc".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 30),
&test_colors(),
&clock,
);
// header + note header + diff path + body line = 4
assert!(rows >= 3);
});
}
#[test]
fn test_render_discussion_tree_system_note() {
with_frame!(80, 20, |frame| {
let discussions = vec![DiscussionNode {
discussion_id: "sys-disc".into(),
notes: vec![system_note("changed the description", 1_700_000_000_000)],
resolvable: false,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("sys-disc".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
assert!(rows >= 2);
});
}
#[test]
fn test_compute_visual_rows_collapsed() {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// 3 collapsed headers.
assert_eq!(rows.len(), 3);
assert!(matches!(
rows[0],
VisualRow::DiscussionHeader {
expanded: false,
..
}
));
}
#[test]
fn test_compute_visual_rows_expanded() {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
state.expanded.insert("disc-1".into());
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// disc-1: header + note1 (header + body) + separator + note2 (header + body) = 6
// disc-2: 1 header
// disc-3: 1 header
// Total: 8
assert!(rows.len() >= 6);
assert!(matches!(
rows[0],
VisualRow::DiscussionHeader { expanded: true, .. }
));
}
#[test]
fn test_long_body_truncation() {
let long_body = (0..20)
.map(|i| format!("Line {i} of a very long discussion note"))
.collect::<Vec<_>>()
.join("\n");
let discussions = vec![DiscussionNode {
discussion_id: "long".into(),
notes: vec![sample_note("alice", &long_body, 1_700_000_000_000)],
resolvable: false,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("long".into());
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// Header + note header + MAX_BODY_LINES + 1 ("...") = 1 + 1 + 10 + 1 = 13
let body_lines: Vec<_> = rows
.iter()
.filter(|r| matches!(r, VisualRow::BodyLine { .. }))
.collect();
// Should cap at MAX_BODY_LINES + 1 (for the "..." truncation line).
assert!(body_lines.len() <= MAX_BODY_LINES + 1);
}
#[test]
fn test_preview_multibyte_utf8_no_panic() {
// Emoji are 4 bytes each. Truncating at a byte boundary that falls
// inside a multi-byte char must not panic.
let disc = DiscussionNode {
discussion_id: "d-utf8".into(),
notes: vec![sample_note(
"alice",
"Hello 🌍🌎🌏 world of emoji 🎉🎊🎈",
0,
)],
resolvable: false,
resolved: false,
};
// max_chars=10 would land inside the first emoji's bytes.
let preview = disc.preview(10);
assert!(preview.ends_with("..."));
assert!(preview.len() <= 20); // char-bounded + "..."
// Edge: max_chars smaller than a single multi-byte char.
let disc2 = DiscussionNode {
discussion_id: "d-utf8-2".into(),
notes: vec![sample_note("bob", "🌍🌎🌏", 0)],
resolvable: false,
resolved: false,
};
let preview2 = disc2.preview(3);
assert!(preview2.ends_with("..."));
}
}

View File

@@ -0,0 +1,676 @@
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
//! Generic entity table widget for list screens.
//!
//! `EntityTable<R>` renders rows with sortable, responsive columns.
//! Columns hide gracefully when the terminal is too narrow, using
//! priority-based visibility.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
// ---------------------------------------------------------------------------
// Column definition
// ---------------------------------------------------------------------------
/// Describes a single table column.
#[derive(Debug, Clone)]
pub struct ColumnDef {
/// Display name shown in the header.
pub name: &'static str,
/// Minimum width in characters. Column is hidden if it can't meet this.
pub min_width: u16,
/// Flex weight for distributing extra space.
pub flex_weight: u16,
/// Visibility priority (0 = always shown, higher = hidden first).
pub priority: u8,
/// Text alignment within the column.
pub align: Align,
}
/// Text alignment within a column.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Right,
}
// ---------------------------------------------------------------------------
// TableRow trait
// ---------------------------------------------------------------------------
/// Trait for types that can be rendered as a table row.
pub trait TableRow {
/// Return the cell text for each column, in column order.
fn cells(&self, col_count: usize) -> Vec<String>;
}
// ---------------------------------------------------------------------------
// EntityTable state
// ---------------------------------------------------------------------------
/// Rendering state for the entity table.
#[derive(Debug, Clone)]
pub struct EntityTableState {
/// Index of the selected row (0-based, within the full data set).
pub selected: usize,
/// Scroll offset (first visible row index).
pub scroll_offset: usize,
/// Index of the column used for sorting.
pub sort_column: usize,
/// Sort direction.
pub sort_ascending: bool,
}
impl Default for EntityTableState {
fn default() -> Self {
Self {
selected: 0,
scroll_offset: 0,
sort_column: 0,
sort_ascending: true,
}
}
}
impl EntityTableState {
/// Move selection down by 1.
pub fn select_next(&mut self, total_rows: usize) {
if total_rows == 0 {
return;
}
self.selected = (self.selected + 1).min(total_rows - 1);
}
/// Move selection up by 1.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
/// Page down (move by `page_size` rows).
pub fn page_down(&mut self, total_rows: usize, page_size: usize) {
if total_rows == 0 {
return;
}
self.selected = (self.selected + page_size).min(total_rows - 1);
}
/// Page up.
pub fn page_up(&mut self, page_size: usize) {
self.selected = self.selected.saturating_sub(page_size);
}
/// Jump to top.
pub fn select_first(&mut self) {
self.selected = 0;
}
/// Jump to bottom.
pub fn select_last(&mut self, total_rows: usize) {
if total_rows > 0 {
self.selected = total_rows - 1;
}
}
/// Cycle sort column forward (wraps around).
pub fn cycle_sort(&mut self, col_count: usize) {
if col_count == 0 {
return;
}
self.sort_column = (self.sort_column + 1) % col_count;
}
/// Toggle sort direction on current column.
pub fn toggle_sort_direction(&mut self) {
self.sort_ascending = !self.sort_ascending;
}
/// Ensure scroll offset keeps selection visible.
fn adjust_scroll(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
if self.selected >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected - visible_rows + 1;
}
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Colors for the entity table. Will be replaced by Theme injection.
pub struct TableColors {
pub header_fg: PackedRgba,
pub header_bg: PackedRgba,
pub row_fg: PackedRgba,
pub row_alt_bg: PackedRgba,
pub selected_fg: PackedRgba,
pub selected_bg: PackedRgba,
pub sort_indicator: PackedRgba,
pub border: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Compute which columns are visible given the available width.
///
/// Returns indices of visible columns sorted by original order,
/// along with their allocated widths.
pub fn visible_columns(columns: &[ColumnDef], available_width: u16) -> Vec<(usize, u16)> {
// Sort by priority (lowest = most important).
let mut indexed: Vec<(usize, &ColumnDef)> = columns.iter().enumerate().collect();
indexed.sort_by_key(|(_, col)| col.priority);
let mut result: Vec<(usize, u16)> = Vec::new();
let mut used_width: u16 = 0;
let gap = 1u16; // 1-char gap between columns.
for (idx, col) in &indexed {
let needed = col.min_width + if result.is_empty() { 0 } else { gap };
if used_width + needed <= available_width {
result.push((*idx, col.min_width));
used_width += needed;
}
}
// Distribute remaining space by flex weight.
let remaining = available_width.saturating_sub(used_width);
if remaining > 0 {
let total_weight: u16 = result
.iter()
.map(|(idx, _)| columns[*idx].flex_weight)
.sum();
if total_weight > 0 {
for (idx, width) in &mut result {
let weight = columns[*idx].flex_weight;
let extra =
(u32::from(remaining) * u32::from(weight) / u32::from(total_weight)) as u16;
*width += extra;
}
}
}
// Sort by original column order for rendering.
result.sort_by_key(|(idx, _)| *idx);
result
}
/// Render the entity table header row.
pub fn render_header(
frame: &mut Frame<'_>,
columns: &[ColumnDef],
visible: &[(usize, u16)],
state: &EntityTableState,
y: u16,
area_x: u16,
colors: &TableColors,
) {
let header_cell = Cell {
fg: colors.header_fg,
bg: colors.header_bg,
..Cell::default()
};
let sort_cell = Cell {
fg: colors.sort_indicator,
bg: colors.header_bg,
..Cell::default()
};
// Fill header background.
let total_width: u16 = visible.iter().map(|(_, w)| w + 1).sum();
let header_rect = Rect::new(area_x, y, total_width, 1);
frame.draw_rect_filled(
header_rect,
Cell {
bg: colors.header_bg,
..Cell::default()
},
);
let mut x = area_x;
for (col_idx, col_width) in visible {
let col = &columns[*col_idx];
let col_max = x.saturating_add(*col_width);
let after_name = frame.print_text_clipped(x, y, col.name, header_cell, col_max);
// Sort indicator.
if *col_idx == state.sort_column {
let arrow = if state.sort_ascending { " ^" } else { " v" };
frame.print_text_clipped(after_name, y, arrow, sort_cell, col_max);
}
x = col_max.saturating_add(1); // gap
}
}
/// Style context for rendering a single row.
pub struct RowContext<'a> {
pub columns: &'a [ColumnDef],
pub visible: &'a [(usize, u16)],
pub is_selected: bool,
pub is_alt: bool,
pub colors: &'a TableColors,
}
/// Render a data row.
pub fn render_row<R: TableRow>(
frame: &mut Frame<'_>,
row: &R,
y: u16,
area_x: u16,
ctx: &RowContext<'_>,
) {
let (fg, bg) = if ctx.is_selected {
(ctx.colors.selected_fg, ctx.colors.selected_bg)
} else if ctx.is_alt {
(ctx.colors.row_fg, ctx.colors.row_alt_bg)
} else {
(ctx.colors.row_fg, Cell::default().bg)
};
let cell_style = Cell {
fg,
bg,
..Cell::default()
};
// Fill row background if selected or alt.
if ctx.is_selected || ctx.is_alt {
let total_width: u16 = ctx.visible.iter().map(|(_, w)| w + 1).sum();
frame.draw_rect_filled(
Rect::new(area_x, y, total_width, 1),
Cell {
bg,
..Cell::default()
},
);
}
let cells = row.cells(ctx.columns.len());
let mut x = area_x;
for (col_idx, col_width) in ctx.visible {
let col_max = x.saturating_add(*col_width);
let text = cells.get(*col_idx).map(String::as_str).unwrap_or("");
match ctx.columns[*col_idx].align {
Align::Left => {
frame.print_text_clipped(x, y, text, cell_style, col_max);
}
Align::Right => {
let text_len = text.len() as u16;
let start = if text_len < *col_width {
x + col_width - text_len
} else {
x
};
frame.print_text_clipped(start, y, text, cell_style, col_max);
}
}
x = col_max.saturating_add(1); // gap
}
}
/// Render a complete entity table: header + scrollable rows.
pub fn render_entity_table<R: TableRow>(
frame: &mut Frame<'_>,
rows: &[R],
columns: &[ColumnDef],
state: &mut EntityTableState,
area: Rect,
colors: &TableColors,
) {
if area.height < 2 || area.width < 5 {
return;
}
let visible = visible_columns(columns, area.width);
if visible.is_empty() {
return;
}
// Header row.
render_header(frame, columns, &visible, state, area.y, area.x, colors);
// Separator.
let sep_y = area.y.saturating_add(1);
let sep_cell = Cell {
fg: colors.border,
..Cell::default()
};
let rule = "".repeat(area.width as usize);
frame.print_text_clipped(
area.x,
sep_y,
&rule,
sep_cell,
area.x.saturating_add(area.width),
);
// Data rows.
let data_start_y = area.y.saturating_add(2);
let visible_rows = area.height.saturating_sub(2) as usize; // minus header + separator
state.adjust_scroll(visible_rows);
let start = state.scroll_offset;
let end = (start + visible_rows).min(rows.len());
for (i, row) in rows[start..end].iter().enumerate() {
let row_y = data_start_y.saturating_add(i as u16);
let absolute_idx = start + i;
let ctx = RowContext {
columns,
visible: &visible,
is_selected: absolute_idx == state.selected,
is_alt: absolute_idx % 2 == 1,
colors,
};
render_row(frame, row, row_y, area.x, &ctx);
}
// Scroll indicator if more rows below.
if end < rows.len() {
let indicator_y = data_start_y.saturating_add(visible_rows as u16);
if indicator_y < area.y.saturating_add(area.height) {
let muted = Cell {
fg: colors.border,
..Cell::default()
};
let remaining = rows.len() - end;
frame.print_text_clipped(
area.x,
indicator_y,
&format!("... {remaining} more"),
muted,
area.x.saturating_add(area.width),
);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_columns() -> Vec<ColumnDef> {
vec![
ColumnDef {
name: "IID",
min_width: 5,
flex_weight: 0,
priority: 0,
align: Align::Right,
},
ColumnDef {
name: "Title",
min_width: 10,
flex_weight: 3,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "State",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Author",
min_width: 8,
flex_weight: 1,
priority: 2,
align: Align::Left,
},
ColumnDef {
name: "Updated",
min_width: 10,
flex_weight: 0,
priority: 3,
align: Align::Right,
},
]
}
struct TestRow {
cells: Vec<String>,
}
impl TableRow for TestRow {
fn cells(&self, _col_count: usize) -> Vec<String> {
self.cells.clone()
}
}
fn test_colors() -> TableColors {
TableColors {
header_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
header_bg: PackedRgba::rgb(0x30, 0x30, 0x30),
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
row_alt_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
selected_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
selected_bg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
sort_indicator: PackedRgba::rgb(0xDA, 0x70, 0x2C),
border: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
#[test]
fn test_visible_columns_all_fit() {
let cols = test_columns();
let vis = visible_columns(&cols, 100);
assert_eq!(vis.len(), 5, "All 5 columns should fit at 100 cols");
}
#[test]
fn test_visible_columns_hides_low_priority() {
let cols = test_columns();
// min widths: 5 + 10 + 8 + 8 + 10 + 4 gaps = 45.
// At 25 cols, only priority 0 columns (IID + Title) should fit.
let vis = visible_columns(&cols, 25);
let visible_indices: Vec<usize> = vis.iter().map(|(idx, _)| *idx).collect();
assert!(visible_indices.contains(&0), "IID should always be visible");
assert!(
visible_indices.contains(&1),
"Title should always be visible"
);
assert!(
!visible_indices.contains(&4),
"Updated (priority 3) should be hidden"
);
}
#[test]
fn test_column_hiding_at_60_cols() {
let cols = test_columns();
let vis = visible_columns(&cols, 60);
// min widths for priority 0,1,2: 5+10+8+8 + 3 gaps = 34.
// Priority 3 (Updated, min 10 + gap) = 45 total, should still fit.
assert!(vis.len() >= 3, "At least 3 columns at 60 cols");
}
#[test]
fn test_state_select_next_prev() {
let mut state = EntityTableState::default();
state.select_next(5);
assert_eq!(state.selected, 1);
state.select_next(5);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
}
#[test]
fn test_state_select_bounds() {
let mut state = EntityTableState::default();
state.select_prev(); // at 0, can't go below
assert_eq!(state.selected, 0);
state.select_next(3);
state.select_next(3);
state.select_next(3); // at 2, can't go above last
assert_eq!(state.selected, 2);
}
#[test]
fn test_state_page_up_down() {
let mut state = EntityTableState::default();
state.page_down(20, 5);
assert_eq!(state.selected, 5);
state.page_up(3);
assert_eq!(state.selected, 2);
}
#[test]
fn test_state_first_last() {
let mut state = EntityTableState {
selected: 5,
..Default::default()
};
state.select_first();
assert_eq!(state.selected, 0);
state.select_last(10);
assert_eq!(state.selected, 9);
}
#[test]
fn test_state_cycle_sort() {
let mut state = EntityTableState::default();
assert_eq!(state.sort_column, 0);
state.cycle_sort(5);
assert_eq!(state.sort_column, 1);
state.sort_column = 4;
state.cycle_sort(5); // wraps to 0
assert_eq!(state.sort_column, 0);
}
#[test]
fn test_state_toggle_sort_direction() {
let mut state = EntityTableState::default();
assert!(state.sort_ascending);
state.toggle_sort_direction();
assert!(!state.sort_ascending);
}
#[test]
fn test_state_adjust_scroll() {
let mut state = EntityTableState {
selected: 15,
scroll_offset: 0,
..Default::default()
};
state.adjust_scroll(10);
assert_eq!(state.scroll_offset, 6); // selected=15 should be at bottom of 10-row window
}
#[test]
fn test_render_entity_table_no_panic() {
with_frame!(80, 20, |frame| {
let cols = test_columns();
let rows = vec![
TestRow {
cells: vec![
"#42".into(),
"Fix auth bug".into(),
"opened".into(),
"taylor".into(),
"2h ago".into(),
],
},
TestRow {
cells: vec![
"#43".into(),
"Add tests".into(),
"merged".into(),
"alice".into(),
"1d ago".into(),
],
},
];
let mut state = EntityTableState::default();
let colors = test_colors();
render_entity_table(
&mut frame,
&rows,
&cols,
&mut state,
Rect::new(0, 0, 80, 20),
&colors,
);
});
}
#[test]
fn test_render_entity_table_tiny_noop() {
with_frame!(3, 1, |frame| {
let cols = test_columns();
let rows: Vec<TestRow> = vec![];
let mut state = EntityTableState::default();
let colors = test_colors();
render_entity_table(
&mut frame,
&rows,
&cols,
&mut state,
Rect::new(0, 0, 3, 1),
&colors,
);
});
}
#[test]
fn test_render_entity_table_empty_rows() {
with_frame!(80, 10, |frame| {
let cols = test_columns();
let rows: Vec<TestRow> = vec![];
let mut state = EntityTableState::default();
let colors = test_colors();
render_entity_table(
&mut frame,
&rows,
&cols,
&mut state,
Rect::new(0, 0, 80, 10),
&colors,
);
});
}
#[test]
fn test_state_select_next_empty() {
let mut state = EntityTableState::default();
state.select_next(0); // no rows
assert_eq!(state.selected, 0);
}
}

View File

@@ -0,0 +1,132 @@
//! Floating error toast at bottom-right.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
/// Render a floating error toast at the bottom-right of the area.
///
/// The toast has a colored background and truncates long messages.
pub fn render_error_toast(
frame: &mut Frame<'_>,
area: Rect,
msg: &str,
error_bg: PackedRgba,
error_fg: PackedRgba,
) {
if area.height < 3 || area.width < 10 || msg.is_empty() {
return;
}
// Toast dimensions: message + padding, max 60 chars or half screen.
let max_toast_width = (area.width / 2).clamp(20, 60);
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
let trunc_len = max_toast_width.saturating_sub(7) as usize;
// Find a char boundary at or before trunc_len to avoid panicking
// on multi-byte UTF-8 (e.g., emoji or CJK in error messages).
let safe_end = msg
.char_indices()
.take_while(|&(i, _)| i <= trunc_len)
.last()
.map_or(0, |(i, c)| i + c.len_utf8())
.min(msg.len());
format!(" {}... ", &msg[..safe_end])
} else {
format!(" {msg} ")
};
let toast_width = toast_text.len() as u16;
let toast_height: u16 = 1;
// Position: bottom-right with 1-cell margin.
let x = area.right().saturating_sub(toast_width + 1);
let y = area.bottom().saturating_sub(toast_height + 1);
let toast_rect = Rect::new(x, y, toast_width, toast_height);
// Fill background.
let bg_cell = Cell {
bg: error_bg,
..Cell::default()
};
frame.draw_rect_filled(toast_rect, bg_cell);
// Render text.
let text_cell = Cell {
fg: error_fg,
bg: error_bg,
..Cell::default()
};
frame.print_text_clipped(x, y, &toast_text, text_cell, area.right());
}
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn red_bg() -> PackedRgba {
PackedRgba::rgb(0xFF, 0x00, 0x00)
}
#[test]
fn test_error_toast_renders() {
with_frame!(80, 24, |frame| {
render_error_toast(
&mut frame,
Rect::new(0, 0, 80, 24),
"Database is busy",
red_bg(),
white(),
);
let y = 22u16;
let has_content = (40..80u16).any(|x| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
});
assert!(has_content, "Expected error toast at bottom-right");
});
}
#[test]
fn test_error_toast_empty_message_noop() {
with_frame!(80, 24, |frame| {
render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white());
let has_content = (0..80u16).any(|x| {
(0..24u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(!has_content, "Empty message should render nothing");
});
}
#[test]
fn test_error_toast_truncates_long_message() {
with_frame!(80, 24, |frame| {
let long_msg = "A".repeat(200);
render_error_toast(
&mut frame,
Rect::new(0, 0, 80, 24),
&long_msg,
red_bg(),
white(),
);
});
}
}

View File

@@ -0,0 +1,469 @@
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
//! Filter bar widget for list screens.
//!
//! Wraps a text input with DSL parsing, inline diagnostics for unknown
//! fields, and rendered filter chips below the input.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::filter_dsl::{self, FilterToken};
// ---------------------------------------------------------------------------
// Filter bar state
// ---------------------------------------------------------------------------
/// State for the filter bar widget.
#[derive(Debug, Clone, Default)]
pub struct FilterBarState {
/// Current filter input text.
pub input: String,
/// Cursor position within the input string (byte offset).
pub cursor: usize,
/// Whether the filter bar has focus.
pub focused: bool,
/// Parsed tokens from the current input.
pub tokens: Vec<FilterToken>,
/// Fields that are unknown for the current entity type.
pub unknown_fields: Vec<String>,
}
impl FilterBarState {
/// Update parsed tokens from the current input text.
pub fn reparse(&mut self, known_fields: &[&str]) {
self.tokens = filter_dsl::parse_filter_tokens(&self.input);
self.unknown_fields = filter_dsl::unknown_fields(&self.tokens, known_fields)
.into_iter()
.map(String::from)
.collect();
}
/// Insert a character at the cursor position.
pub fn insert_char(&mut self, ch: char) {
if self.cursor > self.input.len() {
self.cursor = self.input.len();
}
self.input.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
}
/// Delete the character before the cursor (backspace).
pub fn delete_back(&mut self) {
if self.cursor > 0 && !self.input.is_empty() {
// Find the previous character boundary.
let prev = self.input[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.input.remove(prev);
self.cursor = prev;
}
}
/// Delete the character at the cursor (delete key).
pub fn delete_forward(&mut self) {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
/// Move cursor left by one character.
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.input[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
/// Move cursor right by one character.
pub fn move_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor = self.input[self.cursor..]
.chars()
.next()
.map(|ch| self.cursor + ch.len_utf8())
.unwrap_or(self.input.len());
}
}
/// Move cursor to start.
pub fn move_home(&mut self) {
self.cursor = 0;
}
/// Move cursor to end.
pub fn move_end(&mut self) {
self.cursor = self.input.len();
}
/// Clear the input.
pub fn clear(&mut self) {
self.input.clear();
self.cursor = 0;
self.tokens.clear();
self.unknown_fields.clear();
}
/// Whether the filter has any active tokens.
pub fn is_active(&self) -> bool {
!self.tokens.is_empty()
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Colors for the filter bar.
pub struct FilterBarColors {
pub input_fg: PackedRgba,
pub input_bg: PackedRgba,
pub cursor_fg: PackedRgba,
pub cursor_bg: PackedRgba,
pub chip_fg: PackedRgba,
pub chip_bg: PackedRgba,
pub error_fg: PackedRgba,
pub label_fg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the filter bar.
///
/// Layout:
/// ```text
/// Row 0: [Filter: ][input text with cursor___________]
/// Row 1: [chip1] [chip2] [chip3] (if tokens present)
/// ```
///
/// Returns the number of rows consumed (1 or 2).
pub fn render_filter_bar(
frame: &mut Frame<'_>,
state: &FilterBarState,
area: Rect,
colors: &FilterBarColors,
) -> u16 {
if area.height == 0 || area.width < 10 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let y = area.y;
// Label.
let label = if state.focused { "Filter: " } else { "/ " };
let label_cell = Cell {
fg: colors.label_fg,
..Cell::default()
};
let after_label = frame.print_text_clipped(area.x, y, label, label_cell, max_x);
// Input text.
let input_cell = Cell {
fg: colors.input_fg,
bg: if state.focused {
colors.input_bg
} else {
Cell::default().bg
},
..Cell::default()
};
if state.input.is_empty() && !state.focused {
let muted = Cell {
fg: colors.label_fg,
..Cell::default()
};
frame.print_text_clipped(after_label, y, "type / to filter", muted, max_x);
} else {
// Render input text with cursor highlight.
render_input_with_cursor(frame, state, after_label, y, max_x, input_cell, colors);
}
// Error indicators for unknown fields.
if !state.unknown_fields.is_empty() {
let err_cell = Cell {
fg: colors.error_fg,
..Cell::default()
};
let err_msg = format!("Unknown: {}", state.unknown_fields.join(", "));
// Right-align the error.
let err_x = max_x.saturating_sub(err_msg.len() as u16 + 1);
frame.print_text_clipped(err_x, y, &err_msg, err_cell, max_x);
}
// Chip row (if tokens present and space available).
if !state.tokens.is_empty() && area.height >= 2 {
let chip_y = y.saturating_add(1);
render_chips(frame, &state.tokens, area.x, chip_y, max_x, colors);
return 2;
}
1
}
/// Render input text with cursor highlight at the correct position.
fn render_input_with_cursor(
frame: &mut Frame<'_>,
state: &FilterBarState,
start_x: u16,
y: u16,
max_x: u16,
base_cell: Cell,
colors: &FilterBarColors,
) {
if !state.focused {
frame.print_text_clipped(start_x, y, &state.input, base_cell, max_x);
return;
}
// Split at cursor position.
let cursor = state.cursor;
let input = &state.input;
let (before, after) = if cursor <= input.len() {
(&input[..cursor], &input[cursor..])
} else {
(input.as_str(), "")
};
let mut x = frame.print_text_clipped(start_x, y, before, base_cell, max_x);
// Cursor character (or space if at end).
let cursor_cell = Cell {
fg: colors.cursor_fg,
bg: colors.cursor_bg,
..Cell::default()
};
if let Some(ch) = after.chars().next() {
let s = String::from(ch);
x = frame.print_text_clipped(x, y, &s, cursor_cell, max_x);
let remaining = &after[ch.len_utf8()..];
frame.print_text_clipped(x, y, remaining, base_cell, max_x);
} else {
// Cursor at end — render a visible block.
frame.print_text_clipped(x, y, " ", cursor_cell, max_x);
}
}
/// Render filter chips as compact tags.
fn render_chips(
frame: &mut Frame<'_>,
tokens: &[FilterToken],
start_x: u16,
y: u16,
max_x: u16,
colors: &FilterBarColors,
) {
let chip_cell = Cell {
fg: colors.chip_fg,
bg: colors.chip_bg,
..Cell::default()
};
let mut x = start_x;
for token in tokens {
if x >= max_x {
break;
}
let label = match token {
FilterToken::FieldValue { field, value } => format!("{field}:{value}"),
FilterToken::Negation { field, value } => format!("-{field}:{value}"),
FilterToken::FreeText(text) => text.clone(),
FilterToken::QuotedValue(text) => format!("\"{text}\""),
};
let chip_text = format!("[{label}]");
x = frame.print_text_clipped(x, y, &chip_text, chip_cell, max_x);
x = x.saturating_add(1); // gap between chips
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::filter_dsl::ISSUE_FIELDS;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_colors() -> FilterBarColors {
FilterBarColors {
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
#[test]
fn test_filter_bar_state_insert_char() {
let mut state = FilterBarState::default();
state.insert_char('a');
state.insert_char('b');
assert_eq!(state.input, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_filter_bar_state_delete_back() {
let mut state = FilterBarState {
input: "abc".into(),
cursor: 3,
..Default::default()
};
state.delete_back();
assert_eq!(state.input, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_filter_bar_state_delete_back_at_start() {
let mut state = FilterBarState {
input: "abc".into(),
cursor: 0,
..Default::default()
};
state.delete_back();
assert_eq!(state.input, "abc");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_filter_bar_state_move_left_right() {
let mut state = FilterBarState {
input: "abc".into(),
cursor: 2,
..Default::default()
};
state.move_left();
assert_eq!(state.cursor, 1);
state.move_right();
assert_eq!(state.cursor, 2);
}
#[test]
fn test_filter_bar_state_home_end() {
let mut state = FilterBarState {
input: "hello".into(),
cursor: 3,
..Default::default()
};
state.move_home();
assert_eq!(state.cursor, 0);
state.move_end();
assert_eq!(state.cursor, 5);
}
#[test]
fn test_filter_bar_state_clear() {
let mut state = FilterBarState {
input: "state:opened".into(),
cursor: 12,
tokens: vec![FilterToken::FieldValue {
field: "state".into(),
value: "opened".into(),
}],
..Default::default()
};
state.clear();
assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
assert!(state.tokens.is_empty());
}
#[test]
fn test_filter_bar_state_reparse() {
let mut state = FilterBarState {
input: "state:opened bogus:val".into(),
..Default::default()
};
state.reparse(ISSUE_FIELDS);
assert_eq!(state.tokens.len(), 2);
assert_eq!(state.unknown_fields, vec!["bogus"]);
}
#[test]
fn test_filter_bar_state_is_active() {
let mut state = FilterBarState::default();
assert!(!state.is_active());
state.input = "state:opened".into();
state.reparse(ISSUE_FIELDS);
assert!(state.is_active());
}
#[test]
fn test_render_filter_bar_unfocused_no_panic() {
with_frame!(80, 2, |frame| {
let state = FilterBarState::default();
let colors = test_colors();
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
assert_eq!(rows, 1);
});
}
#[test]
fn test_render_filter_bar_focused_no_panic() {
with_frame!(80, 2, |frame| {
let mut state = FilterBarState {
input: "state:opened".into(),
cursor: 12,
focused: true,
..Default::default()
};
state.reparse(ISSUE_FIELDS);
let colors = test_colors();
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
assert_eq!(rows, 2); // chips rendered
});
}
#[test]
fn test_render_filter_bar_tiny_noop() {
with_frame!(5, 1, |frame| {
let state = FilterBarState::default();
let colors = test_colors();
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 5, 1), &colors);
assert_eq!(rows, 0);
});
}
#[test]
fn test_filter_bar_unicode_cursor() {
let mut state = FilterBarState {
input: "author:田中".into(),
cursor: 7, // points at start of 田
..Default::default()
};
state.move_right();
assert_eq!(state.cursor, 10); // past 田 (3 bytes)
state.move_left();
assert_eq!(state.cursor, 7); // back to 田
}
}

View File

@@ -0,0 +1,173 @@
//! Centered modal listing keybindings for the current screen.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::commands::CommandRegistry;
use crate::message::Screen;
/// Render a centered help overlay listing keybindings for the current screen.
///
/// The overlay is a bordered modal that lists all commands from the
/// registry that are available on the current screen.
#[allow(clippy::too_many_arguments)]
pub fn render_help_overlay(
frame: &mut Frame<'_>,
area: Rect,
registry: &CommandRegistry,
screen: &Screen,
border_color: PackedRgba,
text_color: PackedRgba,
muted_color: PackedRgba,
scroll_offset: usize,
) {
if area.height < 5 || area.width < 20 {
return;
}
// Overlay dimensions: 60% of screen, capped.
let overlay_width = (area.width * 3 / 5).clamp(30, 70);
let overlay_height = (area.height * 3 / 5).clamp(8, 30);
let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2;
let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2;
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
// Draw border.
let border_cell = Cell {
fg: border_color,
..Cell::default()
};
frame.draw_border(
overlay_rect,
ftui::render::drawing::BorderChars::ROUNDED,
border_cell,
);
// Title.
let title = " Help (? to close) ";
let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: border_color,
..Cell::default()
};
frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right());
// Inner content area (inside border).
let inner = Rect::new(
overlay_x + 2,
overlay_y + 1,
overlay_width.saturating_sub(4),
overlay_height.saturating_sub(2),
);
// Get commands for this screen.
let commands = registry.help_entries(screen);
let visible_lines = inner.height as usize;
let key_cell = Cell {
fg: text_color,
..Cell::default()
};
let desc_cell = Cell {
fg: muted_color,
..Cell::default()
};
for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() {
if i >= visible_lines {
break;
}
let y = inner.y + i as u16;
// Key binding label (left).
let key_label = cmd
.keybinding
.as_ref()
.map_or_else(String::new, |kb| kb.display());
let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right());
// Spacer + description (right).
let desc_x = label_end.saturating_add(2);
if desc_x < inner.right() {
frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right());
}
}
// Scroll indicator if needed.
if commands.len() > visible_lines + scroll_offset {
let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len());
let ind_x = inner.right().saturating_sub(indicator.len() as u16);
let ind_y = overlay_rect.bottom().saturating_sub(1);
frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_help_overlay_renders_border() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
render_help_overlay(
&mut frame,
Rect::new(0, 0, 80, 24),
&registry,
&Screen::Dashboard,
gray(),
white(),
gray(),
0,
);
// The overlay should have non-empty cells in the center area.
let has_content = (20..60u16).any(|x| {
(8..16u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(has_content, "Expected help overlay in center area");
});
}
#[test]
fn test_help_overlay_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let registry = build_registry();
render_help_overlay(
&mut frame,
Rect::new(0, 0, 15, 4),
&registry,
&Screen::Dashboard,
gray(),
white(),
gray(),
0,
);
});
}
}

View File

@@ -0,0 +1,179 @@
//! Loading spinner indicators (full-screen and corner).
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::LoadState;
/// Braille spinner frames for loading animation.
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/// Select spinner frame from tick count.
#[must_use]
pub(crate) fn spinner_char(tick: u64) -> char {
SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()]
}
/// Render a loading indicator.
///
/// - `LoadingInitial`: centered full-screen spinner with "Loading..."
/// - `Refreshing`: subtle spinner in top-right corner
/// - Other states: no-op
pub fn render_loading(
frame: &mut Frame<'_>,
area: Rect,
load_state: &LoadState,
text_color: PackedRgba,
muted_color: PackedRgba,
tick: u64,
) {
match load_state {
LoadState::LoadingInitial => {
render_centered_spinner(frame, area, "Loading...", text_color, tick);
}
LoadState::Refreshing => {
render_corner_spinner(frame, area, muted_color, tick);
}
_ => {}
}
}
/// Render a centered spinner with message.
fn render_centered_spinner(
frame: &mut Frame<'_>,
area: Rect,
message: &str,
color: PackedRgba,
tick: u64,
) {
if area.height == 0 || area.width < 5 {
return;
}
let spinner = spinner_char(tick);
let text = format!("{spinner} {message}");
let text_len = text.len() as u16;
// Center horizontally and vertically.
let x = area
.x
.saturating_add(area.width.saturating_sub(text_len) / 2);
let y = area.y.saturating_add(area.height / 2);
let cell = Cell {
fg: color,
..Cell::default()
};
frame.print_text_clipped(x, y, &text, cell, area.right());
}
/// Render a subtle spinner in the top-right corner.
fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) {
if area.width < 2 || area.height == 0 {
return;
}
let spinner = spinner_char(tick);
let x = area.right().saturating_sub(2);
let y = area.y;
let cell = Cell {
fg: color,
..Cell::default()
};
frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right());
}
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_loading_initial_renders_spinner() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::LoadingInitial,
white(),
gray(),
0,
);
let center_y = 12u16;
let has_content = (0..80u16).any(|x| {
let cell = frame.buffer.get(x, center_y).unwrap();
!cell.is_empty()
});
assert!(has_content, "Expected loading spinner at center row");
});
}
#[test]
fn test_loading_refreshing_renders_corner() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::Refreshing,
white(),
gray(),
0,
);
let cell = frame.buffer.get(78, 0).unwrap();
assert!(!cell.is_empty(), "Expected corner spinner");
});
}
#[test]
fn test_loading_idle_noop() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::Idle,
white(),
gray(),
0,
);
let has_content = (0..80u16).any(|x| {
(0..24u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(!has_content, "Idle state should render nothing");
});
}
#[test]
fn test_spinner_animation_cycles() {
let frame0 = spinner_char(0);
let frame1 = spinner_char(1);
let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64);
assert_ne!(frame0, frame1, "Adjacent frames should differ");
assert_eq!(frame0, frame_wrap, "Should wrap around");
}
}

View File

@@ -0,0 +1,43 @@
//! Common widgets shared across all TUI screens.
//!
//! Each widget is a pure rendering function — writes directly into the
//! [`Frame`] buffer using ftui's `Draw` trait. No state mutation,
//! no side effects.
mod breadcrumb;
pub mod cross_ref;
pub mod discussion_tree;
pub mod entity_table;
mod error_toast;
pub mod filter_bar;
mod help_overlay;
mod loading;
mod status_bar;
pub use breadcrumb::render_breadcrumb;
pub use cross_ref::{CrossRef, CrossRefColors, CrossRefKind, CrossRefState, render_cross_refs};
pub use discussion_tree::{
DiscussionNode, DiscussionTreeColors, DiscussionTreeState, NoteNode, format_relative_time,
render_discussion_tree,
};
pub use entity_table::{ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table};
pub use error_toast::render_error_toast;
pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
pub use help_overlay::render_help_overlay;
pub use loading::render_loading;
pub use status_bar::render_status_bar;
/// Truncate a string to at most `max_chars` display characters.
///
/// Uses Unicode ellipsis `…` for truncation. If `max_chars` is too small
/// for an ellipsis (<=1), just truncates without one.
pub fn truncate_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else if max_chars <= 1 {
s.chars().take(max_chars).collect()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}\u{2026}")
}
}

View File

@@ -0,0 +1,173 @@
//! Bottom status bar with key hints and mode indicator.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::commands::CommandRegistry;
use crate::message::{InputMode, Screen};
/// Render the bottom status bar with key hints and mode indicator.
///
/// Layout: `[mode] ─── [key hints]`
///
/// Key hints are sourced from the [`CommandRegistry`] filtered to the
/// current screen, showing only the most important bindings.
#[allow(clippy::too_many_arguments)]
pub fn render_status_bar(
frame: &mut Frame<'_>,
area: Rect,
registry: &CommandRegistry,
screen: &Screen,
mode: &InputMode,
bar_bg: PackedRgba,
text_color: PackedRgba,
accent_color: PackedRgba,
) {
if area.height == 0 || area.width < 5 {
return;
}
// Fill the bar background.
let bg_cell = Cell {
bg: bar_bg,
..Cell::default()
};
frame.draw_rect_filled(area, bg_cell);
let mode_label = match mode {
InputMode::Normal => "NORMAL",
InputMode::Text => "INPUT",
InputMode::Palette => "PALETTE",
InputMode::GoPrefix { .. } => "g...",
};
// Left side: mode indicator.
let mode_cell = Cell {
fg: accent_color,
bg: bar_bg,
..Cell::default()
};
let mut x = frame.print_text_clipped(
area.x.saturating_add(1),
area.y,
mode_label,
mode_cell,
area.right(),
);
// Spacer.
x = x.saturating_add(2);
// Right side: key hints from registry (formatted as "key:action").
let hints = registry.status_hints(screen);
let hint_cell = Cell {
fg: text_color,
bg: bar_bg,
..Cell::default()
};
let key_cell = Cell {
fg: accent_color,
bg: bar_bg,
..Cell::default()
};
for hint in &hints {
if x >= area.right().saturating_sub(1) {
break;
}
// Split "q:quit" into key part and description part.
if let Some((key_part, desc_part)) = hint.split_once(':') {
x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right());
x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right());
x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right());
} else {
x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right());
}
x = x.saturating_add(2);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_status_bar_renders_mode() {
with_frame!(80, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 80, 1),
&registry,
&Screen::Dashboard,
&InputMode::Normal,
gray(),
white(),
white(),
);
let n_cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(n_cell.content.as_char(), Some('N'));
});
}
#[test]
fn test_status_bar_text_mode() {
with_frame!(80, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 80, 1),
&registry,
&Screen::Search,
&InputMode::Text,
gray(),
white(),
white(),
);
let i_cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(i_cell.content.as_char(), Some('I'));
});
}
#[test]
fn test_status_bar_narrow_terminal() {
with_frame!(4, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 4, 1),
&registry,
&Screen::Dashboard,
&InputMode::Normal,
gray(),
white(),
white(),
);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.is_empty());
});
}
}

View File

@@ -0,0 +1,554 @@
#![allow(dead_code)] // Phase 2: wired into render_screen dispatch
//! Dashboard screen view — entity counts, project sync status, recent activity.
//!
//! Responsive layout using [`crate::layout::classify_width`]:
//! - Wide (Lg/Xl, >=120 cols): 3-column `[Stats | Projects | Recent]`
//! - Medium (Md, 90119): 2-column `[Stats+Projects | Recent]`
//! - Narrow (Xs/Sm, <90): single column stacked
use ftui::core::geometry::Rect;
use ftui::layout::{Breakpoint, Constraint, Flex};
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::dashboard::{DashboardState, EntityCounts, LastSyncInfo, RecentActivityItem};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — will use injected Theme in a later phase)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the full dashboard screen into `area`.
pub fn render_dashboard(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
if area.height < 2 || area.width < 10 {
return; // Too small to render.
}
let bp = classify_width(area.width);
match bp {
Breakpoint::Lg | Breakpoint::Xl => render_wide(frame, state, area),
Breakpoint::Md => render_medium(frame, state, area),
Breakpoint::Xs | Breakpoint::Sm => render_narrow(frame, state, area),
}
}
// ---------------------------------------------------------------------------
// Layout variants
// ---------------------------------------------------------------------------
/// Wide: 3-column [Stats | Projects | Recent Activity].
fn render_wide(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
let cols = Flex::horizontal()
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(area);
render_stat_panel(frame, &state.counts, cols[0]);
render_project_list(frame, state, cols[1]);
render_recent_activity(frame, state, cols[2]);
}
/// Medium: 2-column [Stats+Projects stacked | Recent Activity].
fn render_medium(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
let cols = Flex::horizontal()
.constraints([Constraint::Ratio(2, 5), Constraint::Ratio(3, 5)])
.split(area);
// Left column: stats on top, projects below.
let left_rows = Flex::vertical()
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(cols[0]);
render_stat_panel(frame, &state.counts, left_rows[0]);
render_project_list(frame, state, left_rows[1]);
render_recent_activity(frame, state, cols[1]);
}
/// Narrow: single column stacked.
fn render_narrow(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
let rows = Flex::vertical()
.constraints([
Constraint::Fixed(8), // stats
Constraint::Fixed(4), // projects (compact)
Constraint::Fill, // recent
])
.split(area);
render_stat_panel(frame, &state.counts, rows[0]);
render_project_list(frame, state, rows[1]);
render_recent_activity(frame, state, rows[2]);
}
// ---------------------------------------------------------------------------
// Panels
// ---------------------------------------------------------------------------
/// Entity counts panel.
fn render_stat_panel(frame: &mut Frame<'_>, counts: &EntityCounts, area: Rect) {
if area.height == 0 || area.width < 5 {
return;
}
let title_cell = Cell {
fg: ACCENT,
..Cell::default()
};
let label_cell = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let value_cell = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let x = area.x.saturating_add(1); // 1-char left padding
// Title
frame.print_text_clipped(x, y, "Entity Counts", title_cell, max_x);
y = y.saturating_add(1);
// Separator
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
y = y.saturating_add(1);
// Stats rows
let stats: &[(&str, String)] = &[
(
"Issues",
format!("{} open / {}", counts.issues_open, counts.issues_total),
),
(
"MRs",
format!("{} open / {}", counts.mrs_open, counts.mrs_total),
),
("Discussions", counts.discussions.to_string()),
(
"Notes",
format!(
"{} ({}% system)",
counts.notes_total, counts.notes_system_pct
),
),
("Documents", counts.documents.to_string()),
("Embeddings", counts.embeddings.to_string()),
];
for (label, value) in stats {
if y >= area.y.saturating_add(area.height) {
break;
}
let after_label = frame.print_text_clipped(x, y, label, label_cell, max_x);
let after_colon = frame.print_text_clipped(after_label, y, ": ", label_cell, max_x);
frame.print_text_clipped(after_colon, y, value, value_cell, max_x);
y = y.saturating_add(1);
}
}
/// Per-project sync freshness list.
fn render_project_list(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
if area.height == 0 || area.width < 5 {
return;
}
let title_cell = Cell {
fg: ACCENT,
..Cell::default()
};
let label_cell = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let x = area.x.saturating_add(1);
frame.print_text_clipped(x, y, "Projects", title_cell, max_x);
y = y.saturating_add(1);
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
y = y.saturating_add(1);
if state.projects.is_empty() {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x, y, "No projects synced", muted, max_x);
return;
}
for proj in &state.projects {
if y >= area.y.saturating_add(area.height) {
break;
}
let freshness_color = staleness_color(proj.minutes_since_sync);
let freshness_cell = Cell {
fg: freshness_color,
..Cell::default()
};
let indicator = staleness_indicator(proj.minutes_since_sync);
let after_dot = frame.print_text_clipped(x, y, &indicator, freshness_cell, max_x);
let after_space = frame.print_text_clipped(after_dot, y, " ", label_cell, max_x);
frame.print_text_clipped(after_space, y, &proj.path, label_cell, max_x);
y = y.saturating_add(1);
}
// Last sync summary if available.
if let Some(ref sync) = state.last_sync
&& y < area.y.saturating_add(area.height)
{
y = y.saturating_add(1); // blank line
render_sync_summary(frame, sync, x, y, max_x);
}
}
/// Scrollable recent activity list.
fn render_recent_activity(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
if area.height == 0 || area.width < 5 {
return;
}
let title_cell = Cell {
fg: ACCENT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let x = area.x.saturating_add(1);
frame.print_text_clipped(x, y, "Recent Activity", title_cell, max_x);
y = y.saturating_add(1);
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
y = y.saturating_add(1);
if state.recent.is_empty() {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x, y, "No recent activity", muted, max_x);
return;
}
let visible_rows = (area.y.saturating_add(area.height)).saturating_sub(y) as usize;
let items = &state.recent;
let start = state.scroll_offset.min(items.len().saturating_sub(1));
let end = (start + visible_rows).min(items.len());
for item in &items[start..end] {
if y >= area.y.saturating_add(area.height) {
break;
}
render_activity_row(frame, item, x, y, max_x);
y = y.saturating_add(1);
}
// Scroll indicator if there's more content.
if end < items.len() && y < area.y.saturating_add(area.height) {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let remaining = items.len() - end;
frame.print_text_clipped(x, y, &format!("... {remaining} more"), muted, max_x);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Render a single recent activity row.
fn render_activity_row(
frame: &mut Frame<'_>,
item: &RecentActivityItem,
x: u16,
y: u16,
max_x: u16,
) {
let type_color = if item.entity_type == "issue" {
CYAN
} else {
ACCENT
};
let type_cell = Cell {
fg: type_color,
..Cell::default()
};
let text_cell = Cell {
fg: TEXT,
..Cell::default()
};
let muted_cell = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let type_label = if item.entity_type == "issue" {
format!("#{}", item.iid)
} else {
format!("!{}", item.iid)
};
let after_type = frame.print_text_clipped(x, y, &type_label, type_cell, max_x);
let after_space = frame.print_text_clipped(after_type, y, " ", text_cell, max_x);
// Truncate title to leave room for time.
let time_str = format_relative_time(item.minutes_ago);
let time_width = time_str.len() as u16 + 2; // " " + time
let title_max = max_x.saturating_sub(time_width);
let after_title = frame.print_text_clipped(after_space, y, &item.title, text_cell, title_max);
// Right-align time string.
let time_x = max_x.saturating_sub(time_str.len() as u16 + 1);
if time_x > after_title {
frame.print_text_clipped(time_x, y, &time_str, muted_cell, max_x);
}
}
/// Render a last-sync summary line.
fn render_sync_summary(frame: &mut Frame<'_>, sync: &LastSyncInfo, x: u16, y: u16, max_x: u16) {
let status_color = if sync.status == "succeeded" {
GREEN
} else {
RED
};
let cell = Cell {
fg: status_color,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let label_end = frame.print_text_clipped(x, y, "Last sync: ", muted, max_x);
let status_end = frame.print_text_clipped(label_end, y, &sync.status, cell, max_x);
if let Some(ref err) = sync.error {
let err_cell = Cell {
fg: RED,
..Cell::default()
};
let after_space = frame.print_text_clipped(status_end, y, "", muted, max_x);
frame.print_text_clipped(after_space, y, err, err_cell, max_x);
}
}
/// Draw a horizontal rule across a row.
fn render_horizontal_rule(frame: &mut Frame<'_>, x: u16, y: u16, width: u16, color: PackedRgba) {
let cell = Cell {
fg: color,
..Cell::default()
};
let rule = "".repeat(width as usize);
frame.print_text_clipped(x, y, &rule, cell, x.saturating_add(width));
}
/// Staleness color: green <60min, yellow <360min, red >360min.
const fn staleness_color(minutes: u64) -> PackedRgba {
if minutes == u64::MAX {
RED // Never synced.
} else if minutes < 60 {
GREEN
} else if minutes < 360 {
YELLOW
} else {
RED
}
}
/// Staleness dot indicator.
fn staleness_indicator(minutes: u64) -> String {
if minutes == u64::MAX {
"● never".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if minutes < 1440 {
format!("{}h ago", minutes / 60)
} else {
format!("{}d ago", minutes / 1440)
}
}
/// Format relative time for activity feed.
fn format_relative_time(minutes: u64) -> String {
if minutes == 0 {
"just now".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if minutes < 1440 {
format!("{}h ago", minutes / 60)
} else {
format!("{}d ago", minutes / 1440)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::dashboard::{DashboardData, EntityCounts, ProjectSyncInfo};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_state() -> DashboardState {
let mut state = DashboardState::default();
state.update(DashboardData {
counts: EntityCounts {
issues_open: 42,
issues_total: 100,
mrs_open: 10,
mrs_total: 50,
discussions: 200,
notes_total: 500,
notes_system_pct: 30,
documents: 80,
embeddings: 75,
},
projects: vec![
ProjectSyncInfo {
path: "group/alpha".into(),
minutes_since_sync: 15,
},
ProjectSyncInfo {
path: "group/beta".into(),
minutes_since_sync: 120,
},
],
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 42,
title: "Fix authentication bug".into(),
state: "opened".into(),
minutes_ago: 5,
}],
last_sync: None,
});
state
}
#[test]
fn test_render_dashboard_wide_no_panic() {
with_frame!(140, 30, |frame| {
let state = sample_state();
let area = Rect::new(0, 0, 140, 30);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_medium_no_panic() {
with_frame!(100, 24, |frame| {
let state = sample_state();
let area = Rect::new(0, 0, 100, 24);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_narrow_no_panic() {
with_frame!(60, 20, |frame| {
let state = sample_state();
let area = Rect::new(0, 0, 60, 20);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_tiny_noop() {
with_frame!(5, 1, |frame| {
let state = DashboardState::default();
let area = Rect::new(0, 0, 5, 1);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_empty_state_no_panic() {
with_frame!(120, 24, |frame| {
let state = DashboardState::default();
let area = Rect::new(0, 0, 120, 24);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_staleness_color_thresholds() {
assert_eq!(staleness_color(0), GREEN);
assert_eq!(staleness_color(59), GREEN);
assert_eq!(staleness_color(60), YELLOW);
assert_eq!(staleness_color(359), YELLOW);
assert_eq!(staleness_color(360), RED);
assert_eq!(staleness_color(u64::MAX), RED);
}
#[test]
fn test_staleness_indicator() {
assert_eq!(staleness_indicator(15), "● 15m ago");
assert_eq!(staleness_indicator(120), "● 2h ago");
assert_eq!(staleness_indicator(2880), "● 2d ago");
assert_eq!(staleness_indicator(u64::MAX), "● never");
}
#[test]
fn test_format_relative_time() {
assert_eq!(format_relative_time(0), "just now");
assert_eq!(format_relative_time(5), "5m ago");
assert_eq!(format_relative_time(90), "1h ago");
assert_eq!(format_relative_time(1500), "1d ago");
}
#[test]
fn test_stat_panel_renders_title() {
with_frame!(40, 10, |frame| {
let counts = EntityCounts {
issues_open: 3,
issues_total: 10,
..Default::default()
};
render_stat_panel(&mut frame, &counts, Rect::new(0, 0, 40, 10));
// Check that 'E' from "Entity Counts" is rendered at x=1, y=0.
let cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('E'), "Expected 'E' at (1,0)");
});
}
}

View File

@@ -0,0 +1,297 @@
//! Doctor screen view — health check results.
//!
//! Renders a vertical list of health checks with colored status
//! indicators (green PASS, yellow WARN, red FAIL).
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::doctor::{DoctorState, HealthStatus};
use super::{TEXT, TEXT_MUTED};
/// Pass green.
const PASS_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
/// Warning yellow.
const WARN_FG: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
/// Fail red.
const FAIL_FG: PackedRgba = PackedRgba::rgb(0xD1, 0x4D, 0x41);
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the doctor screen.
pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) {
if area.width < 10 || area.height < 3 {
return;
}
let max_x = area.right();
if !state.loaded {
// Not yet loaded — show centered prompt.
let msg = "Loading health checks...";
let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
let y = area.y + area.height / 2;
frame.print_text_clipped(
x,
y,
msg,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
return;
}
// Title.
let overall = state.overall_status();
let title_fg = status_color(overall);
let title = format!("Doctor — {}", overall.label());
frame.print_text_clipped(
area.x + 2,
area.y + 1,
&title,
Cell {
fg: title_fg,
..Cell::default()
},
max_x,
);
// Summary line.
let pass_count = state.count_by_status(HealthStatus::Pass);
let warn_count = state.count_by_status(HealthStatus::Warn);
let fail_count = state.count_by_status(HealthStatus::Fail);
let summary = format!(
"{} passed, {} warnings, {} failed",
pass_count, warn_count, fail_count
);
frame.print_text_clipped(
area.x + 2,
area.y + 2,
&summary,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
// Health check rows — name column adapts to breakpoint.
let bp = classify_width(area.width);
let rows_start_y = area.y + 4;
let name_width = match bp {
ftui::layout::Breakpoint::Xs => 10u16,
ftui::layout::Breakpoint::Sm => 13,
_ => 16,
};
for (i, check) in state.checks.iter().enumerate() {
let y = rows_start_y + i as u16;
if y >= area.bottom().saturating_sub(2) {
break;
}
// Status badge.
let badge = format!("[{}]", check.status.label());
let badge_fg = status_color(check.status);
frame.print_text_clipped(
area.x + 2,
y,
&badge,
Cell {
fg: badge_fg,
..Cell::default()
},
max_x,
);
// Check name.
let name_x = area.x + 2 + 7; // "[PASS] " = 7 chars
let name = format!("{:<width$}", check.name, width = name_width as usize);
frame.print_text_clipped(
name_x,
y,
&name,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
// Detail text.
let detail_x = name_x + name_width;
let max_detail = area.right().saturating_sub(detail_x + 1) as usize;
let detail = if check.detail.len() > max_detail {
format!(
"{}...",
&check.detail[..check
.detail
.floor_char_boundary(max_detail.saturating_sub(3))]
)
} else {
check.detail.clone()
};
frame.print_text_clipped(
detail_x,
y,
&detail,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// Hint at bottom.
let hint_y = area.bottom().saturating_sub(1);
frame.print_text_clipped(
area.x + 2,
hint_y,
"Esc: back | lore doctor (full check)",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
/// Map health status to a display color.
fn status_color(status: HealthStatus) -> PackedRgba {
match status {
HealthStatus::Pass => PASS_FG,
HealthStatus::Warn => WARN_FG,
HealthStatus::Fail => FAIL_FG,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::doctor::HealthCheck;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_checks() -> Vec<HealthCheck> {
vec![
HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "/home/user/.config/lore/config.json".into(),
},
HealthCheck {
name: "Database".into(),
status: HealthStatus::Pass,
detail: "schema v12".into(),
},
HealthCheck {
name: "Projects".into(),
status: HealthStatus::Warn,
detail: "0 projects configured".into(),
},
HealthCheck {
name: "FTS Index".into(),
status: HealthStatus::Fail,
detail: "No documents indexed".into(),
},
]
}
#[test]
fn test_render_not_loaded() {
with_frame!(80, 24, |frame| {
let state = DoctorState::default();
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_with_checks() {
with_frame!(80, 24, |frame| {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_all_pass() {
with_frame!(80, 24, |frame| {
let mut state = DoctorState::default();
state.apply_checks(vec![HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "ok".into(),
}]);
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_tiny_terminal() {
with_frame!(8, 2, |frame| {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
// Should not panic.
});
}
#[test]
fn test_render_narrow_terminal_truncates() {
with_frame!(40, 20, |frame| {
let mut state = DoctorState::default();
state.apply_checks(vec![HealthCheck {
name: "Database".into(),
status: HealthStatus::Pass,
detail: "This is a very long detail string that should be truncated".into(),
}]);
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_many_checks_clips() {
with_frame!(80, 10, |frame| {
let mut state = DoctorState::default();
let mut checks = Vec::new();
for i in 0..20 {
checks.push(HealthCheck {
name: format!("Check {i}"),
status: HealthStatus::Pass,
detail: "ok".into(),
});
}
state.apply_checks(checks);
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
// Should clip without panicking.
});
}
}

View File

@@ -0,0 +1,606 @@
#![allow(dead_code)]
//! File History view — renders per-file MR timeline with rename chains.
//!
//! Layout:
//! ```text
//! +-----------------------------------+
//! | Path: [src/lib.rs_] [R] [M] [D] | <- path input + option toggles
//! | Rename chain: a.rs -> b.rs -> ... | <- shown when renames followed
//! | 5 merge requests across 2 paths | <- summary line
//! +-----------------------------------+
//! | > !42 Fix auth @alice modified ... | <- MR list (selected = >)
//! | !39 Refactor @bob renamed ... |
//! | @carol: "This looks off..." | <- inline discussion (if toggled)
//! +-----------------------------------+
//! | r:renames m:merged d:discussions | <- hint bar
//! +-----------------------------------+
//! ```
use ftui::layout::Breakpoint;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, TEXT, TEXT_MUTED};
use crate::layout::classify_width;
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
use crate::text_width::cursor_cell_offset;
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — screen-specific)
// ---------------------------------------------------------------------------
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x31); // bg-3
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the File History screen.
pub fn render_file_history(
frame: &mut Frame<'_>,
state: &FileHistoryState,
area: ftui::core::geometry::Rect,
) {
if area.width < 10 || area.height < 3 {
return; // Terminal too small.
}
let bp = classify_width(area.width);
let x = area.x;
let max_x = area.right();
let width = area.width;
let mut y = area.y;
// --- Path input bar ---
render_path_input(frame, state, x, y, width);
y += 1;
if area.height < 5 {
return;
}
// --- Option toggles indicator ---
render_toggle_indicators(frame, state, x, y, width);
y += 1;
// --- Loading indicator ---
if state.loading {
render_loading(frame, x, y, max_x);
return;
}
let Some(result) = &state.result else {
render_empty_state(frame, x, y, max_x);
return;
};
// --- Rename chain (if followed) ---
if result.renames_followed && result.rename_chain.len() > 1 {
render_rename_chain(frame, &result.rename_chain, x, y, max_x);
y += 1;
}
// --- Summary line ---
render_summary(frame, result, x, y, max_x);
y += 1;
if result.merge_requests.is_empty() {
render_no_results(frame, x, y, max_x);
return;
}
// Reserve 1 row for hint bar at the bottom.
let hint_y = area.bottom().saturating_sub(1);
let list_height = hint_y.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
// --- MR list ---
render_mr_list(frame, result, state, x, y, width, list_height, bp);
// --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x);
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
fn render_path_input(frame: &mut Frame<'_>, state: &FileHistoryState, x: u16, y: u16, width: u16) {
let max_x = x + width;
let label = "Path: ";
let label_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let after_label = frame.print_text_clipped(x, y, label, label_style, max_x);
// Input text.
let input_style = Cell {
fg: if state.path_focused { TEXT } else { TEXT_MUTED },
..Cell::default()
};
let display_text = if state.path_input.is_empty() && !state.path_focused {
"type a file path..."
} else {
&state.path_input
};
frame.print_text_clipped(after_label, y, display_text, input_style, max_x);
// Cursor indicator.
if state.path_focused {
let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
if cursor_x < max_x {
let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg
bg: TEXT,
..Cell::default()
};
let ch = state
.path_input
.get(state.path_cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &ch.to_string(), cursor_cell, max_x);
}
}
}
fn render_toggle_indicators(
frame: &mut Frame<'_>,
state: &FileHistoryState,
x: u16,
y: u16,
width: u16,
) {
let max_x = x + width;
let on_style = Cell {
fg: GREEN,
..Cell::default()
};
let off_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let renames_tag = if state.follow_renames {
"[renames:on]"
} else {
"[renames:off]"
};
let merged_tag = if state.merged_only {
"[merged:on]"
} else {
"[merged:off]"
};
let disc_tag = if state.show_discussions {
"[disc:on]"
} else {
"[disc:off]"
};
let renames_style = if state.follow_renames {
on_style
} else {
off_style
};
let merged_style = if state.merged_only {
on_style
} else {
off_style
};
let disc_style = if state.show_discussions {
on_style
} else {
off_style
};
let after_r = frame.print_text_clipped(x + 1, y, renames_tag, renames_style, max_x);
let after_m = frame.print_text_clipped(after_r + 1, y, merged_tag, merged_style, max_x);
frame.print_text_clipped(after_m + 1, y, disc_tag, disc_style, max_x);
}
fn render_rename_chain(frame: &mut Frame<'_>, chain: &[String], x: u16, y: u16, max_x: u16) {
let label_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let chain_style = Cell {
fg: CYAN,
..Cell::default()
};
let after_label = frame.print_text_clipped(x + 1, y, "Renames: ", label_style, max_x);
let chain_str = chain.join(" -> ");
frame.print_text_clipped(after_label, y, &chain_str, chain_style, max_x);
}
fn render_summary(frame: &mut Frame<'_>, result: &FileHistoryResult, x: u16, y: u16, max_x: u16) {
let summary = if result.paths_searched > 1 {
format!(
"{} merge request{} across {} paths",
result.total_mrs,
if result.total_mrs == 1 { "" } else { "s" },
result.paths_searched,
)
} else {
format!(
"{} merge request{}",
result.total_mrs,
if result.total_mrs == 1 { "" } else { "s" },
)
};
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
}
/// Responsive truncation widths for file history MR rows.
const fn fh_title_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 25,
Breakpoint::Md => 35,
Breakpoint::Lg | Breakpoint::Xl => 55,
}
}
const fn fh_author_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
const fn fh_disc_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 25,
Breakpoint::Sm => 40,
Breakpoint::Md => 60,
Breakpoint::Lg | Breakpoint::Xl => 80,
}
}
#[allow(clippy::too_many_arguments)]
fn render_mr_list(
frame: &mut Frame<'_>,
result: &FileHistoryResult,
state: &FileHistoryState,
x: u16,
start_y: u16,
width: u16,
height: usize,
bp: Breakpoint,
) {
let max_x = x + width;
let offset = state.scroll_offset as usize;
let title_max = fh_title_max(bp);
let author_max = fh_author_max(bp);
for (i, mr) in result
.merge_requests
.iter()
.skip(offset)
.enumerate()
.take(height)
{
let y = start_y + i as u16;
let row_idx = offset + i;
let selected = row_idx == state.selected_mr_index;
// Selection background.
if selected {
let bg_cell = Cell {
bg: SELECTION_BG,
..Cell::default()
};
for col in x..max_x {
frame.buffer.set(col, y, bg_cell);
}
}
// State icon.
let (icon, icon_color) = match mr.state.as_str() {
"merged" => ("M", GREEN),
"opened" => ("O", YELLOW),
"closed" => ("C", RED),
_ => ("?", TEXT_MUTED),
};
let prefix = if selected { "> " } else { " " };
let sel_bg = if selected { SELECTION_BG } else { BG_SURFACE };
let prefix_style = Cell {
fg: ACCENT,
bg: sel_bg,
..Cell::default()
};
let after_prefix = frame.print_text_clipped(x, y, prefix, prefix_style, max_x);
let icon_style = Cell {
fg: icon_color,
bg: sel_bg,
..Cell::default()
};
let after_icon = frame.print_text_clipped(after_prefix, y, icon, icon_style, max_x);
// !iid
let iid_str = format!(" !{}", mr.iid);
let ref_style = Cell {
fg: ACCENT,
bg: sel_bg,
..Cell::default()
};
let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title (responsive truncation).
let title = truncate_str(&mr.title, title_max);
let title_style = Cell {
fg: TEXT,
bg: sel_bg,
..Cell::default()
};
let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x);
// @author + change_type (responsive author width).
let meta = format!(
"@{} {}",
truncate_str(&mr.author_username, author_max),
mr.change_type
);
let meta_style = Cell {
fg: TEXT_MUTED,
bg: sel_bg,
..Cell::default()
};
frame.print_text_clipped(after_title + 1, y, &meta, meta_style, max_x);
}
// Inline discussion snippets (rendered beneath MRs when toggled on).
if state.show_discussions && !result.discussions.is_empty() {
let visible_mrs = result
.merge_requests
.len()
.saturating_sub(offset)
.min(height);
let disc_start_y = start_y + visible_mrs as u16;
let remaining = height.saturating_sub(visible_mrs);
render_discussions(frame, result, x, disc_start_y, max_x, remaining, bp);
}
}
fn render_discussions(
frame: &mut Frame<'_>,
result: &FileHistoryResult,
x: u16,
start_y: u16,
max_x: u16,
max_rows: usize,
bp: Breakpoint,
) {
if max_rows == 0 {
return;
}
let disc_max = fh_disc_max(bp);
let sep_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, start_y, "-- discussions --", sep_style, max_x);
let disc_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
for (i, disc) in result
.discussions
.iter()
.enumerate()
.take(max_rows.saturating_sub(1))
{
let y = start_y + 1 + i as u16;
let after_author = frame.print_text_clipped(
x + 2,
y,
&format!("@{}: ", disc.author_username),
author_style,
max_x,
);
let snippet = truncate_str(&disc.body_snippet, disc_max);
frame.print_text_clipped(after_author, y, &snippet, disc_style, max_x);
}
}
fn render_loading(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: ACCENT,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, "Loading file history...", style, max_x);
}
fn render_empty_state(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(
x + 1,
y,
"Enter a file path and press Enter to search.",
style,
max_x,
);
}
fn render_no_results(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, "No MRs found for this file.", style, max_x);
frame.print_text_clipped(
x + 1,
y + 1,
"Hint: Ensure 'lore sync' has fetched MR file changes.",
style,
max_x,
);
}
fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
// Fill background.
for col in x..max_x {
frame.buffer.set(col, y, style);
}
let hints = "/:path r:renames m:merged d:discussions Enter:open MR q:back";
frame.print_text_clipped(x + 1, y, hints, style, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::file_history::{FileHistoryMr, FileHistoryResult, FileHistoryState};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_area(w: u16, h: u16) -> ftui::core::geometry::Rect {
ftui::core::geometry::Rect {
x: 0,
y: 0,
width: w,
height: h,
}
}
#[test]
fn test_render_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = FileHistoryState::default();
render_file_history(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_tiny_terminal_noop() {
with_frame!(5, 2, |frame| {
let state = FileHistoryState::default();
render_file_history(&mut frame, &state, test_area(5, 2));
});
}
#[test]
fn test_render_loading() {
with_frame!(80, 24, |frame| {
let state = FileHistoryState {
loading: true,
..FileHistoryState::default()
};
render_file_history(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_with_results() {
with_frame!(100, 30, |frame| {
let state = FileHistoryState {
result: Some(FileHistoryResult {
path: "src/lib.rs".into(),
rename_chain: vec!["src/lib.rs".into()],
renames_followed: false,
merge_requests: vec![
FileHistoryMr {
iid: 42,
title: "Fix authentication flow".into(),
state: "merged".into(),
author_username: "alice".into(),
change_type: "modified".into(),
merged_at_ms: Some(1_700_000_000_000),
updated_at_ms: 1_700_000_000_000,
merge_commit_sha: Some("abc123".into()),
},
FileHistoryMr {
iid: 39,
title: "Refactor module structure".into(),
state: "opened".into(),
author_username: "bob".into(),
change_type: "renamed".into(),
merged_at_ms: None,
updated_at_ms: 1_699_000_000_000,
merge_commit_sha: None,
},
],
discussions: vec![],
total_mrs: 2,
paths_searched: 1,
}),
..FileHistoryState::default()
};
render_file_history(&mut frame, &state, test_area(100, 30));
});
}
#[test]
fn test_render_with_rename_chain() {
with_frame!(80, 24, |frame| {
let state = FileHistoryState {
result: Some(FileHistoryResult {
path: "src/old.rs".into(),
rename_chain: vec!["src/old.rs".into(), "src/new.rs".into()],
renames_followed: true,
merge_requests: vec![],
discussions: vec![],
total_mrs: 0,
paths_searched: 2,
}),
..FileHistoryState::default()
};
render_file_history(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 5), "hell…");
assert_eq!(truncate_str("", 5), "");
}
}

View File

@@ -0,0 +1,673 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Issue detail screen view.
//!
//! Composes metadata header, description, discussion tree, and
//! cross-references into a scrollable detail layout. Supports
//! progressive hydration: metadata renders immediately while
//! discussions load async in Phase 2.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use ftui::layout::Breakpoint;
use crate::layout::{classify_width, detail_side_panel};
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — will use injected Theme in a later phase)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
// ---------------------------------------------------------------------------
// Color constructors
// ---------------------------------------------------------------------------
fn discussion_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: CYAN,
timestamp_fg: TEXT_MUTED,
body_fg: TEXT,
system_fg: TEXT_MUTED,
diff_path_fg: GREEN,
resolved_fg: TEXT_MUTED,
guide_fg: BORDER,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
expand_fg: ACCENT,
}
}
fn cross_ref_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: ACCENT,
label_fg: TEXT,
muted_fg: TEXT_MUTED,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full issue detail screen.
///
/// Layout:
/// ```text
/// Row 0: #42 Fix authentication flow (title bar)
/// Row 1: opened | alice | backend, security (metadata row)
/// Row 2: Milestone: v1.0 | Due: 2026-03-01 (optional)
/// Row 3: ─────────────────────────────────── (separator)
/// Row 4..N: Description text... (scrollable)
/// ─────────────────────────────────── (separator)
/// Discussions (3) (section header)
/// ▶ alice: Fixed the login flow... (collapsed)
/// ▼ bob: I think we should also... (expanded)
/// bob: body line 1...
/// ─────────────────────────────────── (separator)
/// Cross References (section header)
/// [MR] !10 Fix authentication MR
/// ```
pub fn render_issue_detail(
frame: &mut Frame<'_>,
state: &IssueDetailState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 3 || area.width < 10 {
return;
}
let Some(ref meta) = state.metadata else {
// No metadata yet — the loading spinner handles this.
return;
};
let bp = classify_width(area.width);
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
// --- Title bar ---
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, bp, area.x, y, max_x);
// --- Optional milestone / due date row (skip on Xs — too narrow) ---
if !matches!(bp, Breakpoint::Xs) && (meta.milestone.is_some() || meta.due_date.is_some()) {
y = render_milestone_row(frame, meta, area.x, y, max_x);
}
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
let bottom = area.y.saturating_add(area.height);
if y >= bottom {
return;
}
// Remaining space is split between description, discussions, and cross-refs.
let remaining = bottom.saturating_sub(y);
// Compute section heights based on content.
let desc_lines = count_description_lines(meta, area.width);
let disc_count = state.discussions.len();
let xref_count = state.cross_refs.len();
let wide = detail_side_panel(bp);
let (desc_h, disc_h, xref_h) =
allocate_sections(remaining, desc_lines, disc_count, xref_count, wide);
// --- Description section ---
if desc_h > 0 {
let desc_area = Rect::new(area.x, y, area.width, desc_h);
let is_focused = state.active_section == DetailSection::Description;
render_description(frame, meta, state.description_scroll, desc_area, is_focused);
y += desc_h;
}
// --- Separator before discussions ---
if (disc_h > 0 || xref_h > 0) && y < bottom {
y = render_separator(frame, area.x, y, area.width);
}
// --- Discussions section ---
if disc_h > 0 && y < bottom {
let header_h = 1;
let is_focused = state.active_section == DetailSection::Discussions;
// Section header.
render_section_header(
frame,
&format!("Discussions ({})", state.discussions.len()),
area.x,
y,
max_x,
is_focused,
);
y += header_h;
if !state.discussions_loaded {
// Still loading.
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "Loading discussions...", style, max_x);
y += 1;
} else if state.discussions.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No discussions", style, max_x);
y += 1;
} else {
let tree_height = disc_h.saturating_sub(header_h);
if tree_height > 0 {
let tree_area = Rect::new(area.x, y, area.width, tree_height);
let rendered = render_discussion_tree(
frame,
&state.discussions,
&state.tree_state,
tree_area,
&discussion_colors(),
clock,
);
y += rendered;
}
}
}
// --- Separator before cross-refs ---
if xref_h > 0 && y < bottom {
y = render_separator(frame, area.x, y, area.width);
}
// --- Cross-references section ---
if xref_h > 0 && y < bottom {
let is_focused = state.active_section == DetailSection::CrossRefs;
render_section_header(
frame,
&format!("Cross References ({})", state.cross_refs.len()),
area.x,
y,
max_x,
is_focused,
);
y += 1;
if state.cross_refs.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No cross-references", style, max_x);
} else {
let refs_height = xref_h.saturating_sub(1); // minus header
if refs_height > 0 {
let refs_area = Rect::new(area.x, y, area.width, refs_height);
let _ = render_cross_refs(
frame,
&state.cross_refs,
&state.cross_ref_state,
refs_area,
&cross_ref_colors(),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Sub-renderers
// ---------------------------------------------------------------------------
/// Render the issue title bar: `#42 Fix authentication flow`
fn render_title_bar(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let iid_text = format!("#{} ", meta.iid);
let iid_style = Cell {
fg: ACCENT,
..Cell::default()
};
let title_style = Cell {
fg: TEXT,
..Cell::default()
};
let cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
y + 1
}
/// Render the metadata row: `opened | alice | backend, security`
///
/// Responsive: Xs shows state + author only; Sm adds labels; Md+ adds assignees.
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
bp: Breakpoint,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
"closed" => RED,
_ => TEXT_MUTED,
};
let state_style = Cell {
fg: state_fg,
..Cell::default()
};
let muted_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
// Labels: shown on Sm+
if !matches!(bp, Breakpoint::Xs) && !meta.labels.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let labels_text = meta.labels.join(", ");
cx = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
}
// Assignees: shown on Md+
if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) && !meta.assignees.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let assignees_text = format!("-> {}", meta.assignees.join(", "));
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x);
}
y + 1
}
/// Render optional milestone / due date row.
fn render_milestone_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let mut cx = x;
if let Some(ref ms) = meta.milestone {
cx = frame.print_text_clipped(cx, y, "Milestone: ", muted, max_x);
let val_style = Cell {
fg: TEXT,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, ms, val_style, max_x);
}
if let Some(ref due) = meta.due_date {
if cx > x {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
}
cx = frame.print_text_clipped(cx, y, "Due: ", muted, max_x);
let val_style = Cell {
fg: TEXT,
..Cell::default()
};
let _ = frame.print_text_clipped(cx, y, due, val_style, max_x);
}
y + 1
}
/// Render a horizontal separator line.
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
let sep_style = Cell {
fg: BORDER,
..Cell::default()
};
let line: String = "\u{2500}".repeat(width as usize);
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
y + 1
}
/// Render a section header with focus indicator.
fn render_section_header(
frame: &mut Frame<'_>,
label: &str,
x: u16,
y: u16,
max_x: u16,
is_focused: bool,
) {
if is_focused {
let style = Cell {
fg: SELECTED_FG,
bg: SELECTED_BG,
..Cell::default()
};
// Fill the row with selected background.
frame.draw_rect_filled(Rect::new(x, y, max_x.saturating_sub(x), 1), style);
let _ = frame.print_text_clipped(x, y, label, style, max_x);
} else {
let style = Cell {
fg: ACCENT,
..Cell::default()
};
let _ = frame.print_text_clipped(x, y, label, style, max_x);
}
}
/// Render the description section.
fn render_description(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
scroll: usize,
area: Rect,
_is_focused: bool,
) {
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
let lines: Vec<&str> = safe_desc.lines().collect();
let text_style = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
for (i, line) in lines
.iter()
.skip(scroll)
.take(area.height as usize)
.enumerate()
{
let y = area.y + i as u16;
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
}
}
/// Count the number of visible description lines for layout allocation.
fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize {
if meta.description.is_empty() {
return 0;
}
// Rough estimate: count newlines. Proper word-wrap would need unicode width.
meta.description.lines().count().max(1)
}
/// Allocate vertical space between description, discussions, and cross-refs.
///
/// Priority: description gets min(content, 40%), discussions get most of the
/// remaining space, cross-refs get a fixed portion at the bottom.
/// On wide terminals (`wide = true`), description gets up to 60%.
fn allocate_sections(
available: u16,
desc_lines: usize,
_disc_count: usize,
xref_count: usize,
wide: bool,
) -> (u16, u16, u16) {
if available == 0 {
return (0, 0, 0);
}
let total = available as usize;
// Cross-refs: 1 header + count, max 25% of space.
let xref_need = if xref_count > 0 {
(1 + xref_count).min(total / 4)
} else {
0
};
let after_xref = total.saturating_sub(xref_need);
// Description: up to 40% on narrow, 60% on wide terminals.
let desc_pct = if wide { 3 } else { 2 }; // numerator over 5
let desc_max = after_xref * desc_pct / 5;
let desc_alloc = desc_lines.min(desc_max).min(after_xref);
// Discussions: everything else.
let disc_alloc = after_xref.saturating_sub(desc_alloc);
(desc_alloc as u16, disc_alloc as u16, xref_need as u16)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::EntityKey;
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_metadata() -> IssueMetadata {
IssueMetadata {
iid: 42,
project_path: "group/project".into(),
title: "Fix authentication flow".into(),
description: "The login page has a bug.\nSteps to reproduce:\n1. Go to /login\n2. Enter credentials\n3. Click submit".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into(), "security".into()],
milestone: Some("v1.0".into()),
due_date: Some("2026-03-01".into()),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/project/-/issues/42".into(),
discussion_count: 2,
}
}
fn sample_state_with_metadata() -> IssueDetailState {
let mut state = IssueDetailState::default();
state.load_new(EntityKey::issue(1, 42));
state.apply_metadata(IssueDetailData {
metadata: sample_metadata(),
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 10),
label: "Fix auth MR".into(),
navigable: true,
}],
});
state
}
#[test]
fn test_render_issue_detail_no_metadata_no_panic() {
with_frame!(80, 24, |frame| {
let state = IssueDetailState::default();
let clock = FakeClock::from_ms(1_700_000_000_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_with_metadata_no_panic() {
with_frame!(80, 24, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_tiny_area() {
with_frame!(5, 2, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 5, 2), &clock);
// Should bail early, no panic.
});
}
#[test]
fn test_render_issue_detail_with_discussions() {
with_frame!(80, 40, |frame| {
let mut state = sample_state_with_metadata();
state.apply_discussions(vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![NoteNode {
author: "alice".into(),
body: "I found the bug".into(),
created_at: 1_700_000_000_000,
is_system: false,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}],
resolvable: false,
resolved: false,
}]);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 40), &clock);
});
}
#[test]
fn test_render_issue_detail_discussions_loading() {
with_frame!(80, 24, |frame| {
let state = sample_state_with_metadata();
// discussions_loaded is false by default after load_new.
assert!(!state.discussions_loaded);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_narrow_terminal() {
with_frame!(30, 10, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
});
}
#[test]
fn test_allocate_sections_empty() {
assert_eq!(allocate_sections(0, 5, 3, 2, false), (0, 0, 0));
}
#[test]
fn test_allocate_sections_balanced() {
let (d, disc, x) = allocate_sections(20, 5, 3, 2, false);
assert!(d > 0);
assert!(disc > 0);
assert!(x > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_allocate_sections_no_xrefs() {
let (d, disc, x) = allocate_sections(20, 5, 3, 0, false);
assert_eq!(x, 0);
assert_eq!(d + disc, 20);
}
#[test]
fn test_allocate_sections_no_discussions() {
let (d, disc, x) = allocate_sections(20, 5, 0, 2, false);
assert!(d > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_allocate_sections_wide_gives_more_description() {
let (d_narrow, _, _) = allocate_sections(20, 10, 3, 2, false);
let (d_wide, _, _) = allocate_sections(20, 10, 3, 2, true);
assert!(
d_wide >= d_narrow,
"wide should give desc at least as much space"
);
}
#[test]
fn test_count_description_lines() {
let meta = sample_metadata();
let lines = count_description_lines(&meta, 80);
assert_eq!(lines, 5); // 5 lines in the sample description
}
#[test]
fn test_count_description_lines_empty() {
let mut meta = sample_metadata();
meta.description = String::new();
assert_eq!(count_description_lines(&meta, 80), 0);
}
#[test]
fn test_render_issue_detail_responsive_breakpoints() {
let clock = FakeClock::from_ms(1_700_000_060_000);
// Narrow (Xs=50): milestone row hidden, labels/assignees hidden.
with_frame!(50, 24, |frame| {
let state = sample_state_with_metadata();
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 50, 24), &clock);
});
// Medium (Sm=70): milestone shown, labels shown, assignees hidden.
with_frame!(70, 24, |frame| {
let state = sample_state_with_metadata();
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 70, 24), &clock);
});
// Wide (Lg=130): all metadata, description gets more space.
with_frame!(130, 40, |frame| {
let state = sample_state_with_metadata();
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 130, 40), &clock);
});
}
}

View File

@@ -0,0 +1,353 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Issue list screen view.
//!
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
//! with issue-specific column definitions and [`TableRow`] implementation.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::issue_list::{IssueListRow, IssueListState, SortField, SortOrder};
use crate::view::common::entity_table::{
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
};
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
// ---------------------------------------------------------------------------
// TableRow implementation for IssueListRow
// ---------------------------------------------------------------------------
impl TableRow for IssueListRow {
fn cells(&self, col_count: usize) -> Vec<String> {
let mut cells = Vec::with_capacity(col_count);
// Column order must match ISSUE_COLUMNS definition.
// 0: IID
cells.push(format!("#{}", self.iid));
// 1: Title
cells.push(self.title.clone());
// 2: State
cells.push(self.state.clone());
// 3: Author
cells.push(self.author.clone());
// 4: Labels
cells.push(self.labels.join(", "));
// 5: Project
cells.push(self.project_path.clone());
cells.truncate(col_count);
cells
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/// Column definitions for the issue list table.
const ISSUE_COLUMNS: &[ColumnDef] = &[
ColumnDef {
name: "IID",
min_width: 5,
flex_weight: 0,
priority: 0,
align: Align::Right,
},
ColumnDef {
name: "Title",
min_width: 15,
flex_weight: 4,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "State",
min_width: 7,
flex_weight: 0,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "Author",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Labels",
min_width: 10,
flex_weight: 2,
priority: 2,
align: Align::Left,
},
ColumnDef {
name: "Project",
min_width: 12,
flex_weight: 1,
priority: 3,
align: Align::Left,
},
];
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
fn table_colors() -> TableColors {
TableColors {
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
}
}
fn filter_colors() -> FilterBarColors {
FilterBarColors {
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full issue list screen.
///
/// Layout:
/// ```text
/// Row 0: [Filter bar: / filter input_________]
/// Row 1: [chip1] [chip2] (if filter active)
/// Row 2: ─────────────────────────────────────
/// Row 3..N: IID Title State Author ...
/// ───────────────────────────────────────
/// #42 Fix login bug open alice ...
/// #41 Add tests open bob ...
/// Bottom: Showing 42 of 128 issues
/// ```
pub fn render_issue_list(frame: &mut Frame<'_>, state: &IssueListState, area: Rect) {
if area.height < 3 || area.width < 10 {
return;
}
let mut y = area.y;
let max_x = area.x.saturating_add(area.width);
// -- Filter bar ---------------------------------------------------------
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
let fb_state = FilterBarState {
input: state.filter_input.clone(),
cursor: state.filter_input.len(),
focused: state.filter_focused,
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
unknown_fields: Vec::new(),
};
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
y = y.saturating_add(filter_rows);
// -- Status line (total count) ------------------------------------------
let remaining_height = area.height.saturating_sub(y - area.y);
if remaining_height < 2 {
return;
}
// Reserve bottom row for status.
let table_height = remaining_height.saturating_sub(1);
let status_y = y.saturating_add(table_height);
// -- Entity table -------------------------------------------------------
let sort_col = match state.sort_field {
SortField::UpdatedAt => 0, // Map to IID column (closest visual proxy)
SortField::Iid => 0,
SortField::Title => 1,
SortField::State => 2,
SortField::Author => 3,
};
let mut table_state = EntityTableState {
selected: state.selected_index,
scroll_offset: state.scroll_offset,
sort_column: sort_col,
sort_ascending: matches!(state.sort_order, SortOrder::Asc),
};
let table_area = Rect::new(area.x, y, area.width, table_height);
render_entity_table(
frame,
&state.rows,
ISSUE_COLUMNS,
&mut table_state,
table_area,
&table_colors(),
);
// -- Bottom status ------------------------------------------------------
if status_y < area.y.saturating_add(area.height) {
render_status_line(frame, state, area.x, status_y, max_x);
}
}
/// Render the bottom status line showing row count and pagination info.
fn render_status_line(frame: &mut Frame<'_>, state: &IssueListState, x: u16, y: u16, max_x: u16) {
let muted = Cell {
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
..Cell::default()
};
let status = if state.rows.is_empty() {
"No issues found".to_string()
} else {
let showing = state.rows.len();
let total = state.total_count;
if state.next_cursor.is_some() {
format!("Showing {showing} of {total} issues (more available)")
} else {
format!("Showing {showing} of {total} issues")
}
};
frame.print_text_clipped(x, y, &status, muted, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_state(row_count: usize) -> IssueListState {
let rows: Vec<IssueListRow> = (0..row_count)
.map(|i| IssueListRow {
project_path: "group/project".into(),
iid: (i + 1) as i64,
title: format!("Issue {}", i + 1),
state: if i % 2 == 0 { "opened" } else { "closed" }.into(),
author: "taylor".into(),
labels: if i == 0 {
vec!["bug".into(), "critical".into()]
} else {
vec![]
},
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
})
.collect();
IssueListState {
total_count: row_count as u64,
rows,
..Default::default()
}
}
#[test]
fn test_render_issue_list_no_panic() {
with_frame!(120, 30, |frame| {
let state = sample_state(10);
render_issue_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
});
}
#[test]
fn test_render_issue_list_empty_no_panic() {
with_frame!(80, 20, |frame| {
let state = IssueListState::default();
render_issue_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
});
}
#[test]
fn test_render_issue_list_tiny_noop() {
with_frame!(5, 2, |frame| {
let state = sample_state(5);
render_issue_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
// Should not panic with too-small area.
});
}
#[test]
fn test_render_issue_list_narrow_no_panic() {
with_frame!(40, 15, |frame| {
let state = sample_state(5);
render_issue_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
});
}
#[test]
fn test_render_issue_list_with_filter_no_panic() {
with_frame!(100, 25, |frame| {
let mut state = sample_state(5);
state.filter_input = "state:opened".into();
state.filter_focused = true;
render_issue_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
});
}
#[test]
fn test_issue_list_row_cells() {
let row = IssueListRow {
project_path: "group/proj".into(),
iid: 42,
title: "Fix bug".into(),
state: "opened".into(),
author: "alice".into(),
labels: vec!["bug".into(), "urgent".into()],
updated_at: 1_700_000_000_000,
};
let cells = row.cells(6);
assert_eq!(cells[0], "#42");
assert_eq!(cells[1], "Fix bug");
assert_eq!(cells[2], "opened");
assert_eq!(cells[3], "alice");
assert_eq!(cells[4], "bug, urgent");
assert_eq!(cells[5], "group/proj");
}
#[test]
fn test_issue_list_row_cells_truncated() {
let row = IssueListRow {
project_path: "g/p".into(),
iid: 1,
title: "t".into(),
state: "opened".into(),
author: "a".into(),
labels: vec![],
updated_at: 0,
};
// Request fewer columns than available.
let cells = row.cells(3);
assert_eq!(cells.len(), 3);
}
#[test]
fn test_column_count() {
assert_eq!(ISSUE_COLUMNS.len(), 6);
}
}

View File

@@ -0,0 +1,267 @@
#![allow(dead_code)] // Phase 1: screen content renders added in Phase 2+
//! Top-level view dispatch for the lore TUI.
//!
//! [`render_screen`] is the entry point called from `LoreApp::view()`.
//! It composes the layout: breadcrumb bar, screen content area, status
//! bar, and optional overlays (help, error toast).
pub mod bootstrap;
pub mod command_palette;
pub mod common;
pub mod dashboard;
pub mod doctor;
pub mod file_history;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
pub mod scope_picker;
pub mod search;
pub mod stats;
pub mod sync;
pub mod timeline;
pub mod trace;
pub mod who;
use ftui::layout::{Constraint, Flex};
use ftui::render::cell::PackedRgba;
use ftui::render::frame::Frame;
use crate::app::LoreApp;
use crate::message::Screen;
use bootstrap::render_bootstrap;
use command_palette::render_command_palette;
use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
use dashboard::render_dashboard;
use doctor::render_doctor;
use file_history::render_file_history;
use issue_detail::render_issue_detail;
use issue_list::render_issue_list;
use mr_detail::render_mr_detail;
use mr_list::render_mr_list;
use scope_picker::render_scope_picker;
use search::render_search;
use stats::render_stats;
use sync::render_sync;
use timeline::render_timeline;
use trace::render_trace;
use who::render_who;
// ---------------------------------------------------------------------------
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
// ---------------------------------------------------------------------------
// render_screen
// ---------------------------------------------------------------------------
/// Top-level view dispatch: composes breadcrumb + content + status bar + overlays.
///
/// Called from `LoreApp::view()`. The layout is:
/// ```text
/// +-----------------------------------+
/// | Breadcrumb (1 row) |
/// +-----------------------------------+
/// | |
/// | Screen content (fill) |
/// | |
/// +-----------------------------------+
/// | Status bar (1 row) |
/// +-----------------------------------+
/// ```
///
/// Overlays (help, error toast) render on top of existing content.
pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
let bounds = frame.bounds();
if bounds.width < 3 || bounds.height < 3 {
return; // Terminal too small to render anything useful.
}
// Split vertically: breadcrumb (1) | content (fill) | status bar (1).
let regions = Flex::vertical()
.constraints([
Constraint::Fixed(1), // breadcrumb
Constraint::Fill, // content
Constraint::Fixed(1), // status bar
])
.split(bounds);
let breadcrumb_area = regions[0];
let content_area = regions[1];
let status_area = regions[2];
let screen = app.navigation.current();
// --- Breadcrumb ---
render_breadcrumb(frame, breadcrumb_area, &app.navigation, TEXT, TEXT_MUTED);
// --- Screen content ---
let load_state = app.state.load_state.get(screen);
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
// Per-screen content dispatch (other screens wired in later phases).
if screen == &Screen::Bootstrap {
render_bootstrap(frame, &app.state.bootstrap, content_area);
} else if screen == &Screen::Sync {
render_sync(frame, &app.state.sync, content_area);
} else if screen == &Screen::Dashboard {
render_dashboard(frame, &app.state.dashboard, content_area);
} else if screen == &Screen::IssueList {
render_issue_list(frame, &app.state.issue_list, content_area);
} else if screen == &Screen::MrList {
render_mr_list(frame, &app.state.mr_list, content_area);
} else if matches!(screen, Screen::IssueDetail(_)) {
render_issue_detail(frame, &app.state.issue_detail, content_area, &*app.clock);
} else if matches!(screen, Screen::MrDetail(_)) {
render_mr_detail(frame, &app.state.mr_detail, content_area, &*app.clock);
} else if screen == &Screen::Search {
render_search(frame, &app.state.search, content_area);
} else if screen == &Screen::Timeline {
render_timeline(frame, &app.state.timeline, content_area, &*app.clock);
} else if screen == &Screen::Who {
render_who(frame, &app.state.who, content_area);
} else if screen == &Screen::FileHistory {
render_file_history(frame, &app.state.file_history, content_area);
} else if screen == &Screen::Trace {
render_trace(frame, &app.state.trace, content_area);
} else if screen == &Screen::Doctor {
render_doctor(frame, &app.state.doctor, content_area);
} else if screen == &Screen::Stats {
render_stats(frame, &app.state.stats, content_area);
}
// --- Status bar ---
render_status_bar(
frame,
status_area,
&app.command_registry,
screen,
&app.input_mode,
BG_SURFACE,
TEXT,
ACCENT,
);
// --- Overlays (render last, on top of everything) ---
// Error toast.
if let Some(ref error_msg) = app.state.error_toast {
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
}
// Command palette overlay.
render_command_palette(frame, &app.state.command_palette, bounds);
// Scope picker overlay.
render_scope_picker(
frame,
&app.state.scope_picker,
&app.state.global_scope,
bounds,
);
// Help overlay.
if app.state.show_help {
render_help_overlay(
frame,
bounds,
&app.command_registry,
screen,
BORDER,
TEXT,
TEXT_MUTED,
0, // scroll_offset — tracked in future phase
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::app::LoreApp;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
#[test]
fn test_render_screen_does_not_panic() {
with_frame!(80, 24, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
});
}
#[test]
fn test_render_screen_tiny_terminal_noop() {
with_frame!(2, 2, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
// Should not panic — early return for tiny terminals.
});
}
#[test]
fn test_render_screen_with_error_toast() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.state.set_error("test error".into());
render_screen(&mut frame, &app);
// Should render without panicking.
});
}
#[test]
fn test_render_screen_with_help_overlay() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.state.show_help = true;
render_screen(&mut frame, &app);
// Should render without panicking.
});
}
#[test]
fn test_render_screen_narrow_terminal() {
with_frame!(20, 5, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
});
}
#[test]
fn test_render_screen_sync_has_content() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.navigation.push(Screen::Sync);
render_screen(&mut frame, &app);
let has_content = (20..60u16).any(|x| {
(8..16u16).any(|y| frame.buffer.get(x, y).is_some_and(|cell| !cell.is_empty()))
});
assert!(has_content, "Expected sync idle content in center area");
});
}
}

View File

@@ -0,0 +1,667 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Merge request detail screen view.
//!
//! Composes metadata header, tab bar (Overview / Files / Discussions),
//! and tab content. Supports progressive hydration: metadata + file
//! changes render immediately while discussions load async.
use ftui::core::geometry::Rect;
use ftui::layout::Breakpoint;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::layout::classify_width;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C);
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29);
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F);
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F);
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
// ---------------------------------------------------------------------------
// Color constructors
// ---------------------------------------------------------------------------
fn discussion_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: CYAN,
timestamp_fg: TEXT_MUTED,
body_fg: TEXT,
system_fg: TEXT_MUTED,
diff_path_fg: GREEN,
resolved_fg: TEXT_MUTED,
guide_fg: BORDER,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
expand_fg: ACCENT,
}
}
fn cross_ref_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: ACCENT,
label_fg: TEXT,
muted_fg: TEXT_MUTED,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full MR detail screen.
///
/// Layout:
/// ```text
/// Row 0: !10 Fix auth flow (title bar)
/// Row 1: opened | alice | fix-auth -> main (metadata row)
/// Row 2: [Overview] [Files (3)] [Discussions] (tab bar)
/// Row 3: ──────────────────────────────────── (separator)
/// Row 4..N: Tab-specific content
/// ```
pub fn render_mr_detail(
frame: &mut Frame<'_>,
state: &MrDetailState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 4 || area.width < 10 {
return;
}
let bp = classify_width(area.width);
let Some(ref meta) = state.metadata else {
return;
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
// --- Title bar ---
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x, bp);
// --- Tab bar ---
y = render_tab_bar(frame, state, area.x, y, max_x);
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
let bottom = area.y.saturating_add(area.height);
if y >= bottom {
return;
}
let content_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
match state.active_tab {
MrTab::Overview => render_overview_tab(frame, state, meta, content_area, clock),
MrTab::Files => render_files_tab(frame, state, content_area),
MrTab::Discussions => render_discussions_tab(frame, state, content_area, clock),
}
}
// ---------------------------------------------------------------------------
// Sub-renderers
// ---------------------------------------------------------------------------
/// Render `!10 Fix auth flow` (or `!10 [Draft] Fix auth flow`).
fn render_title_bar(frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, max_x: u16) -> u16 {
let iid_text = format!("!{} ", meta.iid);
let iid_style = Cell {
fg: ACCENT,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
if meta.draft {
let draft_style = Cell {
fg: YELLOW,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, "[Draft] ", draft_style, max_x);
}
let title_style = Cell {
fg: TEXT,
..Cell::default()
};
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
y + 1
}
/// Render `opened | alice | fix-auth -> main | mergeable`.
///
/// On narrow terminals (Xs/Sm), branch names and merge status are hidden
/// to avoid truncating more critical information.
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &MrMetadata,
x: u16,
y: u16,
max_x: u16,
bp: Breakpoint,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
"merged" => CYAN,
"closed" => RED,
_ => TEXT_MUTED,
};
let state_style = Cell {
fg: state_fg,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
// Branch names: hidden on Xs/Sm to save horizontal space.
if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x);
}
// Merge status: hidden on Xs/Sm.
if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) && !meta.merge_status.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let status_fg = if meta.merge_status == "mergeable" {
GREEN
} else {
YELLOW
};
let status_style = Cell {
fg: status_fg,
..Cell::default()
};
let _ = frame.print_text_clipped(cx, y, &meta.merge_status, status_style, max_x);
}
y + 1
}
/// Render tab bar: `[Overview] [Files (3)] [Discussions (2)]`.
fn render_tab_bar(frame: &mut Frame<'_>, state: &MrDetailState, x: u16, y: u16, max_x: u16) -> u16 {
// Use metadata counts before async data loads to avoid showing 0.
let disc_count = if state.discussions_loaded {
state.discussions.len()
} else {
state.metadata.as_ref().map_or(0, |m| m.discussion_count)
};
let tabs = [
(MrTab::Overview, "Overview".to_string()),
(
MrTab::Files,
format!("Files ({})", state.file_changes.len()),
),
(MrTab::Discussions, format!("Discussions ({disc_count})")),
];
let mut cx = x;
for (tab, label) in &tabs {
if *tab == state.active_tab {
let style = Cell {
fg: SELECTED_FG,
bg: SELECTED_BG,
..Cell::default()
};
let text = format!(" {label} ");
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
} else {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let text = format!(" {label} ");
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
}
// Tab separator.
let sep = Cell {
fg: BORDER,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, " ", sep, max_x);
}
y + 1
}
/// Render horizontal separator.
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
let sep_style = Cell {
fg: BORDER,
..Cell::default()
};
let line: String = "\u{2500}".repeat(width as usize);
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
y + 1
}
// ---------------------------------------------------------------------------
// Tab content renderers
// ---------------------------------------------------------------------------
/// Overview tab: description + cross-references.
fn render_overview_tab(
frame: &mut Frame<'_>,
state: &MrDetailState,
meta: &MrMetadata,
area: Rect,
_clock: &dyn Clock,
) {
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
// --- Description ---
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
let lines: Vec<&str> = safe_desc.lines().collect();
let text_style = Cell {
fg: TEXT,
..Cell::default()
};
for line in lines
.iter()
.skip(state.description_scroll)
.take((bottom.saturating_sub(y)) as usize)
{
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
y += 1;
}
if y >= bottom {
return;
}
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
if y >= bottom {
return;
}
// --- Cross-references ---
if !state.cross_refs.is_empty() {
let header_style = Cell {
fg: ACCENT,
..Cell::default()
};
let header = format!("Cross References ({})", state.cross_refs.len());
let _ = frame.print_text_clipped(area.x, y, &header, header_style, max_x);
y += 1;
if y < bottom {
let refs_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
let _ = render_cross_refs(
frame,
&state.cross_refs,
&state.cross_ref_state,
refs_area,
&cross_ref_colors(),
);
}
}
}
/// Files tab: list of changed files with change type indicators.
fn render_files_tab(frame: &mut Frame<'_>, state: &MrDetailState, area: Rect) {
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
if state.file_changes.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No file changes", style, max_x);
return;
}
for (i, fc) in state
.file_changes
.iter()
.skip(state.file_scroll)
.take((bottom.saturating_sub(y)) as usize)
.enumerate()
{
let is_selected = i + state.file_scroll == state.file_selected;
let (fg, bg) = if is_selected {
(SELECTED_FG, SELECTED_BG)
} else {
(TEXT, PackedRgba::TRANSPARENT)
};
if is_selected {
let sel_cell = Cell {
fg,
bg,
..Cell::default()
};
frame.draw_rect_filled(Rect::new(area.x, y, area.width, 1), sel_cell);
}
// Change type icon.
let icon_fg = match fc.change_type {
FileChangeType::Added => GREEN,
FileChangeType::Deleted => RED,
FileChangeType::Modified => YELLOW,
FileChangeType::Renamed => CYAN,
};
let icon_style = Cell {
fg: if is_selected { fg } else { icon_fg },
bg,
..Cell::default()
};
let mut cx = frame.print_text_clipped(area.x, y, fc.change_type.icon(), icon_style, max_x);
cx = frame.print_text_clipped(cx, y, " ", icon_style, max_x);
// File path.
let path_style = Cell {
fg,
bg,
..Cell::default()
};
let display_path = if fc.change_type == FileChangeType::Renamed {
if let Some(ref old) = fc.old_path {
format!("{old} -> {}", fc.new_path)
} else {
fc.new_path.clone()
}
} else {
fc.new_path.clone()
};
let _ = frame.print_text_clipped(cx, y, &display_path, path_style, max_x);
y += 1;
}
}
/// Discussions tab: all discussions rendered via the tree widget.
fn render_discussions_tab(
frame: &mut Frame<'_>,
state: &MrDetailState,
area: Rect,
clock: &dyn Clock,
) {
let max_x = area.x.saturating_add(area.width);
if !state.discussions_loaded {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ =
frame.print_text_clipped(area.x + 1, area.y, "Loading discussions...", style, max_x);
return;
}
if state.discussions.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, area.y, "No discussions", style, max_x);
return;
}
let _ = render_discussion_tree(
frame,
&state.discussions,
&state.tree_state,
area,
&discussion_colors(),
clock,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::EntityKey;
use crate::state::mr_detail::{FileChange, FileChangeType, MrDetailData, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_mr_metadata() -> MrMetadata {
MrMetadata {
iid: 10,
project_path: "group/project".into(),
title: "Fix authentication flow".into(),
description: "This MR fixes the login bug.\nSee issue #42.".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec!["backend".into()],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/project/-/merge_requests/10".into(),
discussion_count: 1,
file_change_count: 2,
}
}
fn sample_mr_state() -> MrDetailState {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::issue(1, 42),
label: "Auth bug".into(),
navigable: true,
}],
file_changes: vec![
FileChange {
old_path: None,
new_path: "src/auth.rs".into(),
change_type: FileChangeType::Modified,
},
FileChange {
old_path: None,
new_path: "src/lib.rs".into(),
change_type: FileChangeType::Added,
},
],
});
state
}
#[test]
fn test_render_mr_detail_no_metadata() {
with_frame!(80, 24, |frame| {
let state = MrDetailState::default();
let clock = FakeClock::from_ms(1_700_000_000_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_overview_tab() {
with_frame!(80, 24, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_files_tab() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_discussions_tab_loading() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Discussions;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_discussions_tab_with_data() {
with_frame!(80, 30, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Discussions;
state.apply_discussions(vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![NoteNode {
author: "alice".into(),
body: "Looks good".into(),
created_at: 1_700_000_020_000,
is_system: false,
is_diff_note: true,
diff_file_path: Some("src/auth.rs".into()),
diff_new_line: Some(42),
}],
resolvable: true,
resolved: false,
}]);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 30), &clock);
});
}
#[test]
fn test_render_mr_detail_draft() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.metadata.as_mut().unwrap().draft = true;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_tiny_area() {
with_frame!(5, 3, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 5, 3), &clock);
});
}
#[test]
fn test_render_mr_detail_narrow_terminal() {
with_frame!(30, 10, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
});
}
#[test]
fn test_render_files_empty() {
with_frame!(80, 24, |frame| {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![],
file_changes: vec![],
});
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_files_with_rename() {
with_frame!(80, 24, |frame| {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![],
file_changes: vec![FileChange {
old_path: Some("src/old.rs".into()),
new_path: "src/new.rs".into(),
change_type: FileChangeType::Renamed,
}],
});
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_responsive_breakpoints() {
let clock = FakeClock::from_ms(1_700_000_060_000);
// Narrow (Xs=50): branches and merge status hidden.
with_frame!(50, 24, |frame| {
let state = sample_mr_state();
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 50, 24), &clock);
});
// Medium (Md=100): all metadata shown.
with_frame!(100, 24, |frame| {
let state = sample_mr_state();
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 100, 24), &clock);
});
}
}

View File

@@ -0,0 +1,390 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! MR list screen view.
//!
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
//! with MR-specific column definitions and [`TableRow`] implementation.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::mr_list::{MrListRow, MrListState, MrSortField, MrSortOrder};
use crate::view::common::entity_table::{
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
};
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
// ---------------------------------------------------------------------------
// TableRow implementation for MrListRow
// ---------------------------------------------------------------------------
impl TableRow for MrListRow {
fn cells(&self, col_count: usize) -> Vec<String> {
let mut cells = Vec::with_capacity(col_count);
// Column order must match MR_COLUMNS definition.
// 0: IID (with draft indicator)
let iid_text = if self.draft {
format!("!{} [WIP]", self.iid)
} else {
format!("!{}", self.iid)
};
cells.push(iid_text);
// 1: Title
cells.push(self.title.clone());
// 2: State
cells.push(self.state.clone());
// 3: Author
cells.push(self.author.clone());
// 4: Target Branch
cells.push(self.target_branch.clone());
// 5: Labels
cells.push(self.labels.join(", "));
// 6: Project
cells.push(self.project_path.clone());
cells.truncate(col_count);
cells
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/// Column definitions for the MR list table.
const MR_COLUMNS: &[ColumnDef] = &[
ColumnDef {
name: "IID",
min_width: 6,
flex_weight: 0,
priority: 0,
align: Align::Right,
},
ColumnDef {
name: "Title",
min_width: 15,
flex_weight: 4,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "State",
min_width: 7,
flex_weight: 0,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "Author",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Target",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Labels",
min_width: 10,
flex_weight: 2,
priority: 2,
align: Align::Left,
},
ColumnDef {
name: "Project",
min_width: 12,
flex_weight: 1,
priority: 3,
align: Align::Left,
},
];
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
fn table_colors() -> TableColors {
TableColors {
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
}
}
fn filter_colors() -> FilterBarColors {
FilterBarColors {
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full MR list screen.
///
/// Layout:
/// ```text
/// Row 0: [Filter bar: / filter input_________]
/// Row 1: [chip1] [chip2] (if filter active)
/// Row 2: -----------------------------------------
/// Row 3..N: IID Title State Author ...
/// -----------------------------------------
/// !42 Fix pipeline opened alice ...
/// !41 Add CI config merged bob ...
/// Bottom: Showing 42 of 128 merge requests
/// ```
pub fn render_mr_list(frame: &mut Frame<'_>, state: &MrListState, area: Rect) {
if area.height < 3 || area.width < 10 {
return;
}
let mut y = area.y;
let max_x = area.x.saturating_add(area.width);
// -- Filter bar ---------------------------------------------------------
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
let fb_state = FilterBarState {
input: state.filter_input.clone(),
cursor: state.filter_input.len(),
focused: state.filter_focused,
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
unknown_fields: Vec::new(),
};
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
y = y.saturating_add(filter_rows);
// -- Status line (total count) ------------------------------------------
let remaining_height = area.height.saturating_sub(y - area.y);
if remaining_height < 2 {
return;
}
// Reserve bottom row for status.
let table_height = remaining_height.saturating_sub(1);
let status_y = y.saturating_add(table_height);
// -- Entity table -------------------------------------------------------
let sort_col = match state.sort_field {
MrSortField::UpdatedAt | MrSortField::Iid => 0,
MrSortField::Title => 1,
MrSortField::State => 2,
MrSortField::Author => 3,
MrSortField::TargetBranch => 4,
};
let mut table_state = EntityTableState {
selected: state.selected_index,
scroll_offset: state.scroll_offset,
sort_column: sort_col,
sort_ascending: matches!(state.sort_order, MrSortOrder::Asc),
};
let table_area = Rect::new(area.x, y, area.width, table_height);
render_entity_table(
frame,
&state.rows,
MR_COLUMNS,
&mut table_state,
table_area,
&table_colors(),
);
// -- Bottom status ------------------------------------------------------
if status_y < area.y.saturating_add(area.height) {
render_status_line(frame, state, area.x, status_y, max_x);
}
}
/// Render the bottom status line showing row count and pagination info.
fn render_status_line(frame: &mut Frame<'_>, state: &MrListState, x: u16, y: u16, max_x: u16) {
let muted = Cell {
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
..Cell::default()
};
let status = if state.rows.is_empty() {
"No merge requests found".to_string()
} else {
let showing = state.rows.len();
let total = state.total_count;
if state.next_cursor.is_some() {
format!("Showing {showing} of {total} merge requests (more available)")
} else {
format!("Showing {showing} of {total} merge requests")
}
};
frame.print_text_clipped(x, y, &status, muted, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_state(row_count: usize) -> MrListState {
let rows: Vec<MrListRow> = (0..row_count)
.map(|i| MrListRow {
project_path: "group/project".into(),
iid: (i + 1) as i64,
title: format!("MR {}", i + 1),
state: if i % 2 == 0 { "opened" } else { "merged" }.into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: if i == 0 {
vec!["backend".into(), "urgent".into()]
} else {
vec![]
},
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
draft: i % 3 == 0,
})
.collect();
MrListState {
total_count: row_count as u64,
rows,
..Default::default()
}
}
#[test]
fn test_render_mr_list_no_panic() {
with_frame!(120, 30, |frame| {
let state = sample_state(10);
render_mr_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
});
}
#[test]
fn test_render_mr_list_empty_no_panic() {
with_frame!(80, 20, |frame| {
let state = MrListState::default();
render_mr_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
});
}
#[test]
fn test_render_mr_list_tiny_noop() {
with_frame!(5, 2, |frame| {
let state = sample_state(5);
render_mr_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
});
}
#[test]
fn test_render_mr_list_narrow_no_panic() {
with_frame!(40, 15, |frame| {
let state = sample_state(5);
render_mr_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
});
}
#[test]
fn test_render_mr_list_with_filter_no_panic() {
with_frame!(100, 25, |frame| {
let mut state = sample_state(5);
state.filter_input = "state:opened".into();
state.filter_focused = true;
render_mr_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
});
}
#[test]
fn test_mr_list_row_cells() {
let row = MrListRow {
project_path: "group/proj".into(),
iid: 42,
title: "Fix pipeline".into(),
state: "opened".into(),
author: "alice".into(),
target_branch: "main".into(),
labels: vec!["backend".into(), "urgent".into()],
updated_at: 1_700_000_000_000,
draft: false,
};
let cells = row.cells(7);
assert_eq!(cells[0], "!42");
assert_eq!(cells[1], "Fix pipeline");
assert_eq!(cells[2], "opened");
assert_eq!(cells[3], "alice");
assert_eq!(cells[4], "main");
assert_eq!(cells[5], "backend, urgent");
assert_eq!(cells[6], "group/proj");
}
#[test]
fn test_mr_list_row_cells_draft() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 7,
title: "WIP MR".into(),
state: "opened".into(),
author: "bob".into(),
target_branch: "develop".into(),
labels: vec![],
updated_at: 0,
draft: true,
};
let cells = row.cells(7);
assert_eq!(cells[0], "!7 [WIP]");
}
#[test]
fn test_mr_list_row_cells_truncated() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 1,
title: "t".into(),
state: "opened".into(),
author: "a".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 0,
draft: false,
};
let cells = row.cells(3);
assert_eq!(cells.len(), 3);
}
#[test]
fn test_column_count() {
assert_eq!(MR_COLUMNS.len(), 7);
}
}

View File

@@ -0,0 +1,279 @@
//! Scope picker overlay — modal project filter selector.
//!
//! Renders a centered modal listing all available projects. The user
//! selects "All Projects" or a specific project to filter all screens.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::{BorderChars, Draw};
use ftui::render::frame::Frame;
use crate::state::ScopeContext;
use crate::state::scope_picker::ScopePickerState;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
/// Selection highlight background.
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x3A, 0x3A, 0x34);
// ---------------------------------------------------------------------------
// render_scope_picker
// ---------------------------------------------------------------------------
/// Render the scope picker overlay centered on the screen.
///
/// Only renders if `state.visible`. The modal is 50% width, up to 40x20.
pub fn render_scope_picker(
frame: &mut Frame<'_>,
state: &ScopePickerState,
current_scope: &ScopeContext,
area: Rect,
) {
if !state.visible {
return;
}
if area.height < 5 || area.width < 20 {
return;
}
// Modal dimensions.
let modal_width = (area.width / 2).clamp(25, 40);
let row_count = state.row_count();
// +3 for border top, title gap, border bottom.
let modal_height = ((row_count + 3) as u16).clamp(5, 20).min(area.height - 2);
let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_rect = Rect::new(modal_x, modal_y, modal_width, modal_height);
// Clear background.
let bg_cell = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
for y in modal_rect.y..modal_rect.bottom() {
for x in modal_rect.x..modal_rect.right() {
frame.buffer.set(x, y, bg_cell);
}
}
// Border.
let border_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
frame.draw_border(modal_rect, BorderChars::ROUNDED, border_cell);
// Title.
let title = " Project Scope ";
let title_x = modal_x + (modal_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(title_x, modal_y, title, title_cell, modal_rect.right());
// Content area (inside border).
let content_x = modal_x + 1;
let content_max_x = modal_rect.right().saturating_sub(1);
let content_width = content_max_x.saturating_sub(content_x);
let first_row_y = modal_y + 1;
let max_rows = (modal_height.saturating_sub(2)) as usize; // Inside borders.
// Render rows.
let visible_end = (state.scroll_offset + max_rows).min(row_count);
for vis_idx in 0..max_rows {
let row_idx = state.scroll_offset + vis_idx;
if row_idx >= row_count {
break;
}
let y = first_row_y + vis_idx as u16;
let selected = row_idx == state.selected_index;
let bg = if selected { SELECTION_BG } else { BG_SURFACE };
// Fill row background.
if selected {
let sel_cell = Cell {
fg: TEXT,
bg,
..Cell::default()
};
for x in content_x..content_max_x {
frame.buffer.set(x, y, sel_cell);
}
}
// Row content.
let (label, is_active) = if row_idx == 0 {
let active = current_scope.project_id.is_none();
("All Projects".to_string(), active)
} else {
let project = &state.projects[row_idx - 1];
let active = current_scope.project_id == Some(project.id);
(project.path.clone(), active)
};
// Active indicator.
let prefix = if is_active { "> " } else { " " };
let fg = if is_active { ACCENT } else { TEXT };
let cell = Cell {
fg,
bg,
..Cell::default()
};
// Truncate label to fit.
let max_label_len = content_width.saturating_sub(2) as usize; // 2 for prefix
let display = if label.len() > max_label_len {
format!(
"{prefix}{}...",
&label[..label.floor_char_boundary(max_label_len.saturating_sub(3))]
)
} else {
format!("{prefix}{label}")
};
frame.print_text_clipped(content_x, y, &display, cell, content_max_x);
}
// Scroll indicators.
if state.scroll_offset > 0 {
let arrow_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(
content_max_x.saturating_sub(1),
first_row_y,
"^",
arrow_cell,
modal_rect.right(),
);
}
if visible_end < row_count {
let arrow_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let bottom_y = first_row_y + (max_rows as u16).saturating_sub(1);
frame.print_text_clipped(
content_max_x.saturating_sub(1),
bottom_y,
"v",
arrow_cell,
modal_rect.right(),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::scope::ProjectInfo;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_projects() -> Vec<ProjectInfo> {
vec![
ProjectInfo {
id: 1,
path: "alpha/repo".into(),
},
ProjectInfo {
id: 2,
path: "beta/repo".into(),
},
]
}
#[test]
fn test_render_hidden_noop() {
with_frame!(80, 24, |frame| {
let state = ScopePickerState::default();
let scope = ScopeContext::default();
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
// Should not panic.
});
}
#[test]
fn test_render_visible_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(sample_projects(), &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
});
}
#[test]
fn test_render_with_selection() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(sample_projects(), &scope);
state.select_next(); // Move to first project
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
});
}
#[test]
fn test_render_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(sample_projects(), &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
// Should not panic on tiny terminals.
});
}
#[test]
fn test_render_active_scope_highlighted() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext {
project_id: Some(2),
project_name: Some("beta/repo".into()),
};
state.open(sample_projects(), &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
});
}
#[test]
fn test_render_empty_project_list() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(vec![], &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
// Only "All Projects" row, should not panic.
});
}
}

View File

@@ -0,0 +1,512 @@
#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch
//! Search screen view — query bar, mode indicator, and results list.
//!
//! Layout:
//! ```text
//! +--[ FTS ]--- Search ──────────────────────+
//! | > query text here_ |
//! +───────────────────────────────────────────+
//! | #42 Fix login bug group/proj |
//! | !99 Add retry logic group/proj |
//! | #10 Update docs other/repo |
//! +───────────────────────────────────────────+
//! | Tab: mode /: focus j/k: nav Enter: go |
//! +───────────────────────────────────────────+
//! ```
use ftui::core::geometry::Rect;
use ftui::render::cell::Cell;
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::{classify_width, search_show_project};
use crate::message::EntityKind;
use crate::state::search::SearchState;
use crate::text_width::cursor_cell_offset;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
// ---------------------------------------------------------------------------
// render_search
// ---------------------------------------------------------------------------
/// Render the search screen.
///
/// Composes: mode indicator + query bar (row 0), separator (row 1),
/// results list (fill), and a hint bar at the bottom.
pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
if area.height < 4 || area.width < 20 {
return;
}
let bp = classify_width(area.width);
let show_project = search_show_project(bp);
let mut y = area.y;
let max_x = area.right();
// -- Mode indicator + query bar ------------------------------------------
y = render_query_bar(frame, state, area.x, y, area.width, max_x);
// -- Separator -----------------------------------------------------------
if y >= area.bottom() {
return;
}
let sep_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
let sep_line = "".repeat(area.width as usize);
frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x);
y += 1;
// -- No-index warning ----------------------------------------------------
if !state.capabilities.has_any_index() {
if y >= area.bottom() {
return;
}
let warn_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(area.x + 1, y, "No search indexes found.", warn_cell, max_x);
y += 1;
if y < area.bottom() {
let hint_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(
area.x + 1,
y,
"Run: lore generate-docs && lore embed",
hint_cell,
max_x,
);
}
return;
}
// -- Results list --------------------------------------------------------
let bottom_hint_row = area.bottom().saturating_sub(1);
let list_bottom = bottom_hint_row;
let list_height = list_bottom.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
if state.results.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
render_result_list(
frame,
state,
area.x,
y,
area.width,
list_height,
show_project,
);
}
// -- Bottom hint bar -----------------------------------------------------
if bottom_hint_row < area.bottom() {
render_hint_bar(frame, state, area.x, bottom_hint_row, max_x);
}
}
// ---------------------------------------------------------------------------
// Query bar
// ---------------------------------------------------------------------------
/// Render the mode badge and query input. Returns the next y position.
fn render_query_bar(
frame: &mut Frame<'_>,
state: &SearchState,
x: u16,
y: u16,
width: u16,
max_x: u16,
) -> u16 {
// Mode badge: [ FTS ] or [ Hybrid ] or [ Vec ]
let mode_label = format!("[ {} ]", state.mode.label());
let mode_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
let after_mode = frame.print_text_clipped(x, y, &mode_label, mode_cell, max_x);
// Space separator.
let after_sep = frame.print_text_clipped(
after_mode,
y,
" ",
Cell {
bg: BG_SURFACE,
..Cell::default()
},
max_x,
);
// Prompt.
let prompt = "> ";
let prompt_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
let after_prompt = frame.print_text_clipped(after_sep, y, prompt, prompt_cell, max_x);
// Query text (or placeholder).
let (display_text, text_fg) = if state.query.is_empty() {
("Type to search...", TEXT_MUTED)
} else {
(state.query.as_str(), TEXT)
};
let text_cell = Cell {
fg: text_fg,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x);
// Cursor (only when focused and has query text).
if state.query_focused && !state.query.is_empty() {
let cursor_x = after_prompt + cursor_cell_offset(&state.query, state.cursor);
if cursor_x < max_x {
let cursor_cell = Cell {
fg: BG_SURFACE,
bg: TEXT,
..Cell::default()
};
let cursor_char = state
.query
.get(state.cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x);
}
}
// Loading indicator (right-aligned).
if state.loading {
let loading_text = " searching... ";
let loading_x = (x + width).saturating_sub(loading_text.len() as u16);
let loading_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(loading_x, y, loading_text, loading_cell, max_x);
}
y + 1
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
/// Show a message when there are no results.
fn render_empty_state(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) {
let msg = if state.query.is_empty() {
"Enter a search query above"
} else {
"No results found"
};
let cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(x, y, msg, cell, max_x);
}
// ---------------------------------------------------------------------------
// Result list
// ---------------------------------------------------------------------------
/// Render the scrollable list of search results.
fn render_result_list(
frame: &mut Frame<'_>,
state: &SearchState,
x: u16,
start_y: u16,
width: u16,
list_height: usize,
show_project: bool,
) {
let max_x = x + width;
// Scroll so selected item is always visible.
let scroll_offset = if state.selected_index >= list_height {
state.selected_index - list_height + 1
} else {
0
};
let normal = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
let selected = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let muted_selected = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
for (i, result) in state
.results
.iter()
.skip(scroll_offset)
.enumerate()
.take(list_height)
{
let y = start_y + i as u16;
let is_selected = i + scroll_offset == state.selected_index;
let (label_style, detail_style) = if is_selected {
(selected, muted_selected)
} else {
(normal, muted)
};
// Fill row background for selected item.
if is_selected {
for col in x..max_x {
frame.buffer.set(col, y, selected);
}
}
// Entity prefix: # for issues, ! for MRs.
let prefix = match result.key.kind {
EntityKind::Issue => "#",
EntityKind::MergeRequest => "!",
};
let iid_str = format!("{}{}", prefix, result.key.iid);
let after_iid = frame.print_text_clipped(x + 1, y, &iid_str, label_style, max_x);
// Title.
let after_title =
frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x);
// Project path (right-aligned, hidden on narrow terminals).
if show_project {
let path_width = result.project_path.len() as u16 + 2;
let path_x = max_x.saturating_sub(path_width);
if path_x > after_title + 1 {
frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x);
}
}
}
// Scroll indicator (overlaid on last visible row when results overflow).
if state.results.len() > list_height && list_height > 0 {
let indicator = format!(
" {}/{} ",
(scroll_offset + list_height).min(state.results.len()),
state.results.len()
);
let ind_x = max_x.saturating_sub(indicator.len() as u16);
let ind_y = start_y + list_height.saturating_sub(1) as u16;
let ind_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x);
}
}
// ---------------------------------------------------------------------------
// Hint bar
// ---------------------------------------------------------------------------
/// Render keybinding hints at the bottom of the search screen.
fn render_hint_bar(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) {
let hint_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let hints = if state.query_focused {
"Tab: mode Esc: blur Enter: search"
} else {
"Tab: mode /: focus j/k: nav Enter: open"
};
frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{EntityKey, SearchResult};
use crate::state::search::{SearchCapabilities, SearchState};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn fts_caps() -> SearchCapabilities {
SearchCapabilities {
has_fts: true,
has_embeddings: false,
embedding_coverage_pct: 0.0,
}
}
fn sample_results(count: usize) -> Vec<SearchResult> {
(0..count)
.map(|i| SearchResult {
key: EntityKey::issue(1, (i + 1) as i64),
title: format!("Result {}", i + 1),
score: 1.0 - (i as f64 * 0.1),
snippet: "matched text".into(),
project_path: "group/project".into(),
})
.collect()
}
#[test]
fn test_render_search_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = SearchState::default();
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_with_capabilities_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_with_results_no_panic() {
with_frame!(100, 30, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(5);
render_search(&mut frame, &state, Rect::new(0, 0, 100, 30));
});
}
#[test]
fn test_render_search_with_query_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.insert_char('h');
state.insert_char('e');
state.insert_char('l');
state.insert_char('l');
state.insert_char('o');
state.results = sample_results(3);
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_with_selection_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(10);
state.select_next();
state.select_next();
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_tiny_terminal_noop() {
with_frame!(15, 3, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
render_search(&mut frame, &state, Rect::new(0, 0, 15, 3));
});
}
#[test]
fn test_render_search_no_indexes_warning() {
with_frame!(80, 24, |frame| {
let state = SearchState::default();
// capabilities are default (no indexes)
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
// Should show "No search indexes found" without panicking.
});
}
#[test]
fn test_render_search_loading_indicator() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.loading = true;
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_scrollable_results() {
with_frame!(80, 10, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(20);
// Select item near the bottom to trigger scrolling.
for _ in 0..15 {
state.select_next();
}
render_search(&mut frame, &state, Rect::new(0, 0, 80, 10));
});
}
#[test]
fn test_render_search_responsive_breakpoints() {
// Narrow (Xs=50): project path hidden.
with_frame!(50, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(3);
render_search(&mut frame, &state, Rect::new(0, 0, 50, 24));
});
// Standard (Md=100): project path shown.
with_frame!(100, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(3);
render_search(&mut frame, &state, Rect::new(0, 0, 100, 24));
});
}
}

View File

@@ -0,0 +1,457 @@
//! Stats screen view — database and index statistics.
//!
//! Renders entity counts, FTS/embedding coverage, and queue health
//! as a simple table layout.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::stats::StatsState;
use super::{ACCENT, TEXT, TEXT_MUTED};
/// Success green (for good coverage).
const GOOD_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
/// Warning yellow (for partial coverage).
const WARN_FG: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the stats screen.
pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) {
if area.width < 10 || area.height < 3 {
return;
}
let max_x = area.right();
if !state.loaded {
let msg = "Loading statistics...";
let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
let y = area.y + area.height / 2;
frame.print_text_clipped(
x,
y,
msg,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
return;
}
let data = match &state.data {
Some(d) => d,
None => return,
};
// Title.
frame.print_text_clipped(
area.x + 2,
area.y + 1,
"Database Statistics",
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
let bp = classify_width(area.width);
let mut y = area.y + 3;
let label_width = match bp {
ftui::layout::Breakpoint::Xs => 16u16,
ftui::layout::Breakpoint::Sm => 18,
_ => 22,
};
let value_x = area.x + 2 + label_width;
// --- Entity Counts section ---
if y < area.bottom().saturating_sub(2) {
frame.print_text_clipped(
area.x + 2,
y,
"Entities",
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
y += 1;
}
let entity_rows: [(&str, i64); 4] = [
(" Issues", data.issues),
(" Merge Requests", data.merge_requests),
(" Discussions", data.discussions),
(" Notes", data.notes),
];
for (label, count) in &entity_rows {
if y >= area.bottom().saturating_sub(2) {
break;
}
render_stat_row(
frame,
area.x + 2,
y,
label,
&format_count(*count),
label_width,
max_x,
);
y += 1;
}
// Total.
if y < area.bottom().saturating_sub(2) {
let total = data.issues + data.merge_requests + data.discussions + data.notes;
render_stat_row(
frame,
area.x + 2,
y,
" Total",
&format_count(total),
label_width,
max_x,
);
y += 1;
}
y += 1; // Blank line.
// --- Index Coverage section ---
if y < area.bottom().saturating_sub(2) {
frame.print_text_clipped(
area.x + 2,
y,
"Index Coverage",
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
y += 1;
}
// FTS.
if y < area.bottom().saturating_sub(2) {
let fts_pct = data.fts_coverage_pct();
let fts_text = format!("{} ({:.0}%)", format_count(data.fts_indexed), fts_pct);
let fg = coverage_color(fts_pct);
frame.print_text_clipped(
area.x + 2,
y,
&format!("{:<width$}", " FTS Indexed", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
&fts_text,
Cell {
fg,
..Cell::default()
},
max_x,
);
y += 1;
}
// Embeddings.
if y < area.bottom().saturating_sub(2) {
let embed_text = format!(
"{} ({:.0}%)",
format_count(data.embedded_documents),
data.coverage_pct
);
let fg = coverage_color(data.coverage_pct);
frame.print_text_clipped(
area.x + 2,
y,
&format!("{:<width$}", " Embeddings", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
&embed_text,
Cell {
fg,
..Cell::default()
},
max_x,
);
y += 1;
}
// Chunks.
if y < area.bottom().saturating_sub(2) {
render_stat_row(
frame,
area.x + 2,
y,
" Chunks",
&format_count(data.total_chunks),
label_width,
max_x,
);
y += 1;
}
y += 1; // Blank line.
// --- Queue section ---
if data.has_queue_work() && y < area.bottom().saturating_sub(2) {
frame.print_text_clipped(
area.x + 2,
y,
"Queue",
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
y += 1;
if y < area.bottom().saturating_sub(2) {
render_stat_row(
frame,
area.x + 2,
y,
" Pending",
&format_count(data.queue_pending),
label_width,
max_x,
);
y += 1;
}
if data.queue_failed > 0 && y < area.bottom().saturating_sub(2) {
let failed_cell = Cell {
fg: WARN_FG,
..Cell::default()
};
frame.print_text_clipped(
area.x + 2,
y,
&format!("{:<width$}", " Failed", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
&format_count(data.queue_failed),
failed_cell,
max_x,
);
}
}
// Hint at bottom.
let hint_y = area.bottom().saturating_sub(1);
frame.print_text_clipped(
area.x + 2,
hint_y,
"Esc: back | lore stats (full report)",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
/// Render a label + value row.
fn render_stat_row(
frame: &mut Frame<'_>,
x: u16,
y: u16,
label: &str,
value: &str,
label_width: u16,
max_x: u16,
) {
let value_x = x + label_width;
frame.print_text_clipped(
x,
y,
&format!("{label:<width$}", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
value,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
}
/// Color based on coverage percentage.
fn coverage_color(pct: f64) -> PackedRgba {
if pct >= 90.0 {
GOOD_FG
} else if pct >= 50.0 {
WARN_FG
} else {
TEXT
}
}
/// Format a count with comma separators for readability.
fn format_count(n: i64) -> String {
if n < 1_000 {
return n.to_string();
}
let s = n.to_string();
let mut result = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(c);
}
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::stats::StatsData;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_data() -> StatsData {
StatsData {
total_documents: 500,
issues: 200,
merge_requests: 150,
discussions: 100,
notes: 50,
fts_indexed: 450,
embedded_documents: 300,
total_chunks: 1200,
coverage_pct: 60.0,
queue_pending: 5,
queue_failed: 1,
}
}
#[test]
fn test_render_not_loaded() {
with_frame!(80, 24, |frame| {
let state = StatsState::default();
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_with_data() {
with_frame!(80, 24, |frame| {
let mut state = StatsState::default();
state.apply_data(sample_data());
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_no_queue_work() {
with_frame!(80, 24, |frame| {
let mut state = StatsState::default();
state.apply_data(StatsData {
queue_pending: 0,
queue_failed: 0,
..sample_data()
});
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_tiny_terminal() {
with_frame!(8, 2, |frame| {
let mut state = StatsState::default();
state.apply_data(sample_data());
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_short_terminal() {
with_frame!(80, 8, |frame| {
let mut state = StatsState::default();
state.apply_data(sample_data());
let area = frame.bounds();
render_stats(&mut frame, &state, area);
// Should clip without panicking.
});
}
#[test]
fn test_format_count_small() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(42), "42");
assert_eq!(format_count(999), "999");
}
#[test]
fn test_format_count_thousands() {
assert_eq!(format_count(1_000), "1,000");
assert_eq!(format_count(12_345), "12,345");
assert_eq!(format_count(1_234_567), "1,234,567");
}
#[test]
fn test_coverage_color_thresholds() {
assert_eq!(coverage_color(100.0), GOOD_FG);
assert_eq!(coverage_color(90.0), GOOD_FG);
assert_eq!(coverage_color(89.9), WARN_FG);
assert_eq!(coverage_color(50.0), WARN_FG);
assert_eq!(coverage_color(49.9), TEXT);
}
}

View File

@@ -0,0 +1,587 @@
//! Sync screen view — progress bars, summary table, and log.
//!
//! Renders the sync screen in different phases:
//! - **Idle**: prompt to start sync
//! - **Running**: per-lane progress bars with throughput stats
//! - **Complete**: summary table with change counts
//! - **Cancelled/Failed**: status message with retry hint
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::{classify_width, sync_progress_bar_width};
use crate::state::sync::{SyncLane, SyncPhase, SyncState};
use super::{ACCENT, TEXT, TEXT_MUTED};
/// Progress bar fill color.
const PROGRESS_FG: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
/// Progress bar background.
const PROGRESS_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x30);
/// Success green.
const SUCCESS_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
/// Error red.
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xD1, 0x4D, 0x41);
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the sync screen.
pub fn render_sync(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
if area.width < 10 || area.height < 3 {
return;
}
match &state.phase {
SyncPhase::Idle => render_idle(frame, area),
SyncPhase::Running => render_running(frame, state, area),
SyncPhase::Complete => render_summary(frame, state, area),
SyncPhase::Cancelled => render_cancelled(frame, area),
SyncPhase::Failed(err) => render_failed(frame, area, err),
}
}
// ---------------------------------------------------------------------------
// Idle view
// ---------------------------------------------------------------------------
fn render_idle(frame: &mut Frame<'_>, area: Rect) {
let max_x = area.right();
let center_y = area.y + area.height / 2;
let title = "Sync";
let title_x = area.x + area.width.saturating_sub(title.len() as u16) / 2;
frame.print_text_clipped(
title_x,
center_y.saturating_sub(1),
title,
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
let hint = "Press Enter to start sync, or run `lore sync` externally.";
let hint_x = area.x + area.width.saturating_sub(hint.len() as u16) / 2;
frame.print_text_clipped(
hint_x,
center_y + 1,
hint,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// ---------------------------------------------------------------------------
// Running view — per-lane progress bars
// ---------------------------------------------------------------------------
fn render_running(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
let max_x = area.right();
// Title.
let title = "Syncing...";
let title_x = area.x + 2;
frame.print_text_clipped(
title_x,
area.y + 1,
title,
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
// Stage label.
if !state.stage.is_empty() {
let stage_cell = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(title_x, area.y + 2, &state.stage, stage_cell, max_x);
}
// Per-lane progress bars.
let bp = classify_width(area.width);
let max_bar = sync_progress_bar_width(bp);
let bar_start_y = area.y + 4;
let label_width = 14u16; // "Discussions " is the longest
let bar_x = area.x + 2 + label_width;
let bar_width = area.width.saturating_sub(4 + label_width + 12).min(max_bar); // Cap bar width for very wide terminals
for (i, lane) in SyncLane::ALL.iter().enumerate() {
let y = bar_start_y + i as u16;
if y >= area.bottom().saturating_sub(3) {
break;
}
let lane_progress = &state.lanes[i];
// Lane label.
let label = format!("{:<12}", lane.label());
frame.print_text_clipped(
area.x + 2,
y,
&label,
Cell {
fg: TEXT,
..Cell::default()
},
bar_x,
);
// Progress bar.
if bar_width > 2 {
render_progress_bar(frame, bar_x, y, bar_width, lane_progress.fraction());
}
// Count text (e.g., "50/100").
let count_x = bar_x + bar_width + 1;
let count_text = if lane_progress.total > 0 {
format!("{}/{}", lane_progress.current, lane_progress.total)
} else if lane_progress.current > 0 {
format!("{}", lane_progress.current)
} else {
"--".to_string()
};
frame.print_text_clipped(
count_x,
y,
&count_text,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// Throughput stats.
let stats_y = bar_start_y + SyncLane::ALL.len() as u16 + 1;
if stats_y < area.bottom().saturating_sub(2) && state.items_synced > 0 {
let stats = format!(
"{} items synced ({:.0} items/sec)",
state.items_synced, state.items_per_sec
);
frame.print_text_clipped(
area.x + 2,
stats_y,
&stats,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// Cancel hint at bottom.
let hint_y = area.bottom().saturating_sub(1);
frame.print_text_clipped(
area.x + 2,
hint_y,
"Esc: cancel sync",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
/// Render a horizontal progress bar.
fn render_progress_bar(frame: &mut Frame<'_>, x: u16, y: u16, width: u16, fraction: f64) {
let filled = ((width as f64) * fraction).round() as u16;
let max_x = x + width;
for col in x..max_x {
let is_filled = col < x + filled;
let cell = Cell {
fg: if is_filled { PROGRESS_FG } else { PROGRESS_BG },
bg: if is_filled { PROGRESS_FG } else { PROGRESS_BG },
..Cell::default()
};
frame.buffer.set(col, y, cell);
}
}
// ---------------------------------------------------------------------------
// Summary view
// ---------------------------------------------------------------------------
fn render_summary(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
let max_x = area.right();
// Title.
let title = "Sync Complete";
let title_x = area.x + 2;
frame.print_text_clipped(
title_x,
area.y + 1,
title,
Cell {
fg: SUCCESS_FG,
..Cell::default()
},
max_x,
);
if let Some(ref summary) = state.summary {
// Duration.
let duration = format_duration(summary.elapsed_ms);
frame.print_text_clipped(
title_x,
area.y + 2,
&format!("Duration: {duration}"),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
// Summary table header.
let table_y = area.y + 4;
let header = format!("{:<16} {:>6} {:>8}", "Entity", "New", "Updated");
frame.print_text_clipped(
area.x + 2,
table_y,
&header,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
// Summary rows.
let rows = [
("Issues", summary.issues.new, summary.issues.updated),
(
"MRs",
summary.merge_requests.new,
summary.merge_requests.updated,
),
(
"Discussions",
summary.discussions.new,
summary.discussions.updated,
),
("Notes", summary.notes.new, summary.notes.updated),
];
for (i, (label, new, updated)) in rows.iter().enumerate() {
let row_y = table_y + 1 + i as u16;
if row_y >= area.bottom().saturating_sub(3) {
break;
}
let row = format!("{label:<16} {new:>6} {updated:>8}");
let fg = if *new > 0 || *updated > 0 {
TEXT
} else {
TEXT_MUTED
};
frame.print_text_clipped(
area.x + 2,
row_y,
&row,
Cell {
fg,
..Cell::default()
},
max_x,
);
}
// Total.
let total_y = table_y + 1 + rows.len() as u16;
if total_y < area.bottom().saturating_sub(2) {
let total = format!("Total changes: {}", summary.total_changes());
frame.print_text_clipped(
area.x + 2,
total_y,
&total,
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
}
// Per-project errors.
if summary.has_errors() {
let err_y = total_y + 2;
if err_y < area.bottom().saturating_sub(1) {
frame.print_text_clipped(
area.x + 2,
err_y,
"Errors:",
Cell {
fg: ERROR_FG,
..Cell::default()
},
max_x,
);
for (i, (project, err)) in summary.project_errors.iter().enumerate() {
let y = err_y + 1 + i as u16;
if y >= area.bottom().saturating_sub(1) {
break;
}
let line = format!(" {project}: {err}");
frame.print_text_clipped(
area.x + 2,
y,
&line,
Cell {
fg: ERROR_FG,
..Cell::default()
},
max_x,
);
}
}
}
}
// Navigation hint at bottom.
let hint_y = area.bottom().saturating_sub(1);
frame.print_text_clipped(
area.x + 2,
hint_y,
"Esc: back | Enter: sync again",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// ---------------------------------------------------------------------------
// Cancelled / Failed views
// ---------------------------------------------------------------------------
fn render_cancelled(frame: &mut Frame<'_>, area: Rect) {
let max_x = area.right();
let center_y = area.y + area.height / 2;
frame.print_text_clipped(
area.x + 2,
center_y.saturating_sub(1),
"Sync Cancelled",
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
frame.print_text_clipped(
area.x + 2,
center_y + 1,
"Press Enter to retry, or Esc to go back.",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
fn render_failed(frame: &mut Frame<'_>, area: Rect, error: &str) {
let max_x = area.right();
let center_y = area.y + area.height / 2;
frame.print_text_clipped(
area.x + 2,
center_y.saturating_sub(2),
"Sync Failed",
Cell {
fg: ERROR_FG,
..Cell::default()
},
max_x,
);
// Truncate error to fit screen.
let max_len = area.width.saturating_sub(4) as usize;
let display_err = if error.len() > max_len {
format!(
"{}...",
&error[..error.floor_char_boundary(max_len.saturating_sub(3))]
)
} else {
error.to_string()
};
frame.print_text_clipped(
area.x + 2,
center_y,
&display_err,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
frame.print_text_clipped(
area.x + 2,
center_y + 2,
"Press Enter to retry, or Esc to go back.",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn format_duration(ms: u64) -> String {
let secs = ms / 1000;
let mins = secs / 60;
let remaining_secs = secs % 60;
if mins > 0 {
format!("{mins}m {remaining_secs}s")
} else {
format!("{secs}s")
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::sync::{EntityChangeCounts, SyncSummary};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
#[test]
fn test_render_idle_no_panic() {
with_frame!(80, 24, |frame| {
let state = SyncState::default();
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
#[test]
fn test_render_running_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SyncState::default();
state.start();
state.update_progress("issues", 25, 100);
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
#[test]
fn test_render_complete_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SyncState::default();
state.start();
state.complete(5000);
state.summary = Some(SyncSummary {
issues: EntityChangeCounts { new: 5, updated: 3 },
merge_requests: EntityChangeCounts { new: 2, updated: 1 },
elapsed_ms: 5000,
..Default::default()
});
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
#[test]
fn test_render_cancelled_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SyncState::default();
state.start();
state.cancel();
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
#[test]
fn test_render_failed_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SyncState::default();
state.start();
state.fail("network timeout".into());
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
#[test]
fn test_render_tiny_terminal() {
with_frame!(8, 2, |frame| {
let state = SyncState::default();
let area = frame.bounds();
render_sync(&mut frame, &state, area);
// Should not panic.
});
}
#[test]
fn test_render_complete_with_errors() {
with_frame!(80, 24, |frame| {
let mut state = SyncState::default();
state.start();
state.complete(3000);
state.summary = Some(SyncSummary {
elapsed_ms: 3000,
project_errors: vec![("grp/repo".into(), "timeout".into())],
..Default::default()
});
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(3500), "3s");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration(125_000), "2m 5s");
}
#[test]
fn test_render_running_with_stats() {
with_frame!(80, 24, |frame| {
let mut state = SyncState::default();
state.start();
state.update_progress("issues", 50, 200);
state.update_stream_stats(1024, 50);
let area = frame.bounds();
render_sync(&mut frame, &state, area);
});
}
}

View File

@@ -0,0 +1,462 @@
#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch
//! Timeline screen view — chronological event stream with color-coded types.
//!
//! Layout:
//! ```text
//! +─── Timeline ──────────────────────────────+
//! | 3h ago #42 Created: Fix login bug |
//! | 2h ago #42 State changed to closed |
//! | 1h ago !99 Label added: backend |
//! | 30m ago !99 Merged |
//! +───────────────────────────────────────────+
//! | j/k: nav Enter: open q: back |
//! +───────────────────────────────────────────+
//! ```
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::layout::{classify_width, timeline_time_width};
use crate::message::TimelineEventKind;
use crate::state::timeline::TimelineState;
use crate::view::common::discussion_tree::format_relative_time;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
// ---------------------------------------------------------------------------
// Colors for event kinds (Flexoki palette)
// ---------------------------------------------------------------------------
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // Created
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // StateChanged
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // Closed (via StateChanged)
const PURPLE: PackedRgba = PackedRgba::rgb(0x8B, 0x7E, 0xC8); // Merged
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // Label
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg (dark)
/// Map event kind to its display color.
fn event_color(kind: TimelineEventKind, detail: Option<&str>) -> PackedRgba {
match kind {
TimelineEventKind::Created => GREEN,
TimelineEventKind::StateChanged => {
if detail == Some("closed") {
RED
} else {
YELLOW
}
}
TimelineEventKind::LabelAdded | TimelineEventKind::LabelRemoved => CYAN,
TimelineEventKind::MilestoneSet | TimelineEventKind::MilestoneRemoved => ACCENT,
TimelineEventKind::Merged => PURPLE,
}
}
// ---------------------------------------------------------------------------
// render_timeline
// ---------------------------------------------------------------------------
/// Render the timeline screen.
///
/// Composes: scope header (row 0), separator (row 1),
/// event list (fill), and a hint bar at the bottom.
pub fn render_timeline(
frame: &mut Frame<'_>,
state: &TimelineState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 4 || area.width < 20 {
return;
}
let mut y = area.y;
let max_x = area.right();
// -- Scope header --
let scope_label = match &state.scope {
crate::state::timeline::TimelineScope::All => "All events".to_string(),
crate::state::timeline::TimelineScope::Entity(key) => {
let sigil = match key.kind {
crate::message::EntityKind::Issue => "#",
crate::message::EntityKind::MergeRequest => "!",
};
format!("Entity {sigil}{}", key.iid)
}
crate::state::timeline::TimelineScope::Author(name) => format!("Author: {name}"),
};
let header = format!("Timeline: {scope_label}");
let header_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(area.x, y, &header, header_cell, max_x);
y += 1;
// -- Separator --
if y >= area.bottom() {
return;
}
let sep_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
let sep_line = "".repeat(area.width as usize);
frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x);
y += 1;
// -- Event list --
let bottom_hint_row = area.bottom().saturating_sub(1);
let list_height = bottom_hint_row.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
if state.events.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
let bp = classify_width(area.width);
let time_col_width = timeline_time_width(bp);
render_event_list(
frame,
state,
area.x,
y,
area.width,
list_height,
clock,
time_col_width,
);
}
// -- Hint bar --
if bottom_hint_row < area.bottom() {
render_hint_bar(frame, area.x, bottom_hint_row, max_x);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
fn render_empty_state(frame: &mut Frame<'_>, state: &TimelineState, x: u16, y: u16, max_x: u16) {
let msg = if state.loading {
"Loading timeline..."
} else {
"No timeline events found"
};
let cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(x, y, msg, cell, max_x);
}
// ---------------------------------------------------------------------------
// Event list
// ---------------------------------------------------------------------------
/// Render the scrollable list of timeline events.
#[allow(clippy::too_many_arguments)]
fn render_event_list(
frame: &mut Frame<'_>,
state: &TimelineState,
x: u16,
start_y: u16,
width: u16,
list_height: usize,
clock: &dyn Clock,
time_col_width: u16,
) {
let max_x = x + width;
// Scroll so selected item is always visible.
let scroll_offset = if state.selected_index >= list_height {
state.selected_index - list_height + 1
} else {
0
};
let selected_cell = Cell {
fg: SELECTED_FG,
bg: ACCENT,
..Cell::default()
};
for (i, event) in state
.events
.iter()
.skip(scroll_offset)
.enumerate()
.take(list_height)
{
let y = start_y + i as u16;
let is_selected = i + scroll_offset == state.selected_index;
let kind_color = event_color(event.event_kind, event.detail.as_deref());
// Fill row background for selected item.
if is_selected {
for col in x..max_x {
frame.buffer.set(col, y, selected_cell);
}
}
let mut cx = x + 1;
// Timestamp gutter (right-aligned, width varies by breakpoint).
let time_str = format_relative_time(event.timestamp_ms, clock);
let time_x = cx + time_col_width.saturating_sub(time_str.len() as u16);
let time_cell = if is_selected {
selected_cell
} else {
Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
}
};
frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_col_width);
cx += time_col_width + 1;
// Entity prefix: #42 or !99
let prefix = match event.entity_key.kind {
crate::message::EntityKind::Issue => "#",
crate::message::EntityKind::MergeRequest => "!",
};
let entity_str = format!("{prefix}{}", event.entity_key.iid);
let entity_cell = if is_selected {
selected_cell
} else {
Cell {
fg: kind_color,
bg: BG_SURFACE,
..Cell::default()
}
};
let after_entity = frame.print_text_clipped(cx, y, &entity_str, entity_cell, max_x);
cx = after_entity + 1;
// Event kind badge.
let badge = event.event_kind.label();
let badge_cell = if is_selected {
selected_cell
} else {
Cell {
fg: kind_color,
bg: BG_SURFACE,
..Cell::default()
}
};
let after_badge = frame.print_text_clipped(cx, y, badge, badge_cell, max_x);
cx = after_badge + 1;
// Summary text.
let summary_cell = if is_selected {
selected_cell
} else {
Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
}
};
frame.print_text_clipped(cx, y, &event.summary, summary_cell, max_x);
// Actor (right-aligned) if there's room.
if let Some(ref actor) = event.actor {
let actor_str = format!(" {actor} ");
let actor_width = actor_str.len() as u16;
let actor_x = max_x.saturating_sub(actor_width);
if actor_x > cx + 5 {
let actor_cell = if is_selected {
selected_cell
} else {
Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
}
};
frame.print_text_clipped(actor_x, y, &actor_str, actor_cell, max_x);
}
}
}
// Scroll indicator (overlaid on last visible row when events overflow).
if state.events.len() > list_height && list_height > 0 {
let indicator = format!(
" {}/{} ",
(scroll_offset + list_height).min(state.events.len()),
state.events.len()
);
let ind_x = max_x.saturating_sub(indicator.len() as u16);
let ind_y = start_y + list_height.saturating_sub(1) as u16;
let ind_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x);
}
}
// ---------------------------------------------------------------------------
// Hint bar
// ---------------------------------------------------------------------------
fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let hint_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let hints = "j/k: nav Enter: open q: back";
frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::{EntityKey, TimelineEvent, TimelineEventKind};
use crate::state::timeline::TimelineState;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_event(timestamp_ms: i64, iid: i64, kind: TimelineEventKind) -> TimelineEvent {
TimelineEvent {
timestamp_ms,
entity_key: EntityKey::issue(1, iid),
event_kind: kind,
summary: format!("Event for #{iid}"),
detail: None,
actor: Some("alice".into()),
project_path: "group/project".into(),
}
}
fn test_clock() -> FakeClock {
FakeClock::from_ms(1_700_000_100_000)
}
#[test]
fn test_render_timeline_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = TimelineState::default();
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_timeline_with_events_no_panic() {
with_frame!(100, 30, |frame| {
let state = TimelineState {
events: vec![
sample_event(1_700_000_000_000, 1, TimelineEventKind::Created),
sample_event(1_700_000_050_000, 2, TimelineEventKind::StateChanged),
sample_event(1_700_000_080_000, 3, TimelineEventKind::Merged),
],
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 100, 30), &clock);
});
}
#[test]
fn test_render_timeline_with_selection_no_panic() {
with_frame!(80, 24, |frame| {
let state = TimelineState {
events: vec![
sample_event(1_700_000_000_000, 1, TimelineEventKind::Created),
sample_event(1_700_000_050_000, 2, TimelineEventKind::LabelAdded),
],
selected_index: 1,
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_timeline_tiny_terminal_noop() {
with_frame!(15, 3, |frame| {
let state = TimelineState::default();
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 15, 3), &clock);
});
}
#[test]
fn test_render_timeline_loading_state() {
with_frame!(80, 24, |frame| {
let state = TimelineState {
loading: true,
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_timeline_scrollable_events() {
with_frame!(80, 10, |frame| {
let state = TimelineState {
events: (0..20)
.map(|i| {
sample_event(
1_700_000_000_000 + i * 10_000,
i + 1,
TimelineEventKind::Created,
)
})
.collect(),
selected_index: 15,
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 10), &clock);
});
}
#[test]
fn test_event_color_created_is_green() {
assert_eq!(event_color(TimelineEventKind::Created, None), GREEN);
}
#[test]
fn test_event_color_closed_is_red() {
assert_eq!(
event_color(TimelineEventKind::StateChanged, Some("closed")),
RED
);
}
#[test]
fn test_event_color_merged_is_purple() {
assert_eq!(event_color(TimelineEventKind::Merged, None), PURPLE);
}
}

View File

@@ -0,0 +1,658 @@
#![allow(dead_code)]
//! Trace view — file → MR → issue chain drill-down.
//!
//! Layout:
//! ```text
//! +-----------------------------------+
//! | Path: [src/main.rs_] [R] [D] | <- path input + toggles
//! | Renames: old.rs -> new.rs | <- shown when renames followed
//! | 3 trace chains | <- summary
//! +-----------------------------------+
//! | > M !42 Fix auth @alice modified | <- collapsed chain (selected)
//! | O !39 Refactor @bob renamed | <- collapsed chain
//! | M !35 Init @carol added | <- expanded chain header
//! | #12 Bug: login broken (close) | <- linked issue
//! | @dave: "This path needs..." | <- discussion snippet
//! +-----------------------------------+
//! | Enter:expand r:renames d:disc | <- hint bar
//! +-----------------------------------+
//! ```
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use ftui::layout::Breakpoint;
use crate::layout::classify_width;
use crate::state::trace::TraceState;
use crate::text_width::cursor_cell_offset;
use lore::core::trace::TraceResult;
use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, TEXT, TEXT_MUTED};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — extras not in parent module)
// ---------------------------------------------------------------------------
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const PURPLE: PackedRgba = PackedRgba::rgb(0x8B, 0x7E, 0xC8); // purple
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x31); // bg-3
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the Trace screen.
pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core::geometry::Rect) {
if area.width < 10 || area.height < 3 {
return;
}
let bp = classify_width(area.width);
let x = area.x;
let max_x = area.right();
let width = area.width;
let mut y = area.y;
// --- Path input ---
render_path_input(frame, state, x, y, width);
y += 1;
if area.height < 5 {
return;
}
// --- Toggle indicators ---
render_toggle_indicators(frame, state, x, y, width);
y += 1;
// --- Loading ---
if state.loading {
render_loading(frame, x, y, max_x);
return;
}
let Some(result) = &state.result else {
render_empty_state(frame, x, y, max_x);
return;
};
// --- Rename chain ---
if result.renames_followed && result.resolved_paths.len() > 1 {
render_rename_chain(frame, &result.resolved_paths, x, y, max_x);
y += 1;
}
// --- Summary ---
render_summary(frame, result, x, y, max_x);
y += 1;
if result.trace_chains.is_empty() {
render_no_results(frame, x, y, max_x);
return;
}
// Reserve hint bar.
let hint_y = area.bottom().saturating_sub(1);
let list_height = hint_y.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
// --- Chain list ---
render_chain_list(frame, result, state, x, y, width, list_height, bp);
// --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x);
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
fn render_path_input(frame: &mut Frame<'_>, state: &TraceState, x: u16, y: u16, width: u16) {
let max_x = x + width;
let label = "Path: ";
let label_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let after_label = frame.print_text_clipped(x, y, label, label_style, max_x);
let input_style = Cell {
fg: if state.path_focused { TEXT } else { TEXT_MUTED },
..Cell::default()
};
let display_text = if state.path_input.is_empty() && !state.path_focused {
"type a file path..."
} else {
&state.path_input
};
frame.print_text_clipped(after_label, y, display_text, input_style, max_x);
// Cursor.
if state.path_focused {
let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
if cursor_x < max_x {
let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
bg: TEXT,
..Cell::default()
};
let ch = state
.path_input
.get(state.path_cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &ch.to_string(), cursor_cell, max_x);
}
}
}
fn render_toggle_indicators(frame: &mut Frame<'_>, state: &TraceState, x: u16, y: u16, width: u16) {
let max_x = x + width;
let on_style = Cell {
fg: GREEN,
..Cell::default()
};
let off_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let renames_tag = if state.follow_renames {
"[renames:on]"
} else {
"[renames:off]"
};
let disc_tag = if state.include_discussions {
"[disc:on]"
} else {
"[disc:off]"
};
let renames_style = if state.follow_renames {
on_style
} else {
off_style
};
let disc_style = if state.include_discussions {
on_style
} else {
off_style
};
let after_r = frame.print_text_clipped(x + 1, y, renames_tag, renames_style, max_x);
frame.print_text_clipped(after_r + 1, y, disc_tag, disc_style, max_x);
}
fn render_rename_chain(frame: &mut Frame<'_>, paths: &[String], x: u16, y: u16, max_x: u16) {
let label_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let chain_style = Cell {
fg: CYAN,
..Cell::default()
};
let after_label = frame.print_text_clipped(x + 1, y, "Renames: ", label_style, max_x);
// For long chains, show first 2 + "..." + last.
let chain_str = if paths.len() > 5 {
let first_two = paths[..2].join(" -> ");
let last = &paths[paths.len() - 1];
format!("{first_two} -> ... ({} more) -> {last}", paths.len() - 3)
} else {
paths.join(" -> ")
};
frame.print_text_clipped(after_label, y, &chain_str, chain_style, max_x);
}
fn render_summary(frame: &mut Frame<'_>, result: &TraceResult, x: u16, y: u16, max_x: u16) {
let summary = format!(
"{} trace chain{}",
result.total_chains,
if result.total_chains == 1 { "" } else { "s" },
);
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
}
/// Responsive truncation widths for trace chain rows.
const fn chain_title_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 22,
Breakpoint::Md => 30,
Breakpoint::Lg | Breakpoint::Xl => 50,
}
}
const fn chain_author_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
const fn expanded_issue_title_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 20,
Breakpoint::Sm => 30,
Breakpoint::Md => 40,
Breakpoint::Lg | Breakpoint::Xl => 60,
}
}
const fn expanded_disc_snippet_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 25,
Breakpoint::Sm => 40,
Breakpoint::Md => 60,
Breakpoint::Lg | Breakpoint::Xl => 80,
}
}
#[allow(clippy::too_many_arguments)]
fn render_chain_list(
frame: &mut Frame<'_>,
result: &TraceResult,
state: &TraceState,
x: u16,
start_y: u16,
width: u16,
height: usize,
bp: Breakpoint,
) {
let max_x = x + width;
let mut row = 0;
let title_max = chain_title_max(bp);
let author_max = chain_author_max(bp);
let issue_title_max = expanded_issue_title_max(bp);
let disc_max = expanded_disc_snippet_max(bp);
for (chain_idx, chain) in result.trace_chains.iter().enumerate() {
if row >= height {
break;
}
let y = start_y + row as u16;
let selected = chain_idx == state.selected_chain_index;
let expanded = state.expanded_chains.contains(&chain_idx);
// Selection background.
if selected {
let bg_cell = Cell {
bg: SELECTION_BG,
..Cell::default()
};
for col in x..max_x {
frame.buffer.set(col, y, bg_cell);
}
}
let sel_bg = if selected { SELECTION_BG } else { BG_SURFACE };
// Expand indicator.
let expand_icon = if expanded { "v " } else { "> " };
let prefix = if selected { expand_icon } else { " " };
let prefix_style = Cell {
fg: ACCENT,
bg: sel_bg,
..Cell::default()
};
let after_prefix = frame.print_text_clipped(x, y, prefix, prefix_style, max_x);
// State icon.
let (icon, icon_color) = match chain.mr_state.as_str() {
"merged" => ("M", PURPLE),
"opened" => ("O", GREEN),
"closed" => ("C", RED),
_ => ("?", TEXT_MUTED),
};
let icon_style = Cell {
fg: icon_color,
bg: sel_bg,
..Cell::default()
};
let after_icon = frame.print_text_clipped(after_prefix, y, icon, icon_style, max_x);
// !iid
let iid_str = format!(" !{}", chain.mr_iid);
let ref_style = Cell {
fg: ACCENT,
bg: sel_bg,
..Cell::default()
};
let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title (responsive).
let title = truncate_str(&chain.mr_title, title_max);
let title_style = Cell {
fg: TEXT,
bg: sel_bg,
..Cell::default()
};
let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x);
// @author + change_type (responsive author width).
let meta = format!(
"@{} {}",
truncate_str(&chain.mr_author, author_max),
chain.change_type
);
let meta_style = Cell {
fg: TEXT_MUTED,
bg: sel_bg,
..Cell::default()
};
frame.print_text_clipped(after_title + 1, y, &meta, meta_style, max_x);
row += 1;
// Expanded content: linked issues + discussions.
if expanded {
// Issues.
for issue in &chain.issues {
if row >= height {
break;
}
let iy = start_y + row as u16;
let issue_icon = match issue.state.as_str() {
"opened" => "O",
"closed" => "C",
_ => "?",
};
let issue_icon_color = match issue.state.as_str() {
"opened" => GREEN,
"closed" => RED,
_ => TEXT_MUTED,
};
let after_indent = frame.print_text_clipped(
x + 4,
iy,
issue_icon,
Cell {
fg: issue_icon_color,
..Cell::default()
},
max_x,
);
let issue_ref = format!(" #{} ", issue.iid);
let issue_ref_style = Cell {
fg: YELLOW,
..Cell::default()
};
let after_ref =
frame.print_text_clipped(after_indent, iy, &issue_ref, issue_ref_style, max_x);
let issue_title = truncate_str(&issue.title, issue_title_max);
frame.print_text_clipped(
after_ref,
iy,
&issue_title,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
row += 1;
}
// Discussions.
for disc in &chain.discussions {
if row >= height {
break;
}
let dy = start_y + row as u16;
let author = format!("@{}: ", truncate_str(&disc.author_username, author_max));
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let after_author =
frame.print_text_clipped(x + 4, dy, &author, author_style, max_x);
let snippet = truncate_str(&disc.body, disc_max);
let snippet_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(after_author, dy, &snippet, snippet_style, max_x);
row += 1;
}
}
}
}
fn render_loading(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: ACCENT,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, "Tracing file provenance...", style, max_x);
}
fn render_empty_state(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(
x + 1,
y,
"Enter a file path and press Enter to trace.",
style,
max_x,
);
}
fn render_no_results(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, "No trace chains found.", style, max_x);
frame.print_text_clipped(
x + 1,
y + 1,
"Hint: Run 'lore sync' to fetch MR file changes.",
style,
max_x,
);
}
fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
for col in x..max_x {
frame.buffer.set(col, y, style);
}
let hints = "/:path Enter:expand r:renames d:discussions q:back";
frame.print_text_clipped(x + 1, y, hints, style, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
use crate::state::trace::TraceState;
use ftui::render::grapheme_pool::GraphemePool;
use lore::core::trace::{TraceChain, TraceResult};
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_area(w: u16, h: u16) -> ftui::core::geometry::Rect {
ftui::core::geometry::Rect {
x: 0,
y: 0,
width: w,
height: h,
}
}
fn sample_chain(iid: i64, title: &str, state: &str) -> TraceChain {
TraceChain {
mr_iid: iid,
mr_title: title.into(),
mr_state: state.into(),
mr_author: "alice".into(),
change_type: "modified".into(),
merged_at_iso: None,
updated_at_iso: "2024-01-01".into(),
web_url: None,
issues: vec![],
discussions: vec![],
}
}
#[test]
fn test_render_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = TraceState::default();
render_trace(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_tiny_terminal_noop() {
with_frame!(5, 2, |frame| {
let state = TraceState::default();
render_trace(&mut frame, &state, test_area(5, 2));
});
}
#[test]
fn test_render_loading() {
with_frame!(80, 24, |frame| {
let state = TraceState {
loading: true,
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_with_chains() {
with_frame!(100, 30, |frame| {
let state = TraceState {
result: Some(TraceResult {
path: "src/main.rs".into(),
resolved_paths: vec!["src/main.rs".into()],
renames_followed: false,
trace_chains: vec![
sample_chain(42, "Fix auth flow", "merged"),
sample_chain(39, "Refactor modules", "opened"),
],
total_chains: 2,
}),
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(100, 30));
});
}
#[test]
fn test_render_expanded_chain() {
with_frame!(100, 30, |frame| {
let state = TraceState {
expanded_chains: HashSet::from([0]),
result: Some(TraceResult {
path: "src/main.rs".into(),
resolved_paths: vec!["src/main.rs".into()],
renames_followed: false,
trace_chains: vec![TraceChain {
mr_iid: 42,
mr_title: "Fix auth".into(),
mr_state: "merged".into(),
mr_author: "alice".into(),
change_type: "modified".into(),
merged_at_iso: None,
updated_at_iso: "2024-01-01".into(),
web_url: None,
issues: vec![lore::core::trace::TraceIssue {
iid: 12,
title: "Login broken".into(),
state: "closed".into(),
reference_type: "closes".into(),
web_url: None,
}],
discussions: vec![lore::core::trace::TraceDiscussion {
discussion_id: "abc".into(),
mr_iid: 42,
author_username: "bob".into(),
body: "This path needs review".into(),
path: "src/main.rs".into(),
created_at_iso: "2024-01-01".into(),
}],
}],
total_chains: 1,
}),
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(100, 30));
});
}
#[test]
fn test_render_with_rename_chain() {
with_frame!(80, 24, |frame| {
let state = TraceState {
result: Some(TraceResult {
path: "src/old.rs".into(),
resolved_paths: vec!["src/old.rs".into(), "src/new.rs".into()],
renames_followed: true,
trace_chains: vec![],
total_chains: 0,
}),
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 5), "hell…");
assert_eq!(truncate_str("", 5), "");
}
}

Some files were not shown because too many files have changed in this diff Show More