authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-04-15 18:26:05+12:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-14 23:26:05-07:00
log44afe784a3b17f0468cc73aeb87b0870d0d78d55
tree1f3a15b7c2fd9f56a79ca8108b80c90205e394c6
parent9ff31f8c35736a9f179996d65803244b11841246
signature Signed by PGP key B5690EEEBB952194

Add filesystem browse API and command palette project picker (#2024)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

35 files changed, 2604 insertions(+), 322 deletions(-)

apps/desktop/src/main.ts+35-8
......@@ -18,7 +18,7 @@ import {
1818 safeStorage,
1919 shell,
2020} from "electron";
21import type { MenuItemConstructorOptions } from "electron";
21import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";
2222import type {
2323 ClientSettings,
2424 DesktopTheme,
......@@ -126,6 +126,32 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
126126const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
127127const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
128128const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
129
130function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined {
131 if (typeof rawOptions !== "object" || rawOptions === null) {
132 return undefined;
133 }
134
135 const { initialPath } = rawOptions as { initialPath?: unknown };
136 if (typeof initialPath !== "string") {
137 return undefined;
138 }
139
140 const trimmedPath = initialPath.trim();
141 if (trimmedPath.length === 0) {
142 return undefined;
143 }
144
145 if (trimmedPath === "~") {
146 return OS.homedir();
147 }
148
149 if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
150 return Path.join(OS.homedir(), trimmedPath.slice(2));
151 }
152
153 return Path.resolve(trimmedPath);
154}
129155const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
130156const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
131157const TITLEBAR_HEIGHT = 40;
......@@ -1641,15 +1667,16 @@ function registerIpcHandlers(): void {
16411667 });
16421668
16431669 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1644 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
1670 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
16451671 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1672 const defaultPath = resolvePickFolderDefaultPath(rawOptions);
1673 const openDialogOptions: OpenDialogOptions = {
1674 properties: ["openDirectory", "createDirectory"],
1675 ...(defaultPath ? { defaultPath } : {}),
1676 };
16461677 const result = owner
1647 ? await dialog.showOpenDialog(owner, {
1648 properties: ["openDirectory", "createDirectory"],
1649 })
1650 : await dialog.showOpenDialog({
1651 properties: ["openDirectory", "createDirectory"],
1652 });
1678 ? await dialog.showOpenDialog(owner, openDialogOptions)
1679 : await dialog.showOpenDialog(openDialogOptions);
16531680 if (result.canceled) return null;
16541681 return result.filePaths[0] ?? null;
16551682 });
apps/desktop/src/preload.ts+1-1
......@@ -53,7 +53,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
5353 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
5454 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
5555 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
56 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
56 pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
5757 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
5858 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
5959 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
apps/server/src/orchestration/Normalizer.ts+22-1
......@@ -28,10 +28,31 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
2828 ),
2929 );
3030
31 const normalizeProjectWorkspaceRootForCreate = (
32 workspaceRoot: string,
33 createIfMissing: boolean | undefined,
34 ) =>
35 workspacePaths
36 .normalizeWorkspaceRoot(workspaceRoot, {
37 createIfMissing: createIfMissing === true,
38 })
39 .pipe(
40 Effect.mapError(
41 (cause) =>
42 new OrchestrationDispatchCommandError({
43 message: cause.message,
44 }),
45 ),
46 );
47
3148 if (command.type === "project.create") {
3249 return {
3350 ...command,
34 workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),
51 workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate(
52 command.workspaceRoot,
53 command.createWorkspaceRootIfMissing,
54 ),
55 createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true,
3556 } satisfies OrchestrationCommand;
3657 }
3758
apps/server/src/server.test.ts+34
......@@ -2039,6 +2039,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
20392039 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
20402040 );
20412041
2042 it.effect("creates a missing workspace root during websocket project.create dispatch", () =>
2043 Effect.gen(function* () {
2044 const fs = yield* FileSystem.FileSystem;
2045 const path = yield* Path.Path;
2046 const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-create-" });
2047 const missingWorkspaceRoot = path.join(parentDir, "nested", "new-project");
2048
2049 yield* buildAppUnderTest();
2050
2051 const wsUrl = yield* getWsServerUrl("/ws");
2052 const response = yield* Effect.scoped(
2053 withWsRpcClient(wsUrl, (client) =>
2054 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
2055 type: "project.create",
2056 commandId: CommandId.make("cmd-project-create-missing-root"),
2057 projectId: ProjectId.make("project-create-missing-root"),
2058 title: "New Project",
2059 workspaceRoot: missingWorkspaceRoot,
2060 createWorkspaceRootIfMissing: true,
2061 defaultModelSelection: {
2062 provider: "codex",
2063 model: "gpt-5-codex",
2064 },
2065 createdAt: new Date().toISOString(),
2066 }),
2067 ),
2068 );
2069 const stat = yield* fs.stat(missingWorkspaceRoot);
2070
2071 assert.isAtLeast(response.sequence, 0);
2072 assert.equal(stat.type, "Directory");
2073 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2074 );
2075
20422076 it.effect("routes websocket rpc projects.writeFile errors", () =>
20432077 Effect.gen(function* () {
20442078 const fs = yield* FileSystem.FileSystem;
apps/server/src/serverSettings.test.ts+4
......@@ -176,12 +176,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
176176 const serverSettings = yield* ServerSettingsService;
177177
178178 const next = yield* serverSettings.updateSettings({
179 addProjectBaseDirectory: " ~/Development ",
179180 observability: {
180181 otlpTracesUrl: " http://localhost:4318/v1/traces ",
181182 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
182183 },
183184 });
184185
186 assert.equal(next.addProjectBaseDirectory, "~/Development");
185187 assert.deepEqual(next.observability, {
186188 otlpTracesUrl: "http://localhost:4318/v1/traces",
187189 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
......@@ -215,6 +217,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
215217 const serverConfig = yield* ServerConfig;
216218 const fileSystem = yield* FileSystem.FileSystem;
217219 const next = yield* serverSettings.updateSettings({
220 addProjectBaseDirectory: "~/Development",
218221 observability: {
219222 otlpTracesUrl: "http://localhost:4318/v1/traces",
220223 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
......@@ -230,6 +233,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
230233
231234 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
232235 assert.deepEqual(JSON.parse(raw), {
236 addProjectBaseDirectory: "~/Development",
233237 observability: {
234238 otlpTracesUrl: "http://localhost:4318/v1/traces",
235239 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+86
......@@ -68,6 +68,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb
6868 return yield* workspaceEntries.search(input);
6969 });
7070
71const appendSeparator = (input: string) =>
72 input.endsWith("/") || input.endsWith("\\")
73 ? input
74 : `${input}${process.platform === "win32" ? "\\" : "/"}`;
75
7176it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
7277 afterEach(() => {
7378 vi.restoreAllMocks();
......@@ -275,4 +280,85 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
275280 }),
276281 );
277282 });
283
284 describe("browse", () => {
285 it.effect("returns matching directories and excludes files", () =>
286 Effect.gen(function* () {
287 const workspaceEntries = yield* WorkspaceEntries;
288 const path = yield* Path.Path;
289 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" });
290 yield* writeTextFile(cwd, "alphabet.txt", "ignore me");
291 yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n");
292 yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n");
293
294 const result = yield* workspaceEntries.browse({
295 partialPath: path.join(cwd, "alp"),
296 });
297
298 expect(result).toEqual({
299 parentPath: cwd,
300 entries: [
301 { name: "alpha", fullPath: path.join(cwd, "alpha") },
302 { name: "alpine", fullPath: path.join(cwd, "alpine") },
303 ],
304 });
305 }),
306 );
307
308 it.effect("shows dot directories in directory mode and hidden-prefix mode", () =>
309 Effect.gen(function* () {
310 const workspaceEntries = yield* WorkspaceEntries;
311 const path = yield* Path.Path;
312 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" });
313 yield* writeTextFile(cwd, ".config/settings.json", "{}");
314 yield* writeTextFile(cwd, "config/settings.json", "{}");
315
316 const directoryResult = yield* workspaceEntries.browse({
317 partialPath: appendSeparator(cwd),
318 });
319 const hiddenPrefixResult = yield* workspaceEntries.browse({
320 partialPath: `${appendSeparator(cwd)}.c`,
321 });
322
323 expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]);
324 expect(hiddenPrefixResult).toEqual({
325 parentPath: cwd,
326 entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }],
327 });
328 }),
329 );
330
331 it.effect("supports relative paths when cwd is provided", () =>
332 Effect.gen(function* () {
333 const workspaceEntries = yield* WorkspaceEntries;
334 const path = yield* Path.Path;
335 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" });
336 yield* writeTextFile(cwd, "packages/pkg.json", "{}");
337
338 const result = yield* workspaceEntries.browse({
339 cwd,
340 partialPath: "./pack",
341 });
342
343 expect(result).toEqual({
344 parentPath: cwd,
345 entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }],
346 });
347 }),
348 );
349
350 it.effect("rejects relative paths without cwd", () =>
351 Effect.gen(function* () {
352 const workspaceEntries = yield* WorkspaceEntries;
353
354 const error = yield* workspaceEntries
355 .browse({
356 partialPath: "./src",
357 })
358 .pipe(Effect.flip);
359
360 expect(error.detail).toBe("Relative filesystem browse paths require a current project.");
361 }),
362 );
363 });
278364});
apps/server/src/workspace/Layers/WorkspaceEntries.ts+85-1
......@@ -1,9 +1,11 @@
1import * as OS from "node:os";
12import fsPromises from "node:fs/promises";
23import type { Dirent } from "node:fs";
34
45import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
56
6import { type ProjectEntry } from "@t3tools/contracts";
7import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";
8import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
79import {
810 insertRankedSearchResult,
911 normalizeSearchQuery,
......@@ -14,6 +16,7 @@ import {
1416import { GitCore } from "../../git/Services/GitCore.ts";
1517import {
1618 WorkspaceEntries,
19 WorkspaceEntriesBrowseError,
1720 WorkspaceEntriesError,
1821 type WorkspaceEntriesShape,
1922} from "../Services/WorkspaceEntries.ts";
......@@ -52,6 +55,16 @@ function toPosixPath(input: string): string {
5255 return input.replaceAll("\\", "/");
5356}
5457
58function expandHomePath(input: string, path: Path.Path): string {
59 if (input === "~") {
60 return OS.homedir();
61 }
62 if (input.startsWith("~/") || input.startsWith("~\\")) {
63 return path.join(OS.homedir(), input.slice(2));
64 }
65 return input;
66}
67
5568function parentPathOf(input: string): string | undefined {
5669 const separatorIndex = input.lastIndexOf("/");
5770 if (separatorIndex === -1) {
......@@ -129,6 +142,36 @@ function directoryAncestorsOf(relativePath: string): string[] {
129142 return directories;
130143}
131144
145const resolveBrowseTarget = (
146 input: FilesystemBrowseInput,
147 pathService: Path.Path,
148): Effect.Effect<string, WorkspaceEntriesBrowseError> =>
149 Effect.gen(function* () {
150 if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) {
151 return yield* new WorkspaceEntriesBrowseError({
152 cwd: input.cwd,
153 partialPath: input.partialPath,
154 operation: "workspaceEntries.resolveBrowseTarget",
155 detail: "Windows-style paths are only supported on Windows.",
156 });
157 }
158
159 if (!isExplicitRelativePath(input.partialPath)) {
160 return pathService.resolve(expandHomePath(input.partialPath, pathService));
161 }
162
163 if (!input.cwd) {
164 return yield* new WorkspaceEntriesBrowseError({
165 cwd: input.cwd,
166 partialPath: input.partialPath,
167 operation: "workspaceEntries.resolveBrowseTarget",
168 detail: "Relative filesystem browse paths require a current project.",
169 });
170 }
171
172 return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath);
173 });
174
132175export const makeWorkspaceEntries = Effect.gen(function* () {
133176 const path = yield* Path.Path;
134177 const gitOption = yield* Effect.serviceOption(GitCore);
......@@ -379,6 +422,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
379422 },
380423 );
381424
425 const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")(
426 function* (input) {
427 const resolvedInputPath = yield* resolveBrowseTarget(input, path);
428 const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~";
429 const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath);
430 const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath);
431
432 const dirents = yield* Effect.tryPromise({
433 try: () => fsPromises.readdir(parentPath, { withFileTypes: true }),
434 catch: (cause) =>
435 new WorkspaceEntriesBrowseError({
436 cwd: input.cwd,
437 partialPath: input.partialPath,
438 operation: "workspaceEntries.browse.readDirectory",
439 detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`,
440 cause,
441 }),
442 });
443
444 const showHidden = endsWithSeparator || prefix.startsWith(".");
445 const lowerPrefix = prefix.toLowerCase();
446
447 return {
448 parentPath,
449 entries: dirents
450 .filter(
451 (dirent) =>
452 dirent.isDirectory() &&
453 dirent.name.toLowerCase().startsWith(lowerPrefix) &&
454 (showHidden || !dirent.name.startsWith(".")),
455 )
456 .map((dirent) => ({
457 name: dirent.name,
458 fullPath: path.join(parentPath, dirent.name),
459 }))
460 .toSorted((left, right) => left.name.localeCompare(right.name)),
461 };
462 },
463 );
464
382465 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(
383466 function* (input) {
384467 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
......@@ -415,6 +498,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
415498 );
416499
417500 return {
501 browse,
418502 invalidate,
419503 search,
420504 } satisfies WorkspaceEntriesShape;
apps/server/src/workspace/Layers/WorkspacePaths.test.ts+18
......@@ -58,6 +58,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
5858 }),
5959 );
6060
61 it.effect("creates missing directories when createIfMissing is enabled", () =>
62 Effect.gen(function* () {
63 const workspacePaths = yield* WorkspacePaths;
64 const fileSystem = yield* FileSystem.FileSystem;
65 const cwd = yield* makeTempDir();
66 const path = yield* Path.Path;
67 const missingPath = path.join(cwd, "nested", "new-project");
68
69 const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, {
70 createIfMissing: true,
71 });
72 const stat = yield* fileSystem.stat(resolved);
73
74 expect(resolved).toBe(missingPath);
75 expect(stat.type).toBe("Directory");
76 }),
77 );
78
6179 it.effect("rejects file paths", () =>
6280 Effect.gen(function* () {
6381 const workspacePaths = yield* WorkspacePaths;
apps/server/src/workspace/Layers/WorkspacePaths.ts+17-2
......@@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect";
44import {
55 WorkspacePaths,
66 WorkspacePathOutsideRootError,
7 WorkspaceRootCreateFailedError,
78 WorkspaceRootNotDirectoryError,
89 WorkspaceRootNotExistsError,
910 type WorkspacePathsShape,
......@@ -29,11 +30,25 @@ export const makeWorkspacePaths = Effect.gen(function* () {
2930
3031 const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn(
3132 "WorkspacePaths.normalizeWorkspaceRoot",
32 )(function* (workspaceRoot) {
33 )(function* (workspaceRoot, options) {
3334 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
34 const workspaceStat = yield* fileSystem
35 let workspaceStat = yield* fileSystem
3536 .stat(normalizedWorkspaceRoot)
3637 .pipe(Effect.catch(() => Effect.succeed(null)));
38 if (!workspaceStat && options?.createIfMissing) {
39 yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
40 Effect.mapError(
41 () =>
42 new WorkspaceRootCreateFailedError({
43 workspaceRoot,
44 normalizedWorkspaceRoot,
45 }),
46 ),
47 );
48 workspaceStat = yield* fileSystem
49 .stat(normalizedWorkspaceRoot)
50 .pipe(Effect.catch(() => Effect.succeed(null)));
51 }
3752 if (!workspaceStat) {
3853 return yield* new WorkspaceRootNotExistsError({
3954 workspaceRoot,
apps/server/src/workspace/Services/WorkspaceEntries.ts+24-1
......@@ -9,7 +9,12 @@
99import { Schema, Context } from "effect";
1010import type { Effect } from "effect";
1111
12import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts";
12import type {
13 FilesystemBrowseInput,
14 FilesystemBrowseResult,
15 ProjectSearchEntriesInput,
16 ProjectSearchEntriesResult,
17} from "@t3tools/contracts";
1318
1419export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(
1520 "WorkspaceEntriesError",
......@@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntr
2126 },
2227) {}
2328
29export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()(
30 "WorkspaceEntriesBrowseError",
31 {
32 cwd: Schema.optional(Schema.String),
33 partialPath: Schema.String,
34 operation: Schema.String,
35 detail: Schema.String,
36 cause: Schema.optional(Schema.Defect),
37 },
38) {}
39
2440/**
2541 * WorkspaceEntriesShape - Service API for workspace entry search and cache
2642 * invalidation.
2743 */
2844export interface WorkspaceEntriesShape {
45 /**
46 * Browse matching directories for the provided partial path.
47 */
48 readonly browse: (
49 input: FilesystemBrowseInput,
50 ) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>;
51
2952 /**
3053 * Search indexed workspace entries for files and directories matching the
3154 * provided query.
apps/server/src/workspace/Services/WorkspacePaths.ts+18-1
......@@ -21,6 +21,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass<Workspa
2121 }
2222}
2323
24export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<WorkspaceRootCreateFailedError>()(
25 "WorkspaceRootCreateFailedError",
26 {
27 workspaceRoot: Schema.String,
28 normalizedWorkspaceRoot: Schema.String,
29 },
30) {
31 override get message(): string {
32 return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`;
33 }
34}
35
2436export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(
2537 "WorkspaceRootNotDirectoryError",
2638 {
......@@ -47,6 +59,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works
4759
4860export const WorkspacePathsError = Schema.Union([
4961 WorkspaceRootNotExistsError,
62 WorkspaceRootCreateFailedError,
5063 WorkspaceRootNotDirectoryError,
5164 WorkspacePathOutsideRootError,
5265]);
......@@ -61,7 +74,11 @@ export interface WorkspacePathsShape {
6174 */
6275 readonly normalizeWorkspaceRoot: (
6376 workspaceRoot: string,
64 ) => Effect.Effect<string, WorkspaceRootNotExistsError | WorkspaceRootNotDirectoryError>;
77 options?: { readonly createIfMissing?: boolean },
78 ) => Effect.Effect<
79 string,
80 WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError
81 >;
6582
6683 /**
6784 * Resolve a relative path within a validated workspace root.
apps/server/src/ws.ts+15
......@@ -17,6 +17,7 @@ import {
1717 ProjectSearchEntriesError,
1818 ProjectWriteFileError,
1919 OrchestrationReplayEventsError,
20 FilesystemBrowseError,
2021 ThreadId,
2122 type TerminalEvent,
2223 WS_METHODS,
......@@ -768,6 +769,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
768769 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
769770 "rpc.aggregate": "workspace",
770771 }),
772 [WS_METHODS.filesystemBrowse]: (input) =>
773 observeRpcEffect(
774 WS_METHODS.filesystemBrowse,
775 workspaceEntries.browse(input).pipe(
776 Effect.mapError(
777 (cause) =>
778 new FilesystemBrowseError({
779 message: cause.detail,
780 cause,
781 }),
782 ),
783 ),
784 { "rpc.aggregate": "workspace" },
785 ),
771786 [WS_METHODS.subscribeGitStatus]: (input) =>
772787 observeRpcStream(
773788 WS_METHODS.subscribeGitStatus,
apps/web/src/commandPaletteStore.ts+21-2
......@@ -1,13 +1,32 @@
11import { create } from "zustand";
22
3interface CommandPaletteOpenIntent {
4 kind: "add-project";
5 requestId: number;
6}
7
38interface CommandPaletteStore {
49 open: boolean;
10 openIntent: CommandPaletteOpenIntent | null;
511 setOpen: (open: boolean) => void;
612 toggleOpen: () => void;
13 openAddProject: () => void;
14 clearOpenIntent: () => void;
715}
816
917export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({
1018 open: false,
11 setOpen: (open) => set({ open }),
12 toggleOpen: () => set((state) => ({ open: !state.open })),
19 openIntent: null,
20 setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }),
21 toggleOpen: () =>
22 set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })),
23 openAddProject: () =>
24 set((state) => ({
25 open: true,
26 openIntent: {
27 kind: "add-project",
28 requestId: (state.openIntent?.requestId ?? 0) + 1,
29 },
30 })),
31 clearOpenIntent: () => set({ openIntent: null }),
1332}));
apps/web/src/components/ChatView.browser.tsx+822
......@@ -5,6 +5,7 @@ import {
55 EventId,
66 ORCHESTRATION_WS_METHODS,
77 EnvironmentId,
8 type EnvironmentApi,
89 type MessageId,
910 type OrchestrationReadModel,
1011 type ProjectId,
......@@ -31,6 +32,16 @@ import { render } from "vitest-browser-react";
3132
3233import { useCommandPaletteStore } from "../commandPaletteStore";
3334import { useComposerDraftStore, DraftId } from "../composerDraftStore";
35import {
36 __resetEnvironmentApiOverridesForTests,
37 __setEnvironmentApiOverrideForTests,
38} from "../environmentApi";
39import {
40 resetSavedEnvironmentRegistryStoreForTests,
41 resetSavedEnvironmentRuntimeStoreForTests,
42 useSavedEnvironmentRegistryStore,
43 useSavedEnvironmentRuntimeStore,
44} from "../environments/runtime";
3445import {
3546 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
3647 removeInlineTerminalContextPlaceholder,
......@@ -62,6 +73,7 @@ const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as Thre
6273const PROJECT_ID = "project-1" as ProjectId;
6374const SECOND_PROJECT_ID = "project-2" as ProjectId;
6475const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
76const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
6577const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
6678const THREAD_KEY = scopedThreadKey(THREAD_REF);
6779const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
......@@ -70,6 +82,7 @@ const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJE
7082const NOW_ISO = "2026-03-04T12:00:00.000Z";
7183const BASE_TIME_MS = Date.parse(NOW_ISO);
7284const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
85const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)";
7386
7487interface TestFixture {
7588 snapshot: OrchestrationReadModel;
......@@ -172,6 +185,32 @@ function createBaseServerConfig(): ServerConfig {
172185 };
173186}
174187
188function createMockEnvironmentApi(input: {
189 browse: EnvironmentApi["filesystem"]["browse"];
190 dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"];
191}): EnvironmentApi {
192 return {
193 terminal: {} as EnvironmentApi["terminal"],
194 projects: {} as EnvironmentApi["projects"],
195 filesystem: {
196 browse: input.browse,
197 },
198 git: {} as EnvironmentApi["git"],
199 orchestration: {
200 dispatchCommand: input.dispatchCommand,
201 getTurnDiff: (() => {
202 throw new Error("Not implemented in browser test.");
203 }) as EnvironmentApi["orchestration"]["getTurnDiff"],
204 getFullThreadDiff: (() => {
205 throw new Error("Not implemented in browser test.");
206 }) as EnvironmentApi["orchestration"]["getFullThreadDiff"],
207 subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"],
208 subscribeThread: (() => () =>
209 undefined) as EnvironmentApi["orchestration"]["subscribeThread"],
210 },
211 };
212}
213
175214function createUserMessage(options: {
176215 id: MessageId;
177216 text: string;
......@@ -1383,6 +1422,44 @@ async function waitForCommandPaletteShortcutLabel(): Promise<void> {
13831422 );
13841423}
13851424
1425async function waitForCommandPaletteInput(placeholder: string): Promise<HTMLInputElement> {
1426 return waitForElement(
1427 () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null,
1428 `Command palette input with placeholder "${placeholder}" did not render.`,
1429 );
1430}
1431
1432function getCommandPaletteLegendEntries(): string[] {
1433 const footer = document.querySelector('[data-slot="command-footer"]');
1434 if (!footer) {
1435 return [];
1436 }
1437
1438 return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]'))
1439 .map((group) =>
1440 Array.from(group.children)
1441 .map((child) => child.textContent?.trim() ?? "")
1442 .filter((value) => value.length > 0)
1443 .join(" "),
1444 )
1445 .filter((value) => value.length > 0);
1446}
1447
1448async function dispatchInputKey(
1449 input: HTMLInputElement,
1450 init: Pick<KeyboardEventInit, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
1451): Promise<void> {
1452 input.focus();
1453 input.dispatchEvent(
1454 new KeyboardEvent("keydown", {
1455 bubbles: true,
1456 cancelable: true,
1457 ...init,
1458 }),
1459 );
1460 await waitForLayout();
1461}
1462
13861463async function mountChatView(options: {
13871464 viewport: ViewportSpec;
13881465 snapshot: OrchestrationReadModel;
......@@ -1524,6 +1601,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
15241601 document.body.innerHTML = "";
15251602 wsRequests.length = 0;
15261603 customWsRpcResolver = null;
1604 __resetEnvironmentApiOverridesForTests();
1605 resetSavedEnvironmentRegistryStoreForTests();
1606 resetSavedEnvironmentRuntimeStoreForTests();
1607 Reflect.deleteProperty(window, "desktopBridge");
15271608 useComposerDraftStore.setState({
15281609 draftsByThreadKey: {},
15291610 draftThreadsByThreadKey: {},
......@@ -1533,6 +1614,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
15331614 });
15341615 useCommandPaletteStore.setState({
15351616 open: false,
1617 openIntent: null,
15361618 });
15371619 useStore.setState({
15381620 activeEnvironmentId: null,
......@@ -4079,6 +4161,746 @@ describe("ChatView timeline estimator parity (full app)", () => {
40794161 }
40804162 });
40814163
4164 it("adds a project from browse mode with Enter when no directory is highlighted", async () => {
4165 const mounted = await mountChatView({
4166 viewport: DEFAULT_VIEWPORT,
4167 snapshot: createSnapshotForTargetUser({
4168 targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId,
4169 targetText: "command palette add project enter",
4170 }),
4171 configureFixture: (nextFixture) => {
4172 nextFixture.serverConfig = {
4173 ...nextFixture.serverConfig,
4174 keybindings: [
4175 {
4176 command: "commandPalette.toggle",
4177 shortcut: {
4178 key: "k",
4179 metaKey: false,
4180 ctrlKey: false,
4181 shiftKey: false,
4182 altKey: false,
4183 modKey: true,
4184 },
4185 whenAst: {
4186 type: "not",
4187 node: { type: "identifier", name: "terminalFocus" },
4188 },
4189 },
4190 ],
4191 };
4192 },
4193 resolveRpc: (body) => {
4194 if (body._tag === WS_METHODS.filesystemBrowse) {
4195 if (body.partialPath === "~/Development/") {
4196 return {
4197 parentPath: "~/Development/",
4198 entries: [
4199 { name: "alpha", fullPath: "~/Development/alpha" },
4200 { name: "beta", fullPath: "~/Development/beta" },
4201 ],
4202 };
4203 }
4204
4205 return {
4206 parentPath: "~/",
4207 entries: [{ name: "Development", fullPath: "~/Development" }],
4208 };
4209 }
4210
4211 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
4212 return {
4213 sequence: fixture.snapshot.snapshotSequence + 1,
4214 };
4215 }
4216
4217 return undefined;
4218 },
4219 });
4220
4221 try {
4222 await waitForServerConfigToApply();
4223 await waitForCommandPaletteShortcutLabel();
4224 const palette = page.getByTestId("command-palette");
4225 await openCommandPaletteFromTrigger();
4226
4227 await expect.element(palette).toBeInTheDocument();
4228 await palette.getByText("Add project", { exact: true }).click();
4229
4230 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4231 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/");
4232 await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument();
4233
4234 await expect
4235 .element(palette.getByRole("button", { name: "Add (Enter)" }))
4236 .toBeInTheDocument();
4237
4238 await dispatchInputKey(browseInput, { key: "Enter" });
4239
4240 await vi.waitFor(
4241 () => {
4242 const dispatchRequest = wsRequests.find(
4243 (request) =>
4244 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
4245 request.type === "project.create",
4246 ) as
4247 | {
4248 _tag: string;
4249 type?: string;
4250 workspaceRoot?: string;
4251 title?: string;
4252 }
4253 | undefined;
4254
4255 expect(dispatchRequest).toMatchObject({
4256 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
4257 type: "project.create",
4258 workspaceRoot: "~/Development",
4259 title: "Development",
4260 });
4261 },
4262 { timeout: 8_000, interval: 16 },
4263 );
4264
4265 await waitForURL(
4266 mounted.router,
4267 (path) => UUID_ROUTE_RE.test(path),
4268 "Route should have changed to a new draft thread after adding a project with Enter.",
4269 );
4270 } finally {
4271 await mounted.cleanup();
4272 }
4273 });
4274
4275 it("opens add project browse mode from the sidebar add button", async () => {
4276 const mounted = await mountChatView({
4277 viewport: DEFAULT_VIEWPORT,
4278 snapshot: createSnapshotForTargetUser({
4279 targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId,
4280 targetText: "sidebar add project trigger",
4281 }),
4282 resolveRpc: (body) => {
4283 if (body._tag === WS_METHODS.filesystemBrowse) {
4284 return {
4285 parentPath: "~/",
4286 entries: [{ name: "Development", fullPath: "~/Development" }],
4287 };
4288 }
4289
4290 return undefined;
4291 },
4292 });
4293
4294 try {
4295 await waitForServerConfigToApply();
4296
4297 await page.getByTestId("sidebar-add-project-trigger").click();
4298
4299 const palette = page.getByTestId("command-palette");
4300 await expect.element(palette).toBeInTheDocument();
4301
4302 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4303 await expect.element(browseInput).toHaveValue("~/");
4304
4305 await vi.waitFor(
4306 () => {
4307 expect(
4308 wsRequests.some(
4309 (request) =>
4310 request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/",
4311 ),
4312 ).toBe(true);
4313 },
4314 { timeout: 8_000, interval: 16 },
4315 );
4316 } finally {
4317 await mounted.cleanup();
4318 }
4319 });
4320
4321 it("starts add project browse mode from the configured base directory", async () => {
4322 const mounted = await mountChatView({
4323 viewport: DEFAULT_VIEWPORT,
4324 snapshot: createSnapshotForTargetUser({
4325 targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId,
4326 targetText: "sidebar add project custom base directory",
4327 }),
4328 configureFixture: (nextFixture) => {
4329 nextFixture.serverConfig = {
4330 ...nextFixture.serverConfig,
4331 settings: {
4332 ...nextFixture.serverConfig.settings,
4333 addProjectBaseDirectory: "~/Development",
4334 },
4335 };
4336 },
4337 resolveRpc: (body) => {
4338 if (body._tag === WS_METHODS.filesystemBrowse) {
4339 if (body.partialPath === "~/Development/") {
4340 return {
4341 parentPath: "~/Development/",
4342 entries: [{ name: "codething", fullPath: "~/Development/codething" }],
4343 };
4344 }
4345
4346 return {
4347 parentPath: "~/",
4348 entries: [{ name: "Development", fullPath: "~/Development" }],
4349 };
4350 }
4351
4352 return undefined;
4353 },
4354 });
4355
4356 try {
4357 await waitForServerConfigToApply();
4358
4359 await page.getByTestId("sidebar-add-project-trigger").click();
4360
4361 const palette = page.getByTestId("command-palette");
4362 await expect.element(palette).toBeInTheDocument();
4363
4364 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4365 await expect.element(browseInput).toHaveValue("~/Development/");
4366
4367 await vi.waitFor(
4368 () => {
4369 expect(
4370 wsRequests.some(
4371 (request) =>
4372 request._tag === WS_METHODS.filesystemBrowse &&
4373 request.partialPath === "~/Development/",
4374 ),
4375 ).toBe(true);
4376 },
4377 { timeout: 8_000, interval: 16 },
4378 );
4379 } finally {
4380 await mounted.cleanup();
4381 }
4382 });
4383
4384 it("shows create-folder affordances for missing project paths", async () => {
4385 const mounted = await mountChatView({
4386 viewport: DEFAULT_VIEWPORT,
4387 snapshot: createSnapshotForTargetUser({
4388 targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId,
4389 targetText: "command palette create missing project",
4390 }),
4391 resolveRpc: (body) => {
4392 if (body._tag === WS_METHODS.filesystemBrowse) {
4393 if (body.partialPath === "~/Desktop/") {
4394 return {
4395 parentPath: "~/Desktop/",
4396 entries: [{ name: "existing", fullPath: "~/Desktop/existing" }],
4397 };
4398 }
4399
4400 return {
4401 parentPath: "~/",
4402 entries: [{ name: "Desktop", fullPath: "~/Desktop" }],
4403 };
4404 }
4405
4406 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
4407 return {
4408 sequence: fixture.snapshot.snapshotSequence + 1,
4409 };
4410 }
4411
4412 return undefined;
4413 },
4414 });
4415
4416 try {
4417 await waitForServerConfigToApply();
4418 const palette = page.getByTestId("command-palette");
4419 await page.getByTestId("sidebar-add-project-trigger").click();
4420
4421 await expect.element(palette).toBeInTheDocument();
4422 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4423 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project");
4424
4425 await expect
4426 .element(palette.getByRole("button", { name: "Create & Add (Enter)" }))
4427 .toBeInTheDocument();
4428 await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument();
4429
4430 await dispatchInputKey(browseInput, { key: "Enter" });
4431
4432 await vi.waitFor(
4433 () => {
4434 const dispatchRequest = wsRequests.find(
4435 (request) =>
4436 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
4437 request.type === "project.create",
4438 ) as
4439 | {
4440 _tag: string;
4441 type?: string;
4442 workspaceRoot?: string;
4443 title?: string;
4444 createWorkspaceRootIfMissing?: boolean;
4445 }
4446 | undefined;
4447
4448 expect(dispatchRequest).toMatchObject({
4449 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
4450 type: "project.create",
4451 workspaceRoot: "~/Desktop/fresh-project",
4452 title: "fresh-project",
4453 createWorkspaceRootIfMissing: true,
4454 });
4455 },
4456 { timeout: 8_000, interval: 16 },
4457 );
4458 } finally {
4459 await mounted.cleanup();
4460 }
4461 });
4462
4463 it("does not show create affordances for an existing directory with a trailing slash", async () => {
4464 const mounted = await mountChatView({
4465 viewport: DEFAULT_VIEWPORT,
4466 snapshot: createSnapshotForTargetUser({
4467 targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId,
4468 targetText: "command palette existing trailing directory",
4469 }),
4470 resolveRpc: (body) => {
4471 if (body._tag === WS_METHODS.filesystemBrowse) {
4472 if (body.partialPath === "~/Development/codex/") {
4473 return {
4474 parentPath: "~/Development/codex/",
4475 entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }],
4476 };
4477 }
4478
4479 return {
4480 parentPath: "~/",
4481 entries: [{ name: "Development", fullPath: "~/Development" }],
4482 };
4483 }
4484
4485 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
4486 return {
4487 sequence: fixture.snapshot.snapshotSequence + 1,
4488 };
4489 }
4490
4491 return undefined;
4492 },
4493 });
4494
4495 try {
4496 await waitForServerConfigToApply();
4497 const palette = page.getByTestId("command-palette");
4498 await page.getByTestId("sidebar-add-project-trigger").click();
4499
4500 await expect.element(palette).toBeInTheDocument();
4501 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4502 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/");
4503
4504 await vi.waitFor(
4505 () => {
4506 expect(
4507 wsRequests.some(
4508 (request) =>
4509 request._tag === WS_METHODS.filesystemBrowse &&
4510 request.partialPath === "~/Development/codex/",
4511 ),
4512 ).toBe(true);
4513 },
4514 { timeout: 8_000, interval: 16 },
4515 );
4516
4517 await expect
4518 .element(palette.getByRole("button", { name: "Add (Enter)" }))
4519 .toBeInTheDocument();
4520 await expect
4521 .element(palette.getByRole("button", { name: "Create & Add (Enter)" }))
4522 .not.toBeInTheDocument();
4523
4524 await dispatchInputKey(browseInput, { key: "Enter" });
4525
4526 await vi.waitFor(
4527 () => {
4528 const dispatchRequest = wsRequests.find(
4529 (request) =>
4530 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
4531 request.type === "project.create",
4532 ) as
4533 | {
4534 _tag: string;
4535 type?: string;
4536 workspaceRoot?: string;
4537 title?: string;
4538 }
4539 | undefined;
4540
4541 expect(dispatchRequest).toMatchObject({
4542 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
4543 type: "project.create",
4544 workspaceRoot: "~/Development/codex",
4545 title: "codex",
4546 });
4547 },
4548 { timeout: 8_000, interval: 16 },
4549 );
4550 } finally {
4551 await mounted.cleanup();
4552 }
4553 });
4554
4555 it("selects an environment before browsing when multiple environments are available", async () => {
4556 const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => {
4557 if (partialPath === "~/workspaces/") {
4558 return {
4559 parentPath: "~/workspaces/",
4560 entries: [{ name: "codething", fullPath: "~/workspaces/codething" }],
4561 };
4562 }
4563
4564 return {
4565 parentPath: "~/",
4566 entries: [{ name: "workspaces", fullPath: "~/workspaces" }],
4567 };
4568 });
4569 const remoteDispatchMock = vi.fn(async () => ({
4570 sequence: fixture.snapshot.snapshotSequence + 1,
4571 }));
4572
4573 __setEnvironmentApiOverrideForTests(
4574 REMOTE_ENVIRONMENT_ID,
4575 createMockEnvironmentApi({
4576 browse: remoteBrowseMock,
4577 dispatchCommand: remoteDispatchMock,
4578 }),
4579 );
4580
4581 const mounted = await mountChatView({
4582 viewport: DEFAULT_VIEWPORT,
4583 snapshot: createSnapshotForTargetUser({
4584 targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId,
4585 targetText: "command palette add project multi env",
4586 }),
4587 });
4588
4589 try {
4590 await waitForServerConfigToApply();
4591 useSavedEnvironmentRegistryStore.getState().upsert({
4592 environmentId: REMOTE_ENVIRONMENT_ID,
4593 label: "Staging",
4594 httpBaseUrl: "https://staging.example.test",
4595 wsBaseUrl: "wss://staging.example.test/ws",
4596 createdAt: NOW_ISO,
4597 lastConnectedAt: NOW_ISO,
4598 });
4599 useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, {
4600 connectionState: "connected",
4601 authState: "authenticated",
4602 descriptor: {
4603 ...fixture.serverConfig.environment,
4604 environmentId: REMOTE_ENVIRONMENT_ID,
4605 label: "Staging",
4606 },
4607 serverConfig: {
4608 ...fixture.serverConfig,
4609 environment: {
4610 ...fixture.serverConfig.environment,
4611 environmentId: REMOTE_ENVIRONMENT_ID,
4612 label: "Staging",
4613 },
4614 settings: {
4615 ...fixture.serverConfig.settings,
4616 addProjectBaseDirectory: "~/workspaces",
4617 },
4618 },
4619 connectedAt: NOW_ISO,
4620 });
4621
4622 const palette = page.getByTestId("command-palette");
4623 await openCommandPaletteFromTrigger();
4624
4625 await expect.element(palette).toBeInTheDocument();
4626 await palette.getByText("Add project", { exact: true }).click();
4627 await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument();
4628 await expect
4629 .element(palette.getByText("This device", { exact: true }).first())
4630 .toBeInTheDocument();
4631 await palette.getByText("Staging", { exact: true }).click();
4632
4633 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4634 await expect.element(browseInput).toHaveValue("~/workspaces/");
4635
4636 await vi.waitFor(
4637 () => {
4638 expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" });
4639 },
4640 { timeout: 8_000, interval: 16 },
4641 );
4642
4643 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/");
4644 await vi.waitFor(
4645 () => {
4646 expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" });
4647 },
4648 { timeout: 8_000, interval: 16 },
4649 );
4650 await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument();
4651 await expect
4652 .element(palette.getByRole("button", { name: "Add (Enter)" }))
4653 .toBeInTheDocument();
4654
4655 await dispatchInputKey(browseInput, { key: "Enter" });
4656
4657 await vi.waitFor(
4658 () => {
4659 expect(remoteDispatchMock).toHaveBeenCalledWith(
4660 expect.objectContaining({
4661 type: "project.create",
4662 workspaceRoot: "~/workspaces",
4663 title: "workspaces",
4664 }),
4665 );
4666 },
4667 { timeout: 8_000, interval: 16 },
4668 );
4669
4670 await waitForURL(
4671 mounted.router,
4672 (path) => UUID_ROUTE_RE.test(path),
4673 "Route should have changed to a new draft thread after adding a remote project.",
4674 );
4675 } finally {
4676 await mounted.cleanup();
4677 }
4678 });
4679
4680 it("picks a local project from the native file manager", async () => {
4681 const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked");
4682
4683 const mounted = await mountChatView({
4684 viewport: DEFAULT_VIEWPORT,
4685 snapshot: createSnapshotForTargetUser({
4686 targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId,
4687 targetText: "command palette add project file manager",
4688 }),
4689 resolveRpc: (body) => {
4690 if (body._tag === WS_METHODS.filesystemBrowse) {
4691 if (body.partialPath === "~/Applications/") {
4692 return {
4693 parentPath: "~/Applications/",
4694 entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }],
4695 };
4696 }
4697
4698 return {
4699 parentPath: "~/",
4700 entries: [{ name: "Applications", fullPath: "~/Applications" }],
4701 };
4702 }
4703
4704 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
4705 return {
4706 sequence: fixture.snapshot.snapshotSequence + 1,
4707 };
4708 }
4709
4710 return undefined;
4711 },
4712 });
4713
4714 try {
4715 await waitForServerConfigToApply();
4716 window.desktopBridge = {
4717 pickFolder,
4718 setTheme: vi.fn().mockResolvedValue(undefined),
4719 } as unknown as NonNullable<typeof window.desktopBridge>;
4720
4721 await page.getByTestId("sidebar-add-project-trigger").click();
4722
4723 const palette = page.getByTestId("command-palette");
4724 await expect.element(palette).toBeInTheDocument();
4725 const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4726 await browseInput.fill("~/Applications/access");
4727
4728 const fileManagerLabel = isMacPlatform(navigator.platform)
4729 ? "Open in Finder"
4730 : navigator.platform.toLowerCase().startsWith("win")
4731 ? "Open in Explorer"
4732 : "Open in Files";
4733 await palette.getByRole("button", { name: fileManagerLabel }).click();
4734
4735 await vi.waitFor(
4736 () => {
4737 expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" });
4738 },
4739 { timeout: 8_000, interval: 16 },
4740 );
4741
4742 await vi.waitFor(
4743 () => {
4744 const dispatchRequest = wsRequests.find(
4745 (request) =>
4746 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
4747 request.type === "project.create",
4748 ) as
4749 | {
4750 _tag: string;
4751 type?: string;
4752 workspaceRoot?: string;
4753 title?: string;
4754 }
4755 | undefined;
4756
4757 expect(dispatchRequest).toMatchObject({
4758 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
4759 type: "project.create",
4760 workspaceRoot: "/Users/julius/Projects/finder-picked",
4761 title: "finder-picked",
4762 });
4763 },
4764 { timeout: 8_000, interval: 16 },
4765 );
4766
4767 await waitForURL(
4768 mounted.router,
4769 (path) => UUID_ROUTE_RE.test(path),
4770 "Route should have changed to a new draft thread after adding a project from the native file manager.",
4771 );
4772 } finally {
4773 await mounted.cleanup();
4774 }
4775 });
4776
4777 it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => {
4778 const mounted = await mountChatView({
4779 viewport: DEFAULT_VIEWPORT,
4780 snapshot: createSnapshotForTargetUser({
4781 targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId,
4782 targetText: "command palette add project mod enter",
4783 }),
4784 configureFixture: (nextFixture) => {
4785 nextFixture.serverConfig = {
4786 ...nextFixture.serverConfig,
4787 keybindings: [
4788 {
4789 command: "commandPalette.toggle",
4790 shortcut: {
4791 key: "k",
4792 metaKey: false,
4793 ctrlKey: false,
4794 shiftKey: false,
4795 altKey: false,
4796 modKey: true,
4797 },
4798 whenAst: {
4799 type: "not",
4800 node: { type: "identifier", name: "terminalFocus" },
4801 },
4802 },
4803 ],
4804 };
4805 },
4806 resolveRpc: (body) => {
4807 if (body._tag === WS_METHODS.filesystemBrowse) {
4808 if (body.partialPath === "~/Development/") {
4809 return {
4810 parentPath: "~/Development/",
4811 entries: [
4812 { name: "alpha", fullPath: "~/Development/alpha" },
4813 { name: "beta", fullPath: "~/Development/beta" },
4814 ],
4815 };
4816 }
4817
4818 return {
4819 parentPath: "~/",
4820 entries: [{ name: "Development", fullPath: "~/Development" }],
4821 };
4822 }
4823
4824 if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) {
4825 return {
4826 sequence: fixture.snapshot.snapshotSequence + 1,
4827 };
4828 }
4829
4830 return undefined;
4831 },
4832 });
4833
4834 try {
4835 await waitForServerConfigToApply();
4836 await waitForCommandPaletteShortcutLabel();
4837 const palette = page.getByTestId("command-palette");
4838 await openCommandPaletteFromTrigger();
4839
4840 await expect.element(palette).toBeInTheDocument();
4841 await palette.getByText("Add project", { exact: true }).click();
4842
4843 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
4844 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/");
4845 await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument();
4846
4847 await dispatchInputKey(browseInput, { key: "ArrowDown" });
4848
4849 const addButtonLabel = isMacPlatform(navigator.platform)
4850 ? "Add (\u2318 Enter)"
4851 : "Add (Ctrl Enter)";
4852 await vi.waitFor(
4853 () => {
4854 const legendEntries = getCommandPaletteLegendEntries();
4855 expect(legendEntries).toContain("Enter Select");
4856 },
4857 { timeout: 8_000, interval: 16 },
4858 );
4859 await expect
4860 .element(palette.getByRole("button", { name: addButtonLabel }))
4861 .toBeInTheDocument();
4862
4863 await dispatchInputKey(browseInput, {
4864 key: "Enter",
4865 metaKey: isMacPlatform(navigator.platform),
4866 ctrlKey: !isMacPlatform(navigator.platform),
4867 });
4868
4869 await vi.waitFor(
4870 () => {
4871 const dispatchRequest = wsRequests.find(
4872 (request) =>
4873 request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand &&
4874 request.type === "project.create",
4875 ) as
4876 | {
4877 _tag: string;
4878 type?: string;
4879 workspaceRoot?: string;
4880 title?: string;
4881 }
4882 | undefined;
4883
4884 expect(dispatchRequest).toMatchObject({
4885 _tag: ORCHESTRATION_WS_METHODS.dispatchCommand,
4886 type: "project.create",
4887 workspaceRoot: "~/Development",
4888 title: "Development",
4889 });
4890 },
4891 { timeout: 8_000, interval: 16 },
4892 );
4893
4894 await waitForURL(
4895 mounted.router,
4896 (path) => UUID_ROUTE_RE.test(path),
4897 "Route should have changed to a new draft thread after adding a project with Mod+Enter.",
4898 );
4899 } finally {
4900 await mounted.cleanup();
4901 }
4902 });
4903
40824904 it("keeps project-context thread matches available when searching by project name", async () => {
40834905 const mounted = await mountChatView({
40844906 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/CommandPalette.logic.ts+85-3
......@@ -1,4 +1,4 @@
1import { type KeybindingCommand } from "@t3tools/contracts";
1import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts";
22import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
33import { type ReactNode } from "react";
44import { sortThreads } from "../lib/threadSort";
......@@ -45,7 +45,39 @@ export interface CommandPaletteView {
4545 readonly initialQuery?: string;
4646}
4747
48export type CommandPaletteMode = "root" | "submenu";
48export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
49
50export function filterBrowseEntries(input: {
51 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
52 browseFilterQuery: string;
53 highlightedItemValue: string | null;
54}): {
55 filteredEntries: FilesystemBrowseEntry[];
56 highlightedEntry: FilesystemBrowseEntry | null;
57 exactEntry: FilesystemBrowseEntry | null;
58} {
59 const lowerFilter = input.browseFilterQuery.toLowerCase();
60 const showHidden = input.browseFilterQuery.startsWith(".");
61
62 const filteredEntries = input.browseEntries.filter(
63 (entry) =>
64 entry.name.toLowerCase().startsWith(lowerFilter) &&
65 (showHidden || !entry.name.startsWith(".")),
66 );
67
68 let highlightedEntry: FilesystemBrowseEntry | null = null;
69 if (input.highlightedItemValue?.startsWith("browse:")) {
70 const highlightedPath = input.highlightedItemValue.slice("browse:".length);
71 highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null;
72 }
73
74 const exactEntry =
75 input.browseFilterQuery.length > 0
76 ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null)
77 : null;
78
79 return { filteredEntries, highlightedEntry, exactEntry };
80}
4981
5082export function normalizeSearchText(value: string): string {
5183 return value.trim().toLowerCase().replace(/\s+/g, " ");
......@@ -230,10 +262,56 @@ export function filterCommandPaletteGroups(input: {
230262 });
231263}
232264
265export function buildBrowseGroups(input: {
266 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
267 browseQuery: string;
268 canBrowseUp: boolean;
269 upIcon: ReactNode;
270 directoryIcon: ReactNode;
271 browseUp: () => void;
272 browseTo: (name: string) => void;
273}): CommandPaletteGroup[] {
274 const items: CommandPaletteActionItem[] = [];
275
276 if (input.canBrowseUp) {
277 items.push({
278 kind: "action",
279 value: "browse:up",
280 searchTerms: [input.browseQuery, ".."],
281 title: "..",
282 icon: input.upIcon,
283 keepOpen: true,
284 run: async () => {
285 input.browseUp();
286 },
287 });
288 }
289
290 for (const entry of input.browseEntries) {
291 items.push({
292 kind: "action",
293 value: `browse:${entry.fullPath}`,
294 searchTerms: [input.browseQuery, entry.fullPath, entry.name],
295 title: entry.name,
296 icon: input.directoryIcon,
297 keepOpen: true,
298 run: async () => {
299 input.browseTo(entry.name);
300 },
301 });
302 }
303
304 return [{ value: "directories", label: "Directories", items }];
305}
306
233307export function getCommandPaletteMode(input: {
234308 currentView: CommandPaletteView | null;
309 isBrowsing: boolean;
235310}): CommandPaletteMode {
236 return input.currentView ? "submenu" : "root";
311 if (input.currentView) {
312 return input.isBrowsing ? "submenu-browse" : "submenu";
313 }
314 return input.isBrowsing ? "root-browse" : "root";
237315}
238316
239317export function buildRootGroups(input: {
......@@ -258,7 +336,11 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str
258336 switch (mode) {
259337 case "root":
260338 return "Search commands, projects, and threads...";
339 case "root-browse":
340 return "Enter project path (e.g. ~/projects/my-app)";
261341 case "submenu":
262342 return "Search...";
343 case "submenu-browse":
344 return "Enter path (e.g. ~/projects/my-app)";
263345 }
264346}
apps/web/src/components/CommandPalette.tsx+693-39
......@@ -1,17 +1,27 @@
11"use client";
22
33import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
4import type { ProjectId } from "@t3tools/contracts";
4import {
5 DEFAULT_MODEL_BY_PROVIDER,
6 type EnvironmentId,
7 type FilesystemBrowseResult,
8 type ProjectId,
9} from "@t3tools/contracts";
10import { useQuery, useQueryClient } from "@tanstack/react-query";
511import { useNavigate, useParams } from "@tanstack/react-router";
612import {
713 ArrowDownIcon,
814 ArrowLeftIcon,
915 ArrowUpIcon,
16 CornerLeftUpIcon,
17 FolderIcon,
18 FolderPlusIcon,
1019 MessageSquareIcon,
1120 SettingsIcon,
1221 SquarePenIcon,
1322} from "lucide-react";
1423import {
24 useCallback,
1525 useDeferredValue,
1626 useEffect,
1727 useMemo,
......@@ -22,15 +32,37 @@ import {
2232} from "react";
2333import { useShallow } from "zustand/react/shallow";
2434import { useCommandPaletteStore } from "../commandPaletteStore";
35import { readEnvironmentApi } from "../environmentApi";
36import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary";
37import {
38 useSavedEnvironmentRegistryStore,
39 useSavedEnvironmentRuntimeStore,
40} from "../environments/runtime";
2541import { useHandleNewThread } from "../hooks/useHandleNewThread";
2642import { useSettings } from "../hooks/useSettings";
43import { readLocalApi } from "../localApi";
2744import {
2845 startNewThreadInProjectFromContext,
2946 startNewThreadFromContext,
3047} from "../lib/chatThreadActions";
48import {
49 appendBrowsePathSegment,
50 canNavigateUp,
51 ensureBrowseDirectoryPath,
52 findProjectByPath,
53 getBrowseDirectoryPath,
54 getBrowseLeafPathSegment,
55 getBrowseParentPath,
56 hasTrailingPathSeparator,
57 inferProjectTitleFromPath,
58 isExplicitRelativeProjectPath,
59 isFilesystemBrowseQuery,
60 isUnsupportedWindowsProjectPath,
61 resolveProjectPathForDispatch,
62} from "../lib/projectPaths";
3163import { isTerminalFocused } from "../lib/terminalFocus";
3264import { getLatestThreadForProject } from "../lib/threadSort";
33import { cn } from "../lib/utils";
65import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils";
3466import {
3567 selectProjectsAcrossEnvironments,
3668 selectSidebarThreadsAcrossEnvironments,
......@@ -40,18 +72,21 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta
4072import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
4173import {
4274 ADDON_ICON_CLASS,
75 buildBrowseGroups,
4376 buildProjectActionItems,
4477 buildRootGroups,
4578 buildThreadActionItems,
4679 type CommandPaletteActionItem,
4780 type CommandPaletteSubmenuItem,
4881 type CommandPaletteView,
82 filterBrowseEntries,
4983 filterCommandPaletteGroups,
5084 getCommandPaletteInputPlaceholder,
5185 getCommandPaletteMode,
5286 ITEM_ICON_CLASS,
5387 RECENT_THREAD_LIMIT,
5488} from "./CommandPalette.logic";
89import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
5590import { CommandPaletteResults } from "./CommandPaletteResults";
5691import { ProjectFavicon } from "./ProjectFavicon";
5792import { useServerKeybindings } from "../rpc/serverState";
......@@ -64,11 +99,44 @@ import {
6499 CommandInput,
65100 CommandPanel,
66101} from "./ui/command";
102import { Button } from "./ui/button";
67103import { Kbd, KbdGroup } from "./ui/kbd";
68104import { toastManager } from "./ui/toast";
69105import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
70106import type { ChatComposerHandle } from "./chat/ChatComposer";
71107
108const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
109const BROWSE_STALE_TIME_MS = 30_000;
110
111function getLocalFileManagerName(platform: string): string {
112 if (isMacPlatform(platform)) {
113 return "Finder";
114 }
115 if (isWindowsPlatform(platform)) {
116 return "Explorer";
117 }
118 return "Files";
119}
120
121function getEnvironmentBrowsePlatform(os: string | null | undefined): string {
122 if (os === "windows") {
123 return "Win32";
124 }
125 if (os === "darwin") {
126 return "MacIntel";
127 }
128 if (os === "linux") {
129 return "Linux";
130 }
131 return typeof navigator === "undefined" ? "" : navigator.platform;
132}
133
134interface AddProjectEnvironmentOption {
135 readonly environmentId: EnvironmentId;
136 readonly label: string;
137 readonly isPrimary: boolean;
138}
139
72140export function CommandPalette({ children }: { children: ReactNode }) {
73141 const open = useCommandPaletteStore((store) => store.open);
74142 const setOpen = useCommandPaletteStore((store) => store.setOpen);
......@@ -136,10 +204,14 @@ function CommandPaletteDialog() {
136204function OpenCommandPaletteDialog() {
137205 const navigate = useNavigate();
138206 const setOpen = useCommandPaletteStore((store) => store.setOpen);
207 const openIntent = useCommandPaletteStore((store) => store.openIntent);
208 const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent);
139209 const composerHandleRef = useComposerHandleContext();
140210 const [query, setQuery] = useState("");
141211 const deferredQuery = useDeferredValue(query);
142212 const isActionsOnly = deferredQuery.startsWith(">");
213 const queryClient = useQueryClient();
214 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
143215 const settings = useSettings();
144216 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
145217 useHandleNewThread();
......@@ -148,15 +220,199 @@ function OpenCommandPaletteDialog() {
148220 const keybindings = useServerKeybindings();
149221 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
150222 const currentView = viewStack.at(-1) ?? null;
151 const paletteMode = getCommandPaletteMode({ currentView });
223 const [browseGeneration, setBrowseGeneration] = useState(0);
224 const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState<EnvironmentId | null>(
225 null,
226 );
227 const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false);
228 const primaryEnvironmentId = usePrimaryEnvironmentId();
229 const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null;
230 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId);
231 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId);
232
233 const addProjectEnvironmentOptions = useMemo(() => {
234 const options: AddProjectEnvironmentOption[] = [];
235 const seenEnvironmentIds = new Set<EnvironmentId>();
236
237 if (primaryEnvironmentId) {
238 seenEnvironmentIds.add(primaryEnvironmentId);
239 options.push({
240 environmentId: primaryEnvironmentId,
241 label: resolveEnvironmentOptionLabel({
242 isPrimary: true,
243 environmentId: primaryEnvironmentId,
244 runtimeLabel: primaryEnvironmentLabel,
245 }),
246 isPrimary: true,
247 });
248 }
249
250 for (const record of Object.values(savedEnvironmentRegistry)) {
251 if (seenEnvironmentIds.has(record.environmentId)) {
252 continue;
253 }
152254
255 const runtimeState = savedEnvironmentRuntimeById[record.environmentId];
256 options.push({
257 environmentId: record.environmentId,
258 label: resolveEnvironmentOptionLabel({
259 isPrimary: false,
260 environmentId: record.environmentId,
261 runtimeLabel: runtimeState?.descriptor?.label ?? null,
262 savedLabel: record.label,
263 }),
264 isPrimary: false,
265 });
266 }
267
268 options.sort((left, right) => {
269 if (left.isPrimary !== right.isPrimary) {
270 return left.isPrimary ? -1 : 1;
271 }
272 return left.label.localeCompare(right.label);
273 });
274
275 return options;
276 }, [
277 primaryEnvironmentId,
278 primaryEnvironmentLabel,
279 savedEnvironmentRegistry,
280 savedEnvironmentRuntimeById,
281 ]);
282 const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null;
283 const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId;
284 const browseEnvironmentPlatform = useMemo(() => {
285 const os =
286 browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId
287 ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null)
288 : browseEnvironmentId
289 ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ??
290 savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform
291 .os ??
292 null)
293 : null;
294 return getEnvironmentBrowsePlatform(os);
295 }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]);
296 const isBrowsing = isFilesystemBrowseQuery(query, browseEnvironmentPlatform);
297 const paletteMode = getCommandPaletteMode({ currentView, isBrowsing });
298 const getAddProjectInitialQueryForEnvironment = useCallback(
299 (environmentId: EnvironmentId | null): string => {
300 const environmentSettings =
301 environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId
302 ? settings
303 : environmentId
304 ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings
305 : null;
306 const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? "";
307 if (baseDirectory.length === 0) {
308 return "~/";
309 }
310 return ensureBrowseDirectoryPath(baseDirectory);
311 },
312 [primaryEnvironmentId, savedEnvironmentRuntimeById, settings],
313 );
314
315 const projectCwdById = useMemo(
316 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.cwd])),
317 [projects],
318 );
153319 const projectTitleById = useMemo(
154320 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.name])),
155321 [projects],
156322 );
157323
158324 const activeThreadId = activeThread?.id;
325 const currentProjectEnvironmentId =
326 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
159327 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
328 const currentProjectCwd = currentProjectId
329 ? (projectCwdById.get(currentProjectId) ?? null)
330 : null;
331 const currentProjectCwdForBrowse =
332 browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId
333 ? currentProjectCwd
334 : null;
335 const relativePathNeedsActiveProject =
336 isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null;
337 const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : "";
338 const browseFilterQuery =
339 isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : "";
340
341 const fetchBrowseResult = useCallback(
342 async (partialPath: string): Promise<FilesystemBrowseResult | null> => {
343 if (!browseEnvironmentId) return null;
344 const api = readEnvironmentApi(browseEnvironmentId);
345 if (!api) return null;
346 return api.filesystem.browse({
347 partialPath,
348 ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}),
349 });
350 },
351 [browseEnvironmentId, currentProjectCwdForBrowse],
352 );
353
354 const { data: browseResult, isPending: isBrowsePending } = useQuery({
355 queryKey: [
356 "filesystemBrowse",
357 browseEnvironmentId,
358 browseDirectoryPath,
359 currentProjectCwdForBrowse,
360 ],
361 queryFn: () => fetchBrowseResult(browseDirectoryPath),
362 staleTime: BROWSE_STALE_TIME_MS,
363 enabled:
364 isBrowsing &&
365 browseDirectoryPath.length > 0 &&
366 browseEnvironmentId !== null &&
367 !relativePathNeedsActiveProject,
368 });
369 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
370 const {
371 filteredEntries: filteredBrowseEntries,
372 highlightedEntry: highlightedBrowseEntry,
373 exactEntry: exactBrowseEntry,
374 } = useMemo(
375 () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }),
376 [browseEntries, browseFilterQuery, highlightedItemValue],
377 );
378
379 const prefetchBrowsePath = useCallback(
380 (partialPath: string) => {
381 void queryClient.prefetchQuery({
382 queryKey: [
383 "filesystemBrowse",
384 browseEnvironmentId,
385 partialPath,
386 currentProjectCwdForBrowse,
387 ],
388 queryFn: () => fetchBrowseResult(partialPath),
389 staleTime: BROWSE_STALE_TIME_MS,
390 });
391 },
392 [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient],
393 );
394
395 // Prefetch the parent and the most likely next child so browse navigation
396 // stays warm without scanning every child directory in large trees.
397 useEffect(() => {
398 if (!isBrowsing || filteredBrowseEntries.length === 0) return;
399
400 if (canNavigateUp(query)) {
401 prefetchBrowsePath(getBrowseParentPath(query)!);
402 }
403
404 const nextChild = highlightedBrowseEntry ?? exactBrowseEntry;
405 if (nextChild) {
406 prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name));
407 }
408 }, [
409 exactBrowseEntry,
410 filteredBrowseEntries.length,
411 highlightedBrowseEntry,
412 isBrowsing,
413 prefetchBrowsePath,
414 query,
415 ]);
160416
161417 const openProjectFromSearch = useMemo(
162418 () => async (project: (typeof projects)[number]) => {
......@@ -259,30 +515,117 @@ function OpenCommandPaletteDialog() {
259515 );
260516 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
261517
262 function pushView(item: CommandPaletteSubmenuItem): void {
518 function pushPaletteView(view: CommandPaletteView): void {
263519 setViewStack((previousViews) => [
264520 ...previousViews,
265521 {
266 addonIcon: item.addonIcon,
267 groups: item.groups,
268 ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}),
522 addonIcon: view.addonIcon,
523 groups: view.groups,
524 ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
269525 },
270526 ]);
271 setQuery(item.initialQuery ?? "");
527 setHighlightedItemValue(null);
528 setQuery(view.initialQuery ?? "");
529 }
530
531 function pushView(item: CommandPaletteSubmenuItem): void {
532 pushPaletteView({
533 addonIcon: item.addonIcon,
534 groups: item.groups,
535 ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}),
536 });
272537 }
273538
274539 function popView(): void {
540 if (viewStack.length <= 1) {
541 setAddProjectEnvironmentId(null);
542 }
275543 setViewStack((previousViews) => previousViews.slice(0, -1));
544 setHighlightedItemValue(null);
276545 setQuery("");
277546 }
278547
279548 function handleQueryChange(nextQuery: string): void {
549 setHighlightedItemValue(null);
280550 setQuery(nextQuery);
281551 if (nextQuery === "" && currentView?.initialQuery) {
282552 popView();
283553 }
284554 }
285555
556 const startAddProjectBrowse = useCallback(
557 (environmentId: EnvironmentId): void => {
558 setAddProjectEnvironmentId(environmentId);
559 pushPaletteView({
560 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
561 groups: [],
562 initialQuery: getAddProjectInitialQueryForEnvironment(environmentId),
563 });
564 },
565 [getAddProjectInitialQueryForEnvironment],
566 );
567
568 const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map(
569 (option) => ({
570 kind: "action",
571 value: `action:add-project:environment:${option.environmentId}`,
572 searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""],
573 title: option.label,
574 description: option.isPrimary ? "This device" : option.environmentId,
575 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
576 keepOpen: true,
577 run: async () => {
578 startAddProjectBrowse(option.environmentId);
579 },
580 }),
581 );
582
583 const addProjectEnvironmentGroups = useMemo<CommandPaletteView["groups"]>(
584 () => [
585 {
586 value: "environments",
587 label: "Environments",
588 items: addProjectEnvironmentItems,
589 },
590 ],
591 [addProjectEnvironmentItems],
592 );
593
594 const openAddProjectFlow = useCallback(() => {
595 if (addProjectEnvironmentOptions.length > 1) {
596 pushPaletteView({
597 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
598 groups: addProjectEnvironmentGroups,
599 });
600 return;
601 }
602
603 const environmentId = defaultAddProjectEnvironmentId;
604 if (!environmentId) {
605 toastManager.add({
606 type: "error",
607 title: "Unable to browse projects",
608 description: "No environment is available.",
609 });
610 return;
611 }
612
613 startAddProjectBrowse(environmentId);
614 }, [
615 addProjectEnvironmentGroups,
616 addProjectEnvironmentOptions.length,
617 defaultAddProjectEnvironmentId,
618 startAddProjectBrowse,
619 ]);
620
621 useEffect(() => {
622 if (openIntent?.kind !== "add-project") {
623 return;
624 }
625 clearOpenIntent();
626 openAddProjectFlow();
627 }, [clearOpenIntent, openAddProjectFlow, openIntent]);
628
286629 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
287630
288631 if (projects.length > 0) {
......@@ -325,6 +668,30 @@ function OpenCommandPaletteDialog() {
325668 });
326669 }
327670
671 if (addProjectEnvironmentOptions.length > 1) {
672 actionItems.push({
673 kind: "submenu",
674 value: "action:add-project",
675 searchTerms: ["add project", "folder", "directory", "browse", "environment"],
676 title: "Add project",
677 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
678 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
679 groups: addProjectEnvironmentGroups,
680 });
681 } else {
682 actionItems.push({
683 kind: "action",
684 value: "action:add-project",
685 searchTerms: ["add project", "folder", "directory", "browse"],
686 title: "Add project",
687 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
688 keepOpen: true,
689 run: async () => {
690 openAddProjectFlow();
691 },
692 });
693 }
694
328695 actionItems.push({
329696 kind: "action",
330697 value: "action:settings",
......@@ -339,7 +706,7 @@ function OpenCommandPaletteDialog() {
339706 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
340707 const activeGroups = currentView ? currentView.groups : rootGroups;
341708
342 const displayedGroups = filterCommandPaletteGroups({
709 const filteredGroups = filterCommandPaletteGroups({
343710 activeGroups,
344711 query: deferredQuery,
345712 isInSubmenu: currentView !== null,
......@@ -347,10 +714,206 @@ function OpenCommandPaletteDialog() {
347714 threadSearchItems: allThreadItems,
348715 });
349716
717 const handleAddProject = useCallback(
718 async (rawCwd: string) => {
719 if (!browseEnvironmentId) return;
720 const api = readEnvironmentApi(browseEnvironmentId);
721 if (!api) return;
722
723 if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) {
724 toastManager.add({
725 type: "error",
726 title: "Failed to add project",
727 description: "Windows-style paths are only supported on Windows.",
728 });
729 return;
730 }
731
732 if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) {
733 toastManager.add({
734 type: "error",
735 title: "Failed to add project",
736 description: "Relative paths require an active project.",
737 });
738 return;
739 }
740
741 const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse);
742 if (cwd.length === 0) return;
743
744 const existing = findProjectByPath(
745 projects.filter((project) => project.environmentId === browseEnvironmentId),
746 cwd,
747 );
748 if (existing) {
749 const latestThread = getLatestThreadForProject(
750 threads.filter((thread) => thread.environmentId === existing.environmentId),
751 existing.id,
752 settings.sidebarThreadSortOrder,
753 );
754 if (latestThread) {
755 await navigate({
756 to: "/$environmentId/$threadId",
757 params: buildThreadRouteParams(
758 scopeThreadRef(latestThread.environmentId, latestThread.id),
759 ),
760 });
761 } else {
762 await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), {
763 envMode: settings.defaultThreadEnvMode,
764 }).catch(() => undefined);
765 }
766 setOpen(false);
767 return;
768 }
769
770 try {
771 const projectId = newProjectId();
772 await api.orchestration.dispatchCommand({
773 type: "project.create",
774 commandId: newCommandId(),
775 projectId,
776 title: inferProjectTitleFromPath(cwd),
777 workspaceRoot: cwd,
778 createWorkspaceRootIfMissing: true,
779 defaultModelSelection: {
780 provider: "codex",
781 model: DEFAULT_MODEL_BY_PROVIDER.codex,
782 },
783 createdAt: new Date().toISOString(),
784 });
785 await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), {
786 envMode: settings.defaultThreadEnvMode,
787 }).catch(() => undefined);
788 setOpen(false);
789 } catch (error) {
790 toastManager.add({
791 type: "error",
792 title: "Failed to add project",
793 description: error instanceof Error ? error.message : "An error occurred.",
794 });
795 }
796 },
797 [
798 browseEnvironmentId,
799 browseEnvironmentPlatform,
800 currentProjectCwdForBrowse,
801 handleNewThread,
802 navigate,
803 projects,
804 setOpen,
805 settings.defaultThreadEnvMode,
806 settings.sidebarThreadSortOrder,
807 threads,
808 ],
809 );
810
811 function browseTo(name: string): void {
812 const nextQuery = appendBrowsePathSegment(query, name);
813 setHighlightedItemValue(null);
814 setQuery(nextQuery);
815 setBrowseGeneration((generation) => generation + 1);
816 }
817
818 function browseUp(): void {
819 const parentPath = getBrowseParentPath(query);
820 if (parentPath === null) {
821 return;
822 }
823
824 setHighlightedItemValue(null);
825 setQuery(parentPath);
826 setBrowseGeneration((generation) => generation + 1);
827 }
828
829 // Resolve the add-project path from browse data when available. When the
830 // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the
831 // directory itself. Otherwise the user typed a partial leaf name, so we need
832 // the exact browse entry's fullPath or fall back to the raw query.
833 const resolvedAddProjectPath = hasTrailingPathSeparator(query)
834 ? (browseResult?.parentPath ?? query.trim())
835 : (exactBrowseEntry?.fullPath ?? query.trim());
836
837 const canBrowseUp =
838 isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath);
839
840 const browseGroups = buildBrowseGroups({
841 browseEntries: filteredBrowseEntries,
842 browseQuery: query,
843 canBrowseUp,
844 upIcon: <CornerLeftUpIcon className={ITEM_ICON_CLASS} />,
845 directoryIcon: <FolderIcon className={ITEM_ICON_CLASS} />,
846 browseUp,
847 browseTo,
848 });
849
850 let displayedGroups = filteredGroups;
851 if (isBrowsing) {
852 displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
853 }
854
350855 const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode);
351 const isSubmenu = paletteMode === "submenu";
856 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
857 const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;
858 const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject;
859 const willCreateProjectPath =
860 canSubmitBrowsePath &&
861 !isBrowsePending &&
862 query.trim().length > 0 &&
863 !hasHighlightedBrowseItem &&
864 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);
865 const useMetaForMod = isMacPlatform(navigator.platform);
866 const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl";
867 const submitActionLabel = willCreateProjectPath ? "Create & Add" : "Add";
868 const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter";
869 const fileManagerName = getLocalFileManagerName(navigator.platform);
870 const canOpenProjectFromFileManager =
871 isBrowsing &&
872 browseEnvironmentId !== null &&
873 primaryEnvironmentId !== null &&
874 browseEnvironmentId === primaryEnvironmentId &&
875 typeof window !== "undefined" &&
876 window.desktopBridge !== undefined;
877 const fileManagerInitialPath = useMemo(() => {
878 if (!canOpenProjectFromFileManager) {
879 return undefined;
880 }
881
882 const trimmedQuery = query.trim();
883 if (trimmedQuery.length === 0) {
884 return undefined;
885 }
886
887 const initialPath = hasTrailingPathSeparator(query)
888 ? (browseResult?.parentPath ?? trimmedQuery)
889 : browseDirectoryPath || trimmedQuery;
890
891 const resolvedPath = resolveProjectPathForDispatch(initialPath, currentProjectCwdForBrowse);
892 return resolvedPath.length > 0 ? resolvedPath : undefined;
893 }, [
894 browseDirectoryPath,
895 browseResult?.parentPath,
896 canOpenProjectFromFileManager,
897 currentProjectCwdForBrowse,
898 query,
899 ]);
900
901 function isPrimaryModifierPressed(event: KeyboardEvent<HTMLInputElement>): boolean {
902 return useMetaForMod ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
903 }
352904
353905 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
906 const shouldSubmitBrowsePath =
907 canSubmitBrowsePath &&
908 event.key === "Enter" &&
909 (!hasHighlightedBrowseItem || isPrimaryModifierPressed(event));
910
911 if (shouldSubmitBrowsePath) {
912 event.preventDefault();
913 void handleAddProject(resolvedAddProjectPath);
914 return;
915 }
916
354917 if (event.key === "Backspace" && query === "" && isSubmenu) {
355918 event.preventDefault();
356919 popView();
......@@ -376,6 +939,38 @@ function OpenCommandPaletteDialog() {
376939 });
377940 }
378941
942 const handleOpenProjectFromFileManager = useCallback(async () => {
943 if (!canOpenProjectFromFileManager || isPickingProjectFolder) {
944 return;
945 }
946 const api = readLocalApi();
947 if (!api) {
948 return;
949 }
950
951 setIsPickingProjectFolder(true);
952 let pickedPath: string | null = null;
953 try {
954 pickedPath = await api.dialogs.pickFolder(
955 fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined,
956 );
957 } catch {
958 // Ignore picker failures and leave the palette open.
959 setIsPickingProjectFolder(false);
960 return;
961 }
962 setIsPickingProjectFolder(false);
963 if (!pickedPath) {
964 return;
965 }
966 await handleAddProject(pickedPath);
967 }, [
968 canOpenProjectFromFileManager,
969 fileManagerInitialPath,
970 handleAddProject,
971 isPickingProjectFolder,
972 ]);
973
379974 return (
380975 <CommandDialogPopup
381976 aria-label="Command palette"
......@@ -387,42 +982,86 @@ function OpenCommandPaletteDialog() {
387982 }}
388983 >
389984 <Command
390 key={viewStack.length}
985 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}`}
391986 aria-label="Command palette"
392 autoHighlight="always"
987 autoHighlight={isBrowsing ? false : "always"}
393988 mode="none"
989 onItemHighlighted={(value) => {
990 setHighlightedItemValue(typeof value === "string" ? value : null);
991 }}
394992 onValueChange={handleQueryChange}
395993 value={query}
396994 >
397 <CommandInput
398 placeholder={inputPlaceholder}
399 wrapperClassName={
400 isSubmenu
401 ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto [&_[data-slot=autocomplete-start-addon]]:cursor-pointer"
402 : undefined
403 }
404 {...(isSubmenu
405 ? {
406 startAddon: (
407 <button
408 type="button"
409 className="flex cursor-pointer items-center"
410 aria-label="Back"
411 onClick={popView}
412 >
413 <ArrowLeftIcon />
414 </button>
415 ),
416 }
417 : {})}
418 onKeyDown={handleKeyDown}
419 />
995 <div className="relative">
996 <CommandInput
997 className={isBrowsing ? (willCreateProjectPath ? "pe-36" : "pe-16") : undefined}
998 placeholder={inputPlaceholder}
999 wrapperClassName={
1000 isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined
1001 }
1002 {...(isSubmenu
1003 ? {
1004 startAddon: (
1005 <button
1006 type="button"
1007 className="flex cursor-pointer items-center"
1008 aria-label="Back"
1009 onClick={popView}
1010 >
1011 <ArrowLeftIcon />
1012 </button>
1013 ),
1014 }
1015 : isBrowsing && !isSubmenu
1016 ? {
1017 startAddon: <FolderPlusIcon />,
1018 }
1019 : {})}
1020 onKeyDown={handleKeyDown}
1021 />
1022 {isBrowsing ? (
1023 <Button
1024 variant="outline"
1025 size="xs"
1026 tabIndex={-1}
1027 className={cn(
1028 "absolute end-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2",
1029 hasHighlightedBrowseItem ? "gap-1" : "gap-1.5",
1030 )}
1031 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
1032 disabled={relativePathNeedsActiveProject}
1033 onMouseDown={(event) => {
1034 event.preventDefault();
1035 }}
1036 onClick={() => {
1037 if (relativePathNeedsActiveProject) {
1038 return;
1039 }
1040 void handleAddProject(resolvedAddProjectPath);
1041 }}
1042 title={`${submitActionLabel} (${addShortcutLabel})`}
1043 >
1044 <span>{submitActionLabel}</span>
1045 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
1046 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
1047 </KbdGroup>
1048 </Button>
1049 ) : null}
1050 </div>
4201051 <CommandPanel className="max-h-[min(28rem,70vh)]">
4211052 <CommandPaletteResults
4221053 groups={displayedGroups}
1054 highlightedItemValue={highlightedItemValue}
4231055 isActionsOnly={isActionsOnly}
4241056 keybindings={keybindings}
4251057 onExecuteItem={executeItem}
1058 {...(relativePathNeedsActiveProject
1059 ? { emptyStateMessage: "Relative paths require an active project." }
1060 : willCreateProjectPath
1061 ? {
1062 emptyStateMessage: "Press Enter to create this folder and add it as a project.",
1063 }
1064 : {})}
4261065 />
4271066 </CommandPanel>
4281067 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
......@@ -436,10 +1075,12 @@ function OpenCommandPaletteDialog() {
4361075 </Kbd>
4371076 <span className={cn("text-muted-foreground/80")}>Navigate</span>
4381077 </KbdGroup>
439 <KbdGroup className="items-center gap-1.5">
440 <Kbd>Enter</Kbd>
441 <span className={cn("text-muted-foreground/80")}>Select</span>
442 </KbdGroup>
1078 {!canSubmitBrowsePath || hasHighlightedBrowseItem ? (
1079 <KbdGroup className="items-center gap-1.5">
1080 <Kbd>Enter</Kbd>
1081 <span className={cn("text-muted-foreground/80")}>Select</span>
1082 </KbdGroup>
1083 ) : null}
4431084 {isSubmenu ? (
4441085 <KbdGroup className="items-center gap-1.5">
4451086 <Kbd>Backspace</Kbd>
......@@ -451,6 +1092,19 @@ function OpenCommandPaletteDialog() {
4511092 <span className={cn("text-muted-foreground/80")}>Close</span>
4521093 </KbdGroup>
4531094 </div>
1095 {canOpenProjectFromFileManager ? (
1096 <Button
1097 variant="ghost"
1098 size="xs"
1099 className="h-auto px-2 text-xs text-muted-foreground/80 hover:bg-transparent hover:text-foreground"
1100 disabled={isPickingProjectFolder}
1101 onClick={() => {
1102 void handleOpenProjectFromFileManager();
1103 }}
1104 >
1105 {`Open in ${fileManagerName}`}
1106 </Button>
1107 ) : null}
4541108 </CommandFooter>
4551109 </Command>
4561110 </CommandDialogPopup>
apps/web/src/components/CommandPaletteResults.tsx+8-1
......@@ -14,10 +14,12 @@ import {
1414 CommandList,
1515 CommandShortcut,
1616} from "./ui/command";
17import { cn } from "~/lib/utils";
1718
1819interface CommandPaletteResultsProps {
1920 emptyStateMessage?: string;
2021 groups: ReadonlyArray<CommandPaletteGroup>;
22 highlightedItemValue?: string | null;
2123 isActionsOnly: boolean;
2224 keybindings: ResolvedKeybindingsConfig;
2325 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
......@@ -46,6 +48,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {
4648 item={item}
4749 key={item.value}
4850 keybindings={props.keybindings}
51 isActive={props.highlightedItemValue === item.value}
4952 onExecuteItem={props.onExecuteItem}
5053 />
5154 )}
......@@ -58,6 +61,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {
5861
5962function CommandPaletteResultRow(props: {
6063 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
64 isActive: boolean;
6165 keybindings: ResolvedKeybindingsConfig;
6266 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
6367}) {
......@@ -68,7 +72,10 @@ function CommandPaletteResultRow(props: {
6872 return (
6973 <CommandItem
7074 value={props.item.value}
71 className="cursor-pointer gap-2"
75 className={cn(
76 "cursor-pointer gap-2 hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit data-selected:bg-transparent data-selected:text-inherit [&[data-highlighted][data-selected]]:bg-transparent [&[data-highlighted][data-selected]]:text-inherit",
77 props.isActive && "bg-accent! text-accent-foreground!",
78 )}
7279 onMouseDown={(event) => {
7380 event.preventDefault();
7481 }}
apps/web/src/components/Sidebar.tsx+12-258
......@@ -3,7 +3,6 @@ import {
33 ArrowUpDownIcon,
44 ChevronRightIcon,
55 CloudIcon,
6 FolderIcon,
76 GitPullRequestIcon,
87 PlusIcon,
98 SearchIcon,
......@@ -32,9 +31,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
3231import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
3332import { CSS } from "@dnd-kit/utilities";
3433import {
35 DEFAULT_MODEL_BY_PROVIDER,
3634 type DesktopUpdateState,
37 type EnvironmentId,
3835 ProjectId,
3936 type ScopedProjectRef,
4037 type ScopedThreadRef,
......@@ -58,7 +55,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary";
5855import { isElectron } from "../env";
5956import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
6057import { isTerminalFocused } from "../lib/terminalFocus";
61import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
58import { isMacPlatform, newCommandId } from "../lib/utils";
6259import {
6360 selectProjectByRef,
6461 selectProjectsAcrossEnvironments,
......@@ -122,7 +119,7 @@ import {
122119 SidebarTrigger,
123120} from "./ui/sidebar";
124121import { useThreadSelectionStore } from "../threadSelectionStore";
125import { isNonEmpty as isNonEmptyString } from "effect/String";
122import { useCommandPaletteStore } from "../commandPaletteStore";
126123import {
127124 getSidebarThreadIdsToPrewarm,
128125 resolveAdjacentThreadId,
......@@ -2033,20 +2030,7 @@ interface SidebarProjectsContentProps {
20332030 projectSortOrder: SidebarProjectSortOrder;
20342031 threadSortOrder: SidebarThreadSortOrder;
20352032 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
2036 shouldShowProjectPathEntry: boolean;
2037 handleStartAddProject: () => void;
2038 isElectron: boolean;
2039 isPickingFolder: boolean;
2040 isAddingProject: boolean;
2041 handlePickFolder: () => Promise<void>;
2042 addProjectInputRef: React.RefObject<HTMLInputElement | null>;
2043 addProjectError: string | null;
2044 newCwd: string;
2045 setNewCwd: React.Dispatch<React.SetStateAction<string>>;
2046 setAddProjectError: React.Dispatch<React.SetStateAction<string | null>>;
2047 handleAddProject: () => void;
2048 setAddingProject: React.Dispatch<React.SetStateAction<boolean>>;
2049 canAddProject: boolean;
2033 openAddProject: () => void;
20502034 isManualProjectSorting: boolean;
20512035 projectDnDSensors: ReturnType<typeof useSensors>;
20522036 projectCollisionDetection: CollisionDetection;
......@@ -2085,20 +2069,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
20852069 projectSortOrder,
20862070 threadSortOrder,
20872071 updateSettings,
2088 shouldShowProjectPathEntry,
2089 handleStartAddProject,
2090 isElectron,
2091 isPickingFolder,
2092 isAddingProject,
2093 handlePickFolder,
2094 addProjectInputRef,
2095 addProjectError,
2096 newCwd,
2097 setNewCwd,
2098 setAddProjectError,
2099 handleAddProject,
2100 setAddingProject,
2101 canAddProject,
2072 openAddProject,
21022073 isManualProjectSorting,
21032074 projectDnDSensors,
21042075 projectCollisionDetection,
......@@ -2137,26 +2108,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
21372108 },
21382109 [updateSettings],
21392110 );
2140 const handleAddProjectInputChange = useCallback(
2141 (event: React.ChangeEvent<HTMLInputElement>) => {
2142 setNewCwd(event.target.value);
2143 setAddProjectError(null);
2144 },
2145 [setAddProjectError, setNewCwd],
2146 );
2147 const handleAddProjectInputKeyDown = useCallback(
2148 (event: React.KeyboardEvent<HTMLInputElement>) => {
2149 if (event.key === "Enter") handleAddProject();
2150 if (event.key === "Escape") {
2151 setAddingProject(false);
2152 setAddProjectError(null);
2153 }
2154 },
2155 [handleAddProject, setAddProjectError, setAddingProject],
2156 );
2157 const handleBrowseForFolderClick = useCallback(() => {
2158 void handlePickFolder();
2159 }, [handlePickFolder]);
21602111
21612112 return (
21622113 <SidebarContent className="gap-0">
......@@ -2223,68 +2174,19 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
22232174 render={
22242175 <button
22252176 type="button"
2226 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2227 aria-pressed={shouldShowProjectPathEntry}
2177 aria-label="Add project"
2178 data-testid="sidebar-add-project-trigger"
22282179 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
2229 onClick={handleStartAddProject}
2180 onClick={openAddProject}
22302181 />
22312182 }
22322183 >
2233 <PlusIcon
2234 className={`size-3.5 transition-transform duration-150 ${
2235 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
2236 }`}
2237 />
2184 <PlusIcon className="size-3.5" />
22382185 </TooltipTrigger>
2239 <TooltipPopup side="right">
2240 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2241 </TooltipPopup>
2186 <TooltipPopup side="right">Add project</TooltipPopup>
22422187 </Tooltip>
22432188 </div>
22442189 </div>
2245 {shouldShowProjectPathEntry && (
2246 <div className="mb-2 px-1">
2247 {isElectron && (
2248 <button
2249 type="button"
2250 className="mb-1.5 flex w-full items-center justify-center gap-2 rounded-md border border-border bg-secondary py-1.5 text-xs text-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
2251 onClick={handleBrowseForFolderClick}
2252 disabled={isPickingFolder || isAddingProject}
2253 >
2254 <FolderIcon className="size-3.5" />
2255 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
2256 </button>
2257 )}
2258 <div className="flex gap-1.5">
2259 <input
2260 ref={addProjectInputRef}
2261 className={`min-w-0 flex-1 rounded-md border bg-secondary px-2 py-1 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none ${
2262 addProjectError
2263 ? "border-red-500/70 focus:border-red-500"
2264 : "border-border focus:border-ring"
2265 }`}
2266 placeholder="/path/to/project"
2267 value={newCwd}
2268 onChange={handleAddProjectInputChange}
2269 onKeyDown={handleAddProjectInputKeyDown}
2270 autoFocus
2271 />
2272 <button
2273 type="button"
2274 className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90 disabled:opacity-60"
2275 onClick={handleAddProject}
2276 disabled={!canAddProject}
2277 >
2278 {isAddingProject ? "Adding..." : "Add"}
2279 </button>
2280 </div>
2281 {addProjectError && (
2282 <p className="mt-1 px-0.5 text-[11px] leading-tight text-red-400">
2283 {addProjectError}
2284 </p>
2285 )}
2286 </div>
2287 )}
22882190
22892191 {isManualProjectSorting ? (
22902192 <DndContext
......@@ -2359,7 +2261,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
23592261 </SidebarMenu>
23602262 )}
23612263
2362 {projectsLength === 0 && !shouldShowProjectPathEntry && (
2264 {projectsLength === 0 && (
23632265 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
23642266 No projects yet
23652267 </div>
......@@ -2372,7 +2274,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
23722274export default function Sidebar() {
23732275 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
23742276 const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
2375 const activeEnvironmentId = useStore((store) => store.activeEnvironmentId);
23762277 const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
23772278 const projectOrder = useUiStateStore((store) => store.projectOrder);
23782279 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
......@@ -2381,7 +2282,6 @@ export default function Sidebar() {
23812282 const isOnSettings = pathname.startsWith("/settings");
23822283 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);
23832284 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);
2384 const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode);
23852285 const { updateSettings } = useUpdateSettings();
23862286 const { handleNewThread } = useNewThreadHandler();
23872287 const { archiveThread, deleteThread } = useThreadActions();
......@@ -2391,12 +2291,7 @@ export default function Sidebar() {
23912291 });
23922292 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
23932293 const keybindings = useServerKeybindings();
2394 const [addingProject, setAddingProject] = useState(false);
2395 const [newCwd, setNewCwd] = useState("");
2396 const [isPickingFolder, setIsPickingFolder] = useState(false);
2397 const [isAddingProject, setIsAddingProject] = useState(false);
2398 const [addProjectError, setAddProjectError] = useState<string | null>(null);
2399 const addProjectInputRef = useRef<HTMLInputElement | null>(null);
2294 const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject);
24002295 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
24012296 ReadonlySet<string>
24022297 >(() => new Set());
......@@ -2408,10 +2303,7 @@ export default function Sidebar() {
24082303 const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size);
24092304 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
24102305 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
2411 const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
24122306 const platform = navigator.platform;
2413 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
2414 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
24152307 const primaryEnvironmentId = usePrimaryEnvironmentId();
24162308 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
24172309 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
......@@ -2574,131 +2466,6 @@ export default function Sidebar() {
25742466 const newThreadShortcutLabel =
25752467 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
25762468 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
2577 const focusMostRecentThreadForProject = useCallback(
2578 (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => {
2579 const physicalKey = scopedProjectKey(
2580 scopeProjectRef(projectRef.environmentId, projectRef.projectId),
2581 );
2582 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2583 const latestThread = sortThreads(
2584 (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null),
2585 sidebarThreadSortOrder,
2586 )[0];
2587 if (!latestThread) return;
2588
2589 void navigate({
2590 to: "/$environmentId/$threadId",
2591 params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)),
2592 });
2593 },
2594 [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey],
2595 );
2596
2597 const addProjectFromInput = useCallback(
2598 async (rawCwd: string) => {
2599 const cwd = rawCwd.trim();
2600 if (!cwd || isAddingProject) return;
2601 const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;
2602 if (!api) return;
2603
2604 setIsAddingProject(true);
2605 const finishAddingProject = () => {
2606 setIsAddingProject(false);
2607 setNewCwd("");
2608 setAddProjectError(null);
2609 setAddingProject(false);
2610 };
2611
2612 const existing = projects.find((project) => project.cwd === cwd);
2613 if (existing) {
2614 focusMostRecentThreadForProject({
2615 environmentId: existing.environmentId,
2616 projectId: existing.id,
2617 });
2618 finishAddingProject();
2619 return;
2620 }
2621
2622 const projectId = newProjectId();
2623 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
2624 try {
2625 await api.orchestration.dispatchCommand({
2626 type: "project.create",
2627 commandId: newCommandId(),
2628 projectId,
2629 title,
2630 workspaceRoot: cwd,
2631 defaultModelSelection: {
2632 provider: "codex",
2633 model: DEFAULT_MODEL_BY_PROVIDER.codex,
2634 },
2635 createdAt: new Date().toISOString(),
2636 });
2637 if (activeEnvironmentId !== null) {
2638 await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), {
2639 envMode: defaultThreadEnvMode,
2640 }).catch(() => undefined);
2641 }
2642 } catch (error) {
2643 const description =
2644 error instanceof Error ? error.message : "An error occurred while adding the project.";
2645 setIsAddingProject(false);
2646 if (shouldBrowseForProjectImmediately) {
2647 toastManager.add({
2648 type: "error",
2649 title: "Failed to add project",
2650 description,
2651 });
2652 } else {
2653 setAddProjectError(description);
2654 }
2655 return;
2656 }
2657 finishAddingProject();
2658 },
2659 [
2660 focusMostRecentThreadForProject,
2661 activeEnvironmentId,
2662 handleNewThread,
2663 isAddingProject,
2664 projects,
2665 shouldBrowseForProjectImmediately,
2666 defaultThreadEnvMode,
2667 ],
2668 );
2669
2670 const handleAddProject = () => {
2671 void addProjectFromInput(newCwd);
2672 };
2673
2674 const canAddProject = newCwd.trim().length > 0 && !isAddingProject;
2675
2676 const handlePickFolder = async () => {
2677 const api = readLocalApi();
2678 if (!api || isPickingFolder) return;
2679 setIsPickingFolder(true);
2680 let pickedPath: string | null = null;
2681 try {
2682 pickedPath = await api.dialogs.pickFolder();
2683 } catch {
2684 // Ignore picker failures and leave the current thread selection unchanged.
2685 }
2686 if (pickedPath) {
2687 await addProjectFromInput(pickedPath);
2688 } else if (!shouldBrowseForProjectImmediately) {
2689 addProjectInputRef.current?.focus();
2690 }
2691 setIsPickingFolder(false);
2692 };
2693
2694 const handleStartAddProject = () => {
2695 setAddProjectError(null);
2696 if (shouldBrowseForProjectImmediately) {
2697 void handlePickFolder();
2698 return;
2699 }
2700 setAddingProject((prev) => !prev);
2701 };
27022469
27032470 const navigateToThread = useCallback(
27042471 (threadRef: ScopedThreadRef) => {
......@@ -3212,20 +2979,7 @@ export default function Sidebar() {
32122979 projectSortOrder={sidebarProjectSortOrder}
32132980 threadSortOrder={sidebarThreadSortOrder}
32142981 updateSettings={updateSettings}
3215 shouldShowProjectPathEntry={shouldShowProjectPathEntry}
3216 handleStartAddProject={handleStartAddProject}
3217 isElectron={isElectron}
3218 isPickingFolder={isPickingFolder}
3219 isAddingProject={isAddingProject}
3220 handlePickFolder={handlePickFolder}
3221 addProjectInputRef={addProjectInputRef}
3222 addProjectError={addProjectError}
3223 newCwd={newCwd}
3224 setNewCwd={setNewCwd}
3225 setAddProjectError={setAddProjectError}
3226 handleAddProject={handleAddProject}
3227 setAddingProject={setAddingProject}
3228 canAddProject={canAddProject}
2982 openAddProject={openAddProjectCommandPalette}
32292983 isManualProjectSorting={isManualProjectSorting}
32302984 projectDnDSensors={projectDnDSensors}
32312985 projectCollisionDetection={projectCollisionDetection}
apps/web/src/components/settings/SettingsPanels.tsx+32
......@@ -444,6 +444,9 @@ export function useSettingsRestore(onRestored?: () => void) {
444444 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
445445 ? ["New thread mode"]
446446 : []),
447 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
448 ? ["Add project base directory"]
449 : []),
447450 ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
448451 ? ["Archive confirmation"]
449452 : []),
......@@ -458,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
458461 isGitWritingModelDirty,
459462 settings.confirmThreadArchive,
460463 settings.confirmThreadDelete,
464 settings.addProjectBaseDirectory,
461465 settings.defaultThreadEnvMode,
462466 settings.diffWordWrap,
463467 settings.enableAssistantStreaming,
......@@ -919,6 +923,34 @@ export function GeneralSettingsPanel() {
919923 }
920924 />
921925
926 <SettingsRow
927 title="Add project starts in"
928 description='Leave empty to use "~/" when the Add Project browser opens.'
929 resetAction={
930 settings.addProjectBaseDirectory !==
931 DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? (
932 <SettingResetButton
933 label="add project base directory"
934 onClick={() =>
935 updateSettings({
936 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
937 })
938 }
939 />
940 ) : null
941 }
942 control={
943 <Input
944 className="w-full sm:w-72"
945 value={settings.addProjectBaseDirectory}
946 onChange={(event) => updateSettings({ addProjectBaseDirectory: event.target.value })}
947 placeholder="~/"
948 spellCheck={false}
949 aria-label="Add project base directory"
950 />
951 }
952 />
953
922954 <SettingsRow
923955 title="Archive confirmation"
924956 description="Require a second click on the inline archive action before a thread is archived."
apps/web/src/environmentApi.ts+21
......@@ -3,6 +3,8 @@ import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
33import type { WsRpcClient } from "./rpc/wsRpcClient";
44import { readEnvironmentConnection } from "./environments/runtime";
55
6const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>();
7
68export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
79 return {
810 terminal: {
......@@ -18,6 +20,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
1820 searchEntries: rpcClient.projects.searchEntries,
1921 writeFile: rpcClient.projects.writeFile,
2022 },
23 filesystem: {
24 browse: rpcClient.filesystem.browse,
25 },
2126 git: {
2227 pull: rpcClient.git.pull,
2328 refreshStatus: rpcClient.git.refreshStatus,
......@@ -52,6 +57,11 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi
5257 return undefined;
5358 }
5459
60 const overriddenApi = environmentApiOverridesForTests.get(environmentId);
61 if (overriddenApi) {
62 return overriddenApi;
63 }
64
5565 const connection = readEnvironmentConnection(environmentId);
5666 return connection ? createEnvironmentApi(connection.client) : undefined;
5767}
......@@ -63,3 +73,14 @@ export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentA
6373 }
6474 return api;
6575}
76
77export function __setEnvironmentApiOverrideForTests(
78 environmentId: EnvironmentId,
79 api: EnvironmentApi,
80): void {
81 environmentApiOverridesForTests.set(environmentId, api);
82}
83
84export function __resetEnvironmentApiOverridesForTests(): void {
85 environmentApiOverridesForTests.clear();
86}
apps/web/src/lib/projectPaths.test.ts created+117
......@@ -0,0 +1,117 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 appendBrowsePathSegment,
5 canNavigateUp,
6 getBrowseDirectoryPath,
7 findProjectByPath,
8 getBrowseLeafPathSegment,
9 getBrowseParentPath,
10 hasTrailingPathSeparator,
11 inferProjectTitleFromPath,
12 isExplicitRelativeProjectPath,
13 isFilesystemBrowseQuery,
14 normalizeProjectPathForComparison,
15 normalizeProjectPathForDispatch,
16 isUnsupportedWindowsProjectPath,
17 resolveProjectPathForDispatch,
18} from "./projectPaths";
19
20describe("projectPaths", () => {
21 it("normalizes trailing separators for dispatch and comparison", () => {
22 expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app");
23 expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app");
24 });
25
26 it("normalizes windows-style paths for comparison", () => {
27 expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo");
28 expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo");
29 });
30
31 it("finds existing projects even when the input formatting differs", () => {
32 const existing = findProjectByPath(
33 [
34 { id: "project-1", cwd: "/repo/app" },
35 { id: "project-2", cwd: "C:\\Work\\Repo" },
36 ],
37 "C:/Work/Repo/",
38 );
39
40 expect(existing?.id).toBe("project-2");
41 });
42
43 it("infers project titles from normalized paths", () => {
44 expect(inferProjectTitleFromPath("/repo/app/")).toBe("app");
45 expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo");
46 expect(inferProjectTitleFromPath("/home/user\\project/")).toBe("user\\project");
47 });
48
49 it("detects browse queries across supported path styles", () => {
50 expect(isFilesystemBrowseQuery(".")).toBe(false);
51 expect(isFilesystemBrowseQuery("..")).toBe(false);
52 expect(isFilesystemBrowseQuery("./")).toBe(true);
53 expect(isFilesystemBrowseQuery("../")).toBe(true);
54 expect(isFilesystemBrowseQuery("~/projects")).toBe(true);
55 expect(isFilesystemBrowseQuery("..\\docs")).toBe(true);
56 expect(isFilesystemBrowseQuery("notes")).toBe(false);
57 });
58
59 it("only treats windows-style paths as browse queries on windows", () => {
60 expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false);
61 expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true);
62 expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true);
63 expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false);
64 });
65
66 it("detects explicit relative project paths", () => {
67 expect(isExplicitRelativeProjectPath(".")).toBe(true);
68 expect(isExplicitRelativeProjectPath("..")).toBe(true);
69 expect(isExplicitRelativeProjectPath("./docs")).toBe(true);
70 expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true);
71 expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false);
72 });
73
74 it("resolves explicit relative paths against the current project", () => {
75 expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app");
76 expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo");
77 expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs");
78 expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs");
79 expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo");
80 expect(resolveProjectPathForDispatch("./docs", "/home/user\\project")).toBe(
81 "/home/user\\project/docs",
82 );
83 });
84
85 it("navigates browse paths with matching separators", () => {
86 expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/");
87 expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\");
88 expect(appendBrowsePathSegment("/home/user\\project/", "docs")).toBe(
89 "/home/user\\project/docs/",
90 );
91 expect(getBrowseParentPath("/repo/src/")).toBe("/repo/");
92 expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\");
93 expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull();
94 expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\");
95 expect(getBrowseParentPath("C:\\")).toBeNull();
96 expect(getBrowseParentPath("/home/user\\project/docs/")).toBe("/home/user\\project/");
97 });
98
99 it("detects browse path boundaries", () => {
100 expect(hasTrailingPathSeparator("/repo/src/")).toBe(true);
101 expect(hasTrailingPathSeparator("/repo/src")).toBe(false);
102 expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/");
103 expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/");
104 expect(getBrowseLeafPathSegment("/repo/src")).toBe("src");
105 expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs");
106 expect(getBrowseDirectoryPath("/home/user\\project/docs")).toBe("/home/user\\project/");
107 expect(getBrowseLeafPathSegment("/home/user\\project/docs")).toBe("docs");
108 });
109
110 it("only allows browse-up after entering a directory", () => {
111 expect(canNavigateUp("~/repo")).toBe(false);
112 expect(canNavigateUp("~/a")).toBe(false);
113 expect(canNavigateUp("~/repo/")).toBe(true);
114 expect(canNavigateUp("\\\\server\\share\\")).toBe(false);
115 expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true);
116 });
117});
apps/web/src/lib/projectPaths.ts created+259
......@@ -0,0 +1,259 @@
1import {
2 isExplicitRelativePath,
3 isUncPath,
4 isWindowsAbsolutePath,
5 isWindowsDrivePath,
6} from "@t3tools/shared/path";
7import { isWindowsPlatform } from "./utils";
8
9function isRootPath(value: string): boolean {
10 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
11}
12
13function getAbsolutePathKind(value: string): "unix" | "windows" | null {
14 if (isWindowsDrivePath(value) || isUncPath(value)) {
15 return "windows";
16 }
17
18 if (value.startsWith("/")) {
19 return "unix";
20 }
21
22 return null;
23}
24
25function trimTrailingPathSeparators(value: string): string {
26 if (value.length === 0 || isRootPath(value)) {
27 return value;
28 }
29
30 const trimmed =
31 getAbsolutePathKind(value) === "unix"
32 ? value.replace(/\/+$/g, "")
33 : value.replace(/[\\/]+$/g, "");
34 if (trimmed.length === 0) {
35 return value;
36 }
37
38 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
39}
40
41function preferredPathSeparator(value: string): "/" | "\\" {
42 const absolutePathKind = getAbsolutePathKind(value);
43 if (absolutePathKind === "windows") {
44 return "\\";
45 }
46 if (absolutePathKind === "unix") {
47 return "/";
48 }
49
50 return value.includes("\\") ? "\\" : "/";
51}
52
53export function hasTrailingPathSeparator(value: string): boolean {
54 return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
55}
56
57export { isExplicitRelativePath as isExplicitRelativeProjectPath };
58
59function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
60 return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
61}
62
63function getLastPathSeparatorIndex(value: string): number {
64 if (getAbsolutePathKind(value) === "unix") {
65 return value.lastIndexOf("/");
66 }
67
68 return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
69}
70
71function splitAbsolutePath(value: string): {
72 root: string;
73 separator: "/" | "\\";
74 segments: string[];
75} | null {
76 if (isWindowsDrivePath(value)) {
77 const root = `${value.slice(0, 2)}\\`;
78 const segments = splitPathSegments(value.slice(root.length), "\\");
79 return { root, separator: "\\", segments };
80 }
81 if (isUncPath(value)) {
82 const segments = splitPathSegments(value, "\\");
83 const [server, share, ...rest] = segments;
84 if (!server || !share) {
85 return null;
86 }
87 return {
88 root: `\\\\${server}\\${share}\\`,
89 separator: "\\",
90 segments: rest,
91 };
92 }
93 if (value.startsWith("/")) {
94 return {
95 root: "/",
96 separator: "/",
97 segments: splitPathSegments(value.slice(1), "/"),
98 };
99 }
100 return null;
101}
102
103export function isFilesystemBrowseQuery(
104 value: string,
105 platform = typeof navigator === "undefined" ? "" : navigator.platform,
106): boolean {
107 const allowWindowsPaths = isWindowsPlatform(platform);
108 return (
109 value.startsWith("./") ||
110 value.startsWith("../") ||
111 value.startsWith(".\\") ||
112 value.startsWith("..\\") ||
113 value.startsWith("/") ||
114 value.startsWith("~/") ||
115 (allowWindowsPaths && isWindowsAbsolutePath(value))
116 );
117}
118
119export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
120 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
121}
122
123export function normalizeProjectPathForDispatch(value: string): string {
124 return trimTrailingPathSeparators(value.trim());
125}
126
127export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
128 const trimmedValue = value.trim();
129 if (!isExplicitRelativePath(trimmedValue) || !cwd) {
130 return normalizeProjectPathForDispatch(trimmedValue);
131 }
132
133 const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd));
134 if (!absoluteBase) {
135 return normalizeProjectPathForDispatch(trimmedValue);
136 }
137
138 const nextSegments = [...absoluteBase.segments];
139 for (const segment of trimmedValue.split(/[\\/]+/)) {
140 if (segment.length === 0 || segment === ".") {
141 continue;
142 }
143 if (segment === "..") {
144 nextSegments.pop();
145 continue;
146 }
147 nextSegments.push(segment);
148 }
149
150 const joinedPath = nextSegments.join(absoluteBase.separator);
151 if (joinedPath.length === 0) {
152 return normalizeProjectPathForDispatch(absoluteBase.root);
153 }
154
155 return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`);
156}
157
158export function normalizeProjectPathForComparison(value: string): string {
159 const normalized = normalizeProjectPathForDispatch(value);
160 if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
161 return normalized.replaceAll("/", "\\").toLowerCase();
162 }
163 return normalized;
164}
165
166export function findProjectByPath<T extends { cwd: string }>(
167 projects: ReadonlyArray<T>,
168 candidatePath: string,
169): T | undefined {
170 const normalizedCandidate = normalizeProjectPathForComparison(candidatePath);
171 if (normalizedCandidate.length === 0) {
172 return undefined;
173 }
174
175 return projects.find(
176 (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate,
177 );
178}
179
180export function inferProjectTitleFromPath(value: string): string {
181 const normalized = normalizeProjectPathForDispatch(value);
182 const absolutePath = splitAbsolutePath(normalized);
183 if (absolutePath) {
184 return absolutePath.segments.findLast(Boolean) ?? normalized;
185 }
186
187 const segments = normalized.split(/[/\\]/);
188 return segments.findLast(Boolean) ?? normalized;
189}
190
191export function appendBrowsePathSegment(currentPath: string, segment: string): string {
192 const separator = preferredPathSeparator(currentPath);
193 return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
194}
195
196export function getBrowseLeafPathSegment(currentPath: string): string {
197 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
198 return currentPath.slice(lastSeparatorIndex + 1);
199}
200
201export function getBrowseDirectoryPath(currentPath: string): string {
202 if (hasTrailingPathSeparator(currentPath)) {
203 return currentPath;
204 }
205
206 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
207 if (lastSeparatorIndex < 0) {
208 return currentPath;
209 }
210
211 return currentPath.slice(0, lastSeparatorIndex + 1);
212}
213
214export function ensureBrowseDirectoryPath(currentPath: string): string {
215 const trimmed = currentPath.trim();
216 if (trimmed.length === 0) {
217 return trimmed;
218 }
219
220 if (hasTrailingPathSeparator(trimmed)) {
221 return trimmed;
222 }
223
224 return `${trimmed}${preferredPathSeparator(trimmed)}`;
225}
226
227export function getBrowseParentPath(currentPath: string): string | null {
228 const trimmed = trimTrailingPathSeparators(currentPath);
229 const absolutePath = splitAbsolutePath(trimmed);
230 if (absolutePath) {
231 if (absolutePath.segments.length === 0) {
232 return null;
233 }
234
235 if (absolutePath.segments.length === 1) {
236 return absolutePath.root;
237 }
238
239 const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator);
240 return `${absolutePath.root}${parentSegments}${absolutePath.separator}`;
241 }
242
243 const separator = preferredPathSeparator(currentPath);
244 const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed);
245
246 if (lastSeparatorIndex < 0) {
247 return null;
248 }
249
250 if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) {
251 return `${trimmed.slice(0, 2)}${separator}`;
252 }
253
254 return trimmed.slice(0, lastSeparatorIndex + 1);
255}
256
257export function canNavigateUp(currentPath: string): boolean {
258 return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null;
259}
apps/web/src/localApi.test.ts+35
......@@ -51,6 +51,9 @@ const rpcClientMock = {
5151 searchEntries: vi.fn(),
5252 writeFile: vi.fn(),
5353 },
54 filesystem: {
55 browse: vi.fn(),
56 },
5457 shell: {
5558 openInEditor: vi.fn(),
5659 },
......@@ -419,6 +422,25 @@ describe("wsApi", () => {
419422 });
420423 });
421424
425 it("forwards filesystem browse requests to the RPC client", async () => {
426 rpcClientMock.filesystem.browse.mockResolvedValue({
427 parentPath: "/tmp/project/",
428 entries: [],
429 });
430 const { createEnvironmentApi } = await import("./environmentApi");
431
432 const api = createEnvironmentApi(rpcClientMock as never);
433 await api.filesystem.browse({
434 partialPath: "/tmp/project/",
435 cwd: "/tmp/project",
436 });
437
438 expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({
439 partialPath: "/tmp/project/",
440 cwd: "/tmp/project",
441 });
442 });
443
422444 it("forwards full-thread diff requests to the orchestration RPC", async () => {
423445 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
424446 const { createEnvironmentApi } = await import("./environmentApi");
......@@ -481,6 +503,19 @@ describe("wsApi", () => {
481503 expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
482504 });
483505
506 it("forwards folder picker options to the desktop bridge", async () => {
507 const pickFolder = vi.fn().mockResolvedValue("/tmp/project");
508 getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder });
509
510 const { createLocalApi } = await import("./localApi");
511 const api = createLocalApi(rpcClientMock as never);
512
513 await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe(
514 "/tmp/project",
515 );
516 expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" });
517 });
518
484519 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
485520 showContextMenuFallbackMock.mockResolvedValue("rename");
486521 const { createLocalApi } = await import("./localApi");
apps/web/src/localApi.ts+2-2
......@@ -29,9 +29,9 @@ let cachedApi: LocalApi | undefined;
2929export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
3030 return {
3131 dialogs: {
32 pickFolder: async () => {
32 pickFolder: async (options) => {
3333 if (!window.desktopBridge) return null;
34 return window.desktopBridge.pickFolder();
34 return window.desktopBridge.pickFolder(options);
3535 },
3636 confirm: async (message) => {
3737 if (window.desktopBridge) {
apps/web/src/rpc/wsRpcClient.ts+6
......@@ -68,6 +68,9 @@ export interface WsRpcClient {
6868 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
6969 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
7070 };
71 readonly filesystem: {
72 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
73 };
7174 readonly shell: {
7275 readonly openInEditor: (input: {
7376 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
......@@ -145,6 +148,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
145148 writeFile: (input) =>
146149 transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),
147150 },
151 filesystem: {
152 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
153 },
148154 shell: {
149155 openInEditor: (input) =>
150156 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
packages/contracts/src/filesystem.ts created+30
......@@ -0,0 +1,30 @@
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
3
4const FILESYSTEM_PATH_MAX_LENGTH = 512;
5
6export const FilesystemBrowseInput = Schema.Struct({
7 partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)),
8 cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))),
9});
10export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type;
11
12export const FilesystemBrowseEntry = Schema.Struct({
13 name: TrimmedNonEmptyString,
14 fullPath: TrimmedNonEmptyString,
15});
16export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type;
17
18export const FilesystemBrowseResult = Schema.Struct({
19 parentPath: TrimmedNonEmptyString,
20 entries: Schema.Array(FilesystemBrowseEntry),
21});
22export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type;
23
24export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBrowseError>()(
25 "FilesystemBrowseError",
26 {
27 message: TrimmedNonEmptyString,
28 cause: Schema.optional(Schema.Defect),
29 },
30) {}
packages/contracts/src/index.ts+1
......@@ -13,4 +13,5 @@ export * from "./git";
1313export * from "./orchestration";
1414export * from "./editor";
1515export * from "./project";
16export * from "./filesystem";
1617export * from "./rpc";
packages/contracts/src/ipc.ts+10-2
......@@ -18,6 +18,7 @@ import type {
1818 GitStatusResult,
1919 GitCreateBranchResult,
2020} from "./git";
21import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem";
2122import type {
2223 ProjectSearchEntriesInput,
2324 ProjectSearchEntriesResult,
......@@ -140,6 +141,10 @@ export interface DesktopServerExposureState {
140141 advertisedHost: string | null;
141142}
142143
144export interface PickFolderOptions {
145 initialPath?: string | null;
146}
147
143148export interface DesktopBridge {
144149 getAppBranding: () => DesktopAppBranding | null;
145150 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
......@@ -154,7 +159,7 @@ export interface DesktopBridge {
154159 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;
155160 getServerExposureState: () => Promise<DesktopServerExposureState>;
156161 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
157 pickFolder: () => Promise<string | null>;
162 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
158163 confirm: (message: string) => Promise<boolean>;
159164 setTheme: (theme: DesktopTheme) => Promise<void>;
160165 showContextMenu: <T extends string>(
......@@ -183,7 +188,7 @@ export interface DesktopBridge {
183188 */
184189export interface LocalApi {
185190 dialogs: {
186 pickFolder: () => Promise<string | null>;
191 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
187192 confirm: (message: string) => Promise<boolean>;
188193 };
189194 shell: {
......@@ -239,6 +244,9 @@ export interface EnvironmentApi {
239244 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
240245 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
241246 };
247 filesystem: {
248 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
249 };
242250 git: {
243251 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
244252 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
packages/contracts/src/orchestration.test.ts+17
......@@ -95,6 +95,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
9595 assert.strictEqual(parsed.projectId, "project-1");
9696 assert.strictEqual(parsed.title, "Project Title");
9797 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");
98 assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined);
9899 assert.deepStrictEqual(parsed.defaultModelSelection, {
99100 provider: "codex",
100101 model: "gpt-5.2",
......@@ -102,6 +103,22 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
102103 }),
103104);
104105
106it.effect("decodes project.create with createWorkspaceRootIfMissing enabled", () =>
107 Effect.gen(function* () {
108 const parsed = yield* decodeProjectCreateCommand({
109 type: "project.create",
110 commandId: "cmd-1",
111 projectId: "project-1",
112 title: "Project Title",
113 workspaceRoot: "/tmp/workspace",
114 createWorkspaceRootIfMissing: true,
115 createdAt: "2026-01-01T00:00:00.000Z",
116 });
117
118 assert.strictEqual(parsed.createWorkspaceRootIfMissing, true);
119 }),
120);
121
105122it.effect("decodes historical project.created payloads with a default provider", () =>
106123 Effect.gen(function* () {
107124 const parsed = yield* decodeProjectCreatedPayload({
packages/contracts/src/orchestration.ts+1
......@@ -402,6 +402,7 @@ export const ProjectCreateCommand = Schema.Struct({
402402 projectId: ProjectId,
403403 title: TrimmedNonEmptyString,
404404 workspaceRoot: TrimmedNonEmptyString,
405 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
405406 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
406407 createdAt: IsoDateTime,
407408});
packages/contracts/src/rpc.ts+11
......@@ -4,6 +4,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
55import { OpenError, OpenInEditorInput } from "./editor";
66import { AuthAccessStreamEvent } from "./auth";
7import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError } from "./filesystem";
78import {
89 GitActionProgressEvent,
910 GitCheckoutInput,
......@@ -83,6 +84,9 @@ export const WS_METHODS = {
8384 // Shell methods
8485 shellOpenInEditor: "shell.openInEditor",
8586
87 // Filesystem methods
88 filesystemBrowse: "filesystem.browse",
89
8690 // Git methods
8791 gitPull: "git.pull",
8892 gitRefreshStatus: "git.refreshStatus",
......@@ -165,6 +169,12 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
165169 error: OpenError,
166170});
167171
172export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
173 payload: FilesystemBrowseInput,
174 success: FilesystemBrowseResult,
175 error: FilesystemBrowseError,
176});
177
168178export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, {
169179 payload: GitStatusInput,
170180 success: GitStatusStreamEvent,
......@@ -350,6 +360,7 @@ export const WsRpcGroup = RpcGroup.make(
350360 WsProjectsSearchEntriesRpc,
351361 WsProjectsWriteFileRpc,
352362 WsShellOpenInEditorRpc,
363 WsFilesystemBrowseRpc,
353364 WsSubscribeGitStatusRpc,
354365 WsGitPullRpc,
355366 WsGitRefreshStatusRpc,
packages/contracts/src/settings.ts+2
......@@ -84,6 +84,7 @@ export const ServerSettings = Schema.Struct({
8484 defaultThreadEnvMode: ThreadEnvMode.pipe(
8585 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
8686 ),
87 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
8788 textGenerationModelSelection: ModelSelection.pipe(
8889 Schema.withDecodingDefault(
8990 Effect.succeed({
......@@ -168,6 +169,7 @@ const ClaudeSettingsPatch = Schema.Struct({
168169export const ServerSettingsPatch = Schema.Struct({
169170 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
170171 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
172 addProjectBaseDirectory: Schema.optionalKey(Schema.String),
171173 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
172174 observability: Schema.optionalKey(
173175 Schema.Struct({
packages/shared/package.json+4
......@@ -59,6 +59,10 @@
5959 "./qrCode": {
6060 "types": "./src/qrCode.ts",
6161 "import": "./src/qrCode.ts"
62 },
63 "./path": {
64 "types": "./src/path.ts",
65 "import": "./src/path.ts"
6266 }
6367 },
6468 "scripts": {
packages/shared/src/path.test.ts created+34
......@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vitest";
2import {
3 isExplicitRelativePath,
4 isUncPath,
5 isWindowsAbsolutePath,
6 isWindowsDrivePath,
7} from "./path";
8
9describe("path helpers", () => {
10 it("detects windows drive paths", () => {
11 expect(isWindowsDrivePath("C:\\repo")).toBe(true);
12 expect(isWindowsDrivePath("D:/repo")).toBe(true);
13 expect(isWindowsDrivePath("/repo")).toBe(false);
14 });
15
16 it("detects UNC paths", () => {
17 expect(isUncPath("\\\\server\\share\\repo")).toBe(true);
18 expect(isUncPath("C:\\repo")).toBe(false);
19 });
20
21 it("detects windows absolute paths", () => {
22 expect(isWindowsAbsolutePath("C:\\repo")).toBe(true);
23 expect(isWindowsAbsolutePath("\\\\server\\share\\repo")).toBe(true);
24 expect(isWindowsAbsolutePath("./repo")).toBe(false);
25 });
26
27 it("detects explicit relative paths", () => {
28 expect(isExplicitRelativePath(".")).toBe(true);
29 expect(isExplicitRelativePath("..")).toBe(true);
30 expect(isExplicitRelativePath("./repo")).toBe(true);
31 expect(isExplicitRelativePath("..\\repo")).toBe(true);
32 expect(isExplicitRelativePath("~/repo")).toBe(false);
33 });
34});
packages/shared/src/path.ts created+22
......@@ -0,0 +1,22 @@
1export function isWindowsDrivePath(value: string): boolean {
2 return /^[a-zA-Z]:([/\\]|$)/.test(value);
3}
4
5export function isUncPath(value: string): boolean {
6 return value.startsWith("\\\\");
7}
8
9export function isWindowsAbsolutePath(value: string): boolean {
10 return isUncPath(value) || isWindowsDrivePath(value);
11}
12
13export function isExplicitRelativePath(value: string): boolean {
14 return (
15 value === "." ||
16 value === ".." ||
17 value.startsWith("./") ||
18 value.startsWith("../") ||
19 value.startsWith(".\\") ||
20 value.startsWith("..\\")
21 );
22}