| author | |
| committer | |
| log | b489ea52a85b6e6d698523a696f42d7fc730015f |
| tree | 76b1dbd6db792a7726de9cd524be272396a2e72b |
| parent | 3a5ec9464b4ba22be623abb3ceeaa28ab7c807e7 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 490 insertions(+), 31 deletions(-)
apps/web/src/components/ChatMarkdown.browser.tsx+7| ... | ... | @@ -288,6 +288,13 @@ describe("ChatMarkdown", () => { |
| 288 | 288 | ).toMatchObject({ |
| 289 | 289 | isOpen: true, |
| 290 | 290 | activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx", |
| 291 | surfaces: [ | |
| 292 | expect.objectContaining({ | |
| 293 | relativePath: "apps/web/src/components/ChatMarkdown.tsx", | |
| 294 | revealLine: 978, | |
| 295 | revealRequestId: 1, | |
| 296 | }), | |
| 297 | ], | |
| 291 | 298 | }); |
| 292 | 299 | expect(openInPreferredEditorMock).not.toHaveBeenCalled(); |
| 293 | 300 | expect(openFileInPreviewMock).not.toHaveBeenCalled(); |
apps/web/src/components/ChatMarkdown.tsx+6-2| ... | ... | @@ -671,6 +671,7 @@ interface MarkdownFileLinkProps { |
| 671 | 671 | iconPath: string; |
| 672 | 672 | displayPath: string; |
| 673 | 673 | workspaceRelativePath: string | null; |
| 674 | line?: number | undefined; | |
| 674 | 675 | label: string; |
| 675 | 676 | copyMarkdown: string; |
| 676 | 677 | theme: "light" | "dark"; |
| ... | ... | @@ -995,6 +996,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 995 | 996 | iconPath, |
| 996 | 997 | displayPath, |
| 997 | 998 | workspaceRelativePath, |
| 999 | line, | |
| 998 | 1000 | label, |
| 999 | 1001 | copyMarkdown, |
| 1000 | 1002 | theme, |
| ... | ... | @@ -1027,8 +1029,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1027 | 1029 | handleOpenInEditor(); |
| 1028 | 1030 | return; |
| 1029 | 1031 | } |
| 1030 | useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath); | |
| 1031 | }, [handleOpenInEditor, threadRef, workspaceRelativePath]); | |
| 1032 | useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); | |
| 1033 | }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); | |
| 1032 | 1034 | |
| 1033 | 1035 | const handleOpenInBrowser = useCallback(() => { |
| 1034 | 1036 | if (!threadRef) return; |
| ... | ... | @@ -1169,6 +1171,7 @@ function areMarkdownFileLinkPropsEqual( |
| 1169 | 1171 | previous.iconPath === next.iconPath && |
| 1170 | 1172 | previous.displayPath === next.displayPath && |
| 1171 | 1173 | previous.workspaceRelativePath === next.workspaceRelativePath && |
| 1174 | previous.line === next.line && | |
| 1172 | 1175 | previous.label === next.label && |
| 1173 | 1176 | previous.copyMarkdown === next.copyMarkdown && |
| 1174 | 1177 | previous.theme === next.theme && |
| ... | ... | @@ -1331,6 +1334,7 @@ function ChatMarkdown({ |
| 1331 | 1334 | iconPath={fileLinkMeta.filePath} |
| 1332 | 1335 | displayPath={fileLinkMeta.displayPath} |
| 1333 | 1336 | workspaceRelativePath={fileLinkMeta.workspaceRelativePath} |
| 1337 | line={fileLinkMeta.line} | |
| 1334 | 1338 | label={labelParts.join(" · ")} |
| 1335 | 1339 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} |
| 1336 | 1340 | theme={resolvedTheme} |
apps/web/src/components/ChatView.browser.tsx+89-5| ... | ... | @@ -2700,13 +2700,97 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2700 | 2700 | expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); |
| 2701 | 2701 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); |
| 2702 | 2702 | |
| 2703 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts"); | |
| 2704 | const codeVirtualizer = await waitForElement( | |
| 2705 | () => document.querySelector<HTMLElement>(".file-preview-virtualizer"), | |
| 2706 | "Unable to find the virtualized file preview.", | |
| 2707 | ); | |
| 2703 | const previousCodeVirtualizer = document.querySelector<HTMLElement>( | |
| 2704 | ".file-preview-virtualizer", | |
| 2705 | ); | |
| 2706 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); | |
| 2707 | const codeVirtualizer = await waitForElement(() => { | |
| 2708 | const current = document.querySelector<HTMLElement>(".file-preview-virtualizer"); | |
| 2709 | return current !== previousCodeVirtualizer ? current : null; | |
| 2710 | }, "Unable to find the virtualized file preview."); | |
| 2708 | 2711 | expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); |
| 2709 | 2712 | expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true); |
| 2713 | await vi.waitFor( | |
| 2714 | () => { | |
| 2715 | const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container"); | |
| 2716 | const targetLine = fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="4000"]'); | |
| 2717 | const targetLineNumber = fileHost?.shadowRoot?.querySelector<HTMLElement>( | |
| 2718 | '[data-column-number="4000"]', | |
| 2719 | ); | |
| 2720 | const previousLine = | |
| 2721 | fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="3999"]'); | |
| 2722 | const previousLineNumber = fileHost?.shadowRoot?.querySelector<HTMLElement>( | |
| 2723 | '[data-column-number="3999"]', | |
| 2724 | ); | |
| 2725 | expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); | |
| 2726 | expect(targetLine).not.toBeNull(); | |
| 2727 | expect(previousLine).not.toBeNull(); | |
| 2728 | expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); | |
| 2729 | expect(targetLineNumber?.hasAttribute("data-file-link-reveal")).toBe(true); | |
| 2730 | expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); | |
| 2731 | expect(targetLineNumber?.hasAttribute("data-selected-line")).toBe(false); | |
| 2732 | expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); | |
| 2733 | expect(window.getComputedStyle(targetLine!).backgroundColor).not.toBe( | |
| 2734 | window.getComputedStyle(previousLine!).backgroundColor, | |
| 2735 | ); | |
| 2736 | expect(window.getComputedStyle(targetLineNumber!).backgroundColor).not.toBe( | |
| 2737 | window.getComputedStyle(previousLineNumber!).backgroundColor, | |
| 2738 | ); | |
| 2739 | ||
| 2740 | const viewportRect = codeVirtualizer.getBoundingClientRect(); | |
| 2741 | const lineRect = targetLine!.getBoundingClientRect(); | |
| 2742 | expect(lineRect.top).toBeGreaterThanOrEqual(viewportRect.top); | |
| 2743 | expect(lineRect.bottom).toBeLessThanOrEqual(viewportRect.bottom); | |
| 2744 | }, | |
| 2745 | { timeout: 8_000, interval: 16 }, | |
| 2746 | ); | |
| 2747 | ||
| 2748 | const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container"); | |
| 2749 | const targetLineNumber = | |
| 2750 | fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="4000"]') ?? null; | |
| 2751 | const previousLineNumber = | |
| 2752 | fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="3999"]') ?? null; | |
| 2753 | expect(targetLineNumber).not.toBeNull(); | |
| 2754 | expect(previousLineNumber).not.toBeNull(); | |
| 2755 | ||
| 2756 | targetLineNumber!.dispatchEvent( | |
| 2757 | new PointerEvent("pointermove", { | |
| 2758 | bubbles: true, | |
| 2759 | cancelable: true, | |
| 2760 | composed: true, | |
| 2761 | pointerType: "mouse", | |
| 2762 | }), | |
| 2763 | ); | |
| 2764 | await vi.waitFor(() => { | |
| 2765 | expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); | |
| 2766 | }); | |
| 2767 | ||
| 2768 | previousLineNumber!.dispatchEvent( | |
| 2769 | new PointerEvent("pointermove", { | |
| 2770 | bubbles: true, | |
| 2771 | cancelable: true, | |
| 2772 | composed: true, | |
| 2773 | pointerType: "mouse", | |
| 2774 | }), | |
| 2775 | ); | |
| 2776 | await vi.waitFor(() => { | |
| 2777 | expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull(); | |
| 2778 | expect(previousLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull(); | |
| 2779 | }); | |
| 2780 | ||
| 2781 | codeVirtualizer.scrollTop = 0; | |
| 2782 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); | |
| 2783 | await vi.waitFor( | |
| 2784 | () => { | |
| 2785 | const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container"); | |
| 2786 | const targetLine = fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="4000"]'); | |
| 2787 | expect(targetLine).not.toBeNull(); | |
| 2788 | expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true); | |
| 2789 | expect(targetLine?.hasAttribute("data-selected-line")).toBe(false); | |
| 2790 | expect(codeVirtualizer.scrollTop).toBeGreaterThan(0); | |
| 2791 | }, | |
| 2792 | { timeout: 8_000, interval: 16 }, | |
| 2793 | ); | |
| 2710 | 2794 | } finally { |
| 2711 | 2795 | await mounted.cleanup(); |
| 2712 | 2796 | } |
apps/web/src/components/ChatView.tsx+4| ... | ... | @@ -1292,6 +1292,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1292 | 1292 | const activeRightPanelSurface = useRightPanelStore((store) => |
| 1293 | 1293 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), |
| 1294 | 1294 | ); |
| 1295 | const activeFileSurface = | |
| 1296 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | |
| 1295 | 1297 | const activePreviewState = usePreviewStateStore((state) => |
| 1296 | 1298 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), |
| 1297 | 1299 | ); |
| ... | ... | @@ -4768,6 +4770,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 4768 | 4770 | relativePath={ |
| 4769 | 4771 | activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null |
| 4770 | 4772 | } |
| 4773 | revealLine={activeFileSurface?.revealLine ?? null} | |
| 4774 | revealRequestId={activeFileSurface?.revealRequestId ?? 0} | |
| 4771 | 4775 | onOpenFile={openFileSurface} |
| 4772 | 4776 | onPendingChange={handleFilePendingChange} |
| 4773 | 4777 | /> |
apps/web/src/components/files/FilePreviewPanel.tsx+242-13| ... | ... | @@ -4,9 +4,9 @@ import type { |
| 4 | 4 | ResolvedKeybindingsConfig, |
| 5 | 5 | ScopedThreadRef, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import type { SelectedLineRange } from "@pierre/diffs"; | |
| 7 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; | |
| 8 | 8 | import { Editor } from "@pierre/diffs/editor"; |
| 9 | import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react"; | |
| 9 | import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; | |
| 10 | 10 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; |
| 11 | 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 12 | 12 | |
| ... | ... | @@ -59,12 +59,176 @@ interface FilePreviewPanelProps { |
| 59 | 59 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 60 | 60 | keybindings: ResolvedKeybindingsConfig; |
| 61 | 61 | availableEditors: ReadonlyArray<EditorId>; |
| 62 | revealLine: number | null; | |
| 63 | revealRequestId: number; | |
| 62 | 64 | onOpenFile: (relativePath: string) => void; |
| 63 | 65 | onPendingChange: (relativePath: string, pending: boolean) => void; |
| 64 | 66 | } |
| 65 | 67 | |
| 66 | 68 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| 67 | 69 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 70 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; | |
| 71 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` | |
| 72 | [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { | |
| 73 | background-color: light-dark( | |
| 74 | color-mix( | |
| 75 | in lab, | |
| 76 | var(--diffs-computed-diff-line-bg) 82%, | |
| 77 | var(--diffs-bg-selection-override, var(--diffs-selection-base)) | |
| 78 | ), | |
| 79 | color-mix( | |
| 80 | in lab, | |
| 81 | var(--diffs-computed-diff-line-bg) 75%, | |
| 82 | var(--diffs-bg-selection-override, var(--diffs-selection-base)) | |
| 83 | ) | |
| 84 | ) !important; | |
| 85 | } | |
| 86 | ||
| 87 | [${FILE_LINK_REVEAL_ATTRIBUTE}][data-column-number] { | |
| 88 | background-color: light-dark( | |
| 89 | color-mix( | |
| 90 | in lab, | |
| 91 | var(--diffs-computed-diff-line-bg) 75%, | |
| 92 | var(--diffs-bg-selection-number-override, var(--diffs-selection-base)) | |
| 93 | ), | |
| 94 | color-mix( | |
| 95 | in lab, | |
| 96 | var(--diffs-computed-diff-line-bg) 60%, | |
| 97 | var(--diffs-bg-selection-number-override, var(--diffs-selection-base)) | |
| 98 | ) | |
| 99 | ) !important; | |
| 100 | color: var(--diffs-selection-number-fg) !important; | |
| 101 | } | |
| 102 | `; | |
| 103 | type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>; | |
| 104 | ||
| 105 | function clampFileLine(contents: string, requestedLine: number): number { | |
| 106 | let lineCount = 1; | |
| 107 | for (let index = 0; index < contents.length; index += 1) { | |
| 108 | const character = contents.charCodeAt(index); | |
| 109 | if (character === 10) { | |
| 110 | lineCount += 1; | |
| 111 | } else if (character === 13) { | |
| 112 | lineCount += 1; | |
| 113 | if (contents.charCodeAt(index + 1) === 10) index += 1; | |
| 114 | } | |
| 115 | } | |
| 116 | return Math.min(Math.max(1, requestedLine), lineCount); | |
| 117 | } | |
| 118 | ||
| 119 | function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): void { | |
| 120 | const root = fileContainer.shadowRoot ?? fileContainer; | |
| 121 | for (const element of root.querySelectorAll<HTMLElement>(`[${FILE_LINK_REVEAL_ATTRIBUTE}]`)) { | |
| 122 | element.removeAttribute(FILE_LINK_REVEAL_ATTRIBUTE); | |
| 123 | } | |
| 124 | if (line === null) return; | |
| 125 | ||
| 126 | root | |
| 127 | .querySelector<HTMLElement>(`[data-line="${line}"]`) | |
| 128 | ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); | |
| 129 | root | |
| 130 | .querySelector<HTMLElement>(`[data-column-number="${line}"]`) | |
| 131 | ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); | |
| 132 | } | |
| 133 | ||
| 134 | function useFileLineReveal( | |
| 135 | relativePath: string | null, | |
| 136 | revealLine: number | null, | |
| 137 | revealRequestId: number, | |
| 138 | ): FilePostRender { | |
| 139 | const [handledRequestIdsByPath] = useState(() => new Map<string, number>()); | |
| 140 | const [latestRequestIdsByPath] = useState(() => new Map<string, number>()); | |
| 141 | const [pendingFramesByPath] = useState(() => new Map<string, number>()); | |
| 142 | ||
| 143 | return useCallback<FilePostRender>( | |
| 144 | (fileContainer, instance, phase) => { | |
| 145 | if (relativePath === null) return; | |
| 146 | ||
| 147 | const cancelPendingReveal = () => { | |
| 148 | const frameId = pendingFramesByPath.get(relativePath); | |
| 149 | if (frameId !== undefined) { | |
| 150 | cancelAnimationFrame(frameId); | |
| 151 | pendingFramesByPath.delete(relativePath); | |
| 152 | } | |
| 153 | }; | |
| 154 | ||
| 155 | if (phase === "unmount") { | |
| 156 | cancelPendingReveal(); | |
| 157 | return; | |
| 158 | } | |
| 159 | ||
| 160 | const targetLine = | |
| 161 | revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); | |
| 162 | updateFileLinkReveal(fileContainer, targetLine); | |
| 163 | ||
| 164 | if (!(instance instanceof VirtualizedFile)) return; | |
| 165 | ||
| 166 | if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { | |
| 167 | cancelPendingReveal(); | |
| 168 | latestRequestIdsByPath.set(relativePath, revealRequestId); | |
| 169 | } | |
| 170 | ||
| 171 | if (targetLine === null) { | |
| 172 | fileContainer.style.minHeight = ""; | |
| 173 | return; | |
| 174 | } | |
| 175 | ||
| 176 | const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer"); | |
| 177 | if (!scrollContainer) return; | |
| 178 | fileContainer.style.minHeight = `${Math.ceil( | |
| 179 | Math.max(instance.height, scrollContainer.clientHeight), | |
| 180 | )}px`; | |
| 181 | ||
| 182 | if ( | |
| 183 | handledRequestIdsByPath.get(relativePath) === revealRequestId || | |
| 184 | pendingFramesByPath.has(relativePath) | |
| 185 | ) { | |
| 186 | return; | |
| 187 | } | |
| 188 | ||
| 189 | const reveal = () => { | |
| 190 | pendingFramesByPath.delete(relativePath); | |
| 191 | if ( | |
| 192 | latestRequestIdsByPath.get(relativePath) !== revealRequestId || | |
| 193 | !fileContainer.isConnected | |
| 194 | ) { | |
| 195 | return; | |
| 196 | } | |
| 197 | ||
| 198 | const linePosition = instance.getLinePosition(targetLine); | |
| 199 | if (!linePosition) return; | |
| 200 | ||
| 201 | const fileTop = | |
| 202 | scrollContainer.scrollTop + | |
| 203 | fileContainer.getBoundingClientRect().top - | |
| 204 | scrollContainer.getBoundingClientRect().top; | |
| 205 | const centeredTop = Math.max( | |
| 206 | 0, | |
| 207 | fileTop + | |
| 208 | linePosition.top - | |
| 209 | Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), | |
| 210 | ); | |
| 211 | const maxScrollTop = Math.max( | |
| 212 | 0, | |
| 213 | scrollContainer.scrollHeight - scrollContainer.clientHeight, | |
| 214 | ); | |
| 215 | ||
| 216 | scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); | |
| 217 | handledRequestIdsByPath.set(relativePath, revealRequestId); | |
| 218 | }; | |
| 219 | ||
| 220 | pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); | |
| 221 | }, | |
| 222 | [ | |
| 223 | handledRequestIdsByPath, | |
| 224 | latestRequestIdsByPath, | |
| 225 | pendingFramesByPath, | |
| 226 | relativePath, | |
| 227 | revealLine, | |
| 228 | revealRequestId, | |
| 229 | ], | |
| 230 | ); | |
| 231 | } | |
| 68 | 232 | |
| 69 | 233 | interface EditableFileSurfaceProps { |
| 70 | 234 | environmentId: EnvironmentId; |
| ... | ... | @@ -73,9 +237,16 @@ interface EditableFileSurfaceProps { |
| 73 | 237 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 74 | 238 | contents: string; |
| 75 | 239 | resolvedTheme: "light" | "dark"; |
| 240 | revealRequestId: number; | |
| 241 | onPostRender: FilePostRender; | |
| 76 | 242 | onPendingChange: (relativePath: string, pending: boolean) => void; |
| 77 | 243 | } |
| 78 | 244 | |
| 245 | interface FileSelectionOverride { | |
| 246 | revealRequestId: number; | |
| 247 | range: SelectedLineRange | null; | |
| 248 | } | |
| 249 | ||
| 79 | 250 | function useFileSaveCoordinator({ |
| 80 | 251 | environmentId, |
| 81 | 252 | cwd, |
| ... | ... | @@ -115,13 +286,24 @@ function EditableFileSurface({ |
| 115 | 286 | composerDraftTarget, |
| 116 | 287 | contents, |
| 117 | 288 | resolvedTheme, |
| 289 | revealRequestId, | |
| 290 | onPostRender, | |
| 118 | 291 | onPendingChange, |
| 119 | 292 | }: EditableFileSurfaceProps) { |
| 120 | 293 | const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); |
| 121 | 294 | const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); |
| 122 | 295 | const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]); |
| 123 | const [selectedRange, setSelectedRange] = useState<SelectedLineRange | null>(null); | |
| 296 | const [selectionOverride, setSelectionOverride] = useState<FileSelectionOverride | null>(null); | |
| 297 | const selectedRange = | |
| 298 | selectionOverride?.revealRequestId === revealRequestId ? selectionOverride.range : null; | |
| 299 | const setSelectedRange = useCallback( | |
| 300 | (range: SelectedLineRange | null) => { | |
| 301 | setSelectionOverride({ revealRequestId, range }); | |
| 302 | }, | |
| 303 | [revealRequestId], | |
| 304 | ); | |
| 124 | 305 | const surfaceRef = useRef<HTMLDivElement>(null); |
| 306 | const selectionFrameRef = useRef<number | null>(null); | |
| 125 | 307 | const saveCoordinator = useFileSaveCoordinator({ |
| 126 | 308 | environmentId, |
| 127 | 309 | cwd, |
| ... | ... | @@ -179,7 +361,7 @@ function EditableFileSurface({ |
| 179 | 361 | }); |
| 180 | 362 | }); |
| 181 | 363 | }, |
| 182 | [composerDraftTarget, removeReviewComment], | |
| 364 | [composerDraftTarget, removeReviewComment, setSelectedRange], | |
| 183 | 365 | ); |
| 184 | 366 | |
| 185 | 367 | const submitAnnotationEntry = useCallback( |
| ... | ... | @@ -214,7 +396,14 @@ function EditableFileSurface({ |
| 214 | 396 | })), |
| 215 | 397 | ); |
| 216 | 398 | }, |
| 217 | [addReviewComment, composerDraftTarget, contents, lineAnnotations, relativePath], | |
| 399 | [ | |
| 400 | addReviewComment, | |
| 401 | composerDraftTarget, | |
| 402 | contents, | |
| 403 | lineAnnotations, | |
| 404 | relativePath, | |
| 405 | setSelectedRange, | |
| 406 | ], | |
| 218 | 407 | ); |
| 219 | 408 | |
| 220 | 409 | const beginComment = useCallback((range: SelectedLineRange) => { |
| ... | ... | @@ -265,7 +454,7 @@ function EditableFileSurface({ |
| 265 | 454 | isBlocked: () => hasOpenCommentForm, |
| 266 | 455 | onDismiss: () => setSelectedRange(null), |
| 267 | 456 | }); |
| 268 | }, [editor, hasOpenCommentForm]); | |
| 457 | }, [editor, hasOpenCommentForm, setSelectedRange]); | |
| 269 | 458 | const handleLineSelectionEnd = useCallback( |
| 270 | 459 | (range: SelectedLineRange | null) => { |
| 271 | 460 | setSelectedRange(range); |
| ... | ... | @@ -273,7 +462,26 @@ function EditableFileSurface({ |
| 273 | 462 | beginComment(range); |
| 274 | 463 | } |
| 275 | 464 | }, |
| 276 | [beginComment], | |
| 465 | [beginComment, setSelectedRange], | |
| 466 | ); | |
| 467 | ||
| 468 | const handlePostRender = useCallback<FilePostRender>( | |
| 469 | (fileContainer, instance, phase) => { | |
| 470 | onPostRender(fileContainer, instance, phase); | |
| 471 | ||
| 472 | if (selectionFrameRef.current !== null) { | |
| 473 | cancelAnimationFrame(selectionFrameRef.current); | |
| 474 | selectionFrameRef.current = null; | |
| 475 | } | |
| 476 | if (phase === "unmount") return; | |
| 477 | ||
| 478 | selectionFrameRef.current = requestAnimationFrame(() => { | |
| 479 | selectionFrameRef.current = null; | |
| 480 | if (!fileContainer.isConnected) return; | |
| 481 | instance.setSelectedLines(selectedRange, { notify: false }); | |
| 482 | }); | |
| 483 | }, | |
| 484 | [onPostRender, selectedRange], | |
| 277 | 485 | ); |
| 278 | 486 | |
| 279 | 487 | return ( |
| ... | ... | @@ -302,6 +510,8 @@ function EditableFileSurface({ |
| 302 | 510 | overflow: "scroll", |
| 303 | 511 | theme: resolveDiffThemeName(resolvedTheme), |
| 304 | 512 | themeType: resolvedTheme, |
| 513 | unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, | |
| 514 | onPostRender: handlePostRender, | |
| 305 | 515 | }} |
| 306 | 516 | selectedLines={selectedRange} |
| 307 | 517 | lineAnnotations={lineAnnotations} |
| ... | ... | @@ -336,7 +546,10 @@ function RenderedMarkdownSurface({ |
| 336 | 546 | contents, |
| 337 | 547 | threadRef, |
| 338 | 548 | onPendingChange, |
| 339 | }: Omit<EditableFileSurfaceProps, "resolvedTheme" | "composerDraftTarget"> & { | |
| 549 | }: Omit< | |
| 550 | EditableFileSurfaceProps, | |
| 551 | "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender" | |
| 552 | > & { | |
| 340 | 553 | threadRef: ScopedThreadRef; |
| 341 | 554 | }) { |
| 342 | 555 | const saveCoordinator = useFileSaveCoordinator({ |
| ... | ... | @@ -384,6 +597,8 @@ export default function FilePreviewPanel({ |
| 384 | 597 | composerDraftTarget, |
| 385 | 598 | keybindings, |
| 386 | 599 | availableEditors, |
| 600 | revealLine, | |
| 601 | revealRequestId, | |
| 387 | 602 | onOpenFile, |
| 388 | 603 | onPendingChange, |
| 389 | 604 | }: FilePreviewPanelProps) { |
| ... | ... | @@ -391,10 +606,16 @@ export default function FilePreviewPanel({ |
| 391 | 606 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 392 | 607 | const file = useProjectFileQuery(environmentId, cwd, relativePath); |
| 393 | 608 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 394 | const [renderedMarkdownPath, setRenderedMarkdownPath] = useState<string | null>(null); | |
| 609 | const [markdownView, setMarkdownView] = useState<{ | |
| 610 | path: string | null; | |
| 611 | revealRequestId: number | null; | |
| 612 | }>({ path: null, revealRequestId: null }); | |
| 395 | 613 | const breadcrumbRef = useRef<HTMLDivElement>(null); |
| 396 | 614 | const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; |
| 397 | const renderMarkdown = isMarkdown && renderedMarkdownPath === relativePath; | |
| 615 | const renderMarkdown = | |
| 616 | isMarkdown && | |
| 617 | markdownView.path === relativePath && | |
| 618 | (revealLine === null || markdownView.revealRequestId === revealRequestId); | |
| 398 | 619 | const canOpenInBrowser = |
| 399 | 620 | relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); |
| 400 | 621 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; |
| ... | ... | @@ -402,6 +623,7 @@ export default function FilePreviewPanel({ |
| 402 | 623 | () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), |
| 403 | 624 | [projectName, relativePath], |
| 404 | 625 | ); |
| 626 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); | |
| 405 | 627 | |
| 406 | 628 | useEffect(() => { |
| 407 | 629 | const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>( |
| ... | ... | @@ -485,9 +707,12 @@ export default function FilePreviewPanel({ |
| 485 | 707 | <Toggle |
| 486 | 708 | className="shrink-0" |
| 487 | 709 | pressed={renderMarkdown} |
| 488 | onPressedChange={(pressed) => | |
| 489 | setRenderedMarkdownPath(pressed ? relativePath : null) | |
| 490 | } | |
| 710 | onPressedChange={(pressed) => { | |
| 711 | setMarkdownView({ | |
| 712 | path: pressed ? relativePath : null, | |
| 713 | revealRequestId: pressed ? revealRequestId : null, | |
| 714 | }); | |
| 715 | }} | |
| 491 | 716 | aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} |
| 492 | 717 | variant="ghost" |
| 493 | 718 | size="sm" |
| ... | ... | @@ -591,6 +816,8 @@ export default function FilePreviewPanel({ |
| 591 | 816 | overflow: "scroll", |
| 592 | 817 | theme: resolveDiffThemeName(resolvedTheme), |
| 593 | 818 | themeType: resolvedTheme, |
| 819 | unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, | |
| 820 | onPostRender: onFilePostRender, | |
| 594 | 821 | }} |
| 595 | 822 | className="min-h-full" |
| 596 | 823 | /> |
| ... | ... | @@ -604,6 +831,8 @@ export default function FilePreviewPanel({ |
| 604 | 831 | composerDraftTarget={composerDraftTarget} |
| 605 | 832 | contents={file.data.contents} |
| 606 | 833 | resolvedTheme={resolvedTheme} |
| 834 | revealRequestId={revealRequestId} | |
| 835 | onPostRender={onFilePostRender} | |
| 607 | 836 | onPendingChange={onPendingChange} |
| 608 | 837 | /> |
| 609 | 838 | ) |
apps/web/src/rightPanelStore.test.ts+88-3| ... | ... | @@ -73,6 +73,36 @@ describe("rightPanelStore", () => { |
| 73 | 73 | }); |
| 74 | 74 | }); |
| 75 | 75 | |
| 76 | it("upgrades saved file surfaces with neutral reveal state", () => { | |
| 77 | expect( | |
| 78 | migratePersistedRightPanelState({ | |
| 79 | byThreadKey: { | |
| 80 | "env-1:thread-A": { | |
| 81 | isOpen: true, | |
| 82 | activeSurfaceId: "file:src/index.ts", | |
| 83 | surfaces: [{ id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }], | |
| 84 | }, | |
| 85 | }, | |
| 86 | }), | |
| 87 | ).toEqual({ | |
| 88 | byThreadKey: { | |
| 89 | "env-1:thread-A": { | |
| 90 | isOpen: true, | |
| 91 | activeSurfaceId: "file:src/index.ts", | |
| 92 | surfaces: [ | |
| 93 | { | |
| 94 | id: "file:src/index.ts", | |
| 95 | kind: "file", | |
| 96 | relativePath: "src/index.ts", | |
| 97 | revealLine: null, | |
| 98 | revealRequestId: 0, | |
| 99 | }, | |
| 100 | ], | |
| 101 | }, | |
| 102 | }, | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 76 | 106 | it("open sets the active panel for a thread", () => { |
| 77 | 107 | useRightPanelStore.getState().open(refA, "preview"); |
| 78 | 108 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| ... | ... | @@ -108,8 +138,55 @@ describe("rightPanelStore", () => { |
| 108 | 138 | isOpen: true, |
| 109 | 139 | activeSurfaceId: "file:README.md", |
| 110 | 140 | surfaces: [ |
| 111 | { id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }, | |
| 112 | { id: "file:README.md", kind: "file", relativePath: "README.md" }, | |
| 141 | { | |
| 142 | id: "file:src/index.ts", | |
| 143 | kind: "file", | |
| 144 | relativePath: "src/index.ts", | |
| 145 | revealLine: null, | |
| 146 | revealRequestId: 2, | |
| 147 | }, | |
| 148 | { | |
| 149 | id: "file:README.md", | |
| 150 | kind: "file", | |
| 151 | relativePath: "README.md", | |
| 152 | revealLine: null, | |
| 153 | revealRequestId: 1, | |
| 154 | }, | |
| 155 | ], | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | it("updates line reveal requests when reopening a file surface", () => { | |
| 160 | useRightPanelStore.getState().openFile(refA, "src/index.ts", 42); | |
| 161 | useRightPanelStore.getState().openFile(refA, "src/index.ts", 87); | |
| 162 | ||
| 163 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 164 | isOpen: true, | |
| 165 | activeSurfaceId: "file:src/index.ts", | |
| 166 | surfaces: [ | |
| 167 | { | |
| 168 | id: "file:src/index.ts", | |
| 169 | kind: "file", | |
| 170 | relativePath: "src/index.ts", | |
| 171 | revealLine: 87, | |
| 172 | revealRequestId: 2, | |
| 173 | }, | |
| 174 | ], | |
| 175 | }); | |
| 176 | ||
| 177 | useRightPanelStore.getState().openFile(refA, "src/index.ts"); | |
| 178 | ||
| 179 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 180 | isOpen: true, | |
| 181 | activeSurfaceId: "file:src/index.ts", | |
| 182 | surfaces: [ | |
| 183 | { | |
| 184 | id: "file:src/index.ts", | |
| 185 | kind: "file", | |
| 186 | relativePath: "src/index.ts", | |
| 187 | revealLine: null, | |
| 188 | revealRequestId: 3, | |
| 189 | }, | |
| 113 | 190 | ], |
| 114 | 191 | }); |
| 115 | 192 | }); |
| ... | ... | @@ -314,7 +391,15 @@ describe("rightPanelStore", () => { |
| 314 | 391 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ |
| 315 | 392 | isOpen: true, |
| 316 | 393 | activeSurfaceId: "file:src/index.ts", |
| 317 | surfaces: [{ id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }], | |
| 394 | surfaces: [ | |
| 395 | { | |
| 396 | id: "file:src/index.ts", | |
| 397 | kind: "file", | |
| 398 | relativePath: "src/index.ts", | |
| 399 | revealLine: null, | |
| 400 | revealRequestId: 1, | |
| 401 | }, | |
| 402 | ], | |
| 318 | 403 | }); |
| 319 | 404 | }); |
| 320 | 405 |
apps/web/src/rightPanelStore.ts+54-8| ... | ... | @@ -30,11 +30,17 @@ export type RightPanelSurface = |
| 30 | 30 | } |
| 31 | 31 | | { id: "diff"; kind: "diff" } |
| 32 | 32 | | { id: "files"; kind: "files" } |
| 33 | | { id: `file:${string}`; kind: "file"; relativePath: string } | |
| 33 | | { | |
| 34 | id: `file:${string}`; | |
| 35 | kind: "file"; | |
| 36 | relativePath: string; | |
| 37 | revealLine: number | null; | |
| 38 | revealRequestId: number; | |
| 39 | } | |
| 34 | 40 | | { id: "plan"; kind: "plan" }; |
| 35 | 41 | |
| 36 | 42 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 37 | const RIGHT_PANEL_STORAGE_VERSION = 6; | |
| 43 | const RIGHT_PANEL_STORAGE_VERSION = 7; | |
| 38 | 44 | |
| 39 | 45 | export interface ThreadRightPanelState { |
| 40 | 46 | isOpen: boolean; |
| ... | ... | @@ -46,7 +52,7 @@ interface RightPanelStoreState { |
| 46 | 52 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 47 | 53 | open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void; |
| 48 | 54 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; |
| 49 | openFile: (ref: ScopedThreadRef, relativePath: string) => void; | |
| 55 | openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; | |
| 50 | 56 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; |
| 51 | 57 | splitTerminal: ( |
| 52 | 58 | ref: ScopedThreadRef, |
| ... | ... | @@ -94,10 +100,16 @@ const browserSurface = (tabId: string | null): RightPanelSurface => |
| 94 | 100 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } |
| 95 | 101 | : { id: "browser:new", kind: "preview", resourceId: null }; |
| 96 | 102 | |
| 97 | const fileSurface = (relativePath: string): RightPanelSurface => ({ | |
| 103 | const fileSurface = ( | |
| 104 | relativePath: string, | |
| 105 | revealLine: number | null, | |
| 106 | revealRequestId: number, | |
| 107 | ): RightPanelSurface => ({ | |
| 98 | 108 | id: `file:${relativePath}`, |
| 99 | 109 | kind: "file", |
| 100 | 110 | relativePath, |
| 111 | revealLine, | |
| 112 | revealRequestId, | |
| 101 | 113 | }); |
| 102 | 114 | |
| 103 | 115 | const terminalSurface = (terminalId: string): RightPanelSurface => ({ |
| ... | ... | @@ -136,6 +148,11 @@ const updateThread = ( |
| 136 | 148 | return { ...byThreadKey, [threadKey]: next }; |
| 137 | 149 | }; |
| 138 | 150 | |
| 151 | function normalizeRevealLine(line: number | undefined): number | null { | |
| 152 | if (line === undefined || !Number.isFinite(line)) return null; | |
| 153 | return Math.max(1, Math.trunc(line)); | |
| 154 | } | |
| 155 | ||
| 139 | 156 | export function migratePersistedRightPanelState(persistedState: unknown): { |
| 140 | 157 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 141 | 158 | } { |
| ... | ... | @@ -153,6 +170,20 @@ export function migratePersistedRightPanelState(persistedState: unknown): { |
| 153 | 170 | threadState && typeof threadState === "object" ? threadState : null; |
| 154 | 171 | const surfaces = Array.isArray(validThreadState?.surfaces) |
| 155 | 172 | ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { |
| 173 | if (surface.kind === "file") { | |
| 174 | const revealLine = | |
| 175 | typeof surface.revealLine === "number" && | |
| 176 | Number.isFinite(surface.revealLine) | |
| 177 | ? Math.max(1, Math.trunc(surface.revealLine)) | |
| 178 | : null; | |
| 179 | const revealRequestId = | |
| 180 | typeof surface.revealRequestId === "number" && | |
| 181 | Number.isSafeInteger(surface.revealRequestId) && | |
| 182 | surface.revealRequestId >= 0 | |
| 183 | ? surface.revealRequestId | |
| 184 | : 0; | |
| 185 | return [{ ...surface, revealLine, revealRequestId }]; | |
| 186 | } | |
| 156 | 187 | if (surface.kind !== "terminal") return [surface]; |
| 157 | 188 | if ( |
| 158 | 189 | !("resourceId" in surface) || |
| ... | ... | @@ -228,16 +259,31 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 228 | 259 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); |
| 229 | 260 | }), |
| 230 | 261 | })), |
| 231 | openFile: (ref, relativePath) => | |
| 262 | openFile: (ref, relativePath, line) => | |
| 232 | 263 | set((state) => ({ |
| 233 | 264 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { |
| 234 | 265 | const withoutStandaloneExplorer = current.surfaces.filter( |
| 235 | 266 | (surface) => surface.kind !== "files", |
| 236 | 267 | ); |
| 237 | return upsertSurface( | |
| 238 | { ...current, surfaces: withoutStandaloneExplorer }, | |
| 239 | fileSurface(relativePath), | |
| 268 | const surfaceId = `file:${relativePath}` as const; | |
| 269 | const existing = withoutStandaloneExplorer.find( | |
| 270 | (surface): surface is Extract<RightPanelSurface, { kind: "file" }> => | |
| 271 | surface.id === surfaceId && surface.kind === "file", | |
| 240 | 272 | ); |
| 273 | const surface = fileSurface( | |
| 274 | relativePath, | |
| 275 | normalizeRevealLine(line), | |
| 276 | (existing?.revealRequestId ?? 0) + 1, | |
| 277 | ); | |
| 278 | return { | |
| 279 | isOpen: true, | |
| 280 | activeSurfaceId: surface.id, | |
| 281 | surfaces: existing | |
| 282 | ? withoutStandaloneExplorer.map((entry) => | |
| 283 | entry.id === surface.id ? surface : entry, | |
| 284 | ) | |
| 285 | : [...withoutStandaloneExplorer, surface], | |
| 286 | }; | |
| 241 | 287 | }), |
| 242 | 288 | })), |
| 243 | 289 | openTerminal: (ref, terminalId) => |