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; - // 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 e04b40a5634d..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, @@ -37,8 +37,8 @@ interface FileBreadcrumbsProps { readonly onOpenFile: (relativePath: string, root?: string) => 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 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 repoRootsKey = props.repoRoots?.join("\0") ?? ""; + const roots = props.roots; const repoName = useMemo(() => { if (!repoRoot) return undefined; - const label = repoRootsKey - ? labelForRoot(buildRootLabels(repoRootsKey.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, repoRootsKey]); + }, [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 72becbdf543e..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"; @@ -39,9 +40,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 ProjectFileRoot[] | undefined; onOpenFile: (relativePath: string, root?: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; @@ -112,7 +114,7 @@ export default function FileBrowserPanel({ selectedPath: selectedRelativePath, selectedRoot, selectedPathRevealId, - repoRoots, + roots, onOpenFile, onRefreshSelectedFile, workspaceMutationId, @@ -123,18 +125,20 @@ 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 ? projectFileRootsKey(roots) : ""; const { rootLabels, directoryRoots, searchRoots } = useMemo(() => { - if (!multiRepoRootsKey) + if (!multiRootsKey) return { rootLabels: null, directoryRoots: undefined, searchRoots: undefined }; - const roots = multiRepoRootsKey.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), }; - }, [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..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, @@ -104,8 +105,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 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; @@ -927,7 +928,7 @@ export default function FilePreviewPanel({ availableEditors, revealLine, revealRequestId, - repoRoots, + roots, fileRoot, onOpenFile, onPendingChange, @@ -971,7 +972,8 @@ export default function FilePreviewPanel({ const isDirectory = file.isNotFile && (!isHostFile || - (relativePath !== null && isRootPath([cwd, ...(repoRoots ?? [])], relativePath))); + (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); @@ -1124,7 +1126,7 @@ export default function FilePreviewPanel({ onOpenFile={onOpenFile} projectName={projectName} relativePath={relativePath} - repoRoots={repoRoots} + roots={roots} root={fileRoot ?? undefined} workspaceMutationId={workspaceMutationId} /> @@ -1316,15 +1318,17 @@ 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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, 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 new file mode 100644 index 000000000000..cc3c869c49b1 --- /dev/null +++ b/apps/web/src/lib/projectFileRoots.ts @@ -0,0 +1,44 @@ +import type { + FilesystemReadWorkspaceFileFolder, + 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 but keeps the repo's label, + * since a worktree's own folder is named for its branch. + */ +export function resolveProjectFileRoots(input: { + readonly workspaceFolders: ReadonlyArray< + Pick + > | null; + readonly repoRoots: ReadonlyArray | undefined; + readonly worktrees: ReadonlyArray | undefined; +}): ProjectFileRoot[] | null { + const roots = input.workspaceFolders + ? input.workspaceFolders.filter((folder) => folder.exists).map((folder) => folder.absolutePath) + : (input.repoRoots ?? []); + if (roots.length < 2) return null; + 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") ?? ""; +}