From 5cd153ce5820845e562f5c46e357bce3b52dd34e Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 6 Jun 2026 19:30:05 +0100 Subject: [PATCH] refactor: markdown rendering into rich_text component --- .../issues.pull_request.PR_kwDOAgent47.json | 2 +- .../issues.pull_request.PR_kwDOInfra19.json | 2 +- .../issues.pull_request.PR_kwDONovem85.json | 2 +- .../issues.pull_requests.all.page1.json | 8 +- .../issues.pull_requests.all.page2.json | 4 +- .../issues.pull_requests.assigned.page1.json | 6 +- .../issues.pull_requests.created.page1.json | 4 +- .../issues.pull_requests.created.page2.json | 2 +- src/api/mock.rs | 28 +- src/component/button.rs | 4 +- src/component/markdown.rs | 635 +++++++++--------- src/component/mod.rs | 1 + src/component/rich_text.rs | 201 ++++++ 13 files changed, 575 insertions(+), 324 deletions(-) create mode 100644 src/component/rich_text.rs diff --git a/fixtures/github/issues.pull_request.PR_kwDOAgent47.json b/fixtures/github/issues.pull_request.PR_kwDOAgent47.json index 24c83e2..c35e1d4 100644 --- a/fixtures/github/issues.pull_request.PR_kwDOAgent47.json +++ b/fixtures/github/issues.pull_request.PR_kwDOAgent47.json @@ -14,5 +14,5 @@ "head_branch_name": "feat/worker-context-envelope", "head_repo_slug": "kennethnym/agent-tooling", "head_ref": "4a8df12be732c0f9e5d194cd2af7430c0d2fb8d4", - "body": "## Goal\n\nSplit context loading from execution workers so delegation stays predictable while this pull request is still in draft.\n\n### Why\n- workers should receive a compact payload\n- prompt packing should be testable without spawning a worker\n- retry policy should stay in one place\n\n### Proposed flow\n1. Load repository context once.\n2. Normalize file excerpts and metadata.\n3. Hand workers a stable execution envelope.\n\n```text\nContextLoader -> PromptAssembler -> WorkerRunner\n```\n\n> Draft status stays until we decide whether token counts belong in the worker response.\n\n### Questions\n- Should `ContextLoader` expose cache hit metrics?\n- Should worker retries carry the same prompt hash?\n- [ ] Add a regression test for interrupted workers" + "body": "## Goal\n\nSplit context loading from execution workers so delegation stays predictable while this pull request is still in draft.\n\n### Why\n- workers should receive a compact payload\n- prompt packing should be testable without spawning a worker\n- retry policy should stay in one place\n\n### Boundaries\n| Boundary | Responsibility |\n| --- | --- |\n| `ContextLoader` | Hydrate repository and file context |\n| `PromptAssembler` | Build compact worker payloads |\n| `WorkerRunner` | Apply retry policy and collect results |\n\n### Proposed flow\n1. Load repository context once.\n2. Normalize file excerpts and metadata.\n3. Hand workers a stable execution envelope.\n\n```text\nContextLoader -> PromptAssembler -> WorkerRunner\n```\n\n> Draft status stays until we decide whether token counts belong in the worker response.\n\n### Questions\n- Should `ContextLoader` expose cache hit metrics?\n- Should worker retries carry the same prompt hash?\n- [ ] Add a regression test for interrupted workers" } diff --git a/fixtures/github/issues.pull_request.PR_kwDOInfra19.json b/fixtures/github/issues.pull_request.PR_kwDOInfra19.json index 7535245..408e84f 100644 --- a/fixtures/github/issues.pull_request.PR_kwDOInfra19.json +++ b/fixtures/github/issues.pull_request.PR_kwDOInfra19.json @@ -14,5 +14,5 @@ "head_branch_name": "docs/manual-failover-steps", "head_repo_slug": "kennethnym/infra-scripts", "head_ref": "6fd11baf0d9d53d18f6d7b7dc265d9b09e6f4217", - "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated recovery path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers.\n4. Warm the cache before reopening traffic.\n\n```bash\n./scripts/failover promote-standby --env staging\n./scripts/failover repoint-workers --env staging\n./scripts/failover verify --env staging\n```\n\n> This pull request was closed because the final DNS validation steps were still changing underneath the runbook.\n\n### Remaining gaps\n- secrets rotation is still manual\n- rollback screenshots are missing\n- [ ] add the final post-cutover checklist" + "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated recovery path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers.\n4. Warm the cache before reopening traffic.\n\n| Step | Owner | State |\n| --- | --- | --- |\n| Promote standby | SRE | Drafted |\n| Repoint workers | App platform | Drafted |\n| DNS validation | Release lead | Pending |\n\n```bash\n./scripts/failover promote-standby --env staging\n./scripts/failover repoint-workers --env staging\n./scripts/failover verify --env staging\n```\n\n> This pull request was closed because the final DNS validation steps were still changing underneath the runbook.\n\n### Remaining gaps\n- secrets rotation is still manual\n- rollback screenshots are missing\n- [ ] add the final post-cutover checklist" } diff --git a/fixtures/github/issues.pull_request.PR_kwDONovem85.json b/fixtures/github/issues.pull_request.PR_kwDONovem85.json index e8a598d..96c1f38 100644 --- a/fixtures/github/issues.pull_request.PR_kwDONovem85.json +++ b/fixtures/github/issues.pull_request.PR_kwDONovem85.json @@ -14,5 +14,5 @@ "head_branch_name": "feat/cached-repo-picker", "head_repo_slug": "kennethnym/novem", "head_ref": "13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0", - "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories visible during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Cache rules\n- explicit refresh invalidates the cached list\n- fresh network data still wins when available\n- empty responses should not overwrite a warm cache\n\n```text\nopen picker -> read cache -> render immediately -> refresh in background\n```\n\n### Follow-up\n1. Measure cache hit rate in debug builds.\n2. Add eviction telemetry.\n3. [ ] Consider persisting the last successful repository list across launches." + "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories visible during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Cache rules\n- explicit refresh invalidates the cached list\n- fresh network data still wins when available\n- empty responses should not overwrite a warm cache\n\n**Fast path:** render the warm cache immediately.\n*Background refresh* still reconciles stale rows.\n~~Empty refreshes~~ should never clear visible repositories.\n\n| Cache path | Expected behavior |\n| --- | --- |\n| Warm cache | Render repositories before the refresh finishes |\n| Refresh success | Replace cached rows with fresh network data |\n| Empty response | Keep the previous warm cache intact |\n\n```text\nopen picker -> read cache -> render immediately -> refresh in background\n```\n\n### Follow-up\n1. Measure cache hit rate in debug builds.\n2. Add eviction telemetry.\n3. [ ] Consider persisting the last successful repository list across launches." } diff --git a/fixtures/github/issues.pull_requests.all.page1.json b/fixtures/github/issues.pull_requests.all.page1.json index fc9786e..9a668c9 100644 --- a/fixtures/github/issues.pull_requests.all.page1.json +++ b/fixtures/github/issues.pull_requests.all.page1.json @@ -12,7 +12,7 @@ "state": "open", "state_reason": null, "title": "feat(dashboard): hydrate issue pane from cached query state", - "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Changes\n- reuse the cached query result before the network request resolves\n- keep the selected issue id pinned across list refreshes\n- fall back to the first visible item when the cached selection disappears\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test for refetch during keyboard navigation", + "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Changes\n- reuse the cached query result before the network request resolves\n- keep the selected issue id pinned across list refreshes\n- fall back to the first visible item when the cached selection disappears\n\n### Behavior matrix\n| Case | Expected behavior |\n| --- | --- |\n| Cache hit | Keep the current selection pinned |\n| Cache miss | Fall back to the first visible item |\n| Refetch in flight | Preserve scroll position |\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test for refetch during keyboard navigation", "body_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.", "body_html": null, "user": { @@ -101,7 +101,7 @@ "state": "closed", "state_reason": "completed", "title": "feat(calendar): ship release handoff checklist in weekly planner", - "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n```text\nrelease_week -> handoff_panel -> checklist_sections\n```", + "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n| Stage | Owner | Status |\n| --- | --- | --- |\n| QA sign-off | `@mariahops` | Done |\n| Docs publish | `@rorycraft` | Done |\n| Release window confirm | `@kennethnym` | Done |\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n```text\nrelease_week -> handoff_panel -> checklist_sections\n```", "body_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.", "body_html": null, "user": { @@ -230,7 +230,7 @@ "state": "open", "state_reason": null, "title": "feat(repo): add cached repository query for titlebar picker", - "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories available during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Notes\n- cache invalidates on explicit refresh\n- fresh network data still wins when available\n- [ ] follow up with eviction metrics", + "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories available during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Notes\n- cache invalidates on explicit refresh\n- fresh network data still wins when available\n- [ ] follow up with eviction metrics\n\n**Fast path:** render the warm cache immediately.\n*Background refresh* still reconciles stale rows.\n~~Empty refreshes~~ should never clear visible repositories.\n\n### Cache paths\n| Cache path | Expected behavior |\n| --- | --- |\n| Warm cache | Render repositories before the refresh finishes |\n| Refresh success | Replace cached rows with fresh network data |\n| Empty response | Keep the previous warm cache intact |", "body_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.", "body_html": null, "user": { @@ -344,7 +344,7 @@ "state": "open", "state_reason": null, "title": "feat(prompts): split context loading from execution workers", - "body": "## Goal\n\nSeparates prompt packing from worker orchestration to make delegation easier to reason about.\n\n### What changed\n- `ContextLoader` now owns repository and file hydration\n- workers receive a normalized prompt payload\n- retry handling stays at the orchestration layer\n\n### Open questions\n- should prompt packing expose token counts in debug builds?\n- should draft workers emit a dry-run preview?\n\n> Keeps the worker boundary small enough to test in isolation.", + "body": "## Goal\n\nSeparates prompt packing from worker orchestration to make delegation easier to reason about.\n\n### What changed\n- `ContextLoader` now owns repository and file hydration\n- workers receive a normalized prompt payload\n- retry handling stays at the orchestration layer\n\n### Boundaries\n| Boundary | Responsibility |\n| --- | --- |\n| `ContextLoader` | Hydrate repository and file context |\n| `PromptAssembler` | Build compact worker payloads |\n| `WorkerRunner` | Apply retry policy and collect results |\n\n### Open questions\n- should prompt packing expose token counts in debug builds?\n- should draft workers emit a dry-run preview?\n\n> Keeps the worker boundary small enough to test in isolation.", "body_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.", "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.all.page2.json b/fixtures/github/issues.pull_requests.all.page2.json index f4007ab..fa2097b 100644 --- a/fixtures/github/issues.pull_requests.all.page2.json +++ b/fixtures/github/issues.pull_requests.all.page2.json @@ -12,7 +12,7 @@ "state": "open", "state_reason": null, "title": "chore(tokens): tighten dashboard spacing scale", - "body": "## Summary\n\nNormalizes horizontal gutters and sidebar section padding before the visual refresh.\n\n### Token updates\n- `space.3` now anchors compact sidebar gaps\n- `space.5` is used for section-to-section rhythm\n- `space.8` stays reserved for page-level breaks\n\n### Review notes\n- compare the dashboard at 1280px and 1440px\n- verify headings still align with list rows\n- [ ] revisit mobile spacing once nav collapse lands", + "body": "## Summary\n\nNormalizes horizontal gutters and sidebar section padding before the visual refresh.\n\n### Token updates\n- `space.3` now anchors compact sidebar gaps\n- `space.5` is used for section-to-section rhythm\n- `space.8` stays reserved for page-level breaks\n\n| Surface | Before | After |\n| --- | --- | --- |\n| Sidebar section gap | `space.6` | `space.5` |\n| Filter row padding | `space.4` | `space.3` |\n| Dashboard gutter | `space.7` | `space.6` |\n\n### Review notes\n- compare the dashboard at 1280px and 1440px\n- verify headings still align with list rows\n- [ ] revisit mobile spacing once nav collapse lands", "body_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.", "body_html": null, "user": { @@ -117,7 +117,7 @@ "state": "closed", "state_reason": "not_planned", "title": "docs(deploy): document manual failover steps", - "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers and warm the cache.\n4. Verify health checks before reopening traffic.\n\n### Risks\n- secrets rotation is still manual\n- rollback steps need screenshots\n- [ ] add the final DNS validation command", + "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers and warm the cache.\n4. Verify health checks before reopening traffic.\n\n| Step | Owner | State |\n| --- | --- | --- |\n| Promote standby | SRE | Drafted |\n| Repoint workers | App platform | Drafted |\n| DNS validation | Release lead | Pending |\n\n### Risks\n- secrets rotation is still manual\n- rollback steps need screenshots\n- [ ] add the final DNS validation command", "body_text": null, "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.assigned.page1.json b/fixtures/github/issues.pull_requests.assigned.page1.json index ba61e04..27ab3c9 100644 --- a/fixtures/github/issues.pull_requests.assigned.page1.json +++ b/fixtures/github/issues.pull_requests.assigned.page1.json @@ -12,7 +12,7 @@ "state": "closed", "state_reason": "completed", "title": "feat(calendar): ship release handoff checklist in weekly planner", - "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n```text\nrelease_week -> handoff_panel -> checklist_sections\n```", + "body": "## Release handoff checklist\n\nAdds the release checklist views and closes the loop for the May rollout.\n\n### Included\n- launch readiness checklist for QA, docs, and release engineering\n- handoff status badges in the weekly planner\n- empty-state copy for weeks without a scheduled release\n\n| Stage | Owner | Status |\n| --- | --- | --- |\n| QA sign-off | `@mariahops` | Done |\n| Docs publish | `@rorycraft` | Done |\n| Release window confirm | `@kennethnym` | Done |\n\n### Verification\n1. Open a release week and confirm checklist sections render in order.\n2. Mark each handoff item complete and confirm the summary badge updates.\n3. Review the planner on a narrow viewport.\n\n```text\nrelease_week -> handoff_panel -> checklist_sections\n```", "body_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.", "body_html": null, "user": { @@ -141,7 +141,7 @@ "state": "open", "state_reason": null, "title": "feat(prompts): split context loading from execution workers", - "body": "## Goal\n\nSeparates prompt packing from worker orchestration to make delegation easier to reason about.\n\n### What changed\n- `ContextLoader` now owns repository and file hydration\n- workers receive a normalized prompt payload\n- retry handling stays at the orchestration layer\n\n### Open questions\n- should prompt packing expose token counts in debug builds?\n- should draft workers emit a dry-run preview?\n\n> Keeps the worker boundary small enough to test in isolation.", + "body": "## Goal\n\nSeparates prompt packing from worker orchestration to make delegation easier to reason about.\n\n### What changed\n- `ContextLoader` now owns repository and file hydration\n- workers receive a normalized prompt payload\n- retry handling stays at the orchestration layer\n\n### Boundaries\n| Boundary | Responsibility |\n| --- | --- |\n| `ContextLoader` | Hydrate repository and file context |\n| `PromptAssembler` | Build compact worker payloads |\n| `WorkerRunner` | Apply retry policy and collect results |\n\n### Open questions\n- should prompt packing expose token counts in debug builds?\n- should draft workers emit a dry-run preview?\n\n> Keeps the worker boundary small enough to test in isolation.", "body_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.", "body_html": null, "user": { @@ -246,7 +246,7 @@ "state": "open", "state_reason": null, "title": "chore(tokens): tighten dashboard spacing scale", - "body": "## Summary\n\nNormalizes horizontal gutters and sidebar section padding before the visual refresh.\n\n### Token updates\n- `space.3` now anchors compact sidebar gaps\n- `space.5` is used for section-to-section rhythm\n- `space.8` stays reserved for page-level breaks\n\n### Review notes\n- compare the dashboard at 1280px and 1440px\n- verify headings still align with list rows\n- [ ] revisit mobile spacing once nav collapse lands", + "body": "## Summary\n\nNormalizes horizontal gutters and sidebar section padding before the visual refresh.\n\n### Token updates\n- `space.3` now anchors compact sidebar gaps\n- `space.5` is used for section-to-section rhythm\n- `space.8` stays reserved for page-level breaks\n\n| Surface | Before | After |\n| --- | --- | --- |\n| Sidebar section gap | `space.6` | `space.5` |\n| Filter row padding | `space.4` | `space.3` |\n| Dashboard gutter | `space.7` | `space.6` |\n\n### Review notes\n- compare the dashboard at 1280px and 1440px\n- verify headings still align with list rows\n- [ ] revisit mobile spacing once nav collapse lands", "body_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.", "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.created.page1.json b/fixtures/github/issues.pull_requests.created.page1.json index de5c19a..330804e 100644 --- a/fixtures/github/issues.pull_requests.created.page1.json +++ b/fixtures/github/issues.pull_requests.created.page1.json @@ -12,7 +12,7 @@ "state": "open", "state_reason": null, "title": "feat(repo): add cached repository query for titlebar picker", - "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories available during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Notes\n- cache invalidates on explicit refresh\n- fresh network data still wins when available\n- [ ] follow up with eviction metrics", + "body": "## Summary\n\nIntroduces a cached repository query so the titlebar picker can switch context without hitting GitHub on every open.\n\n### Why\n- reduces flicker while the picker opens\n- keeps recent repositories available during short reconnects\n- avoids duplicate requests when the titlebar rerenders\n\n### Notes\n- cache invalidates on explicit refresh\n- fresh network data still wins when available\n- [ ] follow up with eviction metrics\n\n**Fast path:** render the warm cache immediately.\n*Background refresh* still reconciles stale rows.\n~~Empty refreshes~~ should never clear visible repositories.\n\n### Cache paths\n| Cache path | Expected behavior |\n| --- | --- |\n| Warm cache | Render repositories before the refresh finishes |\n| Refresh success | Replace cached rows with fresh network data |\n| Empty response | Keep the previous warm cache intact |", "body_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.", "body_html": null, "user": { @@ -126,7 +126,7 @@ "state": "open", "state_reason": null, "title": "feat(dashboard): hydrate issue pane from cached query state", - "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Changes\n- reuse the cached query result before the network request resolves\n- keep the selected issue id pinned across list refreshes\n- fall back to the first visible item when the cached selection disappears\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test for refetch during keyboard navigation", + "body": "## Summary\n\nHydrates the dashboard issue pane from cached query state so selection and scroll position stay stable during refetches.\n\n### Changes\n- reuse the cached query result before the network request resolves\n- keep the selected issue id pinned across list refreshes\n- fall back to the first visible item when the cached selection disappears\n\n### Behavior matrix\n| Case | Expected behavior |\n| --- | --- |\n| Cache hit | Keep the current selection pinned |\n| Cache miss | Fall back to the first visible item |\n| Refetch in flight | Preserve scroll position |\n\n### Follow-up\n- [ ] mirror the same cache behavior in the pull request detail pane\n- [ ] add a smoke test for refetch during keyboard navigation", "body_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.", "body_html": null, "user": { diff --git a/fixtures/github/issues.pull_requests.created.page2.json b/fixtures/github/issues.pull_requests.created.page2.json index 614594b..98f4385 100644 --- a/fixtures/github/issues.pull_requests.created.page2.json +++ b/fixtures/github/issues.pull_requests.created.page2.json @@ -12,7 +12,7 @@ "state": "closed", "state_reason": "not_planned", "title": "docs(deploy): document manual failover steps", - "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers and warm the cache.\n4. Verify health checks before reopening traffic.\n\n### Risks\n- secrets rotation is still manual\n- rollback steps need screenshots\n- [ ] add the final DNS validation command", + "body": "## Context\n\nDocuments the manual failover sequence for the staging stack while the automated path is still unstable.\n\n### Draft runbook\n1. Put the primary deployment in maintenance mode.\n2. Promote the standby database.\n3. Repoint the app workers and warm the cache.\n4. Verify health checks before reopening traffic.\n\n| Step | Owner | State |\n| --- | --- | --- |\n| Promote standby | SRE | Drafted |\n| Repoint workers | App platform | Drafted |\n| DNS validation | Release lead | Pending |\n\n### Risks\n- secrets rotation is still manual\n- rollback steps need screenshots\n- [ ] add the final DNS validation command", "body_text": null, "body_html": null, "user": { diff --git a/src/api/mock.rs b/src/api/mock.rs index 3dbbcd6..1a25eb7 100644 --- a/src/api/mock.rs +++ b/src/api/mock.rs @@ -144,6 +144,23 @@ fn issue_filter_fixture_key(filter: &str) -> &str { mod tests { use super::*; + fn assert_markdown_table(body: &str, header: &str) { + assert!( + body.contains(header), + "pull request markdown fixture should contain table header {header:?}" + ); + assert!( + body.contains("| ---"), + "pull request markdown fixture should contain a markdown table delimiter row" + ); + } + + fn assert_markdown_emphasis(body: &str) { + assert!(body.contains("**Fast path:**")); + assert!(body.contains("*Background refresh*")); + assert!(body.contains("~~Empty refreshes~~")); + } + #[test] fn list_pull_request_fixtures_parse_with_current_filter_strings() { let authored = list_pull_requests(Some("author:@me state:open"), 1) @@ -173,7 +190,7 @@ mod tests { .expect("spacing token pull request fixture should parse"); assert_eq!(merged.state, issues::PullRequestState::Merged); - assert!(merged.body.contains("| Stage | Owner | Status |")); + assert_markdown_table(&merged.body, "| Stage | Owner | Status |"); assert_eq!( merged.author.as_ref().map(|author| author.login.as_ref()), Some("rorycraft") @@ -192,6 +209,7 @@ mod tests { .body .contains("./scripts/failover promote-standby") ); + assert_markdown_table(&documented_failover.body, "| Step | Owner | State |"); assert_eq!( documented_failover .author @@ -209,6 +227,7 @@ mod tests { Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap()) ); assert!(dashboard_markdown.body.contains("```rust")); + assert_markdown_table(&dashboard_markdown.body, "| Case | Expected behavior |"); assert_eq!(dashboard_markdown.base_branch_name.as_ref(), "main"); assert_eq!( dashboard_markdown.head_branch_name.as_ref(), @@ -233,6 +252,11 @@ mod tests { .map(|author| author.login.as_ref()), Some("kennethnym") ); + assert_markdown_table( + &cached_repo_picker.body, + "| Cache path | Expected behavior |", + ); + assert_markdown_emphasis(&cached_repo_picker.body); assert_eq!(cached_repo_picker.base_branch_name.as_ref(), "main"); assert_eq!( cached_repo_picker.head_branch_name.as_ref(), @@ -257,6 +281,7 @@ mod tests { .map(|author| author.login.as_ref()), Some("leaferiksen") ); + assert_markdown_table(&worker_split.body, "| Boundary | Responsibility |"); assert_eq!(worker_split.base_branch_name.as_ref(), "main"); assert_eq!( worker_split.head_branch_name.as_ref(), @@ -273,6 +298,7 @@ mod tests { .map(|author| author.login.as_ref()), Some("mariahops") ); + assert_markdown_table(&spacing_tokens.body, "| Surface | Before | After |"); assert_eq!(spacing_tokens.base_branch_name.as_ref(), "main"); assert_eq!( spacing_tokens.head_branch_name.as_ref(), diff --git a/src/component/button.rs b/src/component/button.rs index 74132a8..7ad8c01 100644 --- a/src/component/button.rs +++ b/src/component/button.rs @@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button { let theme = app::current_theme(cx); let icon_color = match self.variant { - | Variant::Primary => theme.colors.accent_on_solid, - | Variant::Secondary => theme.colors.text, + | Variant::Primary => theme.colors.accent_on_solid, + | Variant::Secondary => theme.colors.text, }; let mut children: Vec = Vec::with_capacity(3); diff --git a/src/component/markdown.rs b/src/component/markdown.rs index efb48fd..eb7e693 100644 --- a/src/component/markdown.rs +++ b/src/component/markdown.rs @@ -1,14 +1,15 @@ // markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/ -use std::{ - ops::Range, - sync::{Arc, LazyLock}, +use std::sync::{Arc, LazyLock}; + +use gpui::{AppContext, FontWeight, ParentElement, Styled, div, relative, rems}; + +use crate::{ + app, + component::rich_text::{RichText, RichTextContent, RichTextContentBuilder, rich_text}, + theme, }; -use gpui::{AppContext, ParentElement, Refineable, Styled, div, px, relative, rems}; - -use crate::{app, theme}; - const MARKDOWN_KIND_ID_SETEXT_H1_UNDERLINE: u16 = 8; const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9; const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11; @@ -94,12 +95,25 @@ pub(crate) struct MarkdownText { } enum ContentBlock { - Text { - decoration: Option, - text: gpui::SharedString, - highlights: Vec<(Range, gpui::HighlightStyle)>, - links: Vec<(Range, gpui::SharedString)>, - style: gpui::StyleRefinement, + Heading { + font_size: gpui::Rems, + font_weight: gpui::FontWeight, + mt: gpui::Rems, + mb: gpui::Rems, + content: RichTextContent, + }, + Code { + content: gpui::SharedString, + }, + Paragraph { + decoration: Option, + content: RichTextContent, + }, + Empty, + Table { + row_count: usize, + col_count: usize, + cells: Vec, }, } @@ -112,14 +126,6 @@ pub(crate) fn new(content: Arc, cx: &mut gpui::Context) -> Ma view } -impl Styled for ContentBlock { - fn style(&mut self) -> &mut gpui::StyleRefinement { - match self { - | ContentBlock::Text { style, .. } => style, - } - } -} - impl MarkdownText { fn on_create(&mut self, cx: &gpui::Context) { let content = Arc::clone(&self.content); @@ -154,17 +160,18 @@ impl MarkdownText { let mut is_first_heading = true; - fn block_for_node( + fn build_rich_text_for_node( cursor: &mut tree_sitter::TreeCursor, + builder: &mut RichTextContentBuilder, content: &str, // byte_offset is the number of bytes to offset the content start byte by byte_offset: usize, theme: &theme::Theme, - ) -> ContentBlock { + parent_style: Option, + ) { let node_start_byte = cursor.node().start_byte(); - let mut highlights: Vec<(Range, gpui::HighlightStyle)> = Vec::new(); - let mut links: Vec<(Range, gpui::SharedString)> = Vec::new(); + let style = parent_style.unwrap_or_default(); cursor.goto_first_child(); @@ -179,56 +186,76 @@ impl MarkdownText { } match node.kind_id() { - | MARKDOWN_KIND_ID_EMPHASIS => { - highlights.push(( - node_range!(), - gpui::HighlightStyle { - font_style: Some(gpui::FontStyle::Italic), - ..Default::default() - }, - )); + | MARKDOWN_KIND_ID_TEXT => { + println!( + "current node start byte {} parent node start byte {}", + node.start_byte(), + node_start_byte + ); + if let Some(t) = node.utf8_text(content.as_ref()).ok() { + builder.push_text(t, style); } - | MARKDOWN_KIND_ID_STRONG_EMPHASIS => highlights.push(( - node_range!(), - gpui::HighlightStyle { + } + + | MARKDOWN_KIND_ID_EMPHASIS => { + build_rich_text_for_node( + cursor, + builder, + content, + byte_offset, + theme, + Some(gpui::HighlightStyle { + font_style: Some(gpui::FontStyle::Italic), + ..style + }), + ); + } + + | MARKDOWN_KIND_ID_STRONG_EMPHASIS => { + build_rich_text_for_node( + cursor, + builder, + content, + byte_offset, + theme, + Some(gpui::HighlightStyle { font_weight: Some(gpui::FontWeight::BOLD), - ..Default::default() - }, - )), + ..style + }), + ); + } - | MARKDOWN_KIND_ID_LINK => { - if cursor.goto_first_child() { - highlights.push(( - node_range!(), - gpui::HighlightStyle { - color: Some(theme.colors.link.into()), - underline: Some(gpui::UnderlineStyle { - color: Some(theme.colors.link.into()), - thickness: px(1.), - wavy: false, - }), - ..Default::default() - }, - )); + | MARKDOWN_KIND_ID_LINK => { + cursor.goto_first_child(); - if cursor.goto_next_sibling() - && let Ok(src) = cursor.node().utf8_text(content.as_bytes()) - { - links.push(( - node_range!(), - gpui::SharedString::from(String::from(src)), - )); + let (description, src) = + if cursor.node().kind_id() == MARKDOWN_KIND_ID_LINK_DESTINATION { + let node = cursor.node(); + let src = &content[node_range!()]; + (src, src) + } else { + let node = cursor.node(); + let description = &content[node_range!()]; + if cursor.goto_next_sibling() { + debug_assert!( + cursor.node().kind_id() == MARKDOWN_KIND_ID_LINK_DESTINATION + ); + let node = cursor.node(); + (description, &content[node_range!()]) } else { - // the link src is invalid, use an empty string as a fallback - // link on click handler will ignore empty string - links.push((node_range!(), "".into())) + // no src for this link node + (description, "") } - } - } + }; - | _ => { - // extend here to support more markdown node stylings - } + builder.push_link(description, src.to_owned()); + + cursor.goto_parent(); + } + + | _ => { + // extend here to support more styles + } }; if !cursor.goto_next_sibling() { @@ -237,35 +264,11 @@ impl MarkdownText { } cursor.goto_parent(); - - ContentBlock::Text { - decoration: None, - text: gpui::SharedString::new( - &content[(node_start_byte + byte_offset)..cursor.node().end_byte()], - ), - highlights: highlights, - links: links, - style: gpui::StyleRefinement::default(), - } } loop { let current_node = cursor.node(); - fn render_fallback_content( - cursor: &tree_sitter::TreeCursor, - content: &str, - blocks: &mut Vec, - ) { - blocks.push(ContentBlock::Text { - decoration: None, - text: gpui::SharedString::new(&content[cursor.node().byte_range()]), - highlights: Vec::new(), - links: Vec::new(), - style: gpui::StyleRefinement::default(), - }); - } - fn render_list_node( cursor: &mut tree_sitter::TreeCursor, content: &str, @@ -281,10 +284,7 @@ impl MarkdownText { // tight_list <-- recursive point // go to list_item node - if !cursor.goto_first_child() { - render_fallback_content(&cursor, content, blocks); - return false; - } + cursor.goto_first_child(); let mut list_index: Option = None; @@ -305,51 +305,37 @@ impl MarkdownText { let marker_content = &content[marker_node.byte_range()]; let list_marker_char = match marker_content { - // unordered list item - | "-" | "+" | "*" => Some("•".to_string()), + // unordered list item + | "-" | "+" | "*" => "•".to_string(), - | marker_content if ORDERED_LIST_MARKER_REGEX.is_match(marker_content) => { - let i = list_index.get_or_insert_with(|| { - marker_content - .strip_suffix('.') - .unwrap() - .parse::() - .unwrap() - }); - let j = *i; - *i = j + 1; - Some(format!("{j}.")) - } + | marker_content if ORDERED_LIST_MARKER_REGEX.is_match(marker_content) => { + let i = list_index.get_or_insert_with(|| { + marker_content + .strip_suffix('.') + .unwrap() + .parse::() + .unwrap() + }); + let j = *i; + *i = j + 1; + format!("{j}.") + } - | _ => None, - }; - - let Some(list_marker_char) = list_marker_char else { - render_fallback_content(&cursor, content, blocks); - return false; + | _ => "•".to_string(), }; // go to paragraph sibling node let block = if cursor.goto_next_sibling() { - let mut b = block_for_node(cursor, content, 0, theme); - match b { - | ContentBlock::Text { - ref mut decoration, .. - } => *decoration = Some(list_marker_char.into()), + let mut builder = RichTextContentBuilder::new(); + build_rich_text_for_node(cursor, &mut builder, content, 0, theme, None); + ContentBlock::Paragraph { + decoration: Some(list_marker_char.clone()), + content: builder.build(), } - b } else { - ContentBlock::Text { - decoration: Some(list_marker_char.into()), - text: gpui::SharedString::default(), - highlights: Vec::new(), - links: Vec::new(), - style: gpui::StyleRefinement::default(), - } - } - .text_sm() - .text_color(theme.colors.text) - .p(rems(indentation as f32)); + // empty block + ContentBlock::Empty + }; blocks.push(block); @@ -374,150 +360,179 @@ impl MarkdownText { } match current_node.kind_id() { - | MARKDOWN_KIND_ID_ATX_HEADING => { - if !cursor.goto_first_child() { - render_fallback_content(&cursor, &self.content, &mut self.blocks); - continue; - } + | MARKDOWN_KIND_ID_ATX_HEADING => { + cursor.goto_first_child(); - let marker_node_kind = cursor.node().kind_id(); + let marker_node_kind = cursor.node().kind_id(); - let block = if cursor.goto_next_sibling() - && cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT - { - // because HEADING_CONTENT node includes the space after the heading marker - // offset by 1 to exclude the space - block_for_node(&mut cursor, &self.content, 1, theme) - } else { - ContentBlock::Text { - decoration: None, - text: gpui::SharedString::new(&self.content[current_node.byte_range()]), - highlights: Vec::new(), - links: Vec::new(), - style: gpui::StyleRefinement::default(), - } - }; + let Some(content) = (if cursor.goto_next_sibling() + && cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT + { + let mut builder = RichTextContentBuilder::new(); + // because HEADING_CONTENT node includes the space after the heading marker + // offset by 1 to exclude the space + build_rich_text_for_node( + &mut cursor, + &mut builder, + &self.content, + 1, + theme, + None, + ); + Some(builder.build()) + } else { + None + }) else { + continue; + }; - let mut block = match marker_node_kind { - | MARKDOWN_KIND_ID_ATX_H1_MARKER => block - .text_size(rems(2.25)) - .font_weight(gpui::FontWeight::EXTRA_BOLD) - .mb_6(), - | MARKDOWN_KIND_ID_ATX_H2_MARKER => block - .text_2xl() - .font_weight(gpui::FontWeight::BOLD) - .mt_12() - .mb_4(), - | MARKDOWN_KIND_ID_ATX_H3_MARKER => block - .text_xl() - .font_weight(gpui::FontWeight::SEMIBOLD) - .mt_8() - .mb_3(), - | MARKDOWN_KIND_ID_ATX_H4_MARKER => block - .text_base() - .font_weight(gpui::FontWeight::SEMIBOLD) - .mt_6() - .mb_2(), - | _ => block, - } - .text_color(theme.colors.text); - - if is_first_heading { - is_first_heading = false; - block = block.mt_0(); - } - - cursor.goto_parent(); - - self.blocks.push(block); - } - - | MARKDOWN_KIND_ID_PARAGRAPH => { - let block = block_for_node(&mut cursor, &self.content, 0, theme) - .text_color(theme.colors.text) - .text_sm(); - - self.blocks.push(block); - } - - | MARKDOWN_KIND_ID_TIGHT_LIST => { - let is_rendered = - render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0); - if !is_rendered { - continue; + let mut block = match marker_node_kind { + | MARKDOWN_KIND_ID_ATX_H1_MARKER => ContentBlock::Heading { + font_size: rems(2.25), + font_weight: gpui::FontWeight::EXTRA_BOLD, + mt: rems(0.), + mb: rems(1.5), + content, + }, + | MARKDOWN_KIND_ID_ATX_H2_MARKER => ContentBlock::Heading { + font_size: rems(1.5), + font_weight: gpui::FontWeight::BOLD, + mt: rems(1.5), + mb: rems(1.), + content, + }, + | MARKDOWN_KIND_ID_ATX_H3_MARKER => ContentBlock::Heading { + font_size: rems(1.25), + font_weight: gpui::FontWeight::SEMIBOLD, + mt: rems(2.), + mb: rems(0.75), + content, + }, + | MARKDOWN_KIND_ID_ATX_H4_MARKER => ContentBlock::Heading { + font_size: rems(1.), + font_weight: FontWeight::SEMIBOLD, + mt: rems(1.5), + mb: rems(0.5), + content, + }, + | MARKDOWN_KIND_ID_ATX_H5_MARKER | MARKDOWN_KIND_ID_ATX_H6_MARKER | _ => { + ContentBlock::Heading { + font_size: rems(1.), + font_weight: FontWeight::NORMAL, + mt: rems(1.5), + mb: rems(0.5), + content, } } + }; - | MARKDOWN_KIND_ID_FENCED_CODE_BLOCK => { - // expected tree shape: - // fenced_code_block - // ├── info_string? (present if there is a language annotation) - // └── code_fence_content? (present if there is some content between the backticks) + if is_first_heading { + is_first_heading = false; + } - if !cursor.goto_first_child() { - render_fallback_content(&cursor, &self.content, &mut self.blocks); - continue; - } + cursor.goto_parent(); - let content = if cursor.node().kind_id() == MARKDOWN_KIND_ID_INFO_STRING { - // skipping info string (which annotates the code block) - if cursor.goto_next_sibling() { - // this is code_fence_content node - gpui::SharedString::new( - cursor - .node() - .utf8_text(self.content.as_bytes()) - .unwrap_or_default(), - ) - } else { - gpui::SharedString::default() - } - } else { - // assuming the current node is already code_fence_content + self.blocks.push(block); + } + + | MARKDOWN_KIND_ID_PARAGRAPH => { + let mut builder = RichTextContentBuilder::new(); + // because HEADING_CONTENT node includes the space after the heading marker + // offset by 1 to exclude the space + build_rich_text_for_node(&mut cursor, &mut builder, &self.content, 0, theme, None); + self.blocks.push(ContentBlock::Paragraph { + decoration: None, + content: builder.build(), + }); + } + + | MARKDOWN_KIND_ID_TIGHT_LIST => { + let is_rendered = + render_list_node(&mut cursor, &self.content, &mut self.blocks, theme, 0); + if !is_rendered { + continue; + } + } + + | MARKDOWN_KIND_ID_FENCED_CODE_BLOCK => { + // expected tree shape: + // fenced_code_block + // ├── info_string? (present if there is a language annotation) + // └── code_fence_content? (present if there is some content between the backticks) + + if !cursor.goto_first_child() { + self.blocks.push(ContentBlock::Empty); + continue; + } + + let content = if cursor.node().kind_id() == MARKDOWN_KIND_ID_INFO_STRING { + // skipping info string (which annotates the code block) + if cursor.goto_next_sibling() { + // this is code_fence_content node gpui::SharedString::new( cursor .node() .utf8_text(self.content.as_bytes()) .unwrap_or_default(), ) - }; - - cursor.goto_parent(); - - let block = ContentBlock::Text { - decoration: None, - text: content, - highlights: Vec::new(), - links: Vec::new(), - style: gpui::StyleRefinement::default(), + } else { + gpui::SharedString::default() } - .text_sm() - .text_color(theme.colors.text) - .line_height(relative(1.2)) - .font_family("Menlo") - .px_3() - .py_2() - .rounded_sm() - .bg(theme.colors.code_bg) - .border_1() - .my_4() - .border_color(theme.colors.code_border); + } else { + // assuming the current node is already code_fence_content + gpui::SharedString::new( + cursor + .node() + .utf8_text(self.content.as_bytes()) + .unwrap_or_default(), + ) + }; - self.blocks.push(block); - } + cursor.goto_parent(); - | _ => { - println!( - "[WARN] formatting not implemenetd for node type {:?}", - current_node.kind() - ); + self.blocks.push(ContentBlock::Code { content }); + } - let block = block_for_node(&mut cursor, &self.content, 0, theme) - .text_color(theme.colors.text) - .text_sm(); + // | MARKDOWN_KIND_ID_TABLE => { + // cursor.goto_first_child(); + // debug_assert!(cursor.node().kind_id() == MARKDOWN_KIND_ID_TABLE_HEADER_ROW); - self.blocks.push(block); - } + // let col_count = cursor.node().child_count(); + // // markdown tables aren't usually that big + // // lets assume the average markdown table has 10 rows (inc header) + // // preallocate the vec with capacity row * col, should be big enough to avoid realloc + // let min_row_count = 10; + + // // cell text blocks are stored in row-major order + // let cell_blocks: Vec = Vec::with_capacity(col_count * min_row_count); + + // cursor.goto_first_child(); + // debug_assert!(cursor.node().kind_id() == MARKDOWN_KIND_ID_TABLE_CELL); + + // loop { + // let cell_node = cursor.node(); + // let cell_text_block = rich_text_for_node(&mut cursor, &self.content, 1, theme); + // cell_blocks.push(ContentBlock::Paragraph(cell_text_block)); + + // if !cursor.goto_next_sibling() { + // break; + // } + // } + // } + | _ => { + println!( + "[WARN] formatting not implemenetd for node type {:?}", + current_node.kind() + ); + + let mut builder = RichTextContentBuilder::new(); + build_rich_text_for_node(&mut cursor, &mut builder, &self.content, 0, theme, None); + + self.blocks.push(ContentBlock::Paragraph { + decoration: None, + content: builder.build(), + }); + } } if !cursor.goto_next_sibling() { @@ -533,59 +548,67 @@ impl gpui::Render for MarkdownText { _window: &mut gpui::Window, cx: &mut gpui::prelude::Context, ) -> impl gpui::prelude::IntoElement { - let children = self.blocks.iter().enumerate().map(|(i, block)| { - match block { - | ContentBlock::Text { - decoration, - text, - highlights, - links, - style, - } => { - let styled_text = - gpui::StyledText::new(text.clone()).with_highlights(highlights.clone()); + let theme = app::current_theme(cx); - let content = if links.is_empty() { - div().w_full().child(styled_text) - } else { - // if link in block, interactive text is needed - // to handle link clicks - let (link_ranges, srcs): (Vec<_>, Vec<_>) = links.iter().cloned().unzip(); + let children = self + .blocks + .iter() + .enumerate() + .map(|(i, block)| match block { + | ContentBlock::Heading { + font_size, + font_weight, + mt, + mb, + content, + } => div() + .min_w_0() + .mt(gpui::Length::from(*mt)) + .mb(gpui::Length::from(*mb)) + .text_size(gpui::AbsoluteLength::from(*font_size)) + .font_weight(*font_weight) + .child(rich_text(content.clone())), - let weak = cx.entity(); - let t = gpui::InteractiveText::new(i, styled_text).on_click( - link_ranges, - move |i, _, cx| { - if let Some(src) = srcs.get(i) { - weak.update(cx, |this, cx| { - this.on_open_link(src, cx); - cx.notify(); - }) - } - }, - ); + | ContentBlock::Paragraph { + decoration, + content, + } => match decoration { + | None => div().min_w_0().child(rich_text(content.clone())), + | Some(decoration) => div() + .w_full() + .flex() + .flex_row() + .gap_2() + .items_start() + .text_color(theme.colors.text) + .child(decoration.clone()) + .child(div().flex_1().min_w_0().child(rich_text(content.clone()))), + }, - div().w_full().child(t) - }; + | ContentBlock::Code { content } => div() + .min_w_0() + .w_full() + .text_sm() + .text_color(theme.colors.text) + .line_height(relative(1.2)) + .font_family("Menlo") + .px_3() + .py_2() + .rounded_sm() + .bg(theme.colors.code_bg) + .border_1() + .my_4() + .border_color(theme.colors.code_border) + .child(content.clone()), - let mut div = match decoration { - | Some(d) => div() - .w_full() - .flex() - .flex_row() - .gap_2() - .items_start() - .child(d.clone()) - .child(div().flex_1().min_w_0().child(content)), - | None => div().w_full().child(content), - }; + | ContentBlock::Table { + row_count, + col_count, + cells, + } => div(), - div.style().refine(&style); - - div - } - } - }); + | ContentBlock::Empty => div(), + }); div().w_full().children(children) } diff --git a/src/component/mod.rs b/src/component/mod.rs index 9773bec..9d603f1 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod diff_view; pub(crate) mod file_tree; pub(crate) mod font_icon; pub(crate) mod markdown; +pub(crate) mod rich_text; pub(crate) mod segmented_control; pub(crate) mod text; pub(crate) mod text_input; diff --git a/src/component/rich_text.rs b/src/component/rich_text.rs new file mode 100644 index 0000000..0e217f5 --- /dev/null +++ b/src/component/rich_text.rs @@ -0,0 +1,201 @@ +use std::rc::Rc; + +use gpui::{IntoElement, ParentElement, Styled, div, px}; + +use crate::{app, util::syntax_highlight}; + +pub(crate) struct RichTextContentBuilder { + raw_content: String, + annotations: Vec, +} + +#[derive(Clone)] +pub(crate) struct RichTextContent { + elements: Rc<[RichTextElement]>, + links: Rc<[gpui::SharedString]>, +} + +#[derive(gpui::IntoElement)] +pub(crate) struct RichText { + content: RichTextContent, + on_click: Option>, +} + +enum Annotation { + Text { + style: gpui::HighlightStyle, + range: std::ops::Range, + }, + Image { + src: gpui::SharedString, + range: std::ops::Range, + }, + Link { + src: gpui::SharedString, + range: std::ops::Range, + }, +} + +enum RichTextElement { + Text { + content: gpui::SharedString, + highlights: Vec, + links: Vec<(std::ops::Range)>, + link_i_offset: usize, + }, + Image { + src: gpui::SharedString, + description: gpui::SharedString, + }, +} + +enum RichTextClickTarget { + Link(gpui::SharedString), +} + +pub(crate) fn rich_text(content: RichTextContent) -> RichText { + RichText { + content, + on_click: None, + } +} + +impl RichTextContentBuilder { + pub(crate) fn new() -> Self { + Self { + raw_content: String::new(), + annotations: Vec::new(), + } + } + + pub(crate) fn push_text(&mut self, text: &str, style: gpui::HighlightStyle) { + let start = self.raw_content.len(); + let end = start + text.len(); + self.raw_content.push_str(text); + self.annotations.push(Annotation::Text { + style, + range: start..end, + }); + } + + pub(crate) fn push_link(&mut self, text: &str, src: String) { + let start = self.raw_content.len(); + let end = start + text.len(); + self.raw_content.push_str(text); + self.annotations.push(Annotation::Link { + src: src.into(), + range: start..end, + }); + } + + pub(crate) fn build(self) -> RichTextContent { + let mut text_start = 0; + let mut text_end = 0; + + let mut highlights: Vec<(std::ops::Range, gpui::HighlightStyle)> = Vec::new(); + let mut links: Vec = Vec::new(); + let mut link_ranges: Vec<(std::ops::Range)> = Vec::new(); + let mut elements: Vec = Vec::new(); + let mut link_i_offset = 0; + + for annotation in self.annotations { + match annotation { + | Annotation::Text { style, range } => { + highlights.push(((range.start - text_start)..(range.end - text_start), style)); + text_end = range.end; + } + | Annotation::Link { src, range } => { + highlights.push(( + (range.start - text_start)..(range.end - text_start), + gpui::HighlightStyle { + underline: Some(gpui::UnderlineStyle { + thickness: px(1.), + ..Default::default() + }), + ..Default::default() + }, + )); + links.push(src); + link_ranges.push((range.start - text_start)..(range.end - text_start)); + + text_end = range.end; + } + | Annotation::Image { src, range } => { + elements.push(RichTextElement::Text { + content: gpui::SharedString::new(&self.raw_content[text_start..text_end]), + highlights: highlights.clone(), + links: link_ranges.clone(), + link_i_offset, + }); + + highlights.clear(); + link_ranges.clear(); + link_i_offset = links.len(); + text_start = range.end; + text_end = range.end + 1; + } + } + } + + if !highlights.is_empty() || !link_ranges.is_empty() { + elements.push(RichTextElement::Text { + content: gpui::SharedString::new(&self.raw_content[text_start..text_end]), + highlights: highlights.clone(), + links: link_ranges.clone(), + link_i_offset, + }); + } + + RichTextContent { + elements: Rc::from(elements), + links: Rc::from(links), + } + } +} + +impl gpui::RenderOnce for RichText { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { + let theme = app::current_theme(cx); + + let children = self + .content + .elements + .iter() + .enumerate() + .map(|(i, elem)| match elem { + | RichTextElement::Text { + content, + highlights, + links, + link_i_offset, + } => { + let styled_text = + gpui::StyledText::new(content).with_highlights(highlights.into_iter().cloned()); + if links.is_empty() { + styled_text.into_any_element() + } else { + let on_click = self.on_click.as_ref().map(Rc::clone); + let all_links = Rc::clone(&self.content.links); + let link_i_offset = *link_i_offset; + + gpui::InteractiveText::new(i, styled_text) + .on_click(links.clone(), move |i, window, cx| { + if let Some(f) = &on_click { + let link = all_links[i + link_i_offset].clone(); + f(&RichTextClickTarget::Link(link), window, cx); + } + }) + .into_any_element() + } + } + | RichTextElement::Image { src, description } => todo!(), + }); + + div() + .flex() + .flex_row() + .flex_wrap() + .text_color(theme.colors.text) + .children(children) + } +}