diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b06..c66ab9a1cf 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -14,71 +14,9 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { cn } from "~/lib/utils"; -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - -function HighlightedSearchText(props: { text: string; query: string }) { - const query = props.query.trim(); - if (query.length === 0) return props.text; - - const normalizedText = foldAsciiCase(props.text); - const normalizedQuery = foldAsciiCase(query); - const parts: Array<{ - readonly text: string; - readonly highlighted: boolean; - readonly start: number; - }> = []; - let cursor = 0; - - while (cursor < props.text.length) { - const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); - if (matchIndex === -1) { - parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); - break; - } - if (matchIndex > cursor) { - parts.push({ - text: props.text.slice(cursor, matchIndex), - highlighted: false, - start: cursor, - }); - } - parts.push({ - text: props.text.slice(matchIndex, matchIndex + query.length), - highlighted: true, - start: matchIndex, - }); - cursor = matchIndex + query.length; - } - - return parts.map((part) => - part.highlighted ? ( - - {part.text} - - ) : ( - part.text - ), - ); -} - -function ThreadContentMatch(props: { - match: NonNullable; -}) { - const isUser = props.match.source === "user"; - return ( - - - {isUser ? "You:" : "Agent:"} - {" "} - - - ); -} - interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; @@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: { {props.item.title} {props.item.threadContentMatch ? ( - + ) : null} {props.item.description ? ( @@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: { {props.item.title} {props.item.threadContentMatch ? ( - + ) : null} {props.item.description ? ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 31c0cf407c..8865a23b6d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -52,6 +52,7 @@ import { type SidebarSection, resolveSidebarDropVerb, } from "./Sidebar.logic"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, OrchestrationLatestTurn, @@ -898,11 +899,23 @@ describe("resolveSidebarThreadStatus", () => { }); describe("searchSidebarThreads", () => { + const searchThread = (id: string, title: string, project: string) => ({ + environmentId: localEnvironmentId, + id: ThreadId.make(id), + title, + project, + }); const threads = [ - { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, - { id: "thread-2", title: "Review providers", project: "Workspace" }, - { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" }, + searchThread("thread-1", "Fix workspace search", "Alpha"), + searchThread("thread-2", "Review providers", "Workspace"), + searchThread("thread-3", "WORKTREE cleanup", "Beta"), ]; + const contentKeys = (...ids: ReadonlyArray) => + new Set( + ids.map((id) => + threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }), + ), + ); it("matches thread titles case-insensitively and preserves their order", () => { expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); @@ -915,6 +928,28 @@ describe("searchSidebarThreads", () => { it("returns no results for an empty query", () => { expect(searchSidebarThreads(threads, " ")).toEqual([]); }); + + it("appends content-only matches after every title match", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([ + threads[0], + threads[2], + threads[1], + ]); + }); + + it("lists a thread matching both title and content once", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([ + threads[0], + threads[2], + ]); + }); + + it("ignores content matches for threads outside the sidebar collection", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([ + threads[0], + threads[2], + ]); + }); }); describe("filterSidebarProjectScopeItems", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9c7c5ab56c..6edc0d4bac 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -5,7 +5,13 @@ import { isAtomCommandInterrupted, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem, OrchestrationThreadShell } from "@t3tools/contracts"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import type { + ContextMenuItem, + EnvironmentId, + OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; @@ -942,21 +948,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; +const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); + /** - * Search the already-ordered sidebar thread collection by title or linked PR. - * Keeping the input order means lifecycle ordering (active, snoozed, settled) - * remains stable while the user narrows the list. + * Search the already-ordered sidebar thread collection by title or linked PR, + * plus any thread whose messages the server matched (`contentMatchKeys`, keyed + * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering + * (active, snoozed, settled) remains stable while the user narrows the list. */ export function searchSidebarThreads< - T extends { readonly title: string } & Parameters[0], ->(threads: readonly T[], query: string): T[] { + T extends { + readonly environmentId: EnvironmentId; + readonly id: ThreadId; + readonly title: string; + } & Parameters[0], +>( + threads: readonly T[], + query: string, + contentMatchKeys: ReadonlySet = EMPTY_CONTENT_MATCH_KEYS, +): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => - [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + const titleMatches: T[] = []; + const contentMatches: T[] = []; + for (const thread of threads) { + const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => term.toLowerCase().includes(normalizedQuery), - ), - ); + ); + if (matchesTitle) { + titleMatches.push(thread); + } else if ( + contentMatchKeys.size > 0 && + contentMatchKeys.has( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) + ) { + contentMatches.push(thread); + } + } + return [...titleMatches, ...contentMatches]; } export function filterSidebarProjectScopeItems(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e1e5473df0..ffb803fd0b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,6 +28,10 @@ import { nestDelegatedThreads, nestedRowContainsThread, } from "@t3tools/client-runtime/state/delegated-threads"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, @@ -138,6 +142,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; +import { useThreadSearch } from "../state/queries"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, @@ -224,6 +229,7 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; +import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -2107,6 +2113,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { isHighlighted: boolean; isRouteActive: boolean; resultId: string; + searchMatch: EnvironmentThreadSearchMatch | null; + searchQuery: string; onHighlight: () => void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; @@ -2196,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2209,9 +2217,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { {props.project ? ( ) : null} - {thread.title} - - {threadTimeLabel(thread)} + + + {thread.title} + + {threadTimeLabel(thread)} + + + {props.searchMatch ? ( + + ) : null} [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); + const searchEnvironmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + // useThreadSearch owns the debounce and the two-character floor. + const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); + const threadSearchMatchByKey = useMemo( + () => + new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)), + [threadSearch.matches], + ); const threadSearchResults = useMemo( - () => searchSidebarThreads(searchableThreads, threadSearchQuery), - [searchableThreads, threadSearchQuery], + () => + searchSidebarThreads( + searchableThreads, + threadSearchQuery, + new Set(threadSearchMatchByKey.keys()), + ), + [searchableThreads, threadSearchQuery, threadSearchMatchByKey], ); const threadSearchResultOrderKey = threadSearchResults .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) @@ -4762,6 +4802,15 @@ export default function Sidebar() { isHighlighted={activeSearchResultIndex === index} isRouteActive={routeThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} + searchMatch={ + threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) ?? null + } + searchQuery={threadSearchQuery} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} onFileDropThreads={handleThreadFileDrop} @@ -4775,7 +4824,7 @@ export default function Sidebar() { role="status" className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" > - No threads found + {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}

) ) : null} diff --git a/apps/web/src/components/ThreadSearchMatch.tsx b/apps/web/src/components/ThreadSearchMatch.tsx new file mode 100644 index 0000000000..db2e286ae2 --- /dev/null +++ b/apps/web/src/components/ThreadSearchMatch.tsx @@ -0,0 +1,66 @@ +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function HighlightedSearchText(props: { text: string; query: string }) { + const query = props.query.trim(); + if (query.length === 0) return props.text; + + const normalizedText = foldAsciiCase(props.text); + const normalizedQuery = foldAsciiCase(query); + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + + while (cursor < props.text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: props.text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: props.text.slice(matchIndex, matchIndex + query.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + query.length; + } + + return parts.map((part) => + part.highlighted ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +export function ThreadSearchMatchExcerpt(props: { + match: { + readonly source: "user" | "assistant"; + readonly snippet: string; + readonly query: string; + }; +}) { + const isUser = props.match.source === "user"; + return ( + + + {isUser ? "You:" : "Agent:"} + {" "} + + + ); +} diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx index d7767ab942..cd8f4d5b32 100644 --- a/apps/web/src/components/diffs/DiffFileTree.test.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -5,7 +5,10 @@ import { act, type MouseEvent, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { FileDiffMetadata } from "@pierre/diffs"; + import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; +import { diffFileTreeEntries } from "./diffFileTree.logic"; import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); @@ -112,6 +115,22 @@ describe("diff tree file activation", () => { vi.unstubAllGlobals(); }); + it("mounts a file-to-symlink type change as one tree row", async () => { + // Git carries a type change as a deletion and an addition of the same path. + const typeChange = diffFileTreeEntries([ + { type: "deleted", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, + { type: "new", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, + { type: "change", name: "CLAUDE.md", prevName: "CLAUDE.md" } as FileDiffMetadata, + ]); + await mount({ files: [...typeChange] }); + + const tree = model(); + expect(tree.getItem("AGENTS.md")?.isDirectory()).toBe(false); + expect(tree.getItem("CLAUDE.md")?.isDirectory()).toBe(false); + await activate("AGENTS.md"); + expect(targets).toEqual([{ type: "item", id: "AGENTS.md\u0000AGENTS.md", align: "start" }]); + }); + it("reissues the reveal when the sole selected file is activated again", async () => { await mount(); await activate("02-short.ts"); diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts index 4aa1fd7208..170e843080 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -34,6 +34,23 @@ describe("diffFileTreeEntries", () => { }); }); +describe("diffFileTreeEntries", () => { + it("folds a file-to-symlink type change into one modified entry", () => { + expect( + diffFileTreeEntries([ + file("change", "CLAUDE.md"), + file("deleted", "AGENTS.md"), + file("new", "AGENTS.md"), + file("new", "docs/new.md"), + ]), + ).toEqual([ + { path: "CLAUDE.md", status: "modified" }, + { path: "AGENTS.md", status: "modified" }, + { path: "docs/new.md", status: "added" }, + ]); + }); +}); + describe("collectDirectoryPaths", () => { it("lists every ancestor once, parents first, with Pierre's trailing slash", () => { expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([ diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index 7331fb150e..b727c3ee3f 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -23,11 +23,22 @@ function toGitStatus(file: FileDiffMetadata): GitStatus { } } -/** Maps parsed diff files to tree entries, keeping the diff's own order. */ +/** + * Maps parsed diff files to tree entries, keeping the diff's own order. A path + * appears once: a type change (regular file to symlink) is a deletion plus an + * addition of the same path, and the tree shows the surviving file as modified. + */ export function diffFileTreeEntries( files: ReadonlyArray, ): ReadonlyArray { - return files.map((file) => ({ path: resolveFileDiffPath(file), status: toGitStatus(file) })); + const statusByPath = new Map(); + for (const file of files) { + const path = resolveFileDiffPath(file); + const status = toGitStatus(file); + const previous = statusByPath.get(path); + statusByPath.set(path, previous === undefined || previous === status ? status : "modified"); + } + return [...statusByPath].map(([path, status]) => ({ path, status })); } /** diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 1fb8204790..1952fc7c3b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -30,7 +30,7 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; - /** File currently open in the preview pane; revealed and selected in the tree. */ + /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */ selectedPath: string | null; /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ selectedPathRevealId: number; @@ -298,8 +298,13 @@ export default function FileBrowserPanel({ ) { return; } - if (entryKinds.get(selectedPath) !== "file") return; - const selectedItem = model.getItem(selectedPath); + const selectedKind = entryKinds.get(selectedPath); + // An unloaded entry has no row to reveal yet; folders do, and chat links can + // point at them. + if (selectedKind === undefined) return; + // Directory rows are registered with a trailing slash (see treePath). + const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath; + const selectedItem = model.getItem(selectedTreePath); if (!selectedItem) return; // A selection that originated inside the tree (clicking a row, possibly @@ -333,8 +338,12 @@ export default function FileBrowserPanel({ if (item && "expand" in item) item.expand(); } + if ("expand" in selectedItem) selectedItem.expand(); selectedItem.select(); - model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + model.scrollToPath(selectedTreePath, { + focus: true, + offset: "center", + }); queueMicrotask(() => { syncingSelectionRef.current = false; }); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 98378065ff..4043159d76 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -941,15 +941,22 @@ export default function FilePreviewPanel({ // A file outside the workspace (an absolute path) is shown, never edited. const isHostFile = attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath)); - const file = useProjectFileQuery( - environmentId, - cwd, - relativePath, - attachment === undefined && !isMedia && !isPdf, - ); + // Media and PDFs render from their absolute path, so their contents are never + // shown. The read still runs: a folder named `assets.png` is only knowable as a + // folder from the read failure, and the server stats before reading, so a folder + // costs an open and a stat and returns no body. + const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined); + // A chat link cannot tell a folder from a file, so a folder arrives here as + // a file surface and the read fails. Keep the breadcrumbs, drop the preview + // pane, and let the tree fill the surface with the folder revealed. Mutation + // refresh stays on so the surface notices if the path becomes a file. A host + // path cannot be revealed in the workspace tree, so it keeps the read error. + const isDirectory = file.isNotFile && !isHostFile; + // Everything preview-related keys off previewPath; a folder has no preview. + const previewPath = isDirectory ? null : relativePath; const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); const showExplorer = shouldShowFileExplorer({ - relativePath, + relativePath: previewPath, explorerOpen, attachmentOpen: attachment !== undefined, }); @@ -977,9 +984,9 @@ export default function FilePreviewPanel({ null, ); const breadcrumbRef = useRef(null); - const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; + const isMarkdown = previewPath ? isMarkdownPreviewFile(previewPath) : false; const tableDelimiter = - relativePath && attachment === undefined ? filePreviewDelimiter({ name: relativePath }) : null; + previewPath && attachment === undefined ? filePreviewDelimiter({ name: previewPath }) : null; // A reveal still wins over the preference: the line only exists in the source. const revealHandled = revealLine === null || @@ -994,7 +1001,8 @@ export default function FilePreviewPanel({ : isHtml ? ("html" as const) : null; - const canToggleRendered = attachment === undefined && renderedMode !== null; + const canToggleRendered = + previewPath !== null && attachment === undefined && renderedMode !== null; const rendered = isMarkdown ? renderMarkdown : tableDelimiter ? renderTable : renderBrowserFile; const setRenderedPreferred = isMarkdown ? setRenderMarkdownPreferred @@ -1002,11 +1010,11 @@ export default function FilePreviewPanel({ ? setRenderTablePreferred : setRenderBrowserFilePreferred; const canOpenInBrowser = - relativePath !== null && + previewPath !== null && attachment === undefined && !isVideo && isPreviewSupportedInRuntime() && - isBrowserPreviewFile(relativePath); + isBrowserPreviewFile(previewPath); const absolutePath = relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); @@ -1014,8 +1022,10 @@ export default function FilePreviewPanel({ enabled: attachment === undefined && relativePath !== null && - !isMedia && - !isPdf && + // Media and PDFs never show their contents, so re-reading them on every + // workspace mutation is waste. A folder named like one still re-reads, so + // it notices when the path becomes a file. + (isDirectory || (!isMedia && !isPdf)) && !selectedFilePending, mutationId: workspaceMutationId, refresh: file.refresh, @@ -1127,7 +1137,7 @@ export default function FilePreviewPanel({ ) : null} - {!isHostFile ? ( + {!isHostFile && previewPath !== null ? ( ) : null} - {relativePath && + {previewPath && attachment === undefined && !isMedia && !renderBrowserFile && @@ -1149,12 +1159,9 @@ export default function FilePreviewPanel({ ) : null}
- {relativePath && attachment ? ( + {isDirectory ? null : relativePath && attachment ? ( diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index 32638211f6..9d7877fec0 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, type ProjectListEntriesResult, + ProjectReadFileError, type ProjectReadFileResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -275,4 +276,62 @@ describe("project query refresh", () => { atomHooks.registry = null; } }); + + it("reports a directory named like an image as not a file", async () => { + const readAtom = Atom.make( + Effect.fail( + new ProjectReadFileError({ + cwd: "/repo", + relativePath: "assets.png", + failure: "path_not_file", + }), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); + expect(query.isNotFile).toBe(true); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("reports a directory read as not a file", async () => { + const readAtom = Atom.make( + Effect.fail( + new ProjectReadFileError({ + cwd: "/repo", + relativePath: ".agents/skills", + failure: "path_not_file", + }), + ), + ); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); + expect(query.isNotFile).toBe(true); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); }); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index a127729209..29fd471aac 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -1,15 +1,13 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - ProjectListEntriesResult, - ProjectReadFileResult, -} from "@t3tools/contracts"; import { - isWorkspaceImagePreviewPath, - isWorkspaceVideoPreviewPath, -} from "@t3tools/shared/filePreview"; + type EnvironmentId, + type ProjectListEntriesResult, + ProjectReadFileError, + type ProjectReadFileResult, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; @@ -33,6 +31,11 @@ interface ProjectQueryState { readonly refresh: () => void; } +interface ProjectFileQueryState extends ProjectQueryState { + /** The path exists but is not a regular file, typically a directory. */ + readonly isNotFile: boolean; +} + function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { return projectEnvironment.listEntries({ environmentId, input: { cwd } }); } @@ -119,12 +122,17 @@ export function clearProjectFileQueryData( appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null); } -function errorMessage(result: AsyncResult.AsyncResult): string | null { - if (result._tag !== "Failure") return null; - const cause = Cause.squash(result.cause); +function failureCause(result: AsyncResult.AsyncResult): unknown { + return result._tag === "Failure" ? Cause.squash(result.cause) : null; +} + +function errorMessage(cause: unknown): string | null { + if (cause === null) return null; return cause instanceof Error ? cause.message : "Workspace query failed."; } +const isProjectReadFileError = Schema.is(ProjectReadFileError); + export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, @@ -135,7 +143,7 @@ export function useProjectEntriesQuery( const refresh = useCallback(() => refreshAtom(), [refreshAtom]); return { data: Option.getOrNull(AsyncResult.value(result)), - error: errorMessage(result), + error: errorMessage(failureCause(result)), isPending: result.waiting, refresh, }; @@ -181,14 +189,12 @@ export function useProjectFileQuery( cwd: string, relativePath: string | null, enabled = true, -): ProjectQueryState { - const isMedia = - relativePath !== null && - (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); - const atom = - enabled && !isMedia - ? getProjectFileQueryAtom(environmentId, cwd, relativePath) - : EMPTY_PROJECT_FILE_QUERY_ATOM; +): ProjectFileQueryState { + // The caller decides what to read. A media path is not skipped here: a folder + // named `assets.png` is only knowable as a folder from the read failure. + const atom = enabled + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); @@ -197,10 +203,12 @@ export function useProjectFileQuery( optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), ); const optimisticFile = relativePath === null ? null : optimisticResult; + const cause = failureCause(result); return { data: optimisticFile?.data ?? data, - error: errorMessage(result), + error: errorMessage(cause), + isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", isPending: result.waiting, refresh, }; diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 33d015d52d..d540606f52 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -35,10 +35,10 @@ describe("buildPatchCacheKey", () => { describe("getRenderablePatch", () => { it.each([ - ["a/example.ts", "a/example.ts"], - ["b/example.ts", "b/example.ts"], - ["a/before.ts", "b/after.ts"], - ])("preserves repository paths from %s to %s", (previousPath, path) => { + ["a/example.ts", "a/example.ts", "change"], + ["b/example.ts", "b/example.ts", "change"], + ["a/before.ts", "b/after.ts", "rename-changed"], + ])("preserves repository paths from %s to %s", (previousPath, path, type) => { const parsed = getRenderablePatch( [ `diff --git a/${previousPath} b/${path}`, @@ -59,7 +59,7 @@ describe("getRenderablePatch", () => { if (!file) return; expect(resolveFileDiffPath(file)).toBe(path); expect(resolveFileDiffPreviousPath(file)).toBe(previousPath); - expect(buildFileDiffIdentityKey(file)).toBe(`${previousPath}\0${path}`); + expect(buildFileDiffIdentityKey(file)).toBe(`${previousPath}\0${path}\0${type}`); }); it("compacts partial hunk render offsets for virtualized review diffs", () => { @@ -137,6 +137,33 @@ describe("diff file reconciliation", () => { expect(buildFileDiffRenderKey(file)).toBe(key); }); + it("gives a type change its own identity per block", () => { + const patch = [ + "diff --git a/AGENTS.md b/AGENTS.md", + "deleted file mode 100644", + "--- a/AGENTS.md", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-duplicated instructions", + "diff --git a/AGENTS.md b/AGENTS.md", + "new file mode 120000", + "--- /dev/null", + "+++ b/AGENTS.md", + "@@ -0,0 +1 @@", + "+CLAUDE.md", + ].join("\n"); + const parsed = getRenderablePatch(patch, "type-change"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + const [deleted, added] = parsed.files; + expect(deleted?.type).toBe("deleted"); + expect(added?.type).toBe("new"); + if (!deleted || !added) return; + + expect(buildFileDiffIdentityKey(deleted)).not.toBe(buildFileDiffIdentityKey(added)); + expect(new Set(parsed.files.map(buildFileDiffIdentityKey)).size).toBe(parsed.files.length); + }); + it("keeps identities stable and versions local to the changed file", () => { const patch = (secondLine: string) => [ diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 2af48db53a..b8b66f6ff0 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -155,8 +155,13 @@ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string return fileDiff.prevName ?? fileDiff.name ?? ""; } +/** + * Stable across re-renders of the same file, distinct for every block in a + * patch. A type change (regular file to symlink) arrives as a deletion and an + * addition of the same path, so the change type is part of the identity. + */ export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { - return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}`; + return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}\u0000${fileDiff.type}`; } export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 6f8a104e9f..151c1699cb 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -475,6 +475,36 @@ describe("rightPanelStore", () => { }); }); + it.each([ + ["generated\\", "generated"], + ["notes/meeting ", "notes/meeting"], + [" notes/meeting", "notes/meeting"], + ])("keeps %j and %j in separate file tabs", (firstPath, secondPath) => { + useRightPanelStore.getState().openFile(refA, firstPath); + useRightPanelStore.getState().openFile(refA, secondPath); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toMatchObject([ + { id: `file:${firstPath}`, relativePath: firstPath }, + { id: `file:${secondPath}`, relativePath: secondPath }, + ]); + }); + + it.each([ + ["docs/", "docs"], + ["docs///", "docs"], + ["/", "/"], + ["C:/", "C:/"], + ])("reuses the folder tab for %j and %j", (linkPath, treePath) => { + useRightPanelStore.getState().openFile(refA, linkPath); + useRightPanelStore.getState().openFile(refA, treePath); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toMatchObject([{ id: `file:${treePath}`, relativePath: treePath, revealRequestId: 2 }]); + }); + it("opens an attachment as a file surface without the standalone explorer", () => { const attachment = { type: "file" as const, diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 00412b1972..c076fac88b 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -575,9 +575,13 @@ export const useRightPanelStore = create()( : next; }), ), - openFile: (ref, relativePath, line) => + openFile: (ref, requestedPath, line) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { + // Workspace entry paths use '/', including on Windows. + const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath) + ? requestedPath + : requestedPath.replace(/\/+$/, "") || requestedPath; const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", );