| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { app } from "../lib/bridge"; |
| 3 | import { |
| 4 | beginKeyedResourceRequest, |
| 5 | emptyKeyedResource, |
| 6 | rejectKeyedResourceRequest, |
| 7 | resolveKeyedResourceRequest, |
| 8 | } from "../lib/keyedResource"; |
| 9 | import type { FilePreview } from "../lib/types"; |
| 10 | import type { FileResourceSource } from "../lib/fileResource"; |
| 11 | |
| 12 | export type WorkspaceFilePreviewInput = Readonly<{ |
| 13 | /** The dock is on screen; a hidden dock reads nothing. */ |
| 14 | open: boolean; |
| 15 | /** The path the preview shows. */ |
| 16 | selectedPath: string | null; |
| 17 | /** Presented tool call of the selected entry; a workspace entry passes none. */ |
| 18 | presentedToolCallId?: string; |
| 19 | /** Selects the host reader that revalidates this entry's access. */ |
| 20 | accessSource: FileResourceSource; |
| 21 | /** The selected entry renders as source. */ |
| 22 | source: boolean; |
| 23 | /** Dock lifecycle generation; a new one re-reads the selection. */ |
| 24 | generation: number; |
| 25 | workspaceScopeKey: string; |
| 26 | workspaceTabId: string; |
| 27 | /** Bumped by the workspace refresh owner when the content behind it changed. */ |
| 28 | contentRevision: number; |
| 29 | }>; |
| 30 | |
| 31 | /** |
| 32 | * The dock's file preview: which read its selection implies, the keyed resource |
| 33 | * that holds the result, and the one command that re-reads it. The read inputs |
| 34 | * come from the selected entry, never from a path-keyed cache, so a file |
| 35 | * reopened from another entry point reads under that command's credentials and |
| 36 | * an unchanged set of inputs never restarts a read that is still valid. |
| 37 | */ |
| 38 | export function useWorkspaceFilePreview(input: WorkspaceFilePreviewInput) { |
| 39 | const { accessSource, contentRevision, generation, open, presentedToolCallId, selectedPath, source, workspaceScopeKey, workspaceTabId } = input; |
| 40 | const [resource, setResource] = useState(() => emptyKeyedResource<FilePreview>()); |
| 41 | const [stale, setStale] = useState(false); |
| 42 | // A read that lands after this dock is gone must not be committed: nothing |
| 43 | // would render it. StrictMode's simulated unmount sets this back to mounted, |
| 44 | // so a replayed mount keeps the read it already started. |
| 45 | const mountedRef = useRef(true); |
| 46 | useEffect(() => { |
| 47 | mountedRef.current = true; |
| 48 | return () => { mountedRef.current = false; }; |
| 49 | }, []); |
| 50 | const requestIdRef = useRef(0); |
| 51 | const scopeRef = useRef(workspaceScopeKey); |
| 52 | const generationRef = useRef(generation); |
| 53 | scopeRef.current = workspaceScopeKey; |
| 54 | generationRef.current = generation; |
| 55 | const previewKey = selectedPath |
| 56 | ? `${workspaceScopeKey}\u0000preview\u0000${source ? "source" : "preview"}\u0000${accessSource}\u0000${presentedToolCallId ?? ""}\u0000${selectedPath}` |
| 57 | : null; |
| 58 | const preview = previewKey && resource.key === previewKey ? resource.data : null; |
| 59 | const loading = previewKey != null && resource.key === previewKey && resource.status === "refreshing"; |
| 60 | const error = previewKey && resource.key === previewKey ? resource.error : ""; |
| 61 | |
| 62 | const refreshSelected = useCallback(() => { |
| 63 | if (!selectedPath) return; |
| 64 | setStale(false); |
| 65 | const requestId = ++requestIdRef.current; |
| 66 | const requestPath = selectedPath; |
| 67 | const requestGeneration = generation; |
| 68 | const requestKey = `${workspaceScopeKey}\u0000preview\u0000${source ? "source" : "preview"}\u0000${accessSource}\u0000${presentedToolCallId ?? ""}\u0000${requestPath}`; |
| 69 | // Dock instance, session scope, resource identity and operation revision |
| 70 | // must all still match before a result may be committed for this read. |
| 71 | const current = () => |
| 72 | mountedRef.current |
| 73 | && requestIdRef.current === requestId |
| 74 | && scopeRef.current === workspaceScopeKey |
| 75 | && generationRef.current === requestGeneration; |
| 76 | setResource((state) => beginKeyedResourceRequest(state, requestKey, requestId, contentRevision)); |
| 77 | const read = accessSource === "reference" |
| 78 | ? source |
| 79 | ? app.ReadReferenceFileSourceForTab(workspaceTabId, requestPath) |
| 80 | : app.ReadReferenceFileForTab(workspaceTabId, requestPath) |
| 81 | : presentedToolCallId |
| 82 | ? source |
| 83 | ? app.ReadPresentedFileSourceForTab(workspaceTabId, presentedToolCallId, requestPath) |
| 84 | : app.ReadPresentedFileForTab(workspaceTabId, presentedToolCallId, requestPath) |
| 85 | : app.ReadFileForTab(workspaceTabId, requestPath); |
| 86 | read |
| 87 | .then((next) => { |
| 88 | if (current()) setResource((state) => resolveKeyedResourceRequest(state, requestKey, requestId, next, contentRevision)); |
| 89 | }) |
| 90 | .catch((reason) => { |
| 91 | if (current()) { |
| 92 | setResource((state) => rejectKeyedResourceRequest(state, requestKey, requestId, String((reason as Error)?.message ?? reason))); |
| 93 | } |
| 94 | }); |
| 95 | }, [accessSource, contentRevision, generation, presentedToolCallId, selectedPath, source, workspaceScopeKey, workspaceTabId]); |
| 96 | |
| 97 | // The read starts once per set of read inputs. A StrictMode mount replay, an |
| 98 | // effect reconnect or a re-render reconnect calls the effect again with the |
| 99 | // very same callback, and must not issue a second read for it. |
| 100 | const startedReadRef = useRef<typeof refreshSelected | null>(null); |
| 101 | useEffect(() => { |
| 102 | if (!open || !selectedPath) { |
| 103 | startedReadRef.current = null; |
| 104 | return; |
| 105 | } |
| 106 | if (startedReadRef.current === refreshSelected) return; |
| 107 | startedReadRef.current = refreshSelected; |
| 108 | refreshSelected(); |
| 109 | }, [open, refreshSelected, selectedPath]); |
| 110 | |
| 111 | const reset = useCallback(() => setResource(emptyKeyedResource()), []); |
| 112 | return { |
| 113 | previewKey, |
| 114 | preview, |
| 115 | loadingPreview: Boolean(loading), |
| 116 | previewErr: error, |
| 117 | presentedFileStale: stale, |
| 118 | setPresentedFileStale: setStale, |
| 119 | refreshSelected, |
| 120 | resetPreview: reset, |
| 121 | }; |
| 122 | } |
| 123 |