From b485d3c6087349af69914d95d9f8bb253eff8b4f Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Wed, 30 Sep 2026 09:02:06 +1000 Subject: [PATCH 1/2] fix(web): files panel and @ search follow the .code-workspace folder list For a .code-workspace project the files panel and @ search now span every folder the file lists that exists on disk, git or not, instead of the git repos (two or more) or the whole workspace folder (one). Git surfaces keep using repoRoots. Fixes #323 --- apps/web/src/components/ChatView.tsx | 31 ++++++-- .../src/components/ProjectFoldersDialog.tsx | 18 ++++- .../src/components/files/FileBreadcrumbs.tsx | 12 ++-- .../src/components/files/FileBrowserPanel.tsx | 17 ++--- .../src/components/files/FilePreviewPanel.tsx | 13 ++-- apps/web/src/lib/projectFileRoots.test.ts | 70 +++++++++++++++++++ apps/web/src/lib/projectFileRoots.ts | 28 ++++++++ 7 files changed, 160 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/lib/projectFileRoots.test.ts create mode 100644 apps/web/src/lib/projectFileRoots.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6c24521ba501..5afd6d8d4e0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -343,6 +343,8 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; +import { filesystemEnvironment } from "../state/filesystem"; +import { resolveProjectFileRoots } from "../lib/projectFileRoots"; import { vcsEnvironment } from "../state/vcs"; import { environmentServerConfigsAtom, @@ -3671,11 +3673,26 @@ export default function ChatView(props: ChatViewProps) { : null, [multiRepoStatusRoots, gitStatusCwd], ); - // @-mention file search spans every repo root for a multi-repo workspace - // (#923); single-repo projects search the worktree-aware `gitCwd` alone. - const mentionRoots = useMemo( - () => (isMultiRepo ? (activeProject?.repoRoots ?? null) : null), - [isMultiRepo, activeProject?.repoRoots], + // The files panel and @-mention search span the folders a `.code-workspace` + // lists, while git surfaces stay on `repoRoots`. Null searches the + // worktree-aware cwd alone. + const workspaceFileQuery = useEnvironmentQuery( + projectWorkspaceFile === null + ? null + : filesystemEnvironment.readWorkspaceFile({ + environmentId, + input: { workspaceFilePath: projectWorkspaceFile }, + }), + ); + const workspaceFolders = workspaceFileQuery.data?.folders ?? null; + const projectFileRoots = useMemo( + () => + resolveProjectFileRoots({ + workspaceFolders, + repoRoots: activeProject?.repoRoots, + worktrees: threadWorktrees, + }), + [workspaceFolders, activeProject?.repoRoots, threadWorktrees], ); // Single-repo git query scoped to the anchor root, backing the branch-sync, // PR-checkout, and refresh flows; the multi-repo status/diff surfaces use @@ -9769,7 +9786,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget={composerDraftTarget} keybindings={keybindings} availableEditors={availableEditors} - repoRoots={isMultiRepo ? (activeProject?.repoRoots ?? undefined) : undefined} + roots={projectFileRoots ?? undefined} relativePath={ renderedRightPanelSurface.kind === "file" ? renderedRightPanelSurface.relativePath @@ -10196,7 +10213,7 @@ export default function ChatView(props: ChatViewProps) { timelineOverflows={timelineOverflows} onComposerOverlayHeightChange={publishComposerOverlayHeight} onRestingChange={onComposerRestingChange} - mentionRoots={mentionRoots} + mentionRoots={projectFileRoots} promptRef={promptRef} composerImagesRef={composerImagesRef} composerFilesRef={composerFilesRef} diff --git a/apps/web/src/components/ProjectFoldersDialog.tsx b/apps/web/src/components/ProjectFoldersDialog.tsx index da4c8e30c8ef..5b131183e2cf 100644 --- a/apps/web/src/components/ProjectFoldersDialog.tsx +++ b/apps/web/src/components/ProjectFoldersDialog.tsx @@ -93,8 +93,12 @@ export function ProjectFoldersDialog({ // Filesystem RPCs are driven through the environment atoms (the old imperative // `readEnvironmentApi().filesystem.*` accessor was removed). Reads run via a // query runner; the write goes through the command atom and re-resolves the file. + // Refresh rather than reuse the cached read: the chat view keeps it alive for + // its files panel, so a stale entry would outlive both an edit made in VS Code + // and a save from this dialog. const readWorkspaceFile = useAtomQueryRunner(filesystemEnvironment.readWorkspaceFile, { reportFailure: false, + refresh: true, }); const writeWorkspaceFile = useAtomCommand(filesystemEnvironment.writeWorkspaceFile, { reportFailure: false, @@ -225,6 +229,10 @@ export function ProjectFoldersDialog({ repoRoots: resolved.repoRoots, }, }); + void readWorkspaceFile({ + environmentId: target.environmentId, + input: { workspaceFilePath: workspaceFile }, + }); const missing = resolved.folders.filter((folder) => !folder.exists); if (missing.length > 0) { toastManager.add({ @@ -247,7 +255,15 @@ export function ProjectFoldersDialog({ } finally { setSaving(false); } - }, [folders, onClose, target, updateProject, workspaceFile, writeWorkspaceFile]); + }, [ + folders, + onClose, + readWorkspaceFile, + target, + updateProject, + workspaceFile, + writeWorkspaceFile, + ]); return ( void; readonly projectName: string; readonly relativePath: string; - /** Every repo root of a multi-repo project, so the repo crumb matches its file tree label. */ - readonly repoRoots?: readonly string[] | undefined; + /** Every root of a multi-root project, so the root crumb matches its file tree label. */ + readonly roots?: readonly string[] | undefined; /** Repo root that `relativePath` is relative to, when it is not `cwd` (multi-repo). */ readonly root?: string | undefined; readonly workspaceMutationId: string | null; @@ -276,14 +276,14 @@ export function FileBreadcrumbs(props: FileBreadcrumbsProps) { // A file in another repo of a multi-repo project reads "project > repo > path", // and every crumb from the repo down browses and opens within that repo. const repoRoot = props.root && props.root !== props.cwd ? props.root : undefined; - const repoRootsKey = props.repoRoots?.join("\0") ?? ""; + const rootsKey = props.roots?.join("\0") ?? ""; const repoName = useMemo(() => { if (!repoRoot) return undefined; - const label = repoRootsKey - ? labelForRoot(buildRootLabels(repoRootsKey.split("\0")), repoRoot) + const label = rootsKey + ? labelForRoot(buildRootLabels(rootsKey.split("\0")), repoRoot) : undefined; return label ?? repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot; - }, [repoRoot, repoRootsKey]); + }, [repoRoot, rootsKey]); const breadcrumbs = useMemo( () => repoName === undefined diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 72becbdf543e..85a6e5741780 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -39,9 +39,10 @@ interface FileBrowserPanelProps { selectedRoot?: string | undefined; /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ selectedPathRevealId: number; - // Multi-repo workspaces (#923): when set, list the union of these repo roots - // and group the tree by repo. Omitted/single-entry keeps single-root behavior. - repoRoots?: readonly string[] | undefined; + // Multi-root projects (#923): when set, list the union of these roots (repos, + // or the folders a `.code-workspace` lists) and group the tree by root. + // Omitted/single-entry keeps single-root behavior. + roots?: readonly string[] | undefined; onOpenFile: (relativePath: string, root?: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; @@ -112,7 +113,7 @@ export default function FileBrowserPanel({ selectedPath: selectedRelativePath, selectedRoot, selectedPathRevealId, - repoRoots, + roots, onOpenFile, onRefreshSelectedFile, workspaceMutationId, @@ -123,18 +124,18 @@ export default function FileBrowserPanel({ // Multi-repo workspaces (#923): each repo is a top-level node named by its // label, and every tree path below it is prefixed with that label so // same-named files across repos don't collide. - const multiRepoRootsKey = repoRoots && repoRoots.length > 1 ? repoRoots.join("\0") : ""; + const multiRootsKey = roots && roots.length > 1 ? roots.join("\0") : ""; const { rootLabels, directoryRoots, searchRoots } = useMemo(() => { - if (!multiRepoRootsKey) + if (!multiRootsKey) return { rootLabels: null, directoryRoots: undefined, searchRoots: undefined }; - const roots = multiRepoRootsKey.split("\0"); + const roots = multiRootsKey.split("\0"); const rootLabels = buildRootLabels(roots); return { rootLabels, directoryRoots: roots.map((root) => ({ root, label: rootLabels.get(root) ?? root })), searchRoots: roots, }; - }, [multiRepoRootsKey]); + }, [multiRootsKey]); // Tree paths sit under their repo's label, so an open from outside the tree // (a chat link, the file picker) maps onto that key to be found and revealed. // A repo root linked by its absolute path selects that repo's top-level node. diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 46568767e8f5..5ef07fce6c28 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -104,8 +104,8 @@ interface FilePreviewPanelProps { availableEditors: ReadonlyArray; revealLine: number | null; revealRequestId: number; - // Multi-repo workspaces (#923): repo roots to list/group in the file tree. - repoRoots?: readonly string[] | undefined; + // Multi-root projects (#923): roots to list/group in the file tree. + roots?: readonly string[] | undefined; // Owning root of the currently-open file. Reads/writes resolve against this // root (it may be a different repo than the anchor `cwd`). Null = anchor. fileRoot?: string | null | undefined; @@ -927,7 +927,7 @@ export default function FilePreviewPanel({ availableEditors, revealLine, revealRequestId, - repoRoots, + roots, fileRoot, onOpenFile, onPendingChange, @@ -970,8 +970,7 @@ export default function FilePreviewPanel({ // unless it is the workspace root or a repo root, which the tree shows. const isDirectory = file.isNotFile && - (!isHostFile || - (relativePath !== null && isRootPath([cwd, ...(repoRoots ?? [])], relativePath))); + (!isHostFile || (relativePath !== null && isRootPath([cwd, ...(roots ?? [])], relativePath))); // Everything preview-related keys off previewPath; a folder has no preview. const previewPath = isDirectory ? null : relativePath; const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); @@ -1124,7 +1123,7 @@ export default function FilePreviewPanel({ onOpenFile={onOpenFile} projectName={projectName} relativePath={relativePath} - repoRoots={repoRoots} + roots={roots} root={fileRoot ?? undefined} workspaceMutationId={workspaceMutationId} /> @@ -1324,7 +1323,7 @@ export default function FilePreviewPanel({ selectedPath={relativePath} selectedRoot={fileRoot ?? undefined} selectedPathRevealId={revealRequestId} - repoRoots={repoRoots} + roots={roots} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} {...(previewPath && !isMedia && !isPdf diff --git a/apps/web/src/lib/projectFileRoots.test.ts b/apps/web/src/lib/projectFileRoots.test.ts new file mode 100644 index 000000000000..cf27bbc2f342 --- /dev/null +++ b/apps/web/src/lib/projectFileRoots.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveProjectFileRoots } from "./projectFileRoots"; + +const folder = (absolutePath: string, exists = true) => ({ absolutePath, exists }); + +describe("resolveProjectFileRoots", () => { + it("spans every listed folder of a workspace file, git or not", () => { + expect( + resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/web"), folder("/ws/docs")], + repoRoots: ["/ws/api", "/ws/web"], + worktrees: [], + }), + ).toEqual(["/ws/api", "/ws/web", "/ws/docs"]); + }); + + it("spans a one-repo workspace's listed folders instead of the workspace folder", () => { + expect( + resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/docs")], + repoRoots: ["/ws/api"], + worktrees: [], + }), + ).toEqual(["/ws/api", "/ws/docs"]); + }); + + it("leaves out listed folders missing from disk", () => { + expect( + resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/gone", false), folder("/ws/docs")], + repoRoots: ["/ws/api"], + worktrees: [], + }), + ).toEqual(["/ws/api", "/ws/docs"]); + }); + + it("falls back to multi-repo roots until the workspace file is read", () => { + expect( + resolveProjectFileRoots({ + workspaceFolders: null, + repoRoots: ["/ws/api", "/ws/web"], + worktrees: [], + }), + ).toEqual(["/ws/api", "/ws/web"]); + expect( + resolveProjectFileRoots({ workspaceFolders: null, repoRoots: ["/ws/api"], worktrees: [] }), + ).toBeNull(); + }); + + it("uses the single cwd when fewer than two folders remain", () => { + expect( + resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/gone", false)], + repoRoots: ["/ws/api"], + worktrees: [], + }), + ).toBeNull(); + }); + + it("swaps a repo an isolated run covers for its worktree", () => { + expect( + resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/docs")], + repoRoots: ["/ws/api"], + worktrees: [{ repoRoot: "/ws/api", worktreePath: "/wt/api-feature" }], + }), + ).toEqual(["/wt/api-feature", "/ws/docs"]); + }); +}); diff --git a/apps/web/src/lib/projectFileRoots.ts b/apps/web/src/lib/projectFileRoots.ts new file mode 100644 index 000000000000..e5300513b4b7 --- /dev/null +++ b/apps/web/src/lib/projectFileRoots.ts @@ -0,0 +1,28 @@ +import type { + FilesystemReadWorkspaceFileFolder, + OrchestrationThreadWorktree, +} from "@t3tools/contracts"; + +/** + * Roots the files panel and @-mention search span, or null to use the single + * project or worktree cwd. A `.code-workspace` project spans every folder the + * file lists that exists on disk, git or not, so plain folders show up and + * unlisted siblings of the file don't. Until the file has been read, or for + * other projects, the roots are the multi-repo `repoRoots`. A repo root an + * isolated run covers is swapped for its worktree. + */ +export function resolveProjectFileRoots(input: { + readonly workspaceFolders: ReadonlyArray< + Pick + > | null; + readonly repoRoots: ReadonlyArray | undefined; + readonly worktrees: ReadonlyArray | undefined; +}): string[] | null { + const roots = input.workspaceFolders + ? input.workspaceFolders.filter((folder) => folder.exists).map((folder) => folder.absolutePath) + : (input.repoRoots ?? []); + if (roots.length < 2) return null; + return roots.map( + (root) => input.worktrees?.find((worktree) => worktree.repoRoot === root)?.worktreePath ?? root, + ); +} From b7ee2cd22eda3481f09da4c758b038307f4b2c96 Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Wed, 30 Sep 2026 09:30:08 +1000 Subject: [PATCH 2/2] fix(web): files tree follows re-pointed folders and names roots by repo The file tree cached folders by their label, so a listed folder whose path changed under the same name (docs -> archive/docs) kept showing the old contents; it now remounts when its roots change. Roots now carry the label of the folder they came from, so an isolated run of two repos named `app` shows client/app and server/app instead of their worktrees' app and app-2, in the tree, the breadcrumbs and @ search. --- apps/web/src/components/chat/ChatComposer.tsx | 24 +++- .../src/components/files/FileBreadcrumbs.tsx | 12 +- .../src/components/files/FileBrowserPanel.tsx | 19 ++-- .../src/components/files/FilePreviewPanel.tsx | 11 +- apps/web/src/lib/projectFileRoots.test.ts | 103 ++++++++++-------- apps/web/src/lib/projectFileRoots.ts | 26 ++++- 6 files changed, 122 insertions(+), 73 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e196e8c40e50..b28e6d22855b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4,6 +4,8 @@ import { usePrimaryEnvironmentId } from "../../state/environments"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; +import { labelForRoot } from "../files/filePath"; +import { type ProjectFileRoot } from "../../lib/projectFileRoots"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; @@ -1428,9 +1430,9 @@ export interface ChatComposerProps { * effect, so it is current before the chat view measures the overlay. */ onRestingChange: (resting: boolean) => void; - // Multi-repo workspaces (#923): roots to union for @-mention file search. - // Omitted/`null` for single-repo projects, which search `gitCwd` alone. - mentionRoots: ReadonlyArray | null; + // Multi-root projects (#923): roots to union for @-mention file search, with + // the label each result is described under. `null` searches `gitCwd` alone. + mentionRoots: ReadonlyArray | null; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -2268,10 +2270,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const settledPullRequestTextQuery = pullRequestTextQuery === debouncedPullRequestTextQuery ? pullRequestTextQuery : null; const isPathTrigger = composerTriggerKind === "path"; + const mentionRootPaths = useMemo( + () => mentionRoots?.map(({ root }) => root) ?? null, + [mentionRoots], + ); + const mentionRootLabels = useMemo( + () => new Map(mentionRoots?.map(({ root, label }) => [root, label])), + [mentionRoots], + ); const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, - roots: isPathTrigger ? mentionRoots : null, + roots: isPathTrigger ? mentionRootPaths : null, query: isPathTrigger ? pathTriggerQuery : null, }); const compactSlashCommandAvailable = @@ -2360,7 +2370,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...(entry.root ? { root: entry.root } : {}), label: basenameOfPath(entry.path), description: entry.root - ? `${basenameOfPath(entry.root)}/${entry.parentPath ?? ""}`.replace(/\/$/, "") + ? `${labelForRoot(mentionRootLabels, entry.root) ?? basenameOfPath(entry.root)}/${entry.parentPath ?? ""}`.replace( + /\/$/, + "", + ) : (entry.parentPath ?? entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/")))), })); } @@ -2497,6 +2510,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactSlashCommandAvailable, composerTrigger, exactPullRequestLookup.data, + mentionRootLabels, planModeUiEnabled, pullRequestLookup.data, pullRequestProjectId, diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index d278e2f279a3..b49a6c83d84d 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -18,11 +18,11 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { type ProjectFileRoot } from "~/lib/projectFileRoots"; import { cn } from "~/lib/utils"; import { isAbsolutePath } from "~/terminal-links"; import { - buildRootLabels, type FileBreadcrumb, fileBreadcrumbChildren, fileBreadcrumbParent, @@ -38,7 +38,7 @@ interface FileBreadcrumbsProps { readonly projectName: string; readonly relativePath: string; /** Every root of a multi-root project, so the root crumb matches its file tree label. */ - readonly roots?: readonly string[] | undefined; + readonly roots?: readonly ProjectFileRoot[] | undefined; /** Repo root that `relativePath` is relative to, when it is not `cwd` (multi-repo). */ readonly root?: string | undefined; readonly workspaceMutationId: string | null; @@ -276,14 +276,14 @@ export function FileBreadcrumbs(props: FileBreadcrumbsProps) { // A file in another repo of a multi-repo project reads "project > repo > path", // and every crumb from the repo down browses and opens within that repo. const repoRoot = props.root && props.root !== props.cwd ? props.root : undefined; - const rootsKey = props.roots?.join("\0") ?? ""; + const roots = props.roots; const repoName = useMemo(() => { if (!repoRoot) return undefined; - const label = rootsKey - ? labelForRoot(buildRootLabels(rootsKey.split("\0")), repoRoot) + const label = roots + ? labelForRoot(new Map(roots.map(({ root, label }) => [root, label])), repoRoot) : undefined; return label ?? repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot; - }, [repoRoot, rootsKey]); + }, [repoRoot, roots]); const breadcrumbs = useMemo( () => repoName === undefined diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 85a6e5741780..aad4dc69243b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -22,7 +22,8 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; -import { buildRootLabels, isRootPath, labelForRoot } from "./filePath"; +import { isRootPath, labelForRoot } from "./filePath"; +import { type ProjectFileRoot, projectFileRootsKey } from "~/lib/projectFileRoots"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; @@ -42,7 +43,7 @@ interface FileBrowserPanelProps { // Multi-root projects (#923): when set, list the union of these roots (repos, // or the folders a `.code-workspace` lists) and group the tree by root. // Omitted/single-entry keeps single-root behavior. - roots?: readonly string[] | undefined; + roots?: readonly ProjectFileRoot[] | undefined; onOpenFile: (relativePath: string, root?: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; @@ -124,16 +125,18 @@ export default function FileBrowserPanel({ // Multi-repo workspaces (#923): each repo is a top-level node named by its // label, and every tree path below it is prefixed with that label so // same-named files across repos don't collide. - const multiRootsKey = roots && roots.length > 1 ? roots.join("\0") : ""; + const multiRootsKey = roots && roots.length > 1 ? projectFileRootsKey(roots) : ""; const { rootLabels, directoryRoots, searchRoots } = useMemo(() => { if (!multiRootsKey) return { rootLabels: null, directoryRoots: undefined, searchRoots: undefined }; - const roots = multiRootsKey.split("\0"); - const rootLabels = buildRootLabels(roots); + const directoryRoots = multiRootsKey.split("\0\0").map((pair) => { + const [label = "", root = ""] = pair.split("\0"); + return { root, label }; + }); return { - rootLabels, - directoryRoots: roots.map((root) => ({ root, label: rootLabels.get(root) ?? root })), - searchRoots: roots, + rootLabels: new Map(directoryRoots.map(({ root, label }) => [root, label])), + directoryRoots, + searchRoots: directoryRoots.map(({ root }) => root), }; }, [multiRootsKey]); // Tree paths sit under their repo's label, so an open from outside the tree diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 5ef07fce6c28..544b3a14580a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -58,6 +58,7 @@ import FileBrowserPanel from "./FileBrowserPanel"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { isRootPath } from "./filePath"; +import { type ProjectFileRoot, projectFileRootsKey } from "~/lib/projectFileRoots"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -105,7 +106,7 @@ interface FilePreviewPanelProps { revealLine: number | null; revealRequestId: number; // Multi-root projects (#923): roots to list/group in the file tree. - roots?: readonly string[] | undefined; + roots?: readonly ProjectFileRoot[] | undefined; // Owning root of the currently-open file. Reads/writes resolve against this // root (it may be a different repo than the anchor `cwd`). Null = anchor. fileRoot?: string | null | undefined; @@ -970,7 +971,9 @@ export default function FilePreviewPanel({ // unless it is the workspace root or a repo root, which the tree shows. const isDirectory = file.isNotFile && - (!isHostFile || (relativePath !== null && isRootPath([cwd, ...(roots ?? [])], relativePath))); + (!isHostFile || + (relativePath !== null && + isRootPath([cwd, ...(roots?.map(({ root }) => root) ?? [])], relativePath))); // Everything preview-related keys off previewPath; a folder has no preview. const previewPath = isDirectory ? null : relativePath; const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); @@ -1315,8 +1318,10 @@ export default function FilePreviewPanel({ : "min-w-0 flex-1", )} > + {/* The tree caches folders by label, so a root that changes path under + the same label must remount it rather than show the old contents. */} ({ absolutePath, exists }); +const paths = (roots: ReturnType) => + roots?.map((entry) => entry.root) ?? null; describe("resolveProjectFileRoots", () => { it("spans every listed folder of a workspace file, git or not", () => { - expect( - resolveProjectFileRoots({ - workspaceFolders: [folder("/ws/api"), folder("/ws/web"), folder("/ws/docs")], - repoRoots: ["/ws/api", "/ws/web"], - worktrees: [], - }), - ).toEqual(["/ws/api", "/ws/web", "/ws/docs"]); + const roots = resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/web"), folder("/ws/docs")], + repoRoots: ["/ws/api", "/ws/web"], + worktrees: [], + }); + expect(paths(roots)).toEqual(["/ws/api", "/ws/web", "/ws/docs"]); }); it("spans a one-repo workspace's listed folders instead of the workspace folder", () => { - expect( - resolveProjectFileRoots({ - workspaceFolders: [folder("/ws/api"), folder("/ws/docs")], - repoRoots: ["/ws/api"], - worktrees: [], - }), - ).toEqual(["/ws/api", "/ws/docs"]); + const roots = resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/docs")], + repoRoots: ["/ws/api"], + worktrees: [], + }); + expect(paths(roots)).toEqual(["/ws/api", "/ws/docs"]); }); it("leaves out listed folders missing from disk", () => { - expect( - resolveProjectFileRoots({ - workspaceFolders: [folder("/ws/api"), folder("/ws/gone", false), folder("/ws/docs")], - repoRoots: ["/ws/api"], - worktrees: [], - }), - ).toEqual(["/ws/api", "/ws/docs"]); + const roots = resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/gone", false), folder("/ws/docs")], + repoRoots: ["/ws/api"], + worktrees: [], + }); + expect(paths(roots)).toEqual(["/ws/api", "/ws/docs"]); }); it("falls back to multi-repo roots until the workspace file is read", () => { - expect( - resolveProjectFileRoots({ - workspaceFolders: null, - repoRoots: ["/ws/api", "/ws/web"], - worktrees: [], - }), - ).toEqual(["/ws/api", "/ws/web"]); - expect( - resolveProjectFileRoots({ workspaceFolders: null, repoRoots: ["/ws/api"], worktrees: [] }), - ).toBeNull(); + const multi = resolveProjectFileRoots({ + workspaceFolders: null, + repoRoots: ["/ws/api", "/ws/web"], + worktrees: [], + }); + expect(paths(multi)).toEqual(["/ws/api", "/ws/web"]); + const single = resolveProjectFileRoots({ + workspaceFolders: null, + repoRoots: ["/ws/api"], + worktrees: [], + }); + expect(single).toBeNull(); }); it("uses the single cwd when fewer than two folders remain", () => { - expect( - resolveProjectFileRoots({ - workspaceFolders: [folder("/ws/api"), folder("/ws/gone", false)], - repoRoots: ["/ws/api"], - worktrees: [], - }), - ).toBeNull(); + const roots = resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/gone", false)], + repoRoots: ["/ws/api"], + worktrees: [], + }); + expect(roots).toBeNull(); }); - it("swaps a repo an isolated run covers for its worktree", () => { - expect( - resolveProjectFileRoots({ - workspaceFolders: [folder("/ws/api"), folder("/ws/docs")], - repoRoots: ["/ws/api"], - worktrees: [{ repoRoot: "/ws/api", worktreePath: "/wt/api-feature" }], - }), - ).toEqual(["/wt/api-feature", "/ws/docs"]); + it("swaps a repo an isolated run covers for its worktree, keeping the repo's label", () => { + const roots = resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/docs")], + repoRoots: ["/ws/api"], + worktrees: [{ repoRoot: "/ws/api", worktreePath: "/wt/api/api-5c882f59" }], + }); + expect(roots).toEqual([ + { root: "/wt/api/api-5c882f59", label: "api" }, + { root: "/ws/docs", label: "docs" }, + ]); + }); + + it("labels same-named folders by their parents", () => { + const roots = resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/a/docs"), folder("/ws/b/docs")], + repoRoots: [], + worktrees: [], + }); + expect(roots?.map((entry) => entry.label)).toEqual(["a/docs", "b/docs"]); }); }); diff --git a/apps/web/src/lib/projectFileRoots.ts b/apps/web/src/lib/projectFileRoots.ts index e5300513b4b7..cc3c869c49b1 100644 --- a/apps/web/src/lib/projectFileRoots.ts +++ b/apps/web/src/lib/projectFileRoots.ts @@ -3,13 +3,22 @@ import type { OrchestrationThreadWorktree, } from "@t3tools/contracts"; +import { buildRootLabels } from "../components/files/filePath"; + +/** A folder the files panel and @-mention search span, and the name it shows under. */ +export interface ProjectFileRoot { + readonly root: string; + readonly label: string; +} + /** * Roots the files panel and @-mention search span, or null to use the single * project or worktree cwd. A `.code-workspace` project spans every folder the * file lists that exists on disk, git or not, so plain folders show up and * unlisted siblings of the file don't. Until the file has been read, or for * other projects, the roots are the multi-repo `repoRoots`. A repo root an - * isolated run covers is swapped for its worktree. + * isolated run covers is swapped for its worktree but keeps the repo's label, + * since a worktree's own folder is named for its branch. */ export function resolveProjectFileRoots(input: { readonly workspaceFolders: ReadonlyArray< @@ -17,12 +26,19 @@ export function resolveProjectFileRoots(input: { > | null; readonly repoRoots: ReadonlyArray | undefined; readonly worktrees: ReadonlyArray | undefined; -}): string[] | null { +}): ProjectFileRoot[] | null { const roots = input.workspaceFolders ? input.workspaceFolders.filter((folder) => folder.exists).map((folder) => folder.absolutePath) : (input.repoRoots ?? []); if (roots.length < 2) return null; - return roots.map( - (root) => input.worktrees?.find((worktree) => worktree.repoRoot === root)?.worktreePath ?? root, - ); + const labels = buildRootLabels(roots); + return roots.map((root) => ({ + root: input.worktrees?.find((worktree) => worktree.repoRoot === root)?.worktreePath ?? root, + label: labels.get(root) ?? root, + })); +} + +/** Stable string identity for a root list, for memo and component keys. */ +export function projectFileRootsKey(roots: readonly ProjectFileRoot[] | undefined | null): string { + return roots?.map(({ label, root }) => `${label}\0${root}`).join("\0\0") ?? ""; }