Compare commits
38 Commits
0fe3737035
...
tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
386dd884ec | ||
|
|
61fbc234d8 | ||
|
|
5aca644da6 | ||
|
|
20db46a514 | ||
|
|
e8ecb561cf | ||
|
|
1e679a6d72 | ||
|
|
9a1dbda522 | ||
|
|
a55f47161b | ||
|
|
2bbd1e3426 | ||
|
|
574cd55eff | ||
|
|
c8dece8c60 | ||
|
|
3e96f19a11 | ||
|
|
8d24138655 | ||
|
|
01491b4180 | ||
|
|
5143befe46 | ||
|
|
1e06cec3df | ||
|
|
9d6352a6af | ||
|
|
656db00c04 | ||
|
|
9bcc512639 | ||
|
|
403800be22 | ||
|
|
04ea1f7673 | ||
|
|
026b3f0754 | ||
|
|
ae1c3e3b05 | ||
|
|
bbfcfa2082 | ||
|
|
45a989637c | ||
|
|
1b66b80ac4 | ||
|
|
09ffcfcf0f | ||
|
|
146eb61623 | ||
|
|
418417b0f4 | ||
|
|
fb40fdc677 | ||
|
|
f8d6180f06 | ||
|
|
c1b1300675 | ||
|
|
050e00345a | ||
|
|
90c8b43267 | ||
|
|
c5b7f4c864 | ||
|
|
28ce63f818 | ||
|
|
eb5b464d03 | ||
|
|
4664e0cfe3 |
312
.beads/.br_history/issues.20260212_211122.jsonl
Normal file
312
.beads/.br_history/issues.20260212_211122.jsonl
Normal file
File diff suppressed because one or more lines are too long
337
.beads/.br_history/issues.20260219_052521.jsonl
Normal file
337
.beads/.br_history/issues.20260219_052521.jsonl
Normal file
File diff suppressed because one or more lines are too long
337
.beads/.br_history/issues.20260219_053801.jsonl
Normal file
337
.beads/.br_history/issues.20260219_053801.jsonl
Normal file
File diff suppressed because one or more lines are too long
337
.beads/.br_history/issues.20260219_055234.jsonl
Normal file
337
.beads/.br_history/issues.20260219_055234.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-1tv8
|
||||
bd-1au9
|
||||
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,11 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
# Rust build output
|
||||
/target
|
||||
**/target/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -25,15 +20,11 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local config files
|
||||
lore.config.json
|
||||
.liquid-mail.toml
|
||||
|
||||
# beads
|
||||
# beads viewer cache
|
||||
.bv/
|
||||
|
||||
# SQLite databases (local development)
|
||||
@@ -41,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
13
.liquid-mail.toml
Normal 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"
|
||||
291
AGENTS.md
291
AGENTS.md
@@ -127,17 +127,66 @@ Prefer deterministic lab-runtime tests for concurrency-sensitive behavior.
|
||||
|
||||
---
|
||||
|
||||
## MCP Agent Mail — Multi-Agent Coordination
|
||||
|
||||
A mail-like layer that lets coding agents coordinate asynchronously via MCP tools and resources. Provides identities, inbox/outbox, searchable threads, and advisory file reservations with human-auditable artifacts in Git.
|
||||
|
||||
### Why It's Useful
|
||||
|
||||
- **Prevents conflicts:** Explicit file reservations (leases) for files/globs
|
||||
- **Token-efficient:** Messages stored in per-project archive, not in context
|
||||
- **Quick reads:** `resource://inbox/...`, `resource://thread/...`
|
||||
|
||||
### Same Repository Workflow
|
||||
|
||||
1. **Register identity:**
|
||||
```
|
||||
ensure_project(project_key=<abs-path>)
|
||||
register_agent(project_key, program, model)
|
||||
```
|
||||
|
||||
2. **Reserve files before editing:**
|
||||
```
|
||||
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true)
|
||||
```
|
||||
|
||||
3. **Communicate with threads:**
|
||||
```
|
||||
send_message(..., thread_id="FEAT-123")
|
||||
fetch_inbox(project_key, agent_name)
|
||||
acknowledge_message(project_key, agent_name, message_id)
|
||||
```
|
||||
|
||||
4. **Quick reads:**
|
||||
```
|
||||
resource://inbox/{Agent}?project=<abs-path>&limit=20
|
||||
resource://thread/{id}?project=<abs-path>&include_bodies=true
|
||||
```
|
||||
|
||||
### Macros vs Granular Tools
|
||||
|
||||
- **Prefer macros for speed:** `macro_start_session`, `macro_prepare_thread`, `macro_file_reservation_cycle`, `macro_contact_handshake`
|
||||
- **Use granular tools for control:** `register_agent`, `file_reservation_paths`, `send_message`, `fetch_inbox`, `acknowledge_message`
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- `"from_agent not registered"`: Always `register_agent` in the correct `project_key` first
|
||||
- `"FILE_RESERVATION_CONFLICT"`: Adjust patterns, wait for expiry, or use non-exclusive reservation
|
||||
- **Auth errors:** If JWT+JWKS enabled, include bearer token with matching `kid`
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements MCP Agent Mail's messaging and file reservations.
|
||||
|
||||
**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
|
||||
- **Single source of truth:** Beads for task status/priority/dependencies; Agent Mail for conversation and audit
|
||||
- **Shared identifiers:** Use Beads issue ID (e.g., `br-123`) as Mail `thread_id` and prefix subjects with `[br-123]`
|
||||
- **Reservations:** When starting a task, call `file_reservation_paths()` with the issue ID in `reason`
|
||||
|
||||
### Typical Agent Flow
|
||||
|
||||
@@ -146,34 +195,35 @@ Beads provides a lightweight, dependency-aware issue database and CLI (`br` / be
|
||||
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
|
||||
2. **Reserve edit surface (Mail):**
|
||||
```
|
||||
file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true, reason="br-123")
|
||||
```
|
||||
|
||||
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>"
|
||||
3. **Announce start (Mail):**
|
||||
```
|
||||
send_message(..., thread_id="br-123", subject="[br-123] Start: <title>", ack_required=true)
|
||||
```
|
||||
|
||||
4. **Complete (Beads is authority):**
|
||||
4. **Work and update:** Reply in-thread with progress
|
||||
|
||||
5. **Complete and release:**
|
||||
```bash
|
||||
br close br-123 --reason "Completed"
|
||||
liquid-mail post "[br-123] Completed: <summary with commit ref>"
|
||||
```
|
||||
```
|
||||
release_file_reservations(project_key, agent_name, paths=["src/**"])
|
||||
```
|
||||
Final Mail reply: `[br-123] Completed` with summary
|
||||
|
||||
### 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 |
|
||||
| Concept | Value |
|
||||
|---------|-------|
|
||||
| Mail `thread_id` | `br-###` |
|
||||
| Mail subject | `[br-###] ...` |
|
||||
| File reservation `reason` | `br-###` |
|
||||
| Commit messages | Include `br-###` for traceability |
|
||||
|
||||
---
|
||||
|
||||
@@ -181,7 +231,7 @@ Beads provides a lightweight, dependency-aware issue database and CLI (`br` / be
|
||||
|
||||
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.
|
||||
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (messaging, work claiming, file reservations), use MCP Agent Mail.
|
||||
|
||||
**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**
|
||||
|
||||
@@ -605,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
|
||||
|
||||
@@ -623,16 +704,6 @@ lore --robot generate-docs
|
||||
# Generate vector embeddings via Ollama
|
||||
lore --robot embed
|
||||
|
||||
# Personal work dashboard
|
||||
lore --robot me
|
||||
lore --robot me --issues
|
||||
lore --robot me --mrs
|
||||
lore --robot me --activity --since 7d
|
||||
lore --robot me --project group/repo
|
||||
lore --robot me --user jdoe
|
||||
lore --robot me --fields minimal
|
||||
lore --robot me --reset-cursor
|
||||
|
||||
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||
lore robot-docs
|
||||
|
||||
@@ -778,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:
|
||||
- 4–50 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 -->
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -642,16 +642,6 @@ lore --robot generate-docs
|
||||
# Generate vector embeddings via Ollama
|
||||
lore --robot embed
|
||||
|
||||
# Personal work dashboard
|
||||
lore --robot me
|
||||
lore --robot me --issues
|
||||
lore --robot me --mrs
|
||||
lore --robot me --activity --since 7d
|
||||
lore --robot me --project group/repo
|
||||
lore --robot me --user jdoe
|
||||
lore --robot me --fields minimal
|
||||
lore --robot me --reset-cursor
|
||||
|
||||
# Agent self-discovery manifest (all commands, flags, exit codes, response schemas)
|
||||
lore robot-docs
|
||||
|
||||
|
||||
38
Cargo.lock
generated
38
Cargo.lock
generated
@@ -171,9 +171,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "charmed-lipgloss"
|
||||
version = "0.2.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5986a4a6d84055da99e44a6c532fd412d636fe5c3fe17da105a7bf40287ccd1"
|
||||
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"colored",
|
||||
@@ -183,7 +183,6 @@ dependencies = [
|
||||
"thiserror",
|
||||
"toml",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
@@ -486,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"
|
||||
@@ -501,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"
|
||||
@@ -1158,7 +1169,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lore"
|
||||
version = "0.9.0"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"charmed-lipgloss",
|
||||
@@ -1192,6 +1203,7 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"which",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -2508,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"
|
||||
@@ -2765,6 +2789,12 @@ 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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lore"
|
||||
version = "0.9.0"
|
||||
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"
|
||||
lipgloss = { package = "charmed-lipgloss", version = "0.2", default-features = false, features = ["native"] }
|
||||
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"
|
||||
|
||||
123
README.md
123
README.md
@@ -12,9 +12,6 @@ Local GitLab data management with semantic search, people intelligence, and temp
|
||||
- **Hybrid search**: Combines FTS5 lexical search with Ollama-powered vector embeddings via Reciprocal Rank Fusion
|
||||
- **People intelligence**: Expert discovery, workload analysis, review patterns, active discussions, and code ownership overlap
|
||||
- **Timeline pipeline**: Reconstructs chronological event histories by combining search, graph traversal, and event aggregation across related entities
|
||||
- **Code provenance tracing**: Traces why code was introduced by linking files to MRs, MRs to issues, and issues to discussion threads
|
||||
- **File-level history**: Shows which MRs touched a file with rename-chain resolution and inline DiffNote snippets
|
||||
- **Surgical sync**: Sync specific issues or MRs by IID without running a full incremental sync, with preflight validation
|
||||
- **Git history linking**: Tracks merge and squash commit SHAs to connect MRs with git history
|
||||
- **File change tracking**: Records which files each MR touches, enabling file-level history queries
|
||||
- **Raw payload storage**: Preserves original GitLab API responses for debugging
|
||||
@@ -24,12 +21,9 @@ Local GitLab data management with semantic search, people intelligence, and temp
|
||||
- **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
|
||||
- **Automated sync scheduling**: Cron-based automatic syncing with configurable intervals (Unix)
|
||||
- **Token management**: Secure interactive or piped token storage with masked display
|
||||
- **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
|
||||
- **Icon system**: Configurable icon sets (Nerd Fonts, Unicode, ASCII) with automatic detection
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -83,15 +77,6 @@ lore timeline "deployment"
|
||||
# Timeline for a specific issue
|
||||
lore timeline issue:42
|
||||
|
||||
# Why was this file changed? (file -> MR -> issue -> discussion)
|
||||
lore trace src/features/auth/login.ts
|
||||
|
||||
# Which MRs touched this file?
|
||||
lore file-history src/features/auth/
|
||||
|
||||
# Sync a specific issue without full sync
|
||||
lore sync --issue 42 -p group/repo
|
||||
|
||||
# Query notes by author
|
||||
lore notes --author alice --since 7d
|
||||
|
||||
@@ -205,8 +190,6 @@ Create a personal access token with `read_api` scope:
|
||||
| `XDG_DATA_HOME` | XDG Base Directory for data (fallback: `~/.local/share`) | No |
|
||||
| `NO_COLOR` | Disable color output when set (any value) | No |
|
||||
| `CLICOLOR` | Standard color control (0 to disable) | No |
|
||||
| `LORE_ICONS` | Override icon set: `nerd`, `unicode`, or `ascii` | No |
|
||||
| `NERD_FONTS` | Enable Nerd Font icons when set to a non-empty value | No |
|
||||
| `RUST_LOG` | Logging level filter (e.g., `lore=debug`) | No |
|
||||
|
||||
## Commands
|
||||
@@ -370,13 +353,12 @@ Shows: total DiffNotes, categorized by code area with percentage breakdown.
|
||||
|
||||
#### Active Mode
|
||||
|
||||
Surface unresolved discussions needing attention. By default, only discussions on open issues and non-merged MRs are shown.
|
||||
Surface unresolved discussions needing attention.
|
||||
|
||||
```bash
|
||||
lore who --active # Unresolved discussions (last 7 days)
|
||||
lore who --active --since 30d # Wider time window
|
||||
lore who --active -p group/repo # Scoped to project
|
||||
lore who --active --include-closed # Include discussions on closed/merged entities
|
||||
```
|
||||
|
||||
Shows: discussion threads with participants and last activity timestamps.
|
||||
@@ -400,7 +382,6 @@ Shows: users with touch counts (author vs. review), linked MR references. Defaul
|
||||
| `--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 |
|
||||
| `--include-closed` | Include discussions on closed issues and merged/closed MRs (active mode) |
|
||||
| `--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) |
|
||||
@@ -484,6 +465,8 @@ 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)
|
||||
@@ -510,52 +493,9 @@ lore -J notes --fields minimal # Compact: id, author_username, bod
|
||||
| `--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 file-history`
|
||||
|
||||
Show which merge requests touched a file, with rename-chain resolution and optional DiffNote discussion snippets.
|
||||
|
||||
```bash
|
||||
lore file-history src/main.rs # MRs that touched this file
|
||||
lore file-history src/auth/ -p group/repo # Scoped to project
|
||||
lore file-history src/foo.rs --discussions # Include DiffNote snippets
|
||||
lore file-history src/bar.rs --no-follow-renames # Skip rename chain resolution
|
||||
lore file-history src/bar.rs --merged # Only merged MRs
|
||||
lore file-history src/bar.rs -n 100 # More results
|
||||
```
|
||||
|
||||
Rename-chain resolution follows file renames through `mr_file_changes` so that querying a renamed file also surfaces MRs that touched previous names. Disable with `--no-follow-renames`.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||
| `--discussions` | off | Include DiffNote discussion snippets on the file |
|
||||
| `--no-follow-renames` | off | Disable rename chain resolution |
|
||||
| `--merged` | off | Only show merged MRs |
|
||||
| `-n` / `--limit` | `50` | Maximum results |
|
||||
|
||||
### `lore trace`
|
||||
|
||||
Trace why code was introduced by building provenance chains: file -> MR -> issue -> discussion threads.
|
||||
|
||||
```bash
|
||||
lore trace src/main.rs # Why was this file changed?
|
||||
lore trace src/auth/ -p group/repo # Scoped to project
|
||||
lore trace src/foo.rs --discussions # Include DiffNote context
|
||||
lore trace src/bar.rs:42 # Line hint (future Tier 2)
|
||||
lore trace src/bar.rs --no-follow-renames # Skip rename chain resolution
|
||||
```
|
||||
|
||||
Each trace chain links a file change to the MR that introduced it, the issue(s) that motivated it (via "closes" references), and the discussion threads on those entities. Line-level hints (`:line` suffix) are accepted but produce an advisory message until Tier 2 git-blame integration is available.
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||
| `--discussions` | off | Include DiffNote discussion snippets |
|
||||
| `--no-follow-renames` | off | Disable rename chain resolution |
|
||||
| `-n` / `--limit` | `20` | Maximum trace chains to display |
|
||||
|
||||
### `lore drift`
|
||||
|
||||
Detect discussion divergence from the original intent of an issue by comparing the semantic similarity of discussion content against the issue description.
|
||||
@@ -566,34 +506,9 @@ lore drift issues 42 --threshold 0.6 # Higher threshold (stricter)
|
||||
lore drift issues 42 -p group/repo # Scope to project
|
||||
```
|
||||
|
||||
### `lore cron`
|
||||
|
||||
Manage cron-based automatic syncing (Unix only). Installs a crontab entry that runs `lore sync --lock -q` at a configurable interval.
|
||||
|
||||
```bash
|
||||
lore cron install # Install cron job (every 8 minutes)
|
||||
lore cron install --interval 15 # Custom interval in minutes
|
||||
lore cron status # Check if cron is installed
|
||||
lore cron uninstall # Remove cron job
|
||||
```
|
||||
|
||||
The `--lock` flag on the auto-sync ensures that if a sync is already running, the cron invocation exits cleanly rather than competing for the database lock.
|
||||
|
||||
### `lore token`
|
||||
|
||||
Manage the stored GitLab token. Supports interactive entry with validation, non-interactive piped input, and masked display.
|
||||
|
||||
```bash
|
||||
lore token set # Interactive token entry + validation
|
||||
lore token set --token glpat-xxx # Non-interactive token storage
|
||||
echo glpat-xxx | lore token set # Pipe token from stdin
|
||||
lore token show # Show token (masked)
|
||||
lore token show --unmask # Show full token
|
||||
```
|
||||
|
||||
### `lore sync`
|
||||
|
||||
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings. Supports both incremental (cursor-based) and surgical (per-IID) modes.
|
||||
Run the full sync pipeline: ingest from GitLab (including work item status enrichment via GraphQL), generate searchable documents, and compute embeddings.
|
||||
|
||||
```bash
|
||||
lore sync # Full pipeline
|
||||
@@ -603,29 +518,11 @@ 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 --no-status # Skip work-item status enrichment via GraphQL
|
||||
lore sync --dry-run # Preview what would be synced
|
||||
lore sync --timings # Show detailed timing breakdown per stage
|
||||
lore sync --lock # Acquire file lock (skip if another sync is running)
|
||||
|
||||
# Surgical sync: fetch specific entities by IID
|
||||
lore sync --issue 42 -p group/repo # Sync a single issue
|
||||
lore sync --mr 99 -p group/repo # Sync a single MR
|
||||
lore sync --issue 42 --mr 99 -p group/repo # Mix issues and MRs
|
||||
lore sync --issue 1 --issue 2 -p group/repo # Multiple issues
|
||||
lore sync --issue 42 -p group/repo --preflight-only # Validate without writing
|
||||
```
|
||||
|
||||
The sync command displays animated progress bars for each stage and outputs timing metrics on completion. In robot mode (`-J`), detailed stage timing is included in the JSON response.
|
||||
|
||||
#### Surgical Sync
|
||||
|
||||
When `--issue` or `--mr` flags are provided, sync switches to surgical mode which fetches only the specified entities and their dependents (discussions, events, file changes) from GitLab. This is faster than a full incremental sync and useful for refreshing specific entities on demand.
|
||||
|
||||
Surgical mode requires `-p` / `--project` to scope the operation. Each entity goes through preflight validation against the GitLab API, then ingestion, document regeneration, and embedding. Entities that haven't changed since the last sync are skipped (TOCTOU check).
|
||||
|
||||
Use `--preflight-only` to validate that entities exist on GitLab without writing to the database.
|
||||
|
||||
### `lore ingest`
|
||||
|
||||
Sync data from GitLab to local database. Runs only the ingestion step (no doc generation or embeddings). For issue ingestion, this includes a status enrichment phase that fetches work item statuses via the GitLab GraphQL API.
|
||||
@@ -856,7 +753,7 @@ The CLI auto-corrects common mistakes before parsing, emitting a teaching note t
|
||||
|-----------|---------|------|
|
||||
| Single-dash long flag | `-robot` -> `--robot` | All |
|
||||
| Case normalization | `--Robot` -> `--robot` | All |
|
||||
| Flag prefix expansion | `--proj` -> `--project`, `--no-color` -> `--color never` (unambiguous only) | 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 |
|
||||
@@ -888,7 +785,7 @@ Commands accept aliases for common variations:
|
||||
| `stats` | `stat` |
|
||||
| `status` | `st` |
|
||||
|
||||
Unambiguous prefixes also work via subcommand inference (e.g., `lore iss` -> `lore issues`, `lore time` -> `lore timeline`, `lore tra` -> `lore trace`).
|
||||
Unambiguous prefixes also work via subcommand inference (e.g., `lore iss` -> `lore issues`, `lore time` -> `lore timeline`).
|
||||
|
||||
### Agent Self-Discovery
|
||||
|
||||
@@ -943,8 +840,6 @@ lore --robot <command> # Machine-readable JSON
|
||||
lore -J <command> # JSON shorthand
|
||||
lore --color never <command> # Disable color output
|
||||
lore --color always <command> # Force color output
|
||||
lore --icons nerd <command> # Nerd Font icons
|
||||
lore --icons ascii <command> # ASCII-only icons (no Unicode)
|
||||
lore -q <command> # Suppress non-essential output
|
||||
lore -v <command> # Debug logging
|
||||
lore -vv <command> # More verbose debug logging
|
||||
@@ -952,7 +847,7 @@ lore -vvv <command> # Trace-level logging
|
||||
lore --log-format json <command> # JSON-formatted log output to stderr
|
||||
```
|
||||
|
||||
Color output respects `NO_COLOR` and `CLICOLOR` environment variables in `auto` mode (the default). Icon sets default to `unicode` and can be overridden via `--icons`, `LORE_ICONS`, or `NERD_FONTS` environment variables.
|
||||
Color output respects `NO_COLOR` and `CLICOLOR` environment variables in `auto` mode (the default).
|
||||
|
||||
## Shell Completions
|
||||
|
||||
@@ -1000,7 +895,7 @@ Data is stored in SQLite with WAL mode and foreign keys enabled. Main tables:
|
||||
| `embeddings` | Vector embeddings for semantic search |
|
||||
| `dirty_sources` | Entities needing document regeneration after ingest |
|
||||
| `pending_discussion_fetches` | Queue for discussion fetch operations |
|
||||
| `sync_runs` | Audit trail of sync operations (supports surgical mode tracking with per-entity results) |
|
||||
| `sync_runs` | Audit trail of sync operations |
|
||||
| `sync_cursors` | Cursor positions for incremental sync |
|
||||
| `app_locks` | Crash-safe single-flight lock |
|
||||
| `raw_payloads` | Compressed original API responses |
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Trace/File-History Empty-Result Diagnostics
|
||||
|
||||
## AC-1: Human mode shows searched paths on empty results
|
||||
|
||||
When `lore trace <path>` returns 0 chains in human mode, the output includes the resolved path(s) that were searched. If renames were followed, show the full rename chain.
|
||||
|
||||
## AC-2: Human mode shows actionable reason on empty results
|
||||
|
||||
When 0 chains are found, the hint message distinguishes between:
|
||||
- "No MR file changes synced yet" (mr_file_changes table is empty for this project) -> suggest `lore sync`
|
||||
- "File paths not found in MR file changes" (sync has run but this file has no matches) -> suggest checking the path or that the file may predate the sync window
|
||||
|
||||
## AC-3: Robot mode includes diagnostics object on empty results
|
||||
|
||||
When `total_chains == 0` in robot JSON output, add a `"diagnostics"` key to `"meta"` containing:
|
||||
- `paths_searched: [...]` (already present as `resolved_paths` in data -- no duplication needed)
|
||||
- `hints: [string]` -- same actionable reasons as AC-2 but machine-readable
|
||||
|
||||
## AC-4: Info-level logging at each pipeline stage
|
||||
|
||||
Add `tracing::info!` calls visible with `-v`:
|
||||
- After rename resolution: number of paths found
|
||||
- After MR query: number of MRs found
|
||||
- After issue/discussion enrichment: counts per MR
|
||||
|
||||
## AC-5: Apply same pattern to `lore file-history`
|
||||
|
||||
All of the above (AC-1 through AC-4) also apply to `lore file-history` empty results.
|
||||
|
||||
---
|
||||
|
||||
# Secure Token Resolution for Cron
|
||||
|
||||
## AC-6: Stored token in config
|
||||
|
||||
The configuration file supports an optional `token` field in the `gitlab` section, allowing users to persist their GitLab personal access token alongside other settings. Existing configuration files that omit this field continue to load and function normally.
|
||||
|
||||
## AC-7: Token resolution precedence
|
||||
|
||||
Lore resolves the GitLab token by checking the environment variable first, then falling back to the stored config token. This means environment variables always take priority, preserving CI/CD workflows and one-off overrides, while the stored token provides a reliable default for non-interactive contexts like cron jobs. If neither source provides a non-empty value, the user receives a clear `TOKEN_NOT_SET` error with guidance on how to fix it.
|
||||
|
||||
## AC-8: `lore token set` command
|
||||
|
||||
The `lore token set` command provides a secure, guided workflow for storing a GitLab token. It accepts the token via a `--token` flag, standard input (for piped automation), or an interactive masked prompt. Before storing, it validates the token against the GitLab API to catch typos and expired credentials early. After writing the token to the configuration file, it restricts file permissions to owner-only read/write (mode 0600) to prevent other users on the system from reading the token. The command supports both human and robot output modes.
|
||||
|
||||
## AC-9: `lore token show` command
|
||||
|
||||
The `lore token show` command displays the currently active token along with its source ("config file" or "environment variable"). By default the token value is masked for safety; the `--unmask` flag reveals the full value when needed. The command supports both human and robot output modes.
|
||||
|
||||
## AC-10: Consistent token resolution across all commands
|
||||
|
||||
Every command that requires a GitLab token uses the same two-step resolution logic described in AC-7. This ensures that storing a token once via `lore token set` is sufficient to make all commands work, including background cron syncs that have no access to shell environment variables.
|
||||
|
||||
## AC-11: Cron install warns about missing stored token
|
||||
|
||||
When `lore cron install` completes, it checks whether a token is available in the configuration file. If not, it displays a prominent warning explaining that cron jobs cannot access shell environment variables and directs the user to run `lore token set` to ensure unattended syncs will authenticate successfully.
|
||||
|
||||
## AC-12: `TOKEN_NOT_SET` error recommends `lore token set`
|
||||
|
||||
The `TOKEN_NOT_SET` error message recommends `lore token set` as the primary fix for missing credentials, with the environment variable export shown as an alternative for users who prefer that approach. In robot mode, the `actions` array lists both options so that automated recovery workflows can act on them.
|
||||
|
||||
## AC-13: Doctor reports token source
|
||||
|
||||
The `lore doctor` command includes the token's source in its GitLab connectivity check, reporting whether the token was found in the configuration file or an environment variable. This makes it straightforward to verify that cron jobs will have access to the token without relying on the user's interactive shell environment.
|
||||
3286
crates/lore-tui/Cargo.lock
generated
Normal file
3286
crates/lore-tui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
crates/lore-tui/Cargo.toml
Normal file
53
crates/lore-tui/Cargo.toml
Normal 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"
|
||||
61
crates/lore-tui/TERMINAL_COMPAT.md
Normal file
61
crates/lore-tui/TERMINAL_COMPAT.md
Normal 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._
|
||||
4
crates/lore-tui/rust-toolchain.toml
Normal file
4
crates/lore-tui/rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2026-02-08"
|
||||
profile = "minimal"
|
||||
components = ["rustfmt", "clippy"]
|
||||
316
crates/lore-tui/src/action/bootstrap.rs
Normal file
316
crates/lore-tui/src/action/bootstrap.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
485
crates/lore-tui/src/action/dashboard.rs
Normal file
485
crates/lore-tui/src/action/dashboard.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
379
crates/lore-tui/src/action/file_history.rs
Normal file
379
crates/lore-tui/src/action/file_history.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
611
crates/lore-tui/src/action/issue_detail.rs
Normal file
611
crates/lore-tui/src/action/issue_detail.rs
Normal 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, "");
|
||||
}
|
||||
}
|
||||
532
crates/lore-tui/src/action/issue_list.rs
Normal file
532
crates/lore-tui/src/action/issue_list.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
crates/lore-tui/src/action/mod.rs
Normal file
31
crates/lore-tui/src/action/mod.rs
Normal 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::*;
|
||||
694
crates/lore-tui/src/action/mr_detail.rs
Normal file
694
crates/lore-tui/src/action/mr_detail.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
629
crates/lore-tui/src/action/mr_list.rs
Normal file
629
crates/lore-tui/src/action/mr_list.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
361
crates/lore-tui/src/action/search.rs
Normal file
361
crates/lore-tui/src/action/search.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
668
crates/lore-tui/src/action/sync.rs
Normal file
668
crates/lore-tui/src/action/sync.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
871
crates/lore-tui/src/action/timeline.rs
Normal file
871
crates/lore-tui/src/action/timeline.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
234
crates/lore-tui/src/action/trace.rs
Normal file
234
crates/lore-tui/src/action/trace.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
285
crates/lore-tui/src/action/who.rs
Normal file
285
crates/lore-tui/src/action/who.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
73
crates/lore-tui/src/app/mod.rs
Normal file
73
crates/lore-tui/src/app/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
496
crates/lore-tui/src/app/tests.rs
Normal file
496
crates/lore-tui/src/app/tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
732
crates/lore-tui/src/app/update.rs
Normal file
732
crates/lore-tui/src/app/update.rs
Normal 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 ¤t {
|
||||
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);
|
||||
}
|
||||
}
|
||||
165
crates/lore-tui/src/clock.rs
Normal file
165
crates/lore-tui/src/clock.rs
Normal 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;
|
||||
}
|
||||
807
crates/lore-tui/src/commands.rs.bak
Normal file
807
crates/lore-tui/src/commands.rs.bak
Normal 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 ®.commands {
|
||||
assert!(
|
||||
!cmd.help_text.is_empty(),
|
||||
"command {} has empty help_text",
|
||||
cmd.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
180
crates/lore-tui/src/commands/defs.rs
Normal file
180
crates/lore-tui/src/commands/defs.rs
Normal 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,
|
||||
}
|
||||
227
crates/lore-tui/src/commands/mod.rs
Normal file
227
crates/lore-tui/src/commands/mod.rs
Normal 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 ®.commands {
|
||||
assert!(
|
||||
!cmd.help_text.is_empty(),
|
||||
"command {} has empty help_text",
|
||||
cmd.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
crates/lore-tui/src/commands/registry.rs
Normal file
468
crates/lore-tui/src/commands/registry.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
450
crates/lore-tui/src/crash_context.rs
Normal file
450
crates/lore-tui/src/crash_context.rs
Normal 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
270
crates/lore-tui/src/db.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
338
crates/lore-tui/src/entity_cache.rs
Normal file
338
crates/lore-tui/src/entity_cache.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
316
crates/lore-tui/src/filter_dsl.rs
Normal file
316
crates/lore-tui/src/filter_dsl.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
202
crates/lore-tui/src/instance_lock.rs
Normal file
202
crates/lore-tui/src/instance_lock.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
236
crates/lore-tui/src/layout.rs
Normal file
236
crates/lore-tui/src/layout.rs
Normal 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
146
crates/lore-tui/src/lib.rs
Normal 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(())
|
||||
}
|
||||
53
crates/lore-tui/src/main.rs
Normal file
53
crates/lore-tui/src/main.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
759
crates/lore-tui/src/message.rs
Normal file
759
crates/lore-tui/src/message.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
350
crates/lore-tui/src/navigation.rs
Normal file
350
crates/lore-tui/src/navigation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
250
crates/lore-tui/src/render_cache.rs
Normal file
250
crates/lore-tui/src/render_cache.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
587
crates/lore-tui/src/safety.rs
Normal file
587
crates/lore-tui/src/safety.rs
Normal 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(¶m_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
crates/lore-tui/src/scope.rs
Normal file
162
crates/lore-tui/src/scope.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
402
crates/lore-tui/src/session.rs
Normal file
402
crates/lore-tui/src/session.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
160
crates/lore-tui/src/state/bootstrap.rs
Normal file
160
crates/lore-tui/src/state/bootstrap.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
304
crates/lore-tui/src/state/command_palette.rs
Normal file
304
crates/lore-tui/src/state/command_palette.rs
Normal 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(®istry, &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(®istry, &Screen::Dashboard);
|
||||
|
||||
let all_count = state.filtered.len();
|
||||
state.insert_char('i', ®istry, &Screen::Dashboard);
|
||||
state.insert_char('s', ®istry, &Screen::Dashboard);
|
||||
state.insert_char('s', ®istry, &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(®istry, &Screen::Dashboard);
|
||||
|
||||
state.insert_char('q', ®istry, &Screen::Dashboard);
|
||||
let narrow_count = state.filtered.len();
|
||||
state.delete_back(®istry, &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(®istry, &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(®istry, &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(®istry, &Screen::Dashboard);
|
||||
state.insert_char('q', ®istry, &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(®istry, &Screen::Dashboard);
|
||||
|
||||
// Type something that matches nothing.
|
||||
for c in "zzzzzz".chars() {
|
||||
state.insert_char(c, ®istry, &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(®istry, &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"));
|
||||
}
|
||||
}
|
||||
255
crates/lore-tui/src/state/dashboard.rs
Normal file
255
crates/lore-tui/src/state/dashboard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
199
crates/lore-tui/src/state/doctor.rs
Normal file
199
crates/lore-tui/src/state/doctor.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
348
crates/lore-tui/src/state/file_history.rs
Normal file
348
crates/lore-tui/src/state/file_history.rs
Normal 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
|
||||
}
|
||||
}
|
||||
284
crates/lore-tui/src/state/issue_detail.rs
Normal file
284
crates/lore-tui/src/state/issue_detail.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
376
crates/lore-tui/src/state/issue_list.rs
Normal file
376
crates/lore-tui/src/state/issue_list.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
393
crates/lore-tui/src/state/mod.rs
Normal file
393
crates/lore-tui/src/state/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
387
crates/lore-tui/src/state/mr_detail.rs
Normal file
387
crates/lore-tui/src/state/mr_detail.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
422
crates/lore-tui/src/state/mr_list.rs
Normal file
422
crates/lore-tui/src/state/mr_list.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
239
crates/lore-tui/src/state/scope_picker.rs
Normal file
239
crates/lore-tui/src/state/scope_picker.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
569
crates/lore-tui/src/state/search.rs
Normal file
569
crates/lore-tui/src/state/search.rs
Normal 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.0–100.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);
|
||||
}
|
||||
}
|
||||
153
crates/lore-tui/src/state/stats.rs
Normal file
153
crates/lore-tui/src/state/stats.rs
Normal 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.0–100.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);
|
||||
}
|
||||
}
|
||||
593
crates/lore-tui/src/state/sync.rs
Normal file
593
crates/lore-tui/src/state/sync.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
222
crates/lore-tui/src/state/sync_delta_ledger.rs
Normal file
222
crates/lore-tui/src/state/sync_delta_ledger.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
271
crates/lore-tui/src/state/timeline.rs
Normal file
271
crates/lore-tui/src/state/timeline.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
545
crates/lore-tui/src/state/trace.rs
Normal file
545
crates/lore-tui/src/state/trace.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
512
crates/lore-tui/src/state/who.rs
Normal file
512
crates/lore-tui/src/state/who.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
388
crates/lore-tui/src/task_supervisor.rs
Normal file
388
crates/lore-tui/src/task_supervisor.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
300
crates/lore-tui/src/text_width.rs
Normal file
300
crates/lore-tui/src/text_width.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
251
crates/lore-tui/src/theme.rs
Normal file
251
crates/lore-tui/src/theme.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
134
crates/lore-tui/src/view/bootstrap.rs
Normal file
134
crates/lore-tui/src/view/bootstrap.rs
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
||||
382
crates/lore-tui/src/view/command_palette.rs
Normal file
382
crates/lore-tui/src/view/command_palette.rs
Normal 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(®istry, &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(®istry, &Screen::Dashboard);
|
||||
state.insert_char('q', ®istry, &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(®istry, &Screen::Dashboard);
|
||||
state.insert_char('é', ®istry, &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(®istry, &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(®istry, &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(®istry, &Screen::Dashboard);
|
||||
for c in "zzzzzz".chars() {
|
||||
state.insert_char(c, ®istry, &Screen::Dashboard);
|
||||
}
|
||||
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
||||
});
|
||||
}
|
||||
}
|
||||
208
crates/lore-tui/src/view/common/breadcrumb.rs
Normal file
208
crates/lore-tui/src/view/common/breadcrumb.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
413
crates/lore-tui/src/view/common/cross_ref.rs
Normal file
413
crates/lore-tui/src/view/common/cross_ref.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
979
crates/lore-tui/src/view/common/discussion_tree.rs
Normal file
979
crates/lore-tui/src/view/common/discussion_tree.rs
Normal 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(¬e.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("..."));
|
||||
}
|
||||
}
|
||||
676
crates/lore-tui/src/view/common/entity_table.rs
Normal file
676
crates/lore-tui/src/view/common/entity_table.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
132
crates/lore-tui/src/view/common/error_toast.rs
Normal file
132
crates/lore-tui/src/view/common/error_toast.rs
Normal 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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
469
crates/lore-tui/src/view/common/filter_bar.rs
Normal file
469
crates/lore-tui/src/view/common/filter_bar.rs
Normal 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 田
|
||||
}
|
||||
}
|
||||
173
crates/lore-tui/src/view/common/help_overlay.rs
Normal file
173
crates/lore-tui/src/view/common/help_overlay.rs
Normal 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),
|
||||
®istry,
|
||||
&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),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
179
crates/lore-tui/src/view/common/loading.rs
Normal file
179
crates/lore-tui/src/view/common/loading.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
43
crates/lore-tui/src/view/common/mod.rs
Normal file
43
crates/lore-tui/src/view/common/mod.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
173
crates/lore-tui/src/view/common/status_bar.rs
Normal file
173
crates/lore-tui/src/view/common/status_bar.rs
Normal 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),
|
||||
®istry,
|
||||
&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),
|
||||
®istry,
|
||||
&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),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
let cell = frame.buffer.get(0, 0).unwrap();
|
||||
assert!(cell.is_empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
554
crates/lore-tui/src/view/dashboard.rs
Normal file
554
crates/lore-tui/src/view/dashboard.rs
Normal 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, 90–119): 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)");
|
||||
});
|
||||
}
|
||||
}
|
||||
297
crates/lore-tui/src/view/doctor.rs
Normal file
297
crates/lore-tui/src/view/doctor.rs
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
||||
606
crates/lore-tui/src/view/file_history.rs
Normal file
606
crates/lore-tui/src/view/file_history.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
673
crates/lore-tui/src/view/issue_detail.rs
Normal file
673
crates/lore-tui/src/view/issue_detail.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
353
crates/lore-tui/src/view/issue_list.rs
Normal file
353
crates/lore-tui/src/view/issue_list.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
267
crates/lore-tui/src/view/mod.rs
Normal file
267
crates/lore-tui/src/view/mod.rs
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
667
crates/lore-tui/src/view/mr_detail.rs
Normal file
667
crates/lore-tui/src/view/mr_detail.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
390
crates/lore-tui/src/view/mr_list.rs
Normal file
390
crates/lore-tui/src/view/mr_list.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
279
crates/lore-tui/src/view/scope_picker.rs
Normal file
279
crates/lore-tui/src/view/scope_picker.rs
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
||||
512
crates/lore-tui/src/view/search.rs
Normal file
512
crates/lore-tui/src/view/search.rs
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
457
crates/lore-tui/src/view/stats.rs
Normal file
457
crates/lore-tui/src/view/stats.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
587
crates/lore-tui/src/view/sync.rs
Normal file
587
crates/lore-tui/src/view/sync.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
462
crates/lore-tui/src/view/timeline.rs
Normal file
462
crates/lore-tui/src/view/timeline.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
658
crates/lore-tui/src/view/trace.rs
Normal file
658
crates/lore-tui/src/view/trace.rs
Normal 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
Reference in New Issue
Block a user