From 7e7697e089c26750e295b5cc579ba9489e4d759f Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 1 Oct 2026 11:20:59 +1000 Subject: [PATCH 1/2] fix(mobile): files and @ search follow the .code-workspace folder list Mobile listed and searched the folder holding the .code-workspace, so unlisted siblings showed up and listed folders outside it were missing. The file tree and @ mention search now span the folders the workspace file lists, one labelled top-level folder per root, with a repo an isolated run covers swapped for its worktree. resolveProjectFileRoots and its label helpers move from apps/web to packages/client-runtime so web and mobile share them. Projects without a workspace file are unchanged. --- .../features/files/ThreadFilesRouteScreen.tsx | 39 +++++-- .../src/features/files/fileTree.test.ts | 44 +++++++- apps/mobile/src/features/files/fileTree.ts | 40 +++++++ .../files/thread-file-navigator-pane.tsx | 26 ++++- .../src/features/files/useFileTreeEntries.ts | 106 +++++++++++++++--- .../src/features/files/useProjectFileRoots.ts | 81 +++++++++++++ .../features/threads/NewTaskDraftScreen.tsx | 7 ++ .../src/features/threads/ThreadComposer.tsx | 7 ++ .../features/threads/ThreadRouteScreen.tsx | 7 +- .../threads/use-composer-command-menu.ts | 20 +++- apps/mobile/src/state/queries.ts | 7 +- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 3 +- .../src/components/files/FileBreadcrumbs.tsx | 3 +- .../src/components/files/FileBrowserPanel.tsx | 8 +- .../src/components/files/FilePreviewPanel.tsx | 5 +- .../web/src/components/files/filePath.test.ts | 11 -- apps/web/src/components/files/filePath.ts | 51 --------- apps/web/src/lib/projectFileRoots.test.ts | 74 +----------- packages/client-runtime/package.json | 4 + .../src/projectFileRoots.test.ts | 90 +++++++++++++++ .../client-runtime/src}/projectFileRoots.ts | 53 ++++++++- 22 files changed, 509 insertions(+), 179 deletions(-) create mode 100644 apps/mobile/src/features/files/useProjectFileRoots.ts create mode 100644 packages/client-runtime/src/projectFileRoots.test.ts rename {apps/web/src/lib => packages/client-runtime/src}/projectFileRoots.ts (53%) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 06b6664431b5..a23e0b335a8f 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -12,6 +12,7 @@ import { mediaMimeTypeFromExtension, } from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; +import { projectFileRootsKey } from "@t3tools/client-runtime/project-file-roots"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { AudioFilePreview } from "../../components/AudioFilePreview"; @@ -41,6 +42,7 @@ import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; +import { useSelectedThreadFileRoots } from "./useProjectFileRoots"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; @@ -52,6 +54,7 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, fileHeaderSubtitle, + fileRoutePathSegments, isAudioPreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, @@ -420,12 +423,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); + const fileRoots = useSelectedThreadFileRoots(); const revealedInspectorRef = useRef(false); const entriesQuery = useFileTreeEntries({ environmentId, cwd: fileInspector.supported ? null : cwd, searchQuery, + fileRoots, }); + const { filePath } = entriesQuery; const handleReturnToThread = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); @@ -449,7 +455,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const params = { environmentId: String(environmentId), threadId: String(threadId), - path: path.split("/").filter((segment) => segment.length > 0), + path: fileRoutePathSegments(path), }; const navigationAction = resolveFileSelectionNavigationAction({ hasPersistentFileInspector: fileInspector.supported, @@ -468,27 +474,32 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ) : null, - [cwd, environmentId, handleSelectFile, projectName], + [cwd, environmentId, fileRoots, handleSelectFile, projectName], ); const handlePreviewFile = useCallback( - (relativePath: string) => { + (path: string) => { if (environmentId === null || cwd === null) { return; } preloadWorkspaceFileContents({ cwd, environmentId, - relativePath, + relativePath: filePath(path), theme: highlightTheme, }); }, - [cwd, environmentId, highlightTheme], + [cwd, environmentId, filePath, highlightTheme], + ); + const handleSelectTreeFile = useCallback( + (path: string) => handleSelectFile(filePath(path)), + [filePath, handleSelectFile], ); useEffect(() => { if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) { @@ -535,7 +546,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { /> @@ -667,9 +678,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); const fileData = fileQuery.data as ProjectReadFileResult | null; + const fileRoots = useSelectedThreadFileRoots(); const handleSelectFile = useCallback( (path: string) => { - const segments = path.split("/").filter(Boolean); + const segments = fileRoutePathSegments(path); // A draft has no thread. `ThreadFile` would stringify null and then wait forever for a // thread to resolve, so a draft stays on its own route and carries its workspace along. if (threadId === null) { @@ -697,13 +709,22 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ) : undefined, - [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath], + [ + cwd, + environmentId, + fileInspector.supported, + fileRoots, + handleSelectFile, + projectName, + relativePath, + ], ); // The workspace inspector column spans the full window height. On iOS the // pane brings its own nested native header; elsewhere it pads itself below diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index edab2ba687b8..ecd124b55968 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import type { ProjectEntry } from "@t3tools/contracts"; -import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree"; +import { + buildFileTree, + defaultExpandedTreePaths, + flattenFileTree, + resolveRootedTreePath, + rootedTreePath, +} from "./fileTree"; const entries = [ { kind: "file", path: "README.md" }, @@ -99,3 +105,39 @@ describe("mobile file tree helpers", () => { expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]); }); }); + +describe("multi-root tree paths", () => { + const roots = [ + { root: "/wt/api-5c882f59", label: "api" }, + { root: "/ws/a/docs", label: "a/docs" }, + { root: "/ws/b/docs/", label: "b/docs" }, + { root: "/ws/a/docs/drafts", label: "drafts" }, + ]; + + it("resolves a tree path to its root and the path inside it", () => { + expect(resolveRootedTreePath(roots, "api/src/main.ts")).toMatchObject({ + root: "/wt/api-5c882f59", + relativePath: "src/main.ts", + }); + expect(resolveRootedTreePath(roots, "a/docs")).toMatchObject({ + root: "/ws/a/docs", + relativePath: "", + }); + expect(resolveRootedTreePath(roots, "b/docs/plan.md")).toMatchObject({ + root: "/ws/b/docs/", + relativePath: "plan.md", + }); + }); + + it("resolves nothing for a path under no root", () => { + expect(resolveRootedTreePath(roots, "a")).toBeNull(); + expect(resolveRootedTreePath(roots, "apis/main.ts")).toBeNull(); + }); + + it("maps an absolute path back under the innermost root that holds it", () => { + expect(rootedTreePath(roots, "/wt/api-5c882f59/src/main.ts")).toBe("api/src/main.ts"); + expect(rootedTreePath(roots, "/ws/b/docs/plan.md")).toBe("b/docs/plan.md"); + expect(rootedTreePath(roots, "/ws/a/docs/drafts/todo.md")).toBe("drafts/todo.md"); + expect(rootedTreePath(roots, "/ws/api/src/main.ts")).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index a8bde49488fc..ed1d17328919 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -1,3 +1,4 @@ +import type { ProjectFileRoot } from "@t3tools/client-runtime/project-file-roots"; import type { ProjectEntry } from "@t3tools/contracts"; import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; @@ -121,6 +122,45 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr return [...root.children.values()].sort(compareNodes).map(freezeNode); } +const trimTrailingSeparators = (path: string) => path.replace(/[\\/]+$/, ""); + +/** + * A multi-root tree nests each root's files under its label. Returns the root + * a tree path sits in and the path inside that root, empty for the root's own + * node. Null for a path under no root, such as an ancestor of a nested label. + */ +export function resolveRootedTreePath( + roots: ReadonlyArray, + treePath: string, +): (ProjectFileRoot & { readonly relativePath: string }) | null { + // The longest label wins, so a label containing "/" beats a shorter one it extends. + let owner: ProjectFileRoot | null = null; + for (const candidate of roots) { + const { label } = candidate; + if (treePath !== label && !treePath.startsWith(`${label}/`)) continue; + if (owner === null || label.length > owner.label.length) owner = candidate; + } + return owner ? { ...owner, relativePath: treePath.slice(owner.label.length + 1) } : null; +} + +/** + * Tree path a multi-root tree lists an absolute file path under, or null when + * no root holds it. A root nested in another claims its own files. + */ +export function rootedTreePath( + roots: ReadonlyArray, + absolutePath: string, +): string | null { + const normalized = absolutePath.replaceAll("\\", "/"); + let match: { readonly prefix: string; readonly label: string } | null = null; + for (const { root, label } of roots) { + const prefix = `${trimTrailingSeparators(root).replaceAll("\\", "/")}/`; + if (!normalized.startsWith(prefix)) continue; + if (match === null || prefix.length > match.prefix.length) match = { prefix, label }; + } + return match ? `${match.label}/${normalized.slice(match.prefix.length)}` : null; +} + export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { const expanded = new Set(); for (const node of nodes) { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index dfe0c05743c2..e49fd826c486 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,3 +1,4 @@ +import { projectFileRootsKey } from "@t3tools/client-runtime/project-file-roots"; import type { EnvironmentId } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; @@ -18,12 +19,14 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; +import type { ProjectFileRootsState } from "./useProjectFileRoots"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; export function ThreadFileNavigatorPane(props: { readonly cwd: string; readonly environmentId: EnvironmentId; + readonly fileRoots?: ProjectFileRootsState | undefined; readonly headerInset: number; readonly projectName: string; readonly selectedPath: string | null; @@ -40,17 +43,24 @@ export function ThreadFileNavigatorPane(props: { environmentId: props.environmentId, cwd: props.cwd, searchQuery, + fileRoots: props.fileRoots, }); + const { filePath } = entriesQuery; + const { onSelectFile } = props; const handlePreviewFile = useCallback( - (relativePath: string) => { + (path: string) => { preloadWorkspaceFileContents({ cwd: props.cwd, environmentId: props.environmentId, - relativePath, + relativePath: filePath(path), theme: highlightTheme, }); }, - [highlightTheme, props.cwd, props.environmentId], + [filePath, highlightTheme, props.cwd, props.environmentId], + ); + const handleSelectFile = useCallback( + (path: string) => onSelectFile(filePath(path)), + [filePath, onSelectFile], ); const nativeHeaderRightBarButtonItems = useMemo( () => @@ -71,7 +81,11 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( ); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 604fb3bd5231..af5cb0daf424 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,3 +1,4 @@ +import { labelForRoot, projectFileRootsKey } from "@t3tools/client-runtime/project-file-roots"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -7,18 +8,48 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +import { resolveRootedTreePath, rootedTreePath } from "./fileTree"; +import type { ProjectFileRootsState } from "./useProjectFileRoots"; +/** + * Entries for the file tree, loaded a directory at a time. + * + * With `fileRoots`, the top level is one directory per root (its label) and + * every path below is prefixed with that label, so same-named files across + * roots don't collide. `filePath` and `treePath` convert between those tree + * paths and the absolute paths file routes and reads take. + */ export function useFileTreeEntries(input: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; + readonly fileRoots?: ProjectFileRootsState | undefined; }) { - const { cwd, environmentId } = input; + const { environmentId } = input; + const rootsPending = input.fileRoots?.pending ?? false; + const refreshRoots = input.fileRoots?.refresh; + // The fallback roots can show unlisted folders, so nothing loads until the folder list has. + const cwd = rootsPending ? null : input.cwd; + const rootsKey = projectFileRootsKey(input.fileRoots?.roots); + const roots = useMemo( + () => + rootsKey + ? rootsKey.split("\0\0").map((pair) => { + const [label = "", root = ""] = pair.split("\0"); + return { root, label }; + }) + : null, + [rootsKey], + ); + const rootLabels = useMemo( + () => (roots ? new Map(roots.map(({ root, label }) => [root, label])) : null), + [roots], + ); const searching = input.searchQuery.trim().length > 0; const query = input.searchQuery.trim().slice(0, 256); const debouncedQuery = useDebouncedValue(query, 200); const root = useEnvironmentQuery( - cwd !== null && environmentId !== null + cwd !== null && environmentId !== null && roots === null ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }) : null, ); @@ -26,7 +57,12 @@ export function useFileTreeEntries(input: { searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null ? projectEnvironment.searchEntries({ environmentId, - input: { cwd, query: debouncedQuery, limit: 200 }, + input: { + cwd, + query: debouncedQuery, + limit: 200, + ...(roots ? { roots: roots.map(({ root }) => root) } : {}), + }, }) : null, ); @@ -41,7 +77,7 @@ export function useFileTreeEntries(input: { pending: new Map(), errors: new Map(), }), - [cwd, environmentId], + [cwd, environmentId, roots], ); useEffect( () => () => { @@ -61,12 +97,20 @@ export function useFileTreeEntries(input: { ) { return; } + const source = roots + ? resolveRootedTreePath(roots, directoryPath) + : { root: cwd, label: "", relativePath: directoryPath }; + // Not under any root label (an ancestor of a nested label): nothing to load. + if (source === null) return; const controller = new AbortController(); directories.requested.add(directoryPath); directories.pending.set(directoryPath, controller); directories.errors.delete(directoryPath); render(); - const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + const atom = projectEnvironment.listEntries({ + environmentId, + input: { cwd: source.root, directoryPath: source.relativePath }, + }); appAtomRegistry.refresh(atom); return executeAtomQuery(appAtomRegistry, atom, { signal: controller.signal, @@ -78,10 +122,17 @@ export function useFileTreeEntries(input: { if (result._tag === "Success") { directories.entries.set( directoryPath, - result.value.entries.filter( - (entry) => - entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, - ), + result.value.entries + .filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === + source.relativePath, + ) + .map((entry) => + roots + ? { ...entry, path: `${source.label}/${entry.path}`, root: source.root } + : entry, + ), ); } else { const error = Cause.squash(result.cause); @@ -93,14 +144,23 @@ export function useFileTreeEntries(input: { render(); }); }, - [cwd, directories, environmentId], + [cwd, directories, environmentId, roots], ); const { refresh: refreshRoot, data: rootData } = root; const { refresh: refreshSearch, data: searchData } = search; const snapshot = useMemo(() => { const merged = new Map(); if (searching) { - for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); + for (const entry of searchData?.entries ?? []) { + if (rootLabels === null) { + merged.set(entry.path, entry); + continue; + } + const label = entry.root ? labelForRoot(rootLabels, entry.root) : undefined; + if (label === undefined) continue; + const path = `${label}/${entry.path}`; + merged.set(path, { ...entry, path }); + } } const reachableDirectories = new Set(); const visit = (items: ReadonlyArray) => { @@ -112,11 +172,16 @@ export function useFileTreeEntries(input: { } } }; - visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); + visit( + roots + ? roots.map(({ root, label }) => ({ path: label, kind: "directory" as const, root })) + : (rootData?.entries ?? []).filter((entry) => !entry.path.includes("/")), + ); return { revision, entries: [...merged.values()], reachableDirectories }; - }, [directories, revision, rootData, searchData, searching]); + }, [directories, revision, rootData, rootLabels, roots, searchData, searching]); const refresh = useCallback(() => { + refreshRoots?.(); refreshRoot(); if (searching) refreshSearch(); const paths = new Set( @@ -140,10 +205,22 @@ export function useFileTreeEntries(input: { directories, loadDirectory, refreshRoot, + refreshRoots, refreshSearch, searching, snapshot.reachableDirectories, ]); + const filePath = useCallback( + (treePath: string) => { + const owner = roots ? resolveRootedTreePath(roots, treePath) : null; + return owner ? `${owner.root.replace(/[\\/]+$/, "")}/${owner.relativePath}` : treePath; + }, + [roots], + ); + const treePath = useCallback( + (path: string | null) => (roots && path !== null ? rootedTreePath(roots, path) : path), + [roots], + ); return { entries: snapshot.entries, @@ -153,6 +230,7 @@ export function useFileTreeEntries(input: { [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? null, isPending: + rootsPending || root.isPending || directories.pending.size > 0 || (searching && (query !== debouncedQuery || search.isPending)), @@ -160,5 +238,7 @@ export function useFileTreeEntries(input: { loadedDirectories: new Set(directories.entries.keys()), loadDirectory, refresh, + filePath, + treePath, }; } diff --git a/apps/mobile/src/features/files/useProjectFileRoots.ts b/apps/mobile/src/features/files/useProjectFileRoots.ts new file mode 100644 index 000000000000..dadb8217ab61 --- /dev/null +++ b/apps/mobile/src/features/files/useProjectFileRoots.ts @@ -0,0 +1,81 @@ +import { + resolveProjectFileRoots, + type ProjectFileRoot, +} from "@t3tools/client-runtime/project-file-roots"; +import type { EnvironmentId, OrchestrationThreadWorktree } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { filesystemEnvironment } from "../../state/filesystem"; +import { useEnvironmentQuery } from "../../state/query"; +import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useThreadSelection } from "../../state/use-thread-selection"; + +export interface ProjectFileRootsState { + /** Null spans the single project or worktree cwd. */ + readonly roots: ReadonlyArray | null; + /** The first read of the folder list has not settled; the file tree holds until it has. */ + readonly pending: boolean; + readonly refresh: () => void; +} + +/** + * Folders the file tree and @-mention search span for a `.code-workspace` + * project: the ones the file lists, with a repo an isolated run covers swapped + * for its worktree. Any other project keeps its single cwd. + */ +export function useProjectFileRoots(input: { + readonly environmentId: EnvironmentId | null; + readonly project: + | { + readonly workspaceFile?: string | undefined; + readonly repoRoots?: ReadonlyArray | undefined; + } + | null + | undefined; + readonly worktrees: ReadonlyArray | undefined; +}): ProjectFileRootsState { + const { environmentId, worktrees } = input; + const workspaceFile = input.project?.workspaceFile ?? null; + const repoRoots = input.project?.repoRoots; + const workspaceFileQuery = useEnvironmentQuery( + environmentId === null || workspaceFile === null + ? null + : filesystemEnvironment.readWorkspaceFile({ + environmentId, + input: { workspaceFilePath: workspaceFile }, + }), + ); + const workspaceFolders = workspaceFileQuery.data?.folders ?? null; + const roots = useMemo( + () => + workspaceFile === null + ? null + : resolveProjectFileRoots({ workspaceFolders, repoRoots, worktrees }), + [workspaceFile, workspaceFolders, repoRoots, worktrees], + ); + return { + roots, + pending: + environmentId !== null && + workspaceFile !== null && + workspaceFileQuery.data === null && + workspaceFileQuery.error === null, + refresh: workspaceFileQuery.refresh, + }; +} + +/** `useProjectFileRoots` for the thread the current route selects. */ +export function useSelectedThreadFileRoots(): ProjectFileRootsState { + const { selectedThread, selectedThreadProject } = useThreadSelection(); + const selectedThreadDetail = useSelectedThreadDetail(); + // The shell arrives before the detail and both carry the per-repo worktree + // map; take whichever has it. + const worktrees = selectedThreadDetail?.worktrees?.length + ? selectedThreadDetail.worktrees + : selectedThread?.worktrees; + return useProjectFileRoots({ + environmentId: selectedThread?.environmentId ?? null, + project: selectedThreadProject, + worktrees, + }); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 7e90b0743374..5cb60c9ce4f6 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -128,6 +128,7 @@ import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-sha import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; import { fileRoutePathSegments } from "../files/filePath"; +import { useProjectFileRoots } from "../files/useProjectFileRoots"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -453,6 +454,11 @@ export function NewTaskDraftScreen(props: { () => composerStripAttachments(flow.attachments), [flow.attachments], ); + const { roots: mentionRoots } = useProjectFileRoots({ + environmentId: selectedProject?.environmentId ?? null, + project: selectedProject, + worktrees: undefined, + }); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, @@ -462,6 +468,7 @@ export function NewTaskDraftScreen(props: { : null, pullRequestRepository: selectedProject?.repositoryIdentity?.displayName ?? null, projectCwd: composerWorkspaceCwd, + mentionRoots, selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d4a845c197a0..b14156d0bb79 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -70,6 +70,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { fileRoutePathSegments } from "../files/filePath"; +import { useProjectFileRoots } from "../files/useProjectFileRoots"; import { ComposerActionButton, ComposerInlineControl, @@ -368,11 +369,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return report !== null; }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); + const { roots: mentionRoots } = useProjectFileRoots({ + environmentId: props.environmentId, + project, + worktrees: props.selectedThread.worktrees, + }); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, ownerKey: composerOwnerKey, environmentId: props.environmentId, projectCwd: props.projectCwd, + mentionRoots, pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests ? (project?.id ?? null) : null, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 4fb22d152496..2baf3c6b72ac 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -85,7 +85,9 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; +import { fileRoutePathSegments } from "../files/filePath"; import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; +import { useSelectedThreadFileRoots } from "../files/useProjectFileRoots"; import { ThreadInspectorContentStack, type ThreadInspectorMode, @@ -550,7 +552,7 @@ function ThreadRouteContent( const params = { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), - path: path.split("/").filter((segment) => segment.length > 0), + path: fileRoutePathSegments(path), }; if (fileInspector.supported) { navigation.navigate("ThreadFile", params); @@ -575,12 +577,14 @@ function ThreadRouteContent( ), [inspectorHeaderInset, props.route.params], ); + const fileRoots = useSelectedThreadFileRoots(); const FilesInspector = useCallback( () => selectedThread !== null && selectedThreadCwd !== null ? ( ) : null, [ + fileRoots, handleSelectInspectorFile, inspectorHeaderInset, selectedThread, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index b0b2cd42ecba..ec2268226285 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -40,6 +40,8 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useComposerPathSearch, useComposerPullRequestSearch } from "../../state/queries"; +import { labelForRoot, type ProjectFileRoot } from "@t3tools/client-runtime/project-file-roots"; +import { basename } from "../files/filePath"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; @@ -165,6 +167,7 @@ export function useComposerCommandMenu({ ownerKey, environmentId, projectCwd, + mentionRoots = null, pullRequestProjectId = null, pullRequestRepository = null, selectedProviderStatus, @@ -180,6 +183,8 @@ export function useComposerCommandMenu({ readonly ownerKey: string | null; readonly environmentId: EnvironmentId | null; readonly projectCwd: string | null; + /** Folders @-mention search spans for a `.code-workspace` project; null searches `projectCwd`. */ + readonly mentionRoots?: ReadonlyArray | null; readonly pullRequestProjectId?: ProjectId | null; readonly pullRequestRepository?: string | null; readonly selectedProviderStatus: ServerProvider | null; @@ -300,6 +305,7 @@ export function useComposerCommandMenu({ environmentId, cwd: trigger?.kind === "path" ? projectCwd : null, query: trigger?.kind === "path" ? trigger.query : null, + roots: mentionRoots?.map(({ root }) => root), }); const pullRequestSearch = useComposerPullRequestSearch({ environmentId, @@ -447,15 +453,22 @@ export function useComposerCommandMenu({ } if (trigger.kind === "path") { + const rootLabels = new Map(mentionRoots?.map(({ root, label }) => [root, label])); return pathSearch.entries.map((entry) => { const parts = entry.path.split("/"); + const parent = parts.slice(0, -1).join("/"); + // Multi-root entries can share a relative path across roots, so the + // owning root keys the id, labels the description and anchors the link. + const { root } = entry; return { - id: `path:${entry.path}`, + id: `path:${root ?? ""}:${entry.path}`, type: "path" as const, - path: entry.path, + path: root ? `${root.replace(/[\\/]$/, "")}/${entry.path}` : entry.path, kind: entry.kind, label: parts[parts.length - 1] ?? entry.path, - description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", + description: root + ? [labelForRoot(rootLabels, root) ?? basename(root), parent].filter(Boolean).join("/") + : parent, }; }); } @@ -464,6 +477,7 @@ export function useComposerCommandMenu({ }, [ hasThread, hasCompactableConversation, + mentionRoots, onUpdateInteractionMode, pathSearch.entries, pullRequestSearch.entries, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index a1e95702054c..5bbb9f61784d 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -64,6 +64,8 @@ export interface ComposerPathSearchTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; readonly query: string | null; + // A `.code-workspace` project searches the union of these roots; entries come back tagged. + readonly roots?: ReadonlyArray | null | undefined; } export function useDebouncedValue(value: A, delayMs: number): A { @@ -320,13 +322,15 @@ export function usePaginatedBranches(target: VcsRefTarget) { } export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const rootsKey = target.roots?.join("\0") ?? ""; const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, query: normalizeComposerPathSearchQuery(target.query), + roots: rootsKey ? rootsKey.split("\0") : null, }), - [target.cwd, target.environmentId, target.query], + [rootsKey, target.cwd, target.environmentId, target.query], ); const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( @@ -339,6 +343,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { cwd: debouncedTarget.cwd, query: debouncedTarget.query, limit: COMPOSER_PATH_SEARCH_LIMIT, + ...(debouncedTarget.roots ? { roots: debouncedTarget.roots } : {}), }, }) : null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7ce6e7461861..3b82d7b24474 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -344,7 +344,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; import { filesystemEnvironment } from "../state/filesystem"; -import { resolveProjectFileRoots } from "../lib/projectFileRoots"; +import { resolveProjectFileRoots } from "@t3tools/client-runtime/project-file-roots"; import { vcsEnvironment } from "../state/vcs"; import { environmentServerConfigsAtom, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b28e6d22855b..4204052ed78e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4,8 +4,7 @@ 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 { labelForRoot, type ProjectFileRoot } from "@t3tools/client-runtime/project-file-roots"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index b49a6c83d84d..6ce7ab9c6fe9 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -18,7 +18,7 @@ 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 { labelForRoot, type ProjectFileRoot } from "@t3tools/client-runtime/project-file-roots"; import { cn } from "~/lib/utils"; import { isAbsolutePath } from "~/terminal-links"; @@ -27,7 +27,6 @@ import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, - labelForRoot, } from "./filePath"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 8f2b131a234f..265fcdcd9031 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -22,8 +22,12 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; -import { isRootPath, labelForRoot } from "./filePath"; -import { type ProjectFileRoot, projectFileRootsKey } from "~/lib/projectFileRoots"; +import { isRootPath } from "./filePath"; +import { + labelForRoot, + type ProjectFileRoot, + projectFileRootsKey, +} from "@t3tools/client-runtime/project-file-roots"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 8688d5f943ae..e9bd22051351 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -58,7 +58,10 @@ 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 ProjectFileRoot, + projectFileRootsKey, +} from "@t3tools/client-runtime/project-file-roots"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index b1ebd09a79dd..a9731ceb058e 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { - buildRootLabels, fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, isRootPath, - labelForRoot, } from "./filePath"; describe("fileBreadcrumbs", () => { @@ -103,15 +101,6 @@ describe("fileBreadcrumbParent", () => { }); }); -describe("buildRootLabels", () => { - it("labels roots by folder name and grows the label when names collide", () => { - const labels = buildRootLabels(["/work/app", "/a/shared", "/b/shared"]); - expect(labelForRoot(labels, "/work/app")).toBe("app"); - expect(labelForRoot(labels, "/a/shared")).toBe("a/shared"); - expect(labelForRoot(labels, "/b/shared/")).toBe("b/shared"); - }); -}); - describe("isRootPath", () => { it("matches a root itself, not a path inside it", () => { const roots = ["/work/app", "/downloads/outlier/"]; diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index b09f56d56131..d518c797f368 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -66,54 +66,3 @@ export function isRootPath(roots: readonly string[] | undefined, path: string): const trimmed = path.replace(/[\\/]+$/, ""); return roots?.some((root) => root.replace(/[\\/]+$/, "") === trimmed) ?? false; } - -/** Label for a root, tolerating the server's normalized form (no trailing separator). */ -export function labelForRoot( - labels: ReadonlyMap, - root: string, -): string | undefined { - const exact = labels.get(root); - if (exact !== undefined) return exact; - const trimmed = root.replace(/[\\/]+$/, ""); - for (const [candidate, label] of labels) { - if (candidate.replace(/[\\/]+$/, "") === trimmed) return label; - } - return undefined; -} - -/** - * Assign each repo root a unique, human-readable label for the tree's top-level - * grouping. Prefer the folder basename (matching the per-repo git controls); - * when two roots share a basename, grow the label by parent segments until the - * labels are distinct. - */ -export function buildRootLabels(roots: readonly string[]): Map { - const segments = new Map(); - for (const root of roots) { - segments.set( - root, - root - .replaceAll("\\", "/") - .replace(/\/+$/, "") - .split("/") - .filter((segment) => segment.length > 0), - ); - } - - const labels = new Map(); - for (const root of roots) { - const parts = segments.get(root) ?? []; - let depth = 1; - let label = parts.slice(-depth).join("/") || root; - const collidesAtDepth = () => - roots.some( - (other) => other !== root && (segments.get(other) ?? []).slice(-depth).join("/") === label, - ); - while (collidesAtDepth() && depth < parts.length) { - depth += 1; - label = parts.slice(-depth).join("/"); - } - labels.set(root, label); - } - return labels; -} diff --git a/apps/web/src/lib/projectFileRoots.test.ts b/apps/web/src/lib/projectFileRoots.test.ts index 7ce4224334f1..742676797887 100644 --- a/apps/web/src/lib/projectFileRoots.test.ts +++ b/apps/web/src/lib/projectFileRoots.test.ts @@ -1,85 +1,13 @@ +import { resolveProjectFileRoots } from "@t3tools/client-runtime/project-file-roots"; import { describe, expect, it } from "vite-plus/test"; import { resolveMarkdownFileLinkMeta } from "../markdown-links"; -import { resolveProjectFileRoots } from "./projectFileRoots"; const folder = (absolutePath: string, exists = true) => ({ 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"]); - }); - // Chat file links are matched against these roots, so a link has to land on // the root the panel lists the file under or the tree cannot highlight it. it("claims chat links for nested, outside and worktree roots", () => { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 80d36759e13b..aabf04aa06ee 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -267,6 +267,10 @@ "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" }, + "./project-file-roots": { + "types": "./src/projectFileRoots.ts", + "default": "./src/projectFileRoots.ts" + }, "./file-preview": { "types": "./src/filePreview.ts", "default": "./src/filePreview.ts" diff --git a/packages/client-runtime/src/projectFileRoots.test.ts b/packages/client-runtime/src/projectFileRoots.test.ts new file mode 100644 index 000000000000..43a779ae0630 --- /dev/null +++ b/packages/client-runtime/src/projectFileRoots.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildRootLabels, labelForRoot, resolveProjectFileRoots } from "./projectFileRoots.ts"; + +const folder = (absolutePath: string, exists = true) => ({ 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"]); + }); +}); + +describe("buildRootLabels", () => { + it("labels roots by folder name and grows the label when names collide", () => { + const labels = buildRootLabels(["/work/app", "/a/shared", "/b/shared"]); + expect(labelForRoot(labels, "/work/app")).toBe("app"); + expect(labelForRoot(labels, "/a/shared")).toBe("a/shared"); + expect(labelForRoot(labels, "/b/shared/")).toBe("b/shared"); + }); +}); diff --git a/apps/web/src/lib/projectFileRoots.ts b/packages/client-runtime/src/projectFileRoots.ts similarity index 53% rename from apps/web/src/lib/projectFileRoots.ts rename to packages/client-runtime/src/projectFileRoots.ts index cc3c869c49b1..853e24f78ddd 100644 --- a/apps/web/src/lib/projectFileRoots.ts +++ b/packages/client-runtime/src/projectFileRoots.ts @@ -3,8 +3,6 @@ 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; @@ -42,3 +40,54 @@ export function resolveProjectFileRoots(input: { export function projectFileRootsKey(roots: readonly ProjectFileRoot[] | undefined | null): string { return roots?.map(({ label, root }) => `${label}\0${root}`).join("\0\0") ?? ""; } + +/** Label for a root, tolerating the server's normalized form (no trailing separator). */ +export function labelForRoot( + labels: ReadonlyMap, + root: string, +): string | undefined { + const exact = labels.get(root); + if (exact !== undefined) return exact; + const trimmed = root.replace(/[\\/]+$/, ""); + for (const [candidate, label] of labels) { + if (candidate.replace(/[\\/]+$/, "") === trimmed) return label; + } + return undefined; +} + +/** + * Assign each repo root a unique, human-readable label for the tree's top-level + * grouping. Prefer the folder basename (matching the per-repo git controls); + * when two roots share a basename, grow the label by parent segments until the + * labels are distinct. + */ +export function buildRootLabels(roots: readonly string[]): Map { + const segments = new Map(); + for (const root of roots) { + segments.set( + root, + root + .replaceAll("\\", "/") + .replace(/\/+$/, "") + .split("/") + .filter((segment) => segment.length > 0), + ); + } + + const labels = new Map(); + for (const root of roots) { + const parts = segments.get(root) ?? []; + let depth = 1; + let label = parts.slice(-depth).join("/") || root; + const collidesAtDepth = () => + roots.some( + (other) => other !== root && (segments.get(other) ?? []).slice(-depth).join("/") === label, + ); + while (collidesAtDepth() && depth < parts.length) { + depth += 1; + label = parts.slice(-depth).join("/"); + } + labels.set(root, label); + } + return labels; +} From 8a56f0c881c7a8b76c22c2c6a97f46cc562d8624 Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 1 Oct 2026 16:54:30 +1000 Subject: [PATCH 2/2] fix(mobile): root labels never nest one root inside another --- .../src/projectFileRoots.test.ts | 15 +++++++ .../client-runtime/src/projectFileRoots.ts | 39 ++++++++++++------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/client-runtime/src/projectFileRoots.test.ts b/packages/client-runtime/src/projectFileRoots.test.ts index 43a779ae0630..2e9ede47f569 100644 --- a/packages/client-runtime/src/projectFileRoots.test.ts +++ b/packages/client-runtime/src/projectFileRoots.test.ts @@ -87,4 +87,19 @@ describe("buildRootLabels", () => { expect(labelForRoot(labels, "/a/shared")).toBe("a/shared"); expect(labelForRoot(labels, "/b/shared/")).toBe("b/shared"); }); + + it("never gives one root a label that is a path prefix of another's", () => { + // /y/api/docs grows to api/docs to differ from /z/docs, which would nest it under /x/api. + const labels = buildRootLabels(["/x/api", "/y/api/docs", "/z/docs"]); + expect(labelForRoot(labels, "/x/api")).toBe("x/api"); + expect(labelForRoot(labels, "/y/api/docs")).toBe("api/docs"); + expect(labelForRoot(labels, "/z/docs")).toBe("z/docs"); + }); + + it("grows the longer label when the shorter one is already the whole path", () => { + const labels = buildRootLabels(["/api", "/y/api/docs", "/z/docs"]); + expect(labelForRoot(labels, "/api")).toBe("api"); + expect(labelForRoot(labels, "/y/api/docs")).toBe("y/api/docs"); + expect(labelForRoot(labels, "/z/docs")).toBe("z/docs"); + }); }); diff --git a/packages/client-runtime/src/projectFileRoots.ts b/packages/client-runtime/src/projectFileRoots.ts index 853e24f78ddd..53fa9dcd5bef 100644 --- a/packages/client-runtime/src/projectFileRoots.ts +++ b/packages/client-runtime/src/projectFileRoots.ts @@ -59,7 +59,9 @@ export function labelForRoot( * Assign each repo root a unique, human-readable label for the tree's top-level * grouping. Prefer the folder basename (matching the per-repo git controls); * when two roots share a basename, grow the label by parent segments until the - * labels are distinct. + * labels are distinct. Labels double as slash-delimited tree paths, so no label + * may be a path prefix of another either: `api` beside `api/docs` would put the + * second root where the first root's own `docs` folder belongs. */ export function buildRootLabels(roots: readonly string[]): Map { const segments = new Map(); @@ -74,20 +76,29 @@ export function buildRootLabels(roots: readonly string[]): Map { ); } - const labels = new Map(); - for (const root of roots) { + const unique = [...segments.keys()]; + const depths = new Map(unique.map((root) => [root, 1])); + const labelOf = (root: string) => { const parts = segments.get(root) ?? []; - let depth = 1; - let label = parts.slice(-depth).join("/") || root; - const collidesAtDepth = () => - roots.some( - (other) => other !== root && (segments.get(other) ?? []).slice(-depth).join("/") === label, - ); - while (collidesAtDepth() && depth < parts.length) { - depth += 1; - label = parts.slice(-depth).join("/"); + return parts.slice(-(depths.get(root) ?? 1)).join("/") || root; + }; + const canGrow = (root: string) => (depths.get(root) ?? 1) < (segments.get(root)?.length ?? 0); + + // Each pass grows every root that collides with this pass's labels, so the + // result does not depend on root order. Labels stop at the full path. + for (;;) { + const labels = new Map(unique.map((root) => [root, labelOf(root)])); + const growing = new Set(); + for (const [root, label] of labels) { + for (const [other, otherLabel] of labels) { + if (other === root) continue; + if (label === otherLabel && canGrow(root)) growing.add(root); + if (otherLabel.startsWith(`${label}/`)) growing.add(canGrow(root) ? root : other); + } } - labels.set(root, label); + const grown = [...growing].filter(canGrow); + if (grown.length === 0) break; + for (const root of grown) depths.set(root, (depths.get(root) ?? 1) + 1); } - return labels; + return new Map(roots.map((root) => [root, labelOf(root)])); }