| 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,6 +288,13 @@ describe("ChatMarkdown", () => { |
| 288 | ).toMatchObject({ | 288 | ).toMatchObject({ |
| 289 | isOpen: true, | 289 | isOpen: true, |
| 290 | activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx", | 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 | expect(openInPreferredEditorMock).not.toHaveBeenCalled(); | 299 | expect(openInPreferredEditorMock).not.toHaveBeenCalled(); |
| 293 | expect(openFileInPreviewMock).not.toHaveBeenCalled(); | 300 | expect(openFileInPreviewMock).not.toHaveBeenCalled(); |
apps/web/src/components/ChatMarkdown.tsx+6-2| ... | @@ -671,6 +671,7 @@ interface MarkdownFileLinkProps { | ... | @@ -671,6 +671,7 @@ interface MarkdownFileLinkProps { |
| 671 | iconPath: string; | 671 | iconPath: string; |
| 672 | displayPath: string; | 672 | displayPath: string; |
| 673 | workspaceRelativePath: string | null; | 673 | workspaceRelativePath: string | null; |
| 674 | line?: number | undefined; | ||
| 674 | label: string; | 675 | label: string; |
| 675 | copyMarkdown: string; | 676 | copyMarkdown: string; |
| 676 | theme: "light" | "dark"; | 677 | theme: "light" | "dark"; |
| ... | @@ -995,6 +996,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -995,6 +996,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 995 | iconPath, | 996 | iconPath, |
| 996 | displayPath, | 997 | displayPath, |
| 997 | workspaceRelativePath, | 998 | workspaceRelativePath, |
| 999 | line, | ||
| 998 | label, | 1000 | label, |
| 999 | copyMarkdown, | 1001 | copyMarkdown, |
| 1000 | theme, | 1002 | theme, |
| ... | @@ -1027,8 +1029,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ | ... | @@ -1027,8 +1029,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1027 | handleOpenInEditor(); | 1029 | handleOpenInEditor(); |
| 1028 | return; | 1030 | return; |
| 1029 | } | 1031 | } |
| 1030 | useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath); | 1032 | useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); |
| 1031 | }, [handleOpenInEditor, threadRef, workspaceRelativePath]); | 1033 | }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); |
| 1032 | 1034 | ||
| 1033 | const handleOpenInBrowser = useCallback(() => { | 1035 | const handleOpenInBrowser = useCallback(() => { |
| 1034 | if (!threadRef) return; | 1036 | if (!threadRef) return; |
| ... | @@ -1169,6 +1171,7 @@ function areMarkdownFileLinkPropsEqual( | ... | @@ -1169,6 +1171,7 @@ function areMarkdownFileLinkPropsEqual( |
| 1169 | previous.iconPath === next.iconPath && | 1171 | previous.iconPath === next.iconPath && |
| 1170 | previous.displayPath === next.displayPath && | 1172 | previous.displayPath === next.displayPath && |
| 1171 | previous.workspaceRelativePath === next.workspaceRelativePath && | 1173 | previous.workspaceRelativePath === next.workspaceRelativePath && |
| 1174 | previous.line === next.line && | ||
| 1172 | previous.label === next.label && | 1175 | previous.label === next.label && |
| 1173 | previous.copyMarkdown === next.copyMarkdown && | 1176 | previous.copyMarkdown === next.copyMarkdown && |
| 1174 | previous.theme === next.theme && | 1177 | previous.theme === next.theme && |
| ... | @@ -1331,6 +1334,7 @@ function ChatMarkdown({ | ... | @@ -1331,6 +1334,7 @@ function ChatMarkdown({ |
| 1331 | iconPath={fileLinkMeta.filePath} | 1334 | iconPath={fileLinkMeta.filePath} |
| 1332 | displayPath={fileLinkMeta.displayPath} | 1335 | displayPath={fileLinkMeta.displayPath} |
| 1333 | workspaceRelativePath={fileLinkMeta.workspaceRelativePath} | 1336 | workspaceRelativePath={fileLinkMeta.workspaceRelativePath} |
| 1337 | line={fileLinkMeta.line} | ||
| 1334 | label={labelParts.join(" · ")} | 1338 | label={labelParts.join(" · ")} |
| 1335 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} | 1339 | copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} |
| 1336 | theme={resolvedTheme} | 1340 | theme={resolvedTheme} |
apps/web/src/components/ChatView.browser.tsx+89-5| ... | @@ -2700,13 +2700,97 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2700,13 +2700,97 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2700 | expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); | 2700 | expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput); |
| 2701 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); | 2701 | expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe(""); |
| 2702 | 2702 | ||
| 2703 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts"); | 2703 | const previousCodeVirtualizer = document.querySelector<HTMLElement>( |
| 2704 | const codeVirtualizer = await waitForElement( | 2704 | ".file-preview-virtualizer", |
| 2705 | () => document.querySelector<HTMLElement>(".file-preview-virtualizer"), | 2705 | ); |
| 2706 | "Unable to find the virtualized file preview.", | 2706 | useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000); |
| 2707 | ); | 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 | expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); | 2711 | expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull(); |
| 2709 | expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true); | 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 | } finally { | 2794 | } finally { |
| 2711 | await mounted.cleanup(); | 2795 | await mounted.cleanup(); |
| 2712 | } | 2796 | } |
apps/web/src/components/ChatView.tsx+4| ... | @@ -1292,6 +1292,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1292,6 +1292,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1292 | const activeRightPanelSurface = useRightPanelStore((store) => | 1292 | const activeRightPanelSurface = useRightPanelStore((store) => |
| 1293 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | 1293 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), |
| 1294 | ); | 1294 | ); |
| 1295 | const activeFileSurface = | ||
| 1296 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | ||
| 1295 | const activePreviewState = usePreviewStateStore((state) => | 1297 | const activePreviewState = usePreviewStateStore((state) => |
| 1296 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | 1298 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), |
| 1297 | ); | 1299 | ); |
| ... | @@ -4768,6 +4770,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4768,6 +4770,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 4768 | relativePath={ | 4770 | relativePath={ |
| 4769 | activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null | 4771 | activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null |
| 4770 | } | 4772 | } |
| 4773 | revealLine={activeFileSurface?.revealLine ?? null} | ||
| 4774 | revealRequestId={activeFileSurface?.revealRequestId ?? 0} | ||
| 4771 | onOpenFile={openFileSurface} | 4775 | onOpenFile={openFileSurface} |
| 4772 | onPendingChange={handleFilePendingChange} | 4776 | onPendingChange={handleFilePendingChange} |
| 4773 | /> | 4777 | /> |
apps/web/src/components/files/FilePreviewPanel.tsx+242-13| ... | @@ -4,9 +4,9 @@ import type { | ... | @@ -4,9 +4,9 @@ import type { |
| 4 | ResolvedKeybindingsConfig, | 4 | ResolvedKeybindingsConfig, |
| 5 | ScopedThreadRef, | 5 | ScopedThreadRef, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import type { SelectedLineRange } from "@pierre/diffs"; | 7 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; |
| 8 | import { Editor } from "@pierre/diffs/editor"; | 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 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; | 10 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; |
| 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 12 | 12 | ||
| ... | @@ -59,12 +59,176 @@ interface FilePreviewPanelProps { | ... | @@ -59,12 +59,176 @@ interface FilePreviewPanelProps { |
| 59 | composerDraftTarget: ScopedThreadRef | DraftId; | 59 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 60 | keybindings: ResolvedKeybindingsConfig; | 60 | keybindings: ResolvedKeybindingsConfig; |
| 61 | availableEditors: ReadonlyArray<EditorId>; | 61 | availableEditors: ReadonlyArray<EditorId>; |
| 62 | revealLine: number | null; | ||
| 63 | revealRequestId: number; | ||
| 62 | onOpenFile: (relativePath: string) => void; | 64 | onOpenFile: (relativePath: string) => void; |
| 63 | onPendingChange: (relativePath: string, pending: boolean) => void; | 65 | onPendingChange: (relativePath: string, pending: boolean) => void; |
| 64 | } | 66 | } |
| 65 | 67 | ||
| 66 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; | 68 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| 67 | const FILE_SAVE_DEBOUNCE_MS = 500; | 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 | interface EditableFileSurfaceProps { | 233 | interface EditableFileSurfaceProps { |
| 70 | environmentId: EnvironmentId; | 234 | environmentId: EnvironmentId; |
| ... | @@ -73,9 +237,16 @@ interface EditableFileSurfaceProps { | ... | @@ -73,9 +237,16 @@ interface EditableFileSurfaceProps { |
| 73 | composerDraftTarget: ScopedThreadRef | DraftId; | 237 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 74 | contents: string; | 238 | contents: string; |
| 75 | resolvedTheme: "light" | "dark"; | 239 | resolvedTheme: "light" | "dark"; |
| 240 | revealRequestId: number; | ||
| 241 | onPostRender: FilePostRender; | ||
| 76 | onPendingChange: (relativePath: string, pending: boolean) => void; | 242 | onPendingChange: (relativePath: string, pending: boolean) => void; |
| 77 | } | 243 | } |
| 78 | 244 | ||
| 245 | interface FileSelectionOverride { | ||
| 246 | revealRequestId: number; | ||
| 247 | range: SelectedLineRange | null; | ||
| 248 | } | ||
| 249 | |||
| 79 | function useFileSaveCoordinator({ | 250 | function useFileSaveCoordinator({ |
| 80 | environmentId, | 251 | environmentId, |
| 81 | cwd, | 252 | cwd, |
| ... | @@ -115,13 +286,24 @@ function EditableFileSurface({ | ... | @@ -115,13 +286,24 @@ function EditableFileSurface({ |
| 115 | composerDraftTarget, | 286 | composerDraftTarget, |
| 116 | contents, | 287 | contents, |
| 117 | resolvedTheme, | 288 | resolvedTheme, |
| 289 | revealRequestId, | ||
| 290 | onPostRender, | ||
| 118 | onPendingChange, | 291 | onPendingChange, |
| 119 | }: EditableFileSurfaceProps) { | 292 | }: EditableFileSurfaceProps) { |
| 120 | const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); | 293 | const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); |
| 121 | const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); | 294 | const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); |
| 122 | const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]); | 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 | const surfaceRef = useRef<HTMLDivElement>(null); | 305 | const surfaceRef = useRef<HTMLDivElement>(null); |
| 306 | const selectionFrameRef = useRef<number | null>(null); | ||
| 125 | const saveCoordinator = useFileSaveCoordinator({ | 307 | const saveCoordinator = useFileSaveCoordinator({ |
| 126 | environmentId, | 308 | environmentId, |
| 127 | cwd, | 309 | cwd, |
| ... | @@ -179,7 +361,7 @@ function EditableFileSurface({ | ... | @@ -179,7 +361,7 @@ function EditableFileSurface({ |
| 179 | }); | 361 | }); |
| 180 | }); | 362 | }); |
| 181 | }, | 363 | }, |
| 182 | [composerDraftTarget, removeReviewComment], | 364 | [composerDraftTarget, removeReviewComment, setSelectedRange], |
| 183 | ); | 365 | ); |
| 184 | 366 | ||
| 185 | const submitAnnotationEntry = useCallback( | 367 | const submitAnnotationEntry = useCallback( |
| ... | @@ -214,7 +396,14 @@ function EditableFileSurface({ | ... | @@ -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 | const beginComment = useCallback((range: SelectedLineRange) => { | 409 | const beginComment = useCallback((range: SelectedLineRange) => { |
| ... | @@ -265,7 +454,7 @@ function EditableFileSurface({ | ... | @@ -265,7 +454,7 @@ function EditableFileSurface({ |
| 265 | isBlocked: () => hasOpenCommentForm, | 454 | isBlocked: () => hasOpenCommentForm, |
| 266 | onDismiss: () => setSelectedRange(null), | 455 | onDismiss: () => setSelectedRange(null), |
| 267 | }); | 456 | }); |
| 268 | }, [editor, hasOpenCommentForm]); | 457 | }, [editor, hasOpenCommentForm, setSelectedRange]); |
| 269 | const handleLineSelectionEnd = useCallback( | 458 | const handleLineSelectionEnd = useCallback( |
| 270 | (range: SelectedLineRange | null) => { | 459 | (range: SelectedLineRange | null) => { |
| 271 | setSelectedRange(range); | 460 | setSelectedRange(range); |
| ... | @@ -273,7 +462,26 @@ function EditableFileSurface({ | ... | @@ -273,7 +462,26 @@ function EditableFileSurface({ |
| 273 | beginComment(range); | 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 | return ( | 487 | return ( |
| ... | @@ -302,6 +510,8 @@ function EditableFileSurface({ | ... | @@ -302,6 +510,8 @@ function EditableFileSurface({ |
| 302 | overflow: "scroll", | 510 | overflow: "scroll", |
| 303 | theme: resolveDiffThemeName(resolvedTheme), | 511 | theme: resolveDiffThemeName(resolvedTheme), |
| 304 | themeType: resolvedTheme, | 512 | themeType: resolvedTheme, |
| 513 | unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, | ||
| 514 | onPostRender: handlePostRender, | ||
| 305 | }} | 515 | }} |
| 306 | selectedLines={selectedRange} | 516 | selectedLines={selectedRange} |
| 307 | lineAnnotations={lineAnnotations} | 517 | lineAnnotations={lineAnnotations} |
| ... | @@ -336,7 +546,10 @@ function RenderedMarkdownSurface({ | ... | @@ -336,7 +546,10 @@ function RenderedMarkdownSurface({ |
| 336 | contents, | 546 | contents, |
| 337 | threadRef, | 547 | threadRef, |
| 338 | onPendingChange, | 548 | onPendingChange, |
| 339 | }: Omit<EditableFileSurfaceProps, "resolvedTheme" | "composerDraftTarget"> & { | 549 | }: Omit< |
| 550 | EditableFileSurfaceProps, | ||
| 551 | "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender" | ||
| 552 | > & { | ||
| 340 | threadRef: ScopedThreadRef; | 553 | threadRef: ScopedThreadRef; |
| 341 | }) { | 554 | }) { |
| 342 | const saveCoordinator = useFileSaveCoordinator({ | 555 | const saveCoordinator = useFileSaveCoordinator({ |
| ... | @@ -384,6 +597,8 @@ export default function FilePreviewPanel({ | ... | @@ -384,6 +597,8 @@ export default function FilePreviewPanel({ |
| 384 | composerDraftTarget, | 597 | composerDraftTarget, |
| 385 | keybindings, | 598 | keybindings, |
| 386 | availableEditors, | 599 | availableEditors, |
| 600 | revealLine, | ||
| 601 | revealRequestId, | ||
| 387 | onOpenFile, | 602 | onOpenFile, |
| 388 | onPendingChange, | 603 | onPendingChange, |
| 389 | }: FilePreviewPanelProps) { | 604 | }: FilePreviewPanelProps) { |
| ... | @@ -391,10 +606,16 @@ export default function FilePreviewPanel({ | ... | @@ -391,10 +606,16 @@ export default function FilePreviewPanel({ |
| 391 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 606 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 392 | const file = useProjectFileQuery(environmentId, cwd, relativePath); | 607 | const file = useProjectFileQuery(environmentId, cwd, relativePath); |
| 393 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); | 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 | const breadcrumbRef = useRef<HTMLDivElement>(null); | 613 | const breadcrumbRef = useRef<HTMLDivElement>(null); |
| 396 | const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; | 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 | const canOpenInBrowser = | 619 | const canOpenInBrowser = |
| 399 | relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); | 620 | relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); |
| 400 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; | 621 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; |
| ... | @@ -402,6 +623,7 @@ export default function FilePreviewPanel({ | ... | @@ -402,6 +623,7 @@ export default function FilePreviewPanel({ |
| 402 | () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), | 623 | () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), |
| 403 | [projectName, relativePath], | 624 | [projectName, relativePath], |
| 404 | ); | 625 | ); |
| 626 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); | ||
| 405 | 627 | ||
| 406 | useEffect(() => { | 628 | useEffect(() => { |
| 407 | const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>( | 629 | const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>( |
| ... | @@ -485,9 +707,12 @@ export default function FilePreviewPanel({ | ... | @@ -485,9 +707,12 @@ export default function FilePreviewPanel({ |
| 485 | <Toggle | 707 | <Toggle |
| 486 | className="shrink-0" | 708 | className="shrink-0" |
| 487 | pressed={renderMarkdown} | 709 | pressed={renderMarkdown} |
| 488 | onPressedChange={(pressed) => | 710 | onPressedChange={(pressed) => { |
| 489 | setRenderedMarkdownPath(pressed ? relativePath : null) | 711 | setMarkdownView({ |
| 490 | } | 712 | path: pressed ? relativePath : null, |
| 713 | revealRequestId: pressed ? revealRequestId : null, | ||
| 714 | }); | ||
| 715 | }} | ||
| 491 | aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} | 716 | aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} |
| 492 | variant="ghost" | 717 | variant="ghost" |
| 493 | size="sm" | 718 | size="sm" |
| ... | @@ -591,6 +816,8 @@ export default function FilePreviewPanel({ | ... | @@ -591,6 +816,8 @@ export default function FilePreviewPanel({ |
| 591 | overflow: "scroll", | 816 | overflow: "scroll", |
| 592 | theme: resolveDiffThemeName(resolvedTheme), | 817 | theme: resolveDiffThemeName(resolvedTheme), |
| 593 | themeType: resolvedTheme, | 818 | themeType: resolvedTheme, |
| 819 | unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, | ||
| 820 | onPostRender: onFilePostRender, | ||
| 594 | }} | 821 | }} |
| 595 | className="min-h-full" | 822 | className="min-h-full" |
| 596 | /> | 823 | /> |
| ... | @@ -604,6 +831,8 @@ export default function FilePreviewPanel({ | ... | @@ -604,6 +831,8 @@ export default function FilePreviewPanel({ |
| 604 | composerDraftTarget={composerDraftTarget} | 831 | composerDraftTarget={composerDraftTarget} |
| 605 | contents={file.data.contents} | 832 | contents={file.data.contents} |
| 606 | resolvedTheme={resolvedTheme} | 833 | resolvedTheme={resolvedTheme} |
| 834 | revealRequestId={revealRequestId} | ||
| 835 | onPostRender={onFilePostRender} | ||
| 607 | onPendingChange={onPendingChange} | 836 | onPendingChange={onPendingChange} |
| 608 | /> | 837 | /> |
| 609 | ) | 838 | ) |
apps/web/src/rightPanelStore.test.ts+88-3| ... | @@ -73,6 +73,36 @@ describe("rightPanelStore", () => { | ... | @@ -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 | it("open sets the active panel for a thread", () => { | 106 | it("open sets the active panel for a thread", () => { |
| 77 | useRightPanelStore.getState().open(refA, "preview"); | 107 | useRightPanelStore.getState().open(refA, "preview"); |
| 78 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); | 108 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| ... | @@ -108,8 +138,55 @@ describe("rightPanelStore", () => { | ... | @@ -108,8 +138,55 @@ describe("rightPanelStore", () => { |
| 108 | isOpen: true, | 138 | isOpen: true, |
| 109 | activeSurfaceId: "file:README.md", | 139 | activeSurfaceId: "file:README.md", |
| 110 | surfaces: [ | 140 | surfaces: [ |
| 111 | { id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }, | 141 | { |
| 112 | { id: "file:README.md", kind: "file", relativePath: "README.md" }, | 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,7 +391,15 @@ describe("rightPanelStore", () => { |
| 314 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | 391 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ |
| 315 | isOpen: true, | 392 | isOpen: true, |
| 316 | activeSurfaceId: "file:src/index.ts", | 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,11 +30,17 @@ export type RightPanelSurface = |
| 30 | } | 30 | } |
| 31 | | { id: "diff"; kind: "diff" } | 31 | | { id: "diff"; kind: "diff" } |
| 32 | | { id: "files"; kind: "files" } | 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 | | { id: "plan"; kind: "plan" }; | 40 | | { id: "plan"; kind: "plan" }; |
| 35 | 41 | ||
| 36 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; | 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 | export interface ThreadRightPanelState { | 45 | export interface ThreadRightPanelState { |
| 40 | isOpen: boolean; | 46 | isOpen: boolean; |
| ... | @@ -46,7 +52,7 @@ interface RightPanelStoreState { | ... | @@ -46,7 +52,7 @@ interface RightPanelStoreState { |
| 46 | byThreadKey: Record<string, ThreadRightPanelState>; | 52 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 47 | open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void; | 53 | open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void; |
| 48 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; | 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 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; | 56 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; |
| 51 | splitTerminal: ( | 57 | splitTerminal: ( |
| 52 | ref: ScopedThreadRef, | 58 | ref: ScopedThreadRef, |
| ... | @@ -94,10 +100,16 @@ const browserSurface = (tabId: string | null): RightPanelSurface => | ... | @@ -94,10 +100,16 @@ const browserSurface = (tabId: string | null): RightPanelSurface => |
| 94 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } | 100 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } |
| 95 | : { id: "browser:new", kind: "preview", resourceId: null }; | 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 | id: `file:${relativePath}`, | 108 | id: `file:${relativePath}`, |
| 99 | kind: "file", | 109 | kind: "file", |
| 100 | relativePath, | 110 | relativePath, |
| 111 | revealLine, | ||
| 112 | revealRequestId, | ||
| 101 | }); | 113 | }); |
| 102 | 114 | ||
| 103 | const terminalSurface = (terminalId: string): RightPanelSurface => ({ | 115 | const terminalSurface = (terminalId: string): RightPanelSurface => ({ |
| ... | @@ -136,6 +148,11 @@ const updateThread = ( | ... | @@ -136,6 +148,11 @@ const updateThread = ( |
| 136 | return { ...byThreadKey, [threadKey]: next }; | 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 | export function migratePersistedRightPanelState(persistedState: unknown): { | 156 | export function migratePersistedRightPanelState(persistedState: unknown): { |
| 140 | byThreadKey: Record<string, ThreadRightPanelState>; | 157 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 141 | } { | 158 | } { |
| ... | @@ -153,6 +170,20 @@ export function migratePersistedRightPanelState(persistedState: unknown): { | ... | @@ -153,6 +170,20 @@ export function migratePersistedRightPanelState(persistedState: unknown): { |
| 153 | threadState && typeof threadState === "object" ? threadState : null; | 170 | threadState && typeof threadState === "object" ? threadState : null; |
| 154 | const surfaces = Array.isArray(validThreadState?.surfaces) | 171 | const surfaces = Array.isArray(validThreadState?.surfaces) |
| 155 | ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { | 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 | if (surface.kind !== "terminal") return [surface]; | 187 | if (surface.kind !== "terminal") return [surface]; |
| 157 | if ( | 188 | if ( |
| 158 | !("resourceId" in surface) || | 189 | !("resourceId" in surface) || |
| ... | @@ -228,16 +259,31 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -228,16 +259,31 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 228 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); | 259 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); |
| 229 | }), | 260 | }), |
| 230 | })), | 261 | })), |
| 231 | openFile: (ref, relativePath) => | 262 | openFile: (ref, relativePath, line) => |
| 232 | set((state) => ({ | 263 | set((state) => ({ |
| 233 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { | 264 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { |
| 234 | const withoutStandaloneExplorer = current.surfaces.filter( | 265 | const withoutStandaloneExplorer = current.surfaces.filter( |
| 235 | (surface) => surface.kind !== "files", | 266 | (surface) => surface.kind !== "files", |
| 236 | ); | 267 | ); |
| 237 | return upsertSurface( | 268 | const surfaceId = `file:${relativePath}` as const; |
| 238 | { ...current, surfaces: withoutStandaloneExplorer }, | 269 | const existing = withoutStandaloneExplorer.find( |
| 239 | fileSurface(relativePath), | 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 | openTerminal: (ref, terminalId) => | 289 | openTerminal: (ref, terminalId) => |