| author | |
| committer | |
| log | 886c83450797c46ee84ae28fc16c2208cafa965e |
| tree | 212155b11fa4787be58965afdd4691420788cee9 |
| parent | 6d1d549441be84f19696ab59ed7e2fbf305280d4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: exe.dev user <exedev@ropeway-swimming.exe.xyz>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Yash Singh <saiansh2525@gmail.com>6 files changed, 167 insertions(+), 50 deletions(-)
apps/web/src/components/files/FileBrowserPanel.tsx+13-4| ... | ... | @@ -31,7 +31,7 @@ interface FileBrowserPanelProps { |
| 31 | 31 | environmentId: EnvironmentId; |
| 32 | 32 | cwd: string; |
| 33 | 33 | projectName: string; |
| 34 | /** File currently open in the preview pane; revealed and selected in the tree. */ | |
| 34 | /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */ | |
| 35 | 35 | selectedPath: string | null; |
| 36 | 36 | /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ |
| 37 | 37 | selectedPathRevealId: number; |
| ... | ... | @@ -376,7 +376,10 @@ export default function FileBrowserPanel({ |
| 376 | 376 | handledRevealRef.current = null; |
| 377 | 377 | return; |
| 378 | 378 | } |
| 379 | if (entryKinds.get(selectedPath) !== "file") { | |
| 379 | const selectedKind = entryKinds.get(selectedPath); | |
| 380 | // An unloaded entry has no row to reveal yet; folders do, and chat links can | |
| 381 | // point at them. | |
| 382 | if (selectedKind === undefined) { | |
| 380 | 383 | handledRevealRef.current = null; |
| 381 | 384 | return; |
| 382 | 385 | } |
| ... | ... | @@ -390,7 +393,9 @@ export default function FileBrowserPanel({ |
| 390 | 393 | ) { |
| 391 | 394 | return; |
| 392 | 395 | } |
| 393 | const selectedItem = model.getItem(selectedPath); | |
| 396 | // Directory rows are registered with a trailing slash (see treePath). | |
| 397 | const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath; | |
| 398 | const selectedItem = model.getItem(selectedTreePath); | |
| 394 | 399 | if (!selectedItem) return; |
| 395 | 400 | |
| 396 | 401 | // A selection that originated inside the tree (clicking a row, possibly |
| ... | ... | @@ -425,8 +430,12 @@ export default function FileBrowserPanel({ |
| 425 | 430 | if (item && "expand" in item) item.expand(); |
| 426 | 431 | } |
| 427 | 432 | |
| 433 | if ("expand" in selectedItem) selectedItem.expand(); | |
| 428 | 434 | selectedItem.select(); |
| 429 | model.scrollToPath(selectedPath, { focus: true, offset: "center" }); | |
| 435 | model.scrollToPath(selectedTreePath, { | |
| 436 | focus: true, | |
| 437 | offset: "center", | |
| 438 | }); | |
| 430 | 439 | queueMicrotask(() => { |
| 431 | 440 | syncingSelectionRef.current = false; |
| 432 | 441 | }); |
apps/web/src/components/files/FilePreviewPanel.tsx+31-24| ... | ... | @@ -941,15 +941,22 @@ export default function FilePreviewPanel({ |
| 941 | 941 | // A file outside the workspace (an absolute path) is shown, never edited. |
| 942 | 942 | const isHostFile = |
| 943 | 943 | attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); |
| 944 | const file = useProjectFileQuery( | |
| 945 | environmentId, | |
| 946 | cwd, | |
| 947 | relativePath, | |
| 948 | attachment === undefined && !isMedia && !isPdf, | |
| 949 | ); | |
| 944 | // Media and PDFs render from their absolute path, so their contents are never | |
| 945 | // shown. The read still runs: a folder named `assets.png` is only knowable as a | |
| 946 | // folder from the read failure, and the server stats before reading, so a folder | |
| 947 | // costs an open and a stat and returns no body. | |
| 948 | const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined); | |
| 949 | // A chat link cannot tell a folder from a file, so a folder arrives here as | |
| 950 | // a file surface and the read fails. Keep the breadcrumbs, drop the preview | |
| 951 | // pane, and let the tree fill the surface with the folder revealed. Mutation | |
| 952 | // refresh stays on so the surface notices if the path becomes a file. A host | |
| 953 | // path cannot be revealed in the workspace tree, so it keeps the read error. | |
| 954 | const isDirectory = file.isNotFile && !isHostFile; | |
| 955 | // Everything preview-related keys off previewPath; a folder has no preview. | |
| 956 | const previewPath = isDirectory ? null : relativePath; | |
| 950 | 957 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 951 | 958 | const showExplorer = shouldShowFileExplorer({ |
| 952 | relativePath, | |
| 959 | relativePath: previewPath, | |
| 953 | 960 | explorerOpen, |
| 954 | 961 | attachmentOpen: attachment !== undefined, |
| 955 | 962 | }); |
| ... | ... | @@ -977,9 +984,9 @@ export default function FilePreviewPanel({ |
| 977 | 984 | null, |
| 978 | 985 | ); |
| 979 | 986 | const breadcrumbRef = useRef<HTMLDivElement>(null); |
| 980 | const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; | |
| 987 | const isMarkdown = previewPath ? isMarkdownPreviewFile(previewPath) : false; | |
| 981 | 988 | const tableDelimiter = |
| 982 | relativePath && attachment === undefined ? filePreviewDelimiter({ name: relativePath }) : null; | |
| 989 | previewPath && attachment === undefined ? filePreviewDelimiter({ name: previewPath }) : null; | |
| 983 | 990 | // A reveal still wins over the preference: the line only exists in the source. |
| 984 | 991 | const revealHandled = |
| 985 | 992 | revealLine === null || |
| ... | ... | @@ -994,12 +1001,13 @@ export default function FilePreviewPanel({ |
| 994 | 1001 | : isHtml |
| 995 | 1002 | ? ("html" as const) |
| 996 | 1003 | : null; |
| 997 | const canToggleRendered = attachment === undefined && renderedMode !== null; | |
| 1004 | const canToggleRendered = | |
| 1005 | previewPath !== null && attachment === undefined && renderedMode !== null; | |
| 998 | 1006 | const updateClientSettings = useUpdateClientSettings(); |
| 999 | 1007 | // Word wrap only reaches the text bodies. A rendered Markdown document, a table and the |
| 1000 | 1008 | // browser frame all lay themselves out, so the toggle stays hidden rather than inert. |
| 1001 | 1009 | const showsRawText = |
| 1002 | relativePath !== null && | |
| 1010 | previewPath !== null && | |
| 1003 | 1011 | file.data !== null && |
| 1004 | 1012 | !(isMarkdown && renderMarkdown) && |
| 1005 | 1013 | !(tableDelimiter && renderTable) && |
| ... | ... | @@ -1011,11 +1019,11 @@ export default function FilePreviewPanel({ |
| 1011 | 1019 | ? setRenderTablePreferred |
| 1012 | 1020 | : setRenderBrowserFilePreferred; |
| 1013 | 1021 | const canOpenInBrowser = |
| 1014 | relativePath !== null && | |
| 1022 | previewPath !== null && | |
| 1015 | 1023 | attachment === undefined && |
| 1016 | 1024 | !isVideo && |
| 1017 | 1025 | isPreviewSupportedInRuntime() && |
| 1018 | isBrowserPreviewFile(relativePath); | |
| 1026 | isBrowserPreviewFile(previewPath); | |
| 1019 | 1027 | const absolutePath = |
| 1020 | 1028 | relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; |
| 1021 | 1029 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| ... | ... | @@ -1023,8 +1031,10 @@ export default function FilePreviewPanel({ |
| 1023 | 1031 | enabled: |
| 1024 | 1032 | attachment === undefined && |
| 1025 | 1033 | relativePath !== null && |
| 1026 | !isMedia && | |
| 1027 | !isPdf && | |
| 1034 | // Media and PDFs never show their contents, so re-reading them on every | |
| 1035 | // workspace mutation is waste. A folder named like one still re-reads, so | |
| 1036 | // it notices when the path becomes a file. | |
| 1037 | (isDirectory || (!isMedia && !isPdf)) && | |
| 1028 | 1038 | !selectedFilePending, |
| 1029 | 1039 | mutationId: workspaceMutationId, |
| 1030 | 1040 | refresh: file.refresh, |
| ... | ... | @@ -1145,7 +1155,7 @@ export default function FilePreviewPanel({ |
| 1145 | 1155 | <Globe2 className="size-3.5" /> |
| 1146 | 1156 | </FileSurfaceAction> |
| 1147 | 1157 | ) : null} |
| 1148 | {!isHostFile ? ( | |
| 1158 | {!isHostFile && previewPath !== null ? ( | |
| 1149 | 1159 | <FileSurfaceAction |
| 1150 | 1160 | label={explorerOpen ? "Hide file explorer" : "Show file explorer"} |
| 1151 | 1161 | pressed={explorerOpen} |
| ... | ... | @@ -1156,7 +1166,7 @@ export default function FilePreviewPanel({ |
| 1156 | 1166 | ) : null} |
| 1157 | 1167 | </div> |
| 1158 | 1168 | ) : null} |
| 1159 | {relativePath && | |
| 1169 | {previewPath && | |
| 1160 | 1170 | attachment === undefined && |
| 1161 | 1171 | !isMedia && |
| 1162 | 1172 | !renderBrowserFile && |
| ... | ... | @@ -1167,12 +1177,9 @@ export default function FilePreviewPanel({ |
| 1167 | 1177 | ) : null} |
| 1168 | 1178 | <div className="flex min-h-0 flex-1 overflow-hidden"> |
| 1169 | 1179 | <div |
| 1170 | className={cn( | |
| 1171 | "min-w-0 flex-1 flex-col overflow-hidden", | |
| 1172 | relativePath ? "flex" : "hidden", | |
| 1173 | )} | |
| 1180 | className={cn("min-w-0 flex-1 flex-col overflow-hidden", previewPath ? "flex" : "hidden")} | |
| 1174 | 1181 | > |
| 1175 | {relativePath && attachment ? ( | |
| 1182 | {isDirectory ? null : relativePath && attachment ? ( | |
| 1176 | 1183 | <AttachmentFilePreview |
| 1177 | 1184 | key={`${environmentId}:${attachment.id}`} |
| 1178 | 1185 | name={attachment.name} |
| ... | ... | @@ -1279,7 +1286,7 @@ export default function FilePreviewPanel({ |
| 1279 | 1286 | <aside |
| 1280 | 1287 | className={cn( |
| 1281 | 1288 | "flex min-h-0 shrink-0 bg-background", |
| 1282 | relativePath | |
| 1289 | previewPath | |
| 1283 | 1290 | ? "w-[min(22rem,46%)] min-w-64 border-l border-border/60" |
| 1284 | 1291 | : "min-w-0 flex-1", |
| 1285 | 1292 | )} |
| ... | ... | @@ -1293,7 +1300,7 @@ export default function FilePreviewPanel({ |
| 1293 | 1300 | selectedPathRevealId={revealRequestId} |
| 1294 | 1301 | onOpenFile={onOpenFile} |
| 1295 | 1302 | workspaceMutationId={workspaceMutationId} |
| 1296 | {...(relativePath && !isMedia && !isPdf | |
| 1303 | {...(previewPath && !isMedia && !isPdf | |
| 1297 | 1304 | ? { onRefreshSelectedFile: file.refresh } |
| 1298 | 1305 | : {})} |
| 1299 | 1306 | /> |
apps/web/src/components/files/projectFilesQueryState.test.tsx+59| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | 3 | type ProjectListEntriesResult, |
| 4 | ProjectReadFileError, | |
| 4 | 5 | type ProjectReadFileResult, |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -250,4 +251,62 @@ describe("project query refresh", () => { |
| 250 | 251 | atomHooks.registry = null; |
| 251 | 252 | } |
| 252 | 253 | }); |
| 254 | ||
| 255 | it("reports a directory named like an image as not a file", async () => { | |
| 256 | const readAtom = Atom.make( | |
| 257 | Effect.fail( | |
| 258 | new ProjectReadFileError({ | |
| 259 | cwd: "/repo", | |
| 260 | relativePath: "assets.png", | |
| 261 | failure: "path_not_file", | |
| 262 | }), | |
| 263 | ), | |
| 264 | ); | |
| 265 | const registry = AtomRegistry.make(); | |
| 266 | const unmount = registry.mount(readAtom); | |
| 267 | projectMocks.readFile.mockReturnValue(readAtom); | |
| 268 | projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); | |
| 269 | atomHooks.registry = registry; | |
| 270 | ||
| 271 | try { | |
| 272 | await flushEffects(); | |
| 273 | reactHooks.beginRender(); | |
| 274 | const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); | |
| 275 | expect(query.isNotFile).toBe(true); | |
| 276 | expect(query.data).toBeNull(); | |
| 277 | } finally { | |
| 278 | unmount(); | |
| 279 | registry.dispose(); | |
| 280 | atomHooks.registry = null; | |
| 281 | } | |
| 282 | }); | |
| 283 | ||
| 284 | it("reports a directory read as not a file", async () => { | |
| 285 | const readAtom = Atom.make( | |
| 286 | Effect.fail( | |
| 287 | new ProjectReadFileError({ | |
| 288 | cwd: "/repo", | |
| 289 | relativePath: ".agents/skills", | |
| 290 | failure: "path_not_file", | |
| 291 | }), | |
| 292 | ), | |
| 293 | ); | |
| 294 | const registry = AtomRegistry.make(); | |
| 295 | const unmount = registry.mount(readAtom); | |
| 296 | projectMocks.readFile.mockReturnValue(readAtom); | |
| 297 | projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); | |
| 298 | atomHooks.registry = registry; | |
| 299 | ||
| 300 | try { | |
| 301 | await flushEffects(); | |
| 302 | reactHooks.beginRender(); | |
| 303 | const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); | |
| 304 | expect(query.isNotFile).toBe(true); | |
| 305 | expect(query.data).toBeNull(); | |
| 306 | } finally { | |
| 307 | unmount(); | |
| 308 | registry.dispose(); | |
| 309 | atomHooks.registry = null; | |
| 310 | } | |
| 311 | }); | |
| 253 | 312 | }); |
apps/web/src/components/files/projectFilesQueryState.ts+29-21| ... | ... | @@ -1,15 +1,13 @@ |
| 1 | 1 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 2 | import type { | |
| 3 | EnvironmentId, | |
| 4 | ProjectListEntriesResult, | |
| 5 | ProjectReadFileResult, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | 2 | import { |
| 8 | isWorkspaceImagePreviewPath, | |
| 9 | isWorkspaceVideoPreviewPath, | |
| 10 | } from "@t3tools/shared/filePreview"; | |
| 3 | type EnvironmentId, | |
| 4 | type ProjectListEntriesResult, | |
| 5 | ProjectReadFileError, | |
| 6 | type ProjectReadFileResult, | |
| 7 | } from "@t3tools/contracts"; | |
| 11 | 8 | import * as Cause from "effect/Cause"; |
| 12 | 9 | import * as Option from "effect/Option"; |
| 10 | import * as Schema from "effect/Schema"; | |
| 13 | 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 14 | 12 | import { useCallback } from "react"; |
| 15 | 13 | |
| ... | ... | @@ -33,6 +31,11 @@ interface ProjectQueryState<A> { |
| 33 | 31 | readonly refresh: () => void; |
| 34 | 32 | } |
| 35 | 33 | |
| 34 | interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> { | |
| 35 | /** The path exists but is not a regular file, typically a directory. */ | |
| 36 | readonly isNotFile: boolean; | |
| 37 | } | |
| 38 | ||
| 36 | 39 | function getProjectEntriesQueryAtom( |
| 37 | 40 | environmentId: EnvironmentId, |
| 38 | 41 | cwd: string, |
| ... | ... | @@ -126,12 +129,17 @@ export function clearProjectFileQueryData( |
| 126 | 129 | appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); |
| 127 | 130 | } |
| 128 | 131 | |
| 129 | function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string | null { | |
| 130 | if (result._tag !== "Failure") return null; | |
| 131 | const cause = Cause.squash(result.cause); | |
| 132 | function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown { | |
| 133 | return result._tag === "Failure" ? Cause.squash(result.cause) : null; | |
| 134 | } | |
| 135 | ||
| 136 | function errorMessage(cause: unknown): string | null { | |
| 137 | if (cause === null) return null; | |
| 132 | 138 | return cause instanceof Error ? cause.message : "Workspace query failed."; |
| 133 | 139 | } |
| 134 | 140 | |
| 141 | const isProjectReadFileError = Schema.is(ProjectReadFileError); | |
| 142 | ||
| 135 | 143 | export function useProjectEntriesQuery( |
| 136 | 144 | environmentId: EnvironmentId, |
| 137 | 145 | cwd: string, |
| ... | ... | @@ -143,7 +151,7 @@ export function useProjectEntriesQuery( |
| 143 | 151 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| 144 | 152 | return { |
| 145 | 153 | data: Option.getOrNull(AsyncResult.value(result)), |
| 146 | error: errorMessage(result), | |
| 154 | error: errorMessage(failureCause(result)), | |
| 147 | 155 | isPending: result.waiting, |
| 148 | 156 | refresh, |
| 149 | 157 | }; |
| ... | ... | @@ -189,14 +197,12 @@ export function useProjectFileQuery( |
| 189 | 197 | cwd: string, |
| 190 | 198 | relativePath: string | null, |
| 191 | 199 | enabled = true, |
| 192 | ): ProjectQueryState<ProjectReadFileResult> { | |
| 193 | const isMedia = | |
| 194 | relativePath !== null && | |
| 195 | (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); | |
| 196 | const atom = | |
| 197 | enabled && !isMedia | |
| 198 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | |
| 199 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | |
| 200 | ): ProjectFileQueryState { | |
| 201 | // The caller decides what to read. A media path is not skipped here: a folder | |
| 202 | // named `assets.png` is only knowable as a folder from the read failure. | |
| 203 | const atom = enabled | |
| 204 | ? getProjectFileQueryAtom(environmentId, cwd, relativePath) | |
| 205 | : EMPTY_PROJECT_FILE_QUERY_ATOM; | |
| 200 | 206 | const result = useAtomValue(atom); |
| 201 | 207 | const refreshAtom = useAtomRefresh(atom); |
| 202 | 208 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
| ... | ... | @@ -205,10 +211,12 @@ export function useProjectFileQuery( |
| 205 | 211 | optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), |
| 206 | 212 | ); |
| 207 | 213 | const optimisticFile = relativePath === null ? null : optimisticResult; |
| 214 | const cause = failureCause(result); | |
| 208 | 215 | |
| 209 | 216 | return { |
| 210 | 217 | data: optimisticFile?.data ?? data, |
| 211 | error: errorMessage(result), | |
| 218 | error: errorMessage(cause), | |
| 219 | isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", | |
| 212 | 220 | isPending: result.waiting, |
| 213 | 221 | refresh, |
| 214 | 222 | }; |
apps/web/src/rightPanelStore.test.ts+30| ... | ... | @@ -475,6 +475,36 @@ describe("rightPanelStore", () => { |
| 475 | 475 | }); |
| 476 | 476 | }); |
| 477 | 477 | |
| 478 | it.each([ | |
| 479 | ["generated\\", "generated"], | |
| 480 | ["notes/meeting ", "notes/meeting"], | |
| 481 | [" notes/meeting", "notes/meeting"], | |
| 482 | ])("keeps %j and %j in separate file tabs", (firstPath, secondPath) => { | |
| 483 | useRightPanelStore.getState().openFile(refA, firstPath); | |
| 484 | useRightPanelStore.getState().openFile(refA, secondPath); | |
| 485 | ||
| 486 | expect( | |
| 487 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, | |
| 488 | ).toMatchObject([ | |
| 489 | { id: `file:${firstPath}`, relativePath: firstPath }, | |
| 490 | { id: `file:${secondPath}`, relativePath: secondPath }, | |
| 491 | ]); | |
| 492 | }); | |
| 493 | ||
| 494 | it.each([ | |
| 495 | ["docs/", "docs"], | |
| 496 | ["docs///", "docs"], | |
| 497 | ["/", "/"], | |
| 498 | ["C:/", "C:/"], | |
| 499 | ])("reuses the folder tab for %j and %j", (linkPath, treePath) => { | |
| 500 | useRightPanelStore.getState().openFile(refA, linkPath); | |
| 501 | useRightPanelStore.getState().openFile(refA, treePath); | |
| 502 | ||
| 503 | expect( | |
| 504 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, | |
| 505 | ).toMatchObject([{ id: `file:${treePath}`, relativePath: treePath, revealRequestId: 2 }]); | |
| 506 | }); | |
| 507 | ||
| 478 | 508 | it("opens an attachment as a file surface without the standalone explorer", () => { |
| 479 | 509 | const attachment = { |
| 480 | 510 | type: "file" as const, |
apps/web/src/rightPanelStore.ts+5-1| ... | ... | @@ -575,9 +575,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 575 | 575 | : next; |
| 576 | 576 | }), |
| 577 | 577 | ), |
| 578 | openFile: (ref, relativePath, line) => | |
| 578 | openFile: (ref, requestedPath, line) => | |
| 579 | 579 | set((state) => |
| 580 | 580 | userAction(state, scopedThreadKey(ref), (current) => { |
| 581 | // Workspace entry paths use '/', including on Windows. | |
| 582 | const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath) | |
| 583 | ? requestedPath | |
| 584 | : requestedPath.replace(/\/+$/, "") || requestedPath; | |
| 581 | 585 | const withoutStandaloneExplorer = current.surfaces.filter( |
| 582 | 586 | (surface) => surface.kind !== "files", |
| 583 | 587 | ); |