refactor: markdown rendering into rich_text component
This commit is contained in:
@@ -14,5 +14,5 @@
|
|||||||
"head_branch_name": "feat/worker-context-envelope",
|
"head_branch_name": "feat/worker-context-envelope",
|
||||||
"head_repo_slug": "kennethnym/agent-tooling",
|
"head_repo_slug": "kennethnym/agent-tooling",
|
||||||
"head_ref": "4a8df12be732c0f9e5d194cd2af7430c0d2fb8d4",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,5 @@
|
|||||||
"head_branch_name": "docs/manual-failover-steps",
|
"head_branch_name": "docs/manual-failover-steps",
|
||||||
"head_repo_slug": "kennethnym/infra-scripts",
|
"head_repo_slug": "kennethnym/infra-scripts",
|
||||||
"head_ref": "6fd11baf0d9d53d18f6d7b7dc265d9b09e6f4217",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,5 @@
|
|||||||
"head_branch_name": "feat/cached-repo-picker",
|
"head_branch_name": "feat/cached-repo-picker",
|
||||||
"head_repo_slug": "kennethnym/novem",
|
"head_repo_slug": "kennethnym/novem",
|
||||||
"head_ref": "13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(dashboard): hydrate issue pane from cached query state",
|
"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_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "completed",
|
"state_reason": "completed",
|
||||||
"title": "feat(calendar): ship release handoff checklist in weekly planner",
|
"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_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(repo): add cached repository query for titlebar picker",
|
"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_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(prompts): split context loading from execution workers",
|
"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_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "chore(tokens): tighten dashboard spacing scale",
|
"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_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "not_planned",
|
"state_reason": "not_planned",
|
||||||
"title": "docs(deploy): document manual failover steps",
|
"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_text": null,
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "completed",
|
"state_reason": "completed",
|
||||||
"title": "feat(calendar): ship release handoff checklist in weekly planner",
|
"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_text": "Adds the release checklist views and marks the handoff flow complete for the May rollout.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(prompts): split context loading from execution workers",
|
"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_text": "Separates prompt packing from worker orchestration to make delegation easier to reason about.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "chore(tokens): tighten dashboard spacing scale",
|
"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_text": "Normalizes horizontal gutters and sidebar section padding before the visual refresh.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(repo): add cached repository query for titlebar picker",
|
"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_text": "Introduces a repository list query so the titlebar can switch context without hitting GitHub repeatedly.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"state": "open",
|
"state": "open",
|
||||||
"state_reason": null,
|
"state_reason": null,
|
||||||
"title": "feat(dashboard): hydrate issue pane from cached query state",
|
"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_text": "Wires the dashboard issue list to the query store and keeps selection stable while refetching.",
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"state": "closed",
|
"state": "closed",
|
||||||
"state_reason": "not_planned",
|
"state_reason": "not_planned",
|
||||||
"title": "docs(deploy): document manual failover steps",
|
"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_text": null,
|
||||||
"body_html": null,
|
"body_html": null,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ fn issue_filter_fixture_key(filter: &str) -> &str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn list_pull_request_fixtures_parse_with_current_filter_strings() {
|
fn list_pull_request_fixtures_parse_with_current_filter_strings() {
|
||||||
let authored = list_pull_requests(Some("author:@me state:open"), 1)
|
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");
|
.expect("spacing token pull request fixture should parse");
|
||||||
|
|
||||||
assert_eq!(merged.state, issues::PullRequestState::Merged);
|
assert_eq!(merged.state, issues::PullRequestState::Merged);
|
||||||
assert!(merged.body.contains("| Stage | Owner | Status |"));
|
assert_markdown_table(&merged.body, "| Stage | Owner | Status |");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merged.author.as_ref().map(|author| author.login.as_ref()),
|
merged.author.as_ref().map(|author| author.login.as_ref()),
|
||||||
Some("rorycraft")
|
Some("rorycraft")
|
||||||
@@ -192,6 +209,7 @@ mod tests {
|
|||||||
.body
|
.body
|
||||||
.contains("./scripts/failover promote-standby")
|
.contains("./scripts/failover promote-standby")
|
||||||
);
|
);
|
||||||
|
assert_markdown_table(&documented_failover.body, "| Step | Owner | State |");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
documented_failover
|
documented_failover
|
||||||
.author
|
.author
|
||||||
@@ -209,6 +227,7 @@ mod tests {
|
|||||||
Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap())
|
Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap())
|
||||||
);
|
);
|
||||||
assert!(dashboard_markdown.body.contains("```rust"));
|
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.base_branch_name.as_ref(), "main");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dashboard_markdown.head_branch_name.as_ref(),
|
dashboard_markdown.head_branch_name.as_ref(),
|
||||||
@@ -233,6 +252,11 @@ mod tests {
|
|||||||
.map(|author| author.login.as_ref()),
|
.map(|author| author.login.as_ref()),
|
||||||
Some("kennethnym")
|
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.base_branch_name.as_ref(), "main");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cached_repo_picker.head_branch_name.as_ref(),
|
cached_repo_picker.head_branch_name.as_ref(),
|
||||||
@@ -257,6 +281,7 @@ mod tests {
|
|||||||
.map(|author| author.login.as_ref()),
|
.map(|author| author.login.as_ref()),
|
||||||
Some("leaferiksen")
|
Some("leaferiksen")
|
||||||
);
|
);
|
||||||
|
assert_markdown_table(&worker_split.body, "| Boundary | Responsibility |");
|
||||||
assert_eq!(worker_split.base_branch_name.as_ref(), "main");
|
assert_eq!(worker_split.base_branch_name.as_ref(), "main");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
worker_split.head_branch_name.as_ref(),
|
worker_split.head_branch_name.as_ref(),
|
||||||
@@ -273,6 +298,7 @@ mod tests {
|
|||||||
.map(|author| author.login.as_ref()),
|
.map(|author| author.login.as_ref()),
|
||||||
Some("mariahops")
|
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.base_branch_name.as_ref(), "main");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
spacing_tokens.head_branch_name.as_ref(),
|
spacing_tokens.head_branch_name.as_ref(),
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button {
|
|||||||
let theme = app::current_theme(cx);
|
let theme = app::current_theme(cx);
|
||||||
|
|
||||||
let icon_color = match self.variant {
|
let icon_color = match self.variant {
|
||||||
| Variant::Primary => theme.colors.accent_on_solid,
|
| Variant::Primary => theme.colors.accent_on_solid,
|
||||||
| Variant::Secondary => theme.colors.text,
|
| Variant::Secondary => theme.colors.text,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut children: Vec<AnyElement> = Vec::with_capacity(3);
|
let mut children: Vec<AnyElement> = Vec::with_capacity(3);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/
|
// markdown treesitter playground: https://ikatyang.github.io/tree-sitter-markdown/
|
||||||
|
|
||||||
use std::{
|
use std::sync::{Arc, LazyLock};
|
||||||
ops::Range,
|
|
||||||
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_H1_UNDERLINE: u16 = 8;
|
||||||
const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9;
|
const MARKDOWN_KIND_ID_SETEXT_H2_UNDERLINE: u16 = 9;
|
||||||
const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11;
|
const MARKDOWN_KIND_ID_ATX_H1_MARKER: u16 = 11;
|
||||||
@@ -94,12 +95,25 @@ pub(crate) struct MarkdownText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ContentBlock {
|
enum ContentBlock {
|
||||||
Text {
|
Heading {
|
||||||
decoration: Option<gpui::SharedString>,
|
font_size: gpui::Rems,
|
||||||
text: gpui::SharedString,
|
font_weight: gpui::FontWeight,
|
||||||
highlights: Vec<(Range<usize>, gpui::HighlightStyle)>,
|
mt: gpui::Rems,
|
||||||
links: Vec<(Range<usize>, gpui::SharedString)>,
|
mb: gpui::Rems,
|
||||||
style: gpui::StyleRefinement,
|
content: RichTextContent,
|
||||||
|
},
|
||||||
|
Code {
|
||||||
|
content: gpui::SharedString,
|
||||||
|
},
|
||||||
|
Paragraph {
|
||||||
|
decoration: Option<String>,
|
||||||
|
content: RichTextContent,
|
||||||
|
},
|
||||||
|
Empty,
|
||||||
|
Table {
|
||||||
|
row_count: usize,
|
||||||
|
col_count: usize,
|
||||||
|
cells: Vec<RichTextContent>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,14 +126,6 @@ pub(crate) fn new(content: Arc<str>, cx: &mut gpui::Context<MarkdownText>) -> Ma
|
|||||||
view
|
view
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Styled for ContentBlock {
|
|
||||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
|
||||||
match self {
|
|
||||||
| ContentBlock::Text { style, .. } => style,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MarkdownText {
|
impl MarkdownText {
|
||||||
fn on_create(&mut self, cx: &gpui::Context<Self>) {
|
fn on_create(&mut self, cx: &gpui::Context<Self>) {
|
||||||
let content = Arc::clone(&self.content);
|
let content = Arc::clone(&self.content);
|
||||||
@@ -154,17 +160,18 @@ impl MarkdownText {
|
|||||||
|
|
||||||
let mut is_first_heading = true;
|
let mut is_first_heading = true;
|
||||||
|
|
||||||
fn block_for_node(
|
fn build_rich_text_for_node(
|
||||||
cursor: &mut tree_sitter::TreeCursor,
|
cursor: &mut tree_sitter::TreeCursor,
|
||||||
|
builder: &mut RichTextContentBuilder,
|
||||||
content: &str,
|
content: &str,
|
||||||
// byte_offset is the number of bytes to offset the content start byte by
|
// byte_offset is the number of bytes to offset the content start byte by
|
||||||
byte_offset: usize,
|
byte_offset: usize,
|
||||||
theme: &theme::Theme,
|
theme: &theme::Theme,
|
||||||
) -> ContentBlock {
|
parent_style: Option<gpui::HighlightStyle>,
|
||||||
|
) {
|
||||||
let node_start_byte = cursor.node().start_byte();
|
let node_start_byte = cursor.node().start_byte();
|
||||||
|
|
||||||
let mut highlights: Vec<(Range<usize>, gpui::HighlightStyle)> = Vec::new();
|
let style = parent_style.unwrap_or_default();
|
||||||
let mut links: Vec<(Range<usize>, gpui::SharedString)> = Vec::new();
|
|
||||||
|
|
||||||
cursor.goto_first_child();
|
cursor.goto_first_child();
|
||||||
|
|
||||||
@@ -179,56 +186,76 @@ impl MarkdownText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match node.kind_id() {
|
match node.kind_id() {
|
||||||
| MARKDOWN_KIND_ID_EMPHASIS => {
|
| MARKDOWN_KIND_ID_TEXT => {
|
||||||
highlights.push((
|
println!(
|
||||||
node_range!(),
|
"current node start byte {} parent node start byte {}",
|
||||||
gpui::HighlightStyle {
|
node.start_byte(),
|
||||||
font_style: Some(gpui::FontStyle::Italic),
|
node_start_byte
|
||||||
..Default::default()
|
);
|
||||||
},
|
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),
|
font_weight: Some(gpui::FontWeight::BOLD),
|
||||||
..Default::default()
|
..style
|
||||||
},
|
}),
|
||||||
)),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
| MARKDOWN_KIND_ID_LINK => {
|
| MARKDOWN_KIND_ID_LINK => {
|
||||||
if cursor.goto_first_child() {
|
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()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
if cursor.goto_next_sibling()
|
let (description, src) =
|
||||||
&& let Ok(src) = cursor.node().utf8_text(content.as_bytes())
|
if cursor.node().kind_id() == MARKDOWN_KIND_ID_LINK_DESTINATION {
|
||||||
{
|
let node = cursor.node();
|
||||||
links.push((
|
let src = &content[node_range!()];
|
||||||
node_range!(),
|
(src, src)
|
||||||
gpui::SharedString::from(String::from(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 {
|
} else {
|
||||||
// the link src is invalid, use an empty string as a fallback
|
// no src for this link node
|
||||||
// link on click handler will ignore empty string
|
(description, "")
|
||||||
links.push((node_range!(), "".into()))
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
| _ => {
|
builder.push_link(description, src.to_owned());
|
||||||
// extend here to support more markdown node stylings
|
|
||||||
}
|
cursor.goto_parent();
|
||||||
|
}
|
||||||
|
|
||||||
|
| _ => {
|
||||||
|
// extend here to support more styles
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !cursor.goto_next_sibling() {
|
if !cursor.goto_next_sibling() {
|
||||||
@@ -237,35 +264,11 @@ impl MarkdownText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cursor.goto_parent();
|
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 {
|
loop {
|
||||||
let current_node = cursor.node();
|
let current_node = cursor.node();
|
||||||
|
|
||||||
fn render_fallback_content(
|
|
||||||
cursor: &tree_sitter::TreeCursor,
|
|
||||||
content: &str,
|
|
||||||
blocks: &mut Vec<ContentBlock>,
|
|
||||||
) {
|
|
||||||
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(
|
fn render_list_node(
|
||||||
cursor: &mut tree_sitter::TreeCursor,
|
cursor: &mut tree_sitter::TreeCursor,
|
||||||
content: &str,
|
content: &str,
|
||||||
@@ -281,10 +284,7 @@ impl MarkdownText {
|
|||||||
// tight_list <-- recursive point
|
// tight_list <-- recursive point
|
||||||
|
|
||||||
// go to list_item node
|
// go to list_item node
|
||||||
if !cursor.goto_first_child() {
|
cursor.goto_first_child();
|
||||||
render_fallback_content(&cursor, content, blocks);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut list_index: Option<usize> = None;
|
let mut list_index: Option<usize> = None;
|
||||||
|
|
||||||
@@ -305,51 +305,37 @@ impl MarkdownText {
|
|||||||
let marker_content = &content[marker_node.byte_range()];
|
let marker_content = &content[marker_node.byte_range()];
|
||||||
|
|
||||||
let list_marker_char = match marker_content {
|
let list_marker_char = match marker_content {
|
||||||
// unordered list item
|
// unordered list item
|
||||||
| "-" | "+" | "*" => Some("•".to_string()),
|
| "-" | "+" | "*" => "•".to_string(),
|
||||||
|
|
||||||
| marker_content if ORDERED_LIST_MARKER_REGEX.is_match(marker_content) => {
|
| marker_content if ORDERED_LIST_MARKER_REGEX.is_match(marker_content) => {
|
||||||
let i = list_index.get_or_insert_with(|| {
|
let i = list_index.get_or_insert_with(|| {
|
||||||
marker_content
|
marker_content
|
||||||
.strip_suffix('.')
|
.strip_suffix('.')
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.parse::<usize>()
|
.parse::<usize>()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
});
|
});
|
||||||
let j = *i;
|
let j = *i;
|
||||||
*i = j + 1;
|
*i = j + 1;
|
||||||
Some(format!("{j}."))
|
format!("{j}.")
|
||||||
}
|
}
|
||||||
|
|
||||||
| _ => None,
|
| _ => "•".to_string(),
|
||||||
};
|
|
||||||
|
|
||||||
let Some(list_marker_char) = list_marker_char else {
|
|
||||||
render_fallback_content(&cursor, content, blocks);
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// go to paragraph sibling node
|
// go to paragraph sibling node
|
||||||
let block = if cursor.goto_next_sibling() {
|
let block = if cursor.goto_next_sibling() {
|
||||||
let mut b = block_for_node(cursor, content, 0, theme);
|
let mut builder = RichTextContentBuilder::new();
|
||||||
match b {
|
build_rich_text_for_node(cursor, &mut builder, content, 0, theme, None);
|
||||||
| ContentBlock::Text {
|
ContentBlock::Paragraph {
|
||||||
ref mut decoration, ..
|
decoration: Some(list_marker_char.clone()),
|
||||||
} => *decoration = Some(list_marker_char.into()),
|
content: builder.build(),
|
||||||
}
|
}
|
||||||
b
|
|
||||||
} else {
|
} else {
|
||||||
ContentBlock::Text {
|
// empty block
|
||||||
decoration: Some(list_marker_char.into()),
|
ContentBlock::Empty
|
||||||
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));
|
|
||||||
|
|
||||||
blocks.push(block);
|
blocks.push(block);
|
||||||
|
|
||||||
@@ -374,150 +360,179 @@ impl MarkdownText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match current_node.kind_id() {
|
match current_node.kind_id() {
|
||||||
| MARKDOWN_KIND_ID_ATX_HEADING => {
|
| MARKDOWN_KIND_ID_ATX_HEADING => {
|
||||||
if !cursor.goto_first_child() {
|
cursor.goto_first_child();
|
||||||
render_fallback_content(&cursor, &self.content, &mut self.blocks);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let marker_node_kind = cursor.node().kind_id();
|
let marker_node_kind = cursor.node().kind_id();
|
||||||
|
|
||||||
let block = if cursor.goto_next_sibling()
|
let Some(content) = (if cursor.goto_next_sibling()
|
||||||
&& cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT
|
&& cursor.node().kind_id() == MARKDOWN_KIND_ID_HEADING_CONTENT
|
||||||
{
|
{
|
||||||
// because HEADING_CONTENT node includes the space after the heading marker
|
let mut builder = RichTextContentBuilder::new();
|
||||||
// offset by 1 to exclude the space
|
// because HEADING_CONTENT node includes the space after the heading marker
|
||||||
block_for_node(&mut cursor, &self.content, 1, theme)
|
// offset by 1 to exclude the space
|
||||||
} else {
|
build_rich_text_for_node(
|
||||||
ContentBlock::Text {
|
&mut cursor,
|
||||||
decoration: None,
|
&mut builder,
|
||||||
text: gpui::SharedString::new(&self.content[current_node.byte_range()]),
|
&self.content,
|
||||||
highlights: Vec::new(),
|
1,
|
||||||
links: Vec::new(),
|
theme,
|
||||||
style: gpui::StyleRefinement::default(),
|
None,
|
||||||
}
|
);
|
||||||
};
|
Some(builder.build())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let mut block = match marker_node_kind {
|
let mut block = match marker_node_kind {
|
||||||
| MARKDOWN_KIND_ID_ATX_H1_MARKER => block
|
| MARKDOWN_KIND_ID_ATX_H1_MARKER => ContentBlock::Heading {
|
||||||
.text_size(rems(2.25))
|
font_size: rems(2.25),
|
||||||
.font_weight(gpui::FontWeight::EXTRA_BOLD)
|
font_weight: gpui::FontWeight::EXTRA_BOLD,
|
||||||
.mb_6(),
|
mt: rems(0.),
|
||||||
| MARKDOWN_KIND_ID_ATX_H2_MARKER => block
|
mb: rems(1.5),
|
||||||
.text_2xl()
|
content,
|
||||||
.font_weight(gpui::FontWeight::BOLD)
|
},
|
||||||
.mt_12()
|
| MARKDOWN_KIND_ID_ATX_H2_MARKER => ContentBlock::Heading {
|
||||||
.mb_4(),
|
font_size: rems(1.5),
|
||||||
| MARKDOWN_KIND_ID_ATX_H3_MARKER => block
|
font_weight: gpui::FontWeight::BOLD,
|
||||||
.text_xl()
|
mt: rems(1.5),
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
mb: rems(1.),
|
||||||
.mt_8()
|
content,
|
||||||
.mb_3(),
|
},
|
||||||
| MARKDOWN_KIND_ID_ATX_H4_MARKER => block
|
| MARKDOWN_KIND_ID_ATX_H3_MARKER => ContentBlock::Heading {
|
||||||
.text_base()
|
font_size: rems(1.25),
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
font_weight: gpui::FontWeight::SEMIBOLD,
|
||||||
.mt_6()
|
mt: rems(2.),
|
||||||
.mb_2(),
|
mb: rems(0.75),
|
||||||
| _ => block,
|
content,
|
||||||
}
|
},
|
||||||
.text_color(theme.colors.text);
|
| MARKDOWN_KIND_ID_ATX_H4_MARKER => ContentBlock::Heading {
|
||||||
|
font_size: rems(1.),
|
||||||
if is_first_heading {
|
font_weight: FontWeight::SEMIBOLD,
|
||||||
is_first_heading = false;
|
mt: rems(1.5),
|
||||||
block = block.mt_0();
|
mb: rems(0.5),
|
||||||
}
|
content,
|
||||||
|
},
|
||||||
cursor.goto_parent();
|
| MARKDOWN_KIND_ID_ATX_H5_MARKER | MARKDOWN_KIND_ID_ATX_H6_MARKER | _ => {
|
||||||
|
ContentBlock::Heading {
|
||||||
self.blocks.push(block);
|
font_size: rems(1.),
|
||||||
}
|
font_weight: FontWeight::NORMAL,
|
||||||
|
mt: rems(1.5),
|
||||||
| MARKDOWN_KIND_ID_PARAGRAPH => {
|
mb: rems(0.5),
|
||||||
let block = block_for_node(&mut cursor, &self.content, 0, theme)
|
content,
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
| MARKDOWN_KIND_ID_FENCED_CODE_BLOCK => {
|
if is_first_heading {
|
||||||
// expected tree shape:
|
is_first_heading = false;
|
||||||
// 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() {
|
cursor.goto_parent();
|
||||||
render_fallback_content(&cursor, &self.content, &mut self.blocks);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = if cursor.node().kind_id() == MARKDOWN_KIND_ID_INFO_STRING {
|
self.blocks.push(block);
|
||||||
// skipping info string (which annotates the code block)
|
}
|
||||||
if cursor.goto_next_sibling() {
|
|
||||||
// this is code_fence_content node
|
| MARKDOWN_KIND_ID_PARAGRAPH => {
|
||||||
gpui::SharedString::new(
|
let mut builder = RichTextContentBuilder::new();
|
||||||
cursor
|
// because HEADING_CONTENT node includes the space after the heading marker
|
||||||
.node()
|
// offset by 1 to exclude the space
|
||||||
.utf8_text(self.content.as_bytes())
|
build_rich_text_for_node(&mut cursor, &mut builder, &self.content, 0, theme, None);
|
||||||
.unwrap_or_default(),
|
self.blocks.push(ContentBlock::Paragraph {
|
||||||
)
|
decoration: None,
|
||||||
} else {
|
content: builder.build(),
|
||||||
gpui::SharedString::default()
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// assuming the current node is already code_fence_content
|
| 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(
|
gpui::SharedString::new(
|
||||||
cursor
|
cursor
|
||||||
.node()
|
.node()
|
||||||
.utf8_text(self.content.as_bytes())
|
.utf8_text(self.content.as_bytes())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
)
|
)
|
||||||
};
|
} else {
|
||||||
|
gpui::SharedString::default()
|
||||||
cursor.goto_parent();
|
|
||||||
|
|
||||||
let block = ContentBlock::Text {
|
|
||||||
decoration: None,
|
|
||||||
text: content,
|
|
||||||
highlights: Vec::new(),
|
|
||||||
links: Vec::new(),
|
|
||||||
style: gpui::StyleRefinement::default(),
|
|
||||||
}
|
}
|
||||||
.text_sm()
|
} else {
|
||||||
.text_color(theme.colors.text)
|
// assuming the current node is already code_fence_content
|
||||||
.line_height(relative(1.2))
|
gpui::SharedString::new(
|
||||||
.font_family("Menlo")
|
cursor
|
||||||
.px_3()
|
.node()
|
||||||
.py_2()
|
.utf8_text(self.content.as_bytes())
|
||||||
.rounded_sm()
|
.unwrap_or_default(),
|
||||||
.bg(theme.colors.code_bg)
|
)
|
||||||
.border_1()
|
};
|
||||||
.my_4()
|
|
||||||
.border_color(theme.colors.code_border);
|
|
||||||
|
|
||||||
self.blocks.push(block);
|
cursor.goto_parent();
|
||||||
}
|
|
||||||
|
|
||||||
| _ => {
|
self.blocks.push(ContentBlock::Code { content });
|
||||||
println!(
|
}
|
||||||
"[WARN] formatting not implemenetd for node type {:?}",
|
|
||||||
current_node.kind()
|
|
||||||
);
|
|
||||||
|
|
||||||
let block = block_for_node(&mut cursor, &self.content, 0, theme)
|
// | MARKDOWN_KIND_ID_TABLE => {
|
||||||
.text_color(theme.colors.text)
|
// cursor.goto_first_child();
|
||||||
.text_sm();
|
// 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<ContentBlock> = 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() {
|
if !cursor.goto_next_sibling() {
|
||||||
@@ -533,59 +548,67 @@ impl gpui::Render for MarkdownText {
|
|||||||
_window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
cx: &mut gpui::prelude::Context<Self>,
|
cx: &mut gpui::prelude::Context<Self>,
|
||||||
) -> impl gpui::prelude::IntoElement {
|
) -> impl gpui::prelude::IntoElement {
|
||||||
let children = self.blocks.iter().enumerate().map(|(i, block)| {
|
let theme = app::current_theme(cx);
|
||||||
match block {
|
|
||||||
| ContentBlock::Text {
|
|
||||||
decoration,
|
|
||||||
text,
|
|
||||||
highlights,
|
|
||||||
links,
|
|
||||||
style,
|
|
||||||
} => {
|
|
||||||
let styled_text =
|
|
||||||
gpui::StyledText::new(text.clone()).with_highlights(highlights.clone());
|
|
||||||
|
|
||||||
let content = if links.is_empty() {
|
let children = self
|
||||||
div().w_full().child(styled_text)
|
.blocks
|
||||||
} else {
|
.iter()
|
||||||
// if link in block, interactive text is needed
|
.enumerate()
|
||||||
// to handle link clicks
|
.map(|(i, block)| match block {
|
||||||
let (link_ranges, srcs): (Vec<_>, Vec<_>) = links.iter().cloned().unzip();
|
| 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();
|
| ContentBlock::Paragraph {
|
||||||
let t = gpui::InteractiveText::new(i, styled_text).on_click(
|
decoration,
|
||||||
link_ranges,
|
content,
|
||||||
move |i, _, cx| {
|
} => match decoration {
|
||||||
if let Some(src) = srcs.get(i) {
|
| None => div().min_w_0().child(rich_text(content.clone())),
|
||||||
weak.update(cx, |this, cx| {
|
| Some(decoration) => div()
|
||||||
this.on_open_link(src, cx);
|
.w_full()
|
||||||
cx.notify();
|
.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 {
|
| ContentBlock::Table {
|
||||||
| Some(d) => div()
|
row_count,
|
||||||
.w_full()
|
col_count,
|
||||||
.flex()
|
cells,
|
||||||
.flex_row()
|
} => div(),
|
||||||
.gap_2()
|
|
||||||
.items_start()
|
|
||||||
.child(d.clone())
|
|
||||||
.child(div().flex_1().min_w_0().child(content)),
|
|
||||||
| None => div().w_full().child(content),
|
|
||||||
};
|
|
||||||
|
|
||||||
div.style().refine(&style);
|
| ContentBlock::Empty => div(),
|
||||||
|
});
|
||||||
div
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
div().w_full().children(children)
|
div().w_full().children(children)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub(crate) mod diff_view;
|
|||||||
pub(crate) mod file_tree;
|
pub(crate) mod file_tree;
|
||||||
pub(crate) mod font_icon;
|
pub(crate) mod font_icon;
|
||||||
pub(crate) mod markdown;
|
pub(crate) mod markdown;
|
||||||
|
pub(crate) mod rich_text;
|
||||||
pub(crate) mod segmented_control;
|
pub(crate) mod segmented_control;
|
||||||
pub(crate) mod text;
|
pub(crate) mod text;
|
||||||
pub(crate) mod text_input;
|
pub(crate) mod text_input;
|
||||||
|
|||||||
201
src/component/rich_text.rs
Normal file
201
src/component/rich_text.rs
Normal file
@@ -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<Annotation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Rc<dyn Fn(&RichTextClickTarget, &mut gpui::Window, &mut gpui::App)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Annotation {
|
||||||
|
Text {
|
||||||
|
style: gpui::HighlightStyle,
|
||||||
|
range: std::ops::Range<usize>,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
src: gpui::SharedString,
|
||||||
|
range: std::ops::Range<usize>,
|
||||||
|
},
|
||||||
|
Link {
|
||||||
|
src: gpui::SharedString,
|
||||||
|
range: std::ops::Range<usize>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RichTextElement {
|
||||||
|
Text {
|
||||||
|
content: gpui::SharedString,
|
||||||
|
highlights: Vec<syntax_highlight::HighlightedRange>,
|
||||||
|
links: Vec<(std::ops::Range<usize>)>,
|
||||||
|
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<usize>, gpui::HighlightStyle)> = Vec::new();
|
||||||
|
let mut links: Vec<gpui::SharedString> = Vec::new();
|
||||||
|
let mut link_ranges: Vec<(std::ops::Range<usize>)> = Vec::new();
|
||||||
|
let mut elements: Vec<RichTextElement> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user