Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
845796c
feat(mobile): review a multi-repo workspace one repository at a time
LoganRupe Oct 14, 2026
635f442
fix(server,web,contracts): open a project's .code-workspace in editors
LoganRupe Oct 14, 2026
cc20150
feat(server): run the setup script in every repository of a multi-rep…
LoganRupe Oct 14, 2026
3880aa7
Merge remote-tracking branch 'origin/feature/multi-repo/editor-worksp…
logzerroneous Oct 14, 2026
821220b
Merge remote-tracking branch 'origin/feature/multi-repo/mobile-review…
logzerroneous Oct 14, 2026
f515de7
feat(server,web): pick a base branch per repository for a multi-repo …
LoganRupe Oct 14, 2026
d28e783
Merge remote-tracking branch 'origin/feature/multi-repo/isolated-run-…
logzerroneous Oct 14, 2026
7b955e1
feat(server): a workspace file's listed folders reach agents, assets …
LoganRupe Oct 14, 2026
92ae7da
feat(web): files panel, @ search, file picker and previews span every…
LoganRupe Oct 14, 2026
0177977
test(web): cover the multi-root search merge and format the touched f…
LoganRupe Oct 14, 2026
c283f58
Merge remote-tracking branch 'origin/feature/multi-repo/workspace-fol…
logzerroneous Oct 14, 2026
b645c30
feat(mobile): files and @ search span every folder a workspace lists
LoganRupe Oct 14, 2026
02eb64f
feat(mobile): pick a base branch per repository when starting a multi…
LoganRupe Oct 14, 2026
d017ee8
Merge remote-tracking branch 'origin/feature/multi-repo/mobile-worksp…
logzerroneous Oct 14, 2026
9bb6b8f
fix(server): review diffs reach repositories a workspace file lists o…
logzerroneous Oct 14, 2026
35aeda4
chore: format ws.ts and drop exports knip reports unused
LoganRupe Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr
import {
NewTaskBranchPickerRouteScreen,
NewTaskEnvironmentPickerRouteScreen,
NewTaskRepositoriesPickerRouteScreen,
NewTaskRepositoryBranchPickerRouteScreen,
} from "./features/threads/NewTaskContextPickerScreens";
import {
ExistingThreadSettingsRouteProvider,
Expand Down Expand Up @@ -513,6 +515,20 @@ const NewTaskSheetStack = createNativeStackNavigator({
title: "Branch",
},
}),
NewTaskRepositories: createNativeStackScreen({
screen: NewTaskRepositoriesPickerRouteScreen,
linking: "draft/repositories",
options: {
title: "Repositories",
},
}),
NewTaskRepositoryBranch: createNativeStackScreen({
screen: NewTaskRepositoryBranchPickerRouteScreen,
linking: "draft/repositories/branch",
options: {
title: "Base branch",
},
}),
// The same file view the thread composer pushes. A draft has no thread, so it names its
// own workspace through route params instead of resolving one from a selected thread.
NewTaskFile: createNativeStackScreen({
Expand Down
42 changes: 34 additions & 8 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
mediaMimeTypeFromExtension,
} from "@t3tools/shared/filePreview";
import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
import {
fileRootForPath,
hasSeveralFileRoots,
projectFileRootsKey,
} from "@t3tools/client-runtime/project-file-roots";

import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { AudioFilePreview } from "../../components/AudioFilePreview";
Expand Down Expand Up @@ -46,6 +51,7 @@ import { FileMarkdownPreview } from "./FileMarkdownPreview";
import { ThreadInspectorContentStack } from "../threads/thread-inspector-content-stack";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { useFileTreeEntries } from "./useFileTreeEntries";
import { useProjectFileRoots } from "./useProjectFileRoots";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
import { SourceFileSurface } from "./SourceFileSurface";
import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
Expand All @@ -58,6 +64,7 @@ import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview";
import {
basename,
fileHeaderSubtitle,
fileRoutePathSegments,
isAudioPreviewFile,
isMarkdownPreviewFile,
isSvgImagePreviewFile,
Expand Down Expand Up @@ -443,11 +450,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const fileRoots = useProjectFileRoots({ environmentId, cwd, projectName });
const entriesQuery = useFileTreeEntries({
environmentId,
cwd: !canReadFiles || fileInspector.supported ? null : cwd,
searchQuery,
fileRoots,
});
const { openPath } = entriesQuery;
const handleReturnToThread = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack();
Expand All @@ -471,7 +481,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,
Expand Down Expand Up @@ -499,18 +509,22 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
[cwd, environmentId, handleSelectFile, projectName],
);
const handlePreviewFile = useCallback(
(relativePath: string) => {
(treePath: string) => {
if (environmentId === null || cwd === null) {
return;
}
preloadWorkspaceFileContents({
cwd,
environmentId,
relativePath,
relativePath: openPath(treePath),
theme: highlightTheme,
});
},
[cwd, environmentId, highlightTheme],
[cwd, environmentId, highlightTheme, openPath],
);
const handleSelectTreeFile = useCallback(
(treePath: string) => handleSelectFile(openPath(treePath)),
[handleSelectFile, openPath],
);
useEffect(() => {
if (fileInspector.supported && cwd !== null && !revealedInspectorRef.current) {
Expand Down Expand Up @@ -565,7 +579,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
/>
<MaterialScreenContent insetHorizontal={layout.usesSplitView}>
<FileTreeBrowser
key={JSON.stringify([environmentId, cwd])}
key={JSON.stringify([environmentId, cwd, projectFileRootsKey(fileRoots.roots)])}
entries={entriesQuery.entries}
loadingDirectories={entriesQuery.loadingDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
Expand All @@ -577,7 +591,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
selectedPath={null}
onPreviewFile={handlePreviewFile}
onRefresh={entriesQuery.refresh}
onSelectFile={handleSelectFile}
onSelectFile={handleSelectTreeFile}
/>
<FilesToolbarBottomFade />
</MaterialScreenContent>
Expand All @@ -603,6 +617,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
readonly path: string;
readonly mode: FileViewMode;
} | null>(null);
// A file in a folder the `.code-workspace` lists is named by its absolute path; its header
// names that folder the way the files tree does, rather than as a host file.
const fileRoots = useProjectFileRoots({ environmentId, cwd, projectName });
const fileRootTarget =
relativePath !== null &&
cwd !== null &&
(hasSeveralFileRoots(fileRoots.roots) || fileRoots.roots[0]?.path !== cwd)
? fileRootForPath(fileRoots.roots, cwd, relativePath)
: null;
const [previewRevision, setPreviewRevision] = useState(0);
const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]);
const [fullScreenPreview, setFullScreenPreview] = useState<FilePreviewSource | null>(null);
Expand Down Expand Up @@ -715,7 +738,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {

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) {
Expand Down Expand Up @@ -935,7 +958,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
);
}

const headerSubtitle = fileHeaderSubtitle(projectName, relativePath);
const headerSubtitle =
fileRootTarget === null
? fileHeaderSubtitle(projectName, relativePath)
: fileHeaderSubtitle(fileRootTarget.root.label, fileRootTarget.relativePath);

return (
<View className="flex-1 bg-sheet">
Expand Down
66 changes: 65 additions & 1 deletion apps/mobile/src/features/files/fileTree.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { describe, expect, it } from "vite-plus/test";
import type { ProjectEntry } from "@t3tools/contracts";

import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree";
import {
fileRootFromTreePath,
fileRootOpenPath,
projectFileRoots,
} from "@t3tools/client-runtime/project-file-roots";

import {
buildFileTree,
defaultExpandedTreePaths,
fileRootDirectoryTreeEntries,
fileRootTreeEntries,
flattenFileTree,
} from "./fileTree";

const entries = [
{ kind: "file", path: "README.md" },
Expand Down Expand Up @@ -99,3 +111,55 @@ describe("mobile file tree helpers", () => {
expect([...defaultExpandedTreePaths(tree)]).toEqual(["src"]);
});
});

describe("multi-root file tree", () => {
const roots = projectFileRoots({
cwd: "/work/context",
projectName: "Context",
folders: [
{ relativePath: ".", name: "context", path: "/work/context" },
{ relativePath: "../team-a/docs", name: "docs", path: "/work/team-a/docs" },
{ relativePath: "../team-b/docs", name: "docs", path: "/work/team-b/docs" },
],
});

it("shows one folder per root and groups same-named roots under their parent", () => {
const tree = buildFileTree(fileRootTreeEntries(roots));
expect(tree.map((node) => node.path)).toEqual(["context", "team-a", "team-b"]);
expect(tree[1]?.children.map((node) => node.path)).toEqual(["team-a/docs"]);
});

it("files a root's listing under that root, keeping only direct children", () => {
const target = fileRootFromTreePath(roots, "team-b/docs")!;
const entries = fileRootDirectoryTreeEntries(roots, target, [
{ path: "plan.md", kind: "file" },
{ path: "drafts", kind: "directory" },
{ path: "drafts/todo.md", kind: "file" },
]);
expect(entries.map((entry) => entry.path)).toEqual([
"team-b/docs/plan.md",
"team-b/docs/drafts",
]);
const tree = buildFileTree([...fileRootTreeEntries(roots), ...entries]);
const teamB = tree.find((node) => node.path === "team-b")!;
expect(teamB.children[0]?.children.map((node) => node.path)).toEqual([
"team-b/docs/drafts",
"team-b/docs/plan.md",
]);
});

it("opens a row's file by the path the rest of the app names it by", () => {
const inFolder = fileRootFromTreePath(roots, "context/src/main.ts")!;
expect(fileRootOpenPath("/work/context", inFolder)).toBe("src/main.ts");
const elsewhere = fileRootFromTreePath(roots, "team-a/docs/plan.md")!;
expect(fileRootOpenPath("/work/context", elsewhere)).toBe("/work/team-a/docs/plan.md");
});

it("keeps an ordinary project's paths as they were", () => {
const single = projectFileRoots({ cwd: "/work/app", folders: undefined });
const target = fileRootFromTreePath(single, "src")!;
expect(
fileRootDirectoryTreeEntries(single, target, [{ path: "src/main.ts", kind: "file" }]),
).toEqual([{ path: "src/main.ts", kind: "file" }]);
});
});
34 changes: 34 additions & 0 deletions apps/mobile/src/features/files/fileTree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
fileRootTreePath,
type ProjectFileRoot,
type ProjectFileRootTarget,
} from "@t3tools/client-runtime/project-file-roots";
import type { ProjectEntry } from "@t3tools/contracts";
import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";

Expand Down Expand Up @@ -123,6 +128,35 @@ export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArr
return [...root.children.values()].sort(compareNodes).map(freezeNode);
}

/**
* The top level of a tree over several roots: one folder row per root, named by its label.
* Same-named roots carry parent segments (`team-a/docs`), and `buildFileTree` shows the shared
* segment as a row of its own.
*/
export function fileRootTreeEntries(roots: ReadonlyArray<ProjectFileRoot>): ProjectEntry[] {
return roots.map((root) => ({ path: root.label, kind: "directory" }));
}

/**
* A listing of one directory in a root, as tree rows: only its direct children, named by their
* tree path so files from different roots never collide.
*/
export function fileRootDirectoryTreeEntries(
roots: ReadonlyArray<ProjectFileRoot>,
target: ProjectFileRootTarget,
entries: ReadonlyArray<ProjectEntry>,
): ProjectEntry[] {
return entries
.filter(
(entry) =>
entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === target.relativePath,
)
.map((entry) => ({
...entry,
path: fileRootTreePath(roots, { root: target.root, relativePath: entry.path }),
}));
}

export function defaultExpandedTreePaths(nodes: ReadonlyArray<FileTreeNode>): ReadonlySet<string> {
const expanded = new Set<string>();
for (const node of nodes) {
Expand Down
26 changes: 20 additions & 6 deletions apps/mobile/src/features/files/thread-file-navigator-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { environmentSession } from "../../state/session";
import type { EnvironmentId } from "@t3tools/contracts";
import { projectFileRootsKey } from "@t3tools/client-runtime/project-file-roots";
import { SymbolView } from "../../components/AppSymbol";
import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { useCallback, useMemo, useState, type ComponentProps } from "react";
Expand All @@ -22,6 +23,7 @@ import { useEnvironmentPresentation } from "../../state/presentation";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { FileTreeBrowser } from "./FileTreeBrowser";
import { useFileTreeEntries } from "./useFileTreeEntries";
import { useProjectFileRoots } from "./useProjectFileRoots";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";

Expand Down Expand Up @@ -51,21 +53,33 @@ export function ThreadFileNavigatorPane(props: {
sessionError: fileAccessSession.error,
});
const { canReadFiles } = fileAccess;
const fileRoots = useProjectFileRoots({
environmentId: props.environmentId,
cwd: props.cwd,
projectName: props.projectName,
});
const entriesQuery = useFileTreeEntries({
environmentId: props.environmentId,
cwd: canReadFiles ? props.cwd : null,
searchQuery,
fileRoots,
});
const { openPath } = entriesQuery;
const { onSelectFile } = props;
const handlePreviewFile = useCallback(
(relativePath: string) => {
(treePath: string) => {
preloadWorkspaceFileContents({
cwd: props.cwd,
environmentId: props.environmentId,
relativePath,
relativePath: openPath(treePath),
theme: highlightTheme,
});
},
[highlightTheme, props.cwd, props.environmentId],
[highlightTheme, openPath, props.cwd, props.environmentId],
);
const handleSelectFile = useCallback(
(treePath: string) => onSelectFile(openPath(treePath)),
[onSelectFile, openPath],
);
const nativeHeaderRightBarButtonItems = useMemo(
() =>
Expand All @@ -85,7 +99,7 @@ export function ThreadFileNavigatorPane(props: {

const fileTree = (
<FileTreeBrowser
key={JSON.stringify([props.environmentId, props.cwd])}
key={JSON.stringify([props.environmentId, props.cwd, projectFileRootsKey(fileRoots.roots)])}
entries={entriesQuery.entries}
loadingDirectories={entriesQuery.loadingDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
Expand All @@ -100,10 +114,10 @@ export function ThreadFileNavigatorPane(props: {
isRefreshing={entriesQuery.isRefreshing}
searchQuery={searchQuery}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={props.selectedPath}
selectedPath={entriesQuery.treePath(props.selectedPath)}
onPreviewFile={handlePreviewFile}
onRefresh={entriesQuery.refresh}
onSelectFile={props.onSelectFile}
onSelectFile={handleSelectFile}
/>
);

Expand Down
Loading
Loading