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 {...@@ -18,7 +18,7 @@ import {
18 safeStorage,18 safeStorage,
19 shell,19 shell,
20} from "electron";20} from "electron";
21import type { MenuItemConstructorOptions } from "electron";21import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";
22import type {22import type {
23 ClientSettings,23 ClientSettings,
24 DesktopTheme,24 DesktopTheme,
...@@ -126,6 +126,32 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");...@@ -126,6 +126,32 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
126const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");126const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
127const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;127const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
128const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;128const 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}
129const DESKTOP_LOOPBACK_HOST = "127.0.0.1";155const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
130const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;156const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
131const TITLEBAR_HEIGHT = 40;157const TITLEBAR_HEIGHT = 40;
...@@ -1641,15 +1667,16 @@ function registerIpcHandlers(): void {...@@ -1641,15 +1667,16 @@ function registerIpcHandlers(): void {
1641 });1667 });
16421668
1643 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);1669 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1644 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {1670 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
1645 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;1671 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1672 const defaultPath = resolvePickFolderDefaultPath(rawOptions);
1673 const openDialogOptions: OpenDialogOptions = {
1674 properties: ["openDirectory", "createDirectory"],
1675 ...(defaultPath ? { defaultPath } : {}),
1676 };
1646 const result = owner1677 const result = owner
1647 ? await dialog.showOpenDialog(owner, {1678 ? await dialog.showOpenDialog(owner, openDialogOptions)
1648 properties: ["openDirectory", "createDirectory"],1679 : await dialog.showOpenDialog(openDialogOptions);
1649 })
1650 : await dialog.showOpenDialog({
1651 properties: ["openDirectory", "createDirectory"],
1652 });
1653 if (result.canceled) return null;1680 if (result.canceled) return null;
1654 return result.filePaths[0] ?? null;1681 return result.filePaths[0] ?? null;
1655 });1682 });
apps/desktop/src/preload.ts+1-1
...@@ -53,7 +53,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -53,7 +53,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
53 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),53 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
54 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),54 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
55 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),55 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),
57 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),57 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
58 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),58 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
59 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),59 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) =>...@@ -28,10 +28,31 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
28 ),28 ),
29 );29 );
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
31 if (command.type === "project.create") {48 if (command.type === "project.create") {
32 return {49 return {
33 ...command,50 ...command,
34 workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot),51 workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate(
52 command.workspaceRoot,
53 command.createWorkspaceRootIfMissing,
54 ),
55 createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true,
35 } satisfies OrchestrationCommand;56 } satisfies OrchestrationCommand;
36 }57 }
3758
apps/server/src/server.test.ts+34
...@@ -2039,6 +2039,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2039,6 +2039,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2039 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2039 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2040 );2040 );
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
2042 it.effect("routes websocket rpc projects.writeFile errors", () =>2076 it.effect("routes websocket rpc projects.writeFile errors", () =>
2043 Effect.gen(function* () {2077 Effect.gen(function* () {
2044 const fs = yield* FileSystem.FileSystem;2078 const fs = yield* FileSystem.FileSystem;
apps/server/src/serverSettings.test.ts+4
...@@ -176,12 +176,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -176,12 +176,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
176 const serverSettings = yield* ServerSettingsService;176 const serverSettings = yield* ServerSettingsService;
177177
178 const next = yield* serverSettings.updateSettings({178 const next = yield* serverSettings.updateSettings({
179 addProjectBaseDirectory: " ~/Development ",
179 observability: {180 observability: {
180 otlpTracesUrl: " http://localhost:4318/v1/traces ",181 otlpTracesUrl: " http://localhost:4318/v1/traces ",
181 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",182 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
182 },183 },
183 });184 });
184185
186 assert.equal(next.addProjectBaseDirectory, "~/Development");
185 assert.deepEqual(next.observability, {187 assert.deepEqual(next.observability, {
186 otlpTracesUrl: "http://localhost:4318/v1/traces",188 otlpTracesUrl: "http://localhost:4318/v1/traces",
187 otlpMetricsUrl: "http://localhost:4318/v1/metrics",189 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
...@@ -215,6 +217,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -215,6 +217,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
215 const serverConfig = yield* ServerConfig;217 const serverConfig = yield* ServerConfig;
216 const fileSystem = yield* FileSystem.FileSystem;218 const fileSystem = yield* FileSystem.FileSystem;
217 const next = yield* serverSettings.updateSettings({219 const next = yield* serverSettings.updateSettings({
220 addProjectBaseDirectory: "~/Development",
218 observability: {221 observability: {
219 otlpTracesUrl: "http://localhost:4318/v1/traces",222 otlpTracesUrl: "http://localhost:4318/v1/traces",
220 otlpMetricsUrl: "http://localhost:4318/v1/metrics",223 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
...@@ -230,6 +233,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -230,6 +233,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
230233
231 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);234 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
232 assert.deepEqual(JSON.parse(raw), {235 assert.deepEqual(JSON.parse(raw), {
236 addProjectBaseDirectory: "~/Development",
233 observability: {237 observability: {
234 otlpTracesUrl: "http://localhost:4318/v1/traces",238 otlpTracesUrl: "http://localhost:4318/v1/traces",
235 otlpMetricsUrl: "http://localhost:4318/v1/metrics",239 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...@@ -68,6 +68,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb
68 return yield* workspaceEntries.search(input);68 return yield* workspaceEntries.search(input);
69 });69 });
7070
71const appendSeparator = (input: string) =>
72 input.endsWith("/") || input.endsWith("\\")
73 ? input
74 : `${input}${process.platform === "win32" ? "\\" : "/"}`;
75
71it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {76it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
72 afterEach(() => {77 afterEach(() => {
73 vi.restoreAllMocks();78 vi.restoreAllMocks();
...@@ -275,4 +280,85 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {...@@ -275,4 +280,85 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
275 }),280 }),
276 );281 );
277 });282 });
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 });
278});364});
apps/server/src/workspace/Layers/WorkspaceEntries.ts+85-1
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import * as OS from "node:os";
1import fsPromises from "node:fs/promises";2import fsPromises from "node:fs/promises";
2import type { Dirent } from "node:fs";3import type { Dirent } from "node:fs";
34
4import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";5import { 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";
7import {9import {
8 insertRankedSearchResult,10 insertRankedSearchResult,
9 normalizeSearchQuery,11 normalizeSearchQuery,
...@@ -14,6 +16,7 @@ import {...@@ -14,6 +16,7 @@ import {
14import { GitCore } from "../../git/Services/GitCore.ts";16import { GitCore } from "../../git/Services/GitCore.ts";
15import {17import {
16 WorkspaceEntries,18 WorkspaceEntries,
19 WorkspaceEntriesBrowseError,
17 WorkspaceEntriesError,20 WorkspaceEntriesError,
18 type WorkspaceEntriesShape,21 type WorkspaceEntriesShape,
19} from "../Services/WorkspaceEntries.ts";22} from "../Services/WorkspaceEntries.ts";
...@@ -52,6 +55,16 @@ function toPosixPath(input: string): string {...@@ -52,6 +55,16 @@ function toPosixPath(input: string): string {
52 return input.replaceAll("\\", "/");55 return input.replaceAll("\\", "/");
53}56}
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
55function parentPathOf(input: string): string | undefined {68function parentPathOf(input: string): string | undefined {
56 const separatorIndex = input.lastIndexOf("/");69 const separatorIndex = input.lastIndexOf("/");
57 if (separatorIndex === -1) {70 if (separatorIndex === -1) {
...@@ -129,6 +142,36 @@ function directoryAncestorsOf(relativePath: string): string[] {...@@ -129,6 +142,36 @@ function directoryAncestorsOf(relativePath: string): string[] {
129 return directories;142 return directories;
130}143}
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
132export const makeWorkspaceEntries = Effect.gen(function* () {175export const makeWorkspaceEntries = Effect.gen(function* () {
133 const path = yield* Path.Path;176 const path = yield* Path.Path;
134 const gitOption = yield* Effect.serviceOption(GitCore);177 const gitOption = yield* Effect.serviceOption(GitCore);
...@@ -379,6 +422,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () {...@@ -379,6 +422,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
379 },422 },
380 );423 );
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
382 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(465 const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(
383 function* (input) {466 function* (input) {
384 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);467 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
...@@ -415,6 +498,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () {...@@ -415,6 +498,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
415 );498 );
416499
417 return {500 return {
501 browse,
418 invalidate,502 invalidate,
419 search,503 search,
420 } satisfies WorkspaceEntriesShape;504 } satisfies WorkspaceEntriesShape;
apps/server/src/workspace/Layers/WorkspacePaths.test.ts+18
...@@ -58,6 +58,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {...@@ -58,6 +58,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
58 }),58 }),
59 );59 );
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
61 it.effect("rejects file paths", () =>79 it.effect("rejects file paths", () =>
62 Effect.gen(function* () {80 Effect.gen(function* () {
63 const workspacePaths = yield* WorkspacePaths;81 const workspacePaths = yield* WorkspacePaths;
apps/server/src/workspace/Layers/WorkspacePaths.ts+17-2
...@@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect";...@@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect";
4import {4import {
5 WorkspacePaths,5 WorkspacePaths,
6 WorkspacePathOutsideRootError,6 WorkspacePathOutsideRootError,
7 WorkspaceRootCreateFailedError,
7 WorkspaceRootNotDirectoryError,8 WorkspaceRootNotDirectoryError,
8 WorkspaceRootNotExistsError,9 WorkspaceRootNotExistsError,
9 type WorkspacePathsShape,10 type WorkspacePathsShape,
...@@ -29,11 +30,25 @@ export const makeWorkspacePaths = Effect.gen(function* () {...@@ -29,11 +30,25 @@ export const makeWorkspacePaths = Effect.gen(function* () {
2930
30 const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn(31 const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn(
31 "WorkspacePaths.normalizeWorkspaceRoot",32 "WorkspacePaths.normalizeWorkspaceRoot",
32 )(function* (workspaceRoot) {33 )(function* (workspaceRoot, options) {
33 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));34 const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
34 const workspaceStat = yield* fileSystem35 let workspaceStat = yield* fileSystem
35 .stat(normalizedWorkspaceRoot)36 .stat(normalizedWorkspaceRoot)
36 .pipe(Effect.catch(() => Effect.succeed(null)));37 .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 }
37 if (!workspaceStat) {52 if (!workspaceStat) {
38 return yield* new WorkspaceRootNotExistsError({53 return yield* new WorkspaceRootNotExistsError({
39 workspaceRoot,54 workspaceRoot,
apps/server/src/workspace/Services/WorkspaceEntries.ts+24-1
...@@ -9,7 +9,12 @@...@@ -9,7 +9,12 @@
9import { Schema, Context } from "effect";9import { Schema, Context } from "effect";
10import type { Effect } from "effect";10import 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
14export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(19export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(
15 "WorkspaceEntriesError",20 "WorkspaceEntriesError",
...@@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntr...@@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntr
21 },26 },
22) {}27) {}
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
24/**40/**
25 * WorkspaceEntriesShape - Service API for workspace entry search and cache41 * WorkspaceEntriesShape - Service API for workspace entry search and cache
26 * invalidation.42 * invalidation.
27 */43 */
28export interface WorkspaceEntriesShape {44export 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
29 /**52 /**
30 * Search indexed workspace entries for files and directories matching the53 * Search indexed workspace entries for files and directories matching the
31 * provided query.54 * provided query.
apps/server/src/workspace/Services/WorkspacePaths.ts+18-1
...@@ -21,6 +21,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass<Workspa...@@ -21,6 +21,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass<Workspa
21 }21 }
22}22}
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
24export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(36export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(
25 "WorkspaceRootNotDirectoryError",37 "WorkspaceRootNotDirectoryError",
26 {38 {
...@@ -47,6 +59,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works...@@ -47,6 +59,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works
4759
48export const WorkspacePathsError = Schema.Union([60export const WorkspacePathsError = Schema.Union([
49 WorkspaceRootNotExistsError,61 WorkspaceRootNotExistsError,
62 WorkspaceRootCreateFailedError,
50 WorkspaceRootNotDirectoryError,63 WorkspaceRootNotDirectoryError,
51 WorkspacePathOutsideRootError,64 WorkspacePathOutsideRootError,
52]);65]);
...@@ -61,7 +74,11 @@ export interface WorkspacePathsShape {...@@ -61,7 +74,11 @@ export interface WorkspacePathsShape {
61 */74 */
62 readonly normalizeWorkspaceRoot: (75 readonly normalizeWorkspaceRoot: (
63 workspaceRoot: string,76 workspaceRoot: string,
64 ) => Effect.Effect<string, WorkspaceRootNotExistsError | WorkspaceRootNotDirectoryError>;77 options?: { readonly createIfMissing?: boolean },
78 ) => Effect.Effect<
79 string,
80 WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError
81 >;
6582
66 /**83 /**
67 * Resolve a relative path within a validated workspace root.84 * Resolve a relative path within a validated workspace root.
apps/server/src/ws.ts+15
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 ProjectSearchEntriesError,17 ProjectSearchEntriesError,
18 ProjectWriteFileError,18 ProjectWriteFileError,
19 OrchestrationReplayEventsError,19 OrchestrationReplayEventsError,
20 FilesystemBrowseError,
20 ThreadId,21 ThreadId,
21 type TerminalEvent,22 type TerminalEvent,
22 WS_METHODS,23 WS_METHODS,
...@@ -768,6 +769,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -768,6 +769,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
768 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {769 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
769 "rpc.aggregate": "workspace",770 "rpc.aggregate": "workspace",
770 }),771 }),
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 ),
771 [WS_METHODS.subscribeGitStatus]: (input) =>786 [WS_METHODS.subscribeGitStatus]: (input) =>
772 observeRpcStream(787 observeRpcStream(
773 WS_METHODS.subscribeGitStatus,788 WS_METHODS.subscribeGitStatus,
apps/web/src/commandPaletteStore.ts+21-2
...@@ -1,13 +1,32 @@...@@ -1,13 +1,32 @@
1import { create } from "zustand";1import { create } from "zustand";
22
3interface CommandPaletteOpenIntent {
4 kind: "add-project";
5 requestId: number;
6}
7
3interface CommandPaletteStore {8interface CommandPaletteStore {
4 open: boolean;9 open: boolean;
10 openIntent: CommandPaletteOpenIntent | null;
5 setOpen: (open: boolean) => void;11 setOpen: (open: boolean) => void;
6 toggleOpen: () => void;12 toggleOpen: () => void;
13 openAddProject: () => void;
14 clearOpenIntent: () => void;
7}15}
816
9export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({17export const useCommandPaletteStore = create<CommandPaletteStore>((set) => ({
10 open: false,18 open: false,
11 setOpen: (open) => set({ open }),19 openIntent: null,
12 toggleOpen: () => set((state) => ({ open: !state.open })),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 }),
13}));32}));
apps/web/src/components/ChatView.browser.tsx+822
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 EventId,5 EventId,
6 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
7 EnvironmentId,7 EnvironmentId,
8 type EnvironmentApi,
8 type MessageId,9 type MessageId,
9 type OrchestrationReadModel,10 type OrchestrationReadModel,
10 type ProjectId,11 type ProjectId,
...@@ -31,6 +32,16 @@ import { render } from "vitest-browser-react";...@@ -31,6 +32,16 @@ import { render } from "vitest-browser-react";
3132
32import { useCommandPaletteStore } from "../commandPaletteStore";33import { useCommandPaletteStore } from "../commandPaletteStore";
33import { useComposerDraftStore, DraftId } from "../composerDraftStore";34import { 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";
34import {45import {
35 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,46 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
36 removeInlineTerminalContextPlaceholder,47 removeInlineTerminalContextPlaceholder,
...@@ -62,6 +73,7 @@ const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as Thre...@@ -62,6 +73,7 @@ const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as Thre
62const PROJECT_ID = "project-1" as ProjectId;73const PROJECT_ID = "project-1" as ProjectId;
63const SECOND_PROJECT_ID = "project-2" as ProjectId;74const SECOND_PROJECT_ID = "project-2" as ProjectId;
64const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");75const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
76const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
65const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);77const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID);
66const THREAD_KEY = scopedThreadKey(THREAD_REF);78const THREAD_KEY = scopedThreadKey(THREAD_REF);
67const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;79const 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...@@ -70,6 +82,7 @@ const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJE
70const NOW_ISO = "2026-03-04T12:00:00.000Z";82const NOW_ISO = "2026-03-04T12:00:00.000Z";
71const BASE_TIME_MS = Date.parse(NOW_ISO);83const BASE_TIME_MS = Date.parse(NOW_ISO);
72const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";84const 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
74interface TestFixture {87interface TestFixture {
75 snapshot: OrchestrationReadModel;88 snapshot: OrchestrationReadModel;
...@@ -172,6 +185,32 @@ function createBaseServerConfig(): ServerConfig {...@@ -172,6 +185,32 @@ function createBaseServerConfig(): ServerConfig {
172 };185 };
173}186}
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
175function createUserMessage(options: {214function createUserMessage(options: {
176 id: MessageId;215 id: MessageId;
177 text: string;216 text: string;
...@@ -1383,6 +1422,44 @@ async function waitForCommandPaletteShortcutLabel(): Promise<void> {...@@ -1383,6 +1422,44 @@ async function waitForCommandPaletteShortcutLabel(): Promise<void> {
1383 );1422 );
1384}1423}
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
1386async function mountChatView(options: {1463async function mountChatView(options: {
1387 viewport: ViewportSpec;1464 viewport: ViewportSpec;
1388 snapshot: OrchestrationReadModel;1465 snapshot: OrchestrationReadModel;
...@@ -1524,6 +1601,10 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1524,6 +1601,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
1524 document.body.innerHTML = "";1601 document.body.innerHTML = "";
1525 wsRequests.length = 0;1602 wsRequests.length = 0;
1526 customWsRpcResolver = null;1603 customWsRpcResolver = null;
1604 __resetEnvironmentApiOverridesForTests();
1605 resetSavedEnvironmentRegistryStoreForTests();
1606 resetSavedEnvironmentRuntimeStoreForTests();
1607 Reflect.deleteProperty(window, "desktopBridge");
1527 useComposerDraftStore.setState({1608 useComposerDraftStore.setState({
1528 draftsByThreadKey: {},1609 draftsByThreadKey: {},
1529 draftThreadsByThreadKey: {},1610 draftThreadsByThreadKey: {},
...@@ -1533,6 +1614,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1533,6 +1614,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1533 });1614 });
1534 useCommandPaletteStore.setState({1615 useCommandPaletteStore.setState({
1535 open: false,1616 open: false,
1617 openIntent: null,
1536 });1618 });
1537 useStore.setState({1619 useStore.setState({
1538 activeEnvironmentId: null,1620 activeEnvironmentId: null,
...@@ -4079,6 +4161,746 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -4079,6 +4161,746 @@ describe("ChatView timeline estimator parity (full app)", () => {
4079 }4161 }
4080 });4162 });
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
4082 it("keeps project-context thread matches available when searching by project name", async () => {4904 it("keeps project-context thread matches available when searching by project name", async () => {
4083 const mounted = await mountChatView({4905 const mounted = await mountChatView({
4084 viewport: DEFAULT_VIEWPORT,4906 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/CommandPalette.logic.ts+85-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type KeybindingCommand } from "@t3tools/contracts";1import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts";
2import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";2import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import { type ReactNode } from "react";3import { type ReactNode } from "react";
4import { sortThreads } from "../lib/threadSort";4import { sortThreads } from "../lib/threadSort";
...@@ -45,7 +45,39 @@ export interface CommandPaletteView {...@@ -45,7 +45,39 @@ export interface CommandPaletteView {
45 readonly initialQuery?: string;45 readonly initialQuery?: string;
46}46}
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
50export function normalizeSearchText(value: string): string {82export function normalizeSearchText(value: string): string {
51 return value.trim().toLowerCase().replace(/\s+/g, " ");83 return value.trim().toLowerCase().replace(/\s+/g, " ");
...@@ -230,10 +262,56 @@ export function filterCommandPaletteGroups(input: {...@@ -230,10 +262,56 @@ export function filterCommandPaletteGroups(input: {
230 });262 });
231}263}
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
233export function getCommandPaletteMode(input: {307export function getCommandPaletteMode(input: {
234 currentView: CommandPaletteView | null;308 currentView: CommandPaletteView | null;
309 isBrowsing: boolean;
235}): CommandPaletteMode {310}): 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";
237}315}
238316
239export function buildRootGroups(input: {317export function buildRootGroups(input: {
...@@ -258,7 +336,11 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str...@@ -258,7 +336,11 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str
258 switch (mode) {336 switch (mode) {
259 case "root":337 case "root":
260 return "Search commands, projects, and threads...";338 return "Search commands, projects, and threads...";
339 case "root-browse":
340 return "Enter project path (e.g. ~/projects/my-app)";
261 case "submenu":341 case "submenu":
262 return "Search...";342 return "Search...";
343 case "submenu-browse":
344 return "Enter path (e.g. ~/projects/my-app)";
263 }345 }
264}346}
apps/web/src/components/CommandPalette.tsx+693-39
...@@ -1,17 +1,27 @@...@@ -1,17 +1,27 @@
1"use client";1"use client";
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";3import { 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";
5import { useNavigate, useParams } from "@tanstack/react-router";11import { useNavigate, useParams } from "@tanstack/react-router";
6import {12import {
7 ArrowDownIcon,13 ArrowDownIcon,
8 ArrowLeftIcon,14 ArrowLeftIcon,
9 ArrowUpIcon,15 ArrowUpIcon,
16 CornerLeftUpIcon,
17 FolderIcon,
18 FolderPlusIcon,
10 MessageSquareIcon,19 MessageSquareIcon,
11 SettingsIcon,20 SettingsIcon,
12 SquarePenIcon,21 SquarePenIcon,
13} from "lucide-react";22} from "lucide-react";
14import {23import {
24 useCallback,
15 useDeferredValue,25 useDeferredValue,
16 useEffect,26 useEffect,
17 useMemo,27 useMemo,
...@@ -22,15 +32,37 @@ import {...@@ -22,15 +32,37 @@ import {
22} from "react";32} from "react";
23import { useShallow } from "zustand/react/shallow";33import { useShallow } from "zustand/react/shallow";
24import { useCommandPaletteStore } from "../commandPaletteStore";34import { useCommandPaletteStore } from "../commandPaletteStore";
35import { readEnvironmentApi } from "../environmentApi";
36import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary";
37import {
38 useSavedEnvironmentRegistryStore,
39 useSavedEnvironmentRuntimeStore,
40} from "../environments/runtime";
25import { useHandleNewThread } from "../hooks/useHandleNewThread";41import { useHandleNewThread } from "../hooks/useHandleNewThread";
26import { useSettings } from "../hooks/useSettings";42import { useSettings } from "../hooks/useSettings";
43import { readLocalApi } from "../localApi";
27import {44import {
28 startNewThreadInProjectFromContext,45 startNewThreadInProjectFromContext,
29 startNewThreadFromContext,46 startNewThreadFromContext,
30} from "../lib/chatThreadActions";47} 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";
31import { isTerminalFocused } from "../lib/terminalFocus";63import { isTerminalFocused } from "../lib/terminalFocus";
32import { getLatestThreadForProject } from "../lib/threadSort";64import { getLatestThreadForProject } from "../lib/threadSort";
33import { cn } from "../lib/utils";65import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils";
34import {66import {
35 selectProjectsAcrossEnvironments,67 selectProjectsAcrossEnvironments,
36 selectSidebarThreadsAcrossEnvironments,68 selectSidebarThreadsAcrossEnvironments,
...@@ -40,18 +72,21 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta...@@ -40,18 +72,21 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta
40import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";72import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
41import {73import {
42 ADDON_ICON_CLASS,74 ADDON_ICON_CLASS,
75 buildBrowseGroups,
43 buildProjectActionItems,76 buildProjectActionItems,
44 buildRootGroups,77 buildRootGroups,
45 buildThreadActionItems,78 buildThreadActionItems,
46 type CommandPaletteActionItem,79 type CommandPaletteActionItem,
47 type CommandPaletteSubmenuItem,80 type CommandPaletteSubmenuItem,
48 type CommandPaletteView,81 type CommandPaletteView,
82 filterBrowseEntries,
49 filterCommandPaletteGroups,83 filterCommandPaletteGroups,
50 getCommandPaletteInputPlaceholder,84 getCommandPaletteInputPlaceholder,
51 getCommandPaletteMode,85 getCommandPaletteMode,
52 ITEM_ICON_CLASS,86 ITEM_ICON_CLASS,
53 RECENT_THREAD_LIMIT,87 RECENT_THREAD_LIMIT,
54} from "./CommandPalette.logic";88} from "./CommandPalette.logic";
89import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
55import { CommandPaletteResults } from "./CommandPaletteResults";90import { CommandPaletteResults } from "./CommandPaletteResults";
56import { ProjectFavicon } from "./ProjectFavicon";91import { ProjectFavicon } from "./ProjectFavicon";
57import { useServerKeybindings } from "../rpc/serverState";92import { useServerKeybindings } from "../rpc/serverState";
...@@ -64,11 +99,44 @@ import {...@@ -64,11 +99,44 @@ import {
64 CommandInput,99 CommandInput,
65 CommandPanel,100 CommandPanel,
66} from "./ui/command";101} from "./ui/command";
102import { Button } from "./ui/button";
67import { Kbd, KbdGroup } from "./ui/kbd";103import { Kbd, KbdGroup } from "./ui/kbd";
68import { toastManager } from "./ui/toast";104import { toastManager } from "./ui/toast";
69import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";105import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
70import type { ChatComposerHandle } from "./chat/ChatComposer";106import 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
72export function CommandPalette({ children }: { children: ReactNode }) {140export function CommandPalette({ children }: { children: ReactNode }) {
73 const open = useCommandPaletteStore((store) => store.open);141 const open = useCommandPaletteStore((store) => store.open);
74 const setOpen = useCommandPaletteStore((store) => store.setOpen);142 const setOpen = useCommandPaletteStore((store) => store.setOpen);
...@@ -136,10 +204,14 @@ function CommandPaletteDialog() {...@@ -136,10 +204,14 @@ function CommandPaletteDialog() {
136function OpenCommandPaletteDialog() {204function OpenCommandPaletteDialog() {
137 const navigate = useNavigate();205 const navigate = useNavigate();
138 const setOpen = useCommandPaletteStore((store) => store.setOpen);206 const setOpen = useCommandPaletteStore((store) => store.setOpen);
207 const openIntent = useCommandPaletteStore((store) => store.openIntent);
208 const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent);
139 const composerHandleRef = useComposerHandleContext();209 const composerHandleRef = useComposerHandleContext();
140 const [query, setQuery] = useState("");210 const [query, setQuery] = useState("");
141 const deferredQuery = useDeferredValue(query);211 const deferredQuery = useDeferredValue(query);
142 const isActionsOnly = deferredQuery.startsWith(">");212 const isActionsOnly = deferredQuery.startsWith(">");
213 const queryClient = useQueryClient();
214 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
143 const settings = useSettings();215 const settings = useSettings();
144 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =216 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
145 useHandleNewThread();217 useHandleNewThread();
...@@ -148,15 +220,199 @@ function OpenCommandPaletteDialog() {...@@ -148,15 +220,199 @@ function OpenCommandPaletteDialog() {
148 const keybindings = useServerKeybindings();220 const keybindings = useServerKeybindings();
149 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);221 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
150 const currentView = viewStack.at(-1) ?? null;222 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 );
153 const projectTitleById = useMemo(319 const projectTitleById = useMemo(
154 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.name])),320 () => new Map<ProjectId, string>(projects.map((project) => [project.id, project.name])),
155 [projects],321 [projects],
156 );322 );
157323
158 const activeThreadId = activeThread?.id;324 const activeThreadId = activeThread?.id;
325 const currentProjectEnvironmentId =
326 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
159 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;327 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
161 const openProjectFromSearch = useMemo(417 const openProjectFromSearch = useMemo(
162 () => async (project: (typeof projects)[number]) => {418 () => async (project: (typeof projects)[number]) => {
...@@ -259,30 +515,117 @@ function OpenCommandPaletteDialog() {...@@ -259,30 +515,117 @@ function OpenCommandPaletteDialog() {
259 );515 );
260 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);516 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
261517
262 function pushView(item: CommandPaletteSubmenuItem): void {518 function pushPaletteView(view: CommandPaletteView): void {
263 setViewStack((previousViews) => [519 setViewStack((previousViews) => [
264 ...previousViews,520 ...previousViews,
265 {521 {
266 addonIcon: item.addonIcon,522 addonIcon: view.addonIcon,
267 groups: item.groups,523 groups: view.groups,
268 ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}),524 ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
269 },525 },
270 ]);526 ]);
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 });
272 }537 }
273538
274 function popView(): void {539 function popView(): void {
540 if (viewStack.length <= 1) {
541 setAddProjectEnvironmentId(null);
542 }
275 setViewStack((previousViews) => previousViews.slice(0, -1));543 setViewStack((previousViews) => previousViews.slice(0, -1));
544 setHighlightedItemValue(null);
276 setQuery("");545 setQuery("");
277 }546 }
278547
279 function handleQueryChange(nextQuery: string): void {548 function handleQueryChange(nextQuery: string): void {
549 setHighlightedItemValue(null);
280 setQuery(nextQuery);550 setQuery(nextQuery);
281 if (nextQuery === "" && currentView?.initialQuery) {551 if (nextQuery === "" && currentView?.initialQuery) {
282 popView();552 popView();
283 }553 }
284 }554 }
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
286 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];629 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
287630
288 if (projects.length > 0) {631 if (projects.length > 0) {
...@@ -325,6 +668,30 @@ function OpenCommandPaletteDialog() {...@@ -325,6 +668,30 @@ function OpenCommandPaletteDialog() {
325 });668 });
326 }669 }
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
328 actionItems.push({695 actionItems.push({
329 kind: "action",696 kind: "action",
330 value: "action:settings",697 value: "action:settings",
...@@ -339,7 +706,7 @@ function OpenCommandPaletteDialog() {...@@ -339,7 +706,7 @@ function OpenCommandPaletteDialog() {
339 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });706 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
340 const activeGroups = currentView ? currentView.groups : rootGroups;707 const activeGroups = currentView ? currentView.groups : rootGroups;
341708
342 const displayedGroups = filterCommandPaletteGroups({709 const filteredGroups = filterCommandPaletteGroups({
343 activeGroups,710 activeGroups,
344 query: deferredQuery,711 query: deferredQuery,
345 isInSubmenu: currentView !== null,712 isInSubmenu: currentView !== null,
...@@ -347,10 +714,206 @@ function OpenCommandPaletteDialog() {...@@ -347,10 +714,206 @@ function OpenCommandPaletteDialog() {
347 threadSearchItems: allThreadItems,714 threadSearchItems: allThreadItems,
348 });715 });
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
350 const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode);855 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
353 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {905 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
354 if (event.key === "Backspace" && query === "" && isSubmenu) {917 if (event.key === "Backspace" && query === "" && isSubmenu) {
355 event.preventDefault();918 event.preventDefault();
356 popView();919 popView();
...@@ -376,6 +939,38 @@ function OpenCommandPaletteDialog() {...@@ -376,6 +939,38 @@ function OpenCommandPaletteDialog() {
376 });939 });
377 }940 }
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
379 return (974 return (
380 <CommandDialogPopup975 <CommandDialogPopup
381 aria-label="Command palette"976 aria-label="Command palette"
...@@ -387,42 +982,86 @@ function OpenCommandPaletteDialog() {...@@ -387,42 +982,86 @@ function OpenCommandPaletteDialog() {
387 }}982 }}
388 >983 >
389 <Command984 <Command
390 key={viewStack.length}985 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}`}
391 aria-label="Command palette"986 aria-label="Command palette"
392 autoHighlight="always"987 autoHighlight={isBrowsing ? false : "always"}
393 mode="none"988 mode="none"
989 onItemHighlighted={(value) => {
990 setHighlightedItemValue(typeof value === "string" ? value : null);
991 }}
394 onValueChange={handleQueryChange}992 onValueChange={handleQueryChange}
395 value={query}993 value={query}
396 >994 >
397 <CommandInput995 <div className="relative">
398 placeholder={inputPlaceholder}996 <CommandInput
399 wrapperClassName={997 className={isBrowsing ? (willCreateProjectPath ? "pe-36" : "pe-16") : undefined}
400 isSubmenu998 placeholder={inputPlaceholder}
401 ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto [&_[data-slot=autocomplete-start-addon]]:cursor-pointer"999 wrapperClassName={
402 : undefined1000 isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined
403 }1001 }
404 {...(isSubmenu1002 {...(isSubmenu
405 ? {1003 ? {
406 startAddon: (1004 startAddon: (
407 <button1005 <button
408 type="button"1006 type="button"
409 className="flex cursor-pointer items-center"1007 className="flex cursor-pointer items-center"
410 aria-label="Back"1008 aria-label="Back"
411 onClick={popView}1009 onClick={popView}
412 >1010 >
413 <ArrowLeftIcon />1011 <ArrowLeftIcon />
414 </button>1012 </button>
415 ),1013 ),
416 }1014 }
417 : {})}1015 : isBrowsing && !isSubmenu
418 onKeyDown={handleKeyDown}1016 ? {
419 />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>
420 <CommandPanel className="max-h-[min(28rem,70vh)]">1051 <CommandPanel className="max-h-[min(28rem,70vh)]">
421 <CommandPaletteResults1052 <CommandPaletteResults
422 groups={displayedGroups}1053 groups={displayedGroups}
1054 highlightedItemValue={highlightedItemValue}
423 isActionsOnly={isActionsOnly}1055 isActionsOnly={isActionsOnly}
424 keybindings={keybindings}1056 keybindings={keybindings}
425 onExecuteItem={executeItem}1057 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 : {})}
426 />1065 />
427 </CommandPanel>1066 </CommandPanel>
428 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">1067 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
...@@ -436,10 +1075,12 @@ function OpenCommandPaletteDialog() {...@@ -436,10 +1075,12 @@ function OpenCommandPaletteDialog() {
436 </Kbd>1075 </Kbd>
437 <span className={cn("text-muted-foreground/80")}>Navigate</span>1076 <span className={cn("text-muted-foreground/80")}>Navigate</span>
438 </KbdGroup>1077 </KbdGroup>
439 <KbdGroup className="items-center gap-1.5">1078 {!canSubmitBrowsePath || hasHighlightedBrowseItem ? (
440 <Kbd>Enter</Kbd>1079 <KbdGroup className="items-center gap-1.5">
441 <span className={cn("text-muted-foreground/80")}>Select</span>1080 <Kbd>Enter</Kbd>
442 </KbdGroup>1081 <span className={cn("text-muted-foreground/80")}>Select</span>
1082 </KbdGroup>
1083 ) : null}
443 {isSubmenu ? (1084 {isSubmenu ? (
444 <KbdGroup className="items-center gap-1.5">1085 <KbdGroup className="items-center gap-1.5">
445 <Kbd>Backspace</Kbd>1086 <Kbd>Backspace</Kbd>
...@@ -451,6 +1092,19 @@ function OpenCommandPaletteDialog() {...@@ -451,6 +1092,19 @@ function OpenCommandPaletteDialog() {
451 <span className={cn("text-muted-foreground/80")}>Close</span>1092 <span className={cn("text-muted-foreground/80")}>Close</span>
452 </KbdGroup>1093 </KbdGroup>
453 </div>1094 </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}
454 </CommandFooter>1108 </CommandFooter>
455 </Command>1109 </Command>
456 </CommandDialogPopup>1110 </CommandDialogPopup>
apps/web/src/components/CommandPaletteResults.tsx+8-1
...@@ -14,10 +14,12 @@ import {...@@ -14,10 +14,12 @@ import {
14 CommandList,14 CommandList,
15 CommandShortcut,15 CommandShortcut,
16} from "./ui/command";16} from "./ui/command";
17import { cn } from "~/lib/utils";
1718
18interface CommandPaletteResultsProps {19interface CommandPaletteResultsProps {
19 emptyStateMessage?: string;20 emptyStateMessage?: string;
20 groups: ReadonlyArray<CommandPaletteGroup>;21 groups: ReadonlyArray<CommandPaletteGroup>;
22 highlightedItemValue?: string | null;
21 isActionsOnly: boolean;23 isActionsOnly: boolean;
22 keybindings: ResolvedKeybindingsConfig;24 keybindings: ResolvedKeybindingsConfig;
23 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;25 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
...@@ -46,6 +48,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {...@@ -46,6 +48,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {
46 item={item}48 item={item}
47 key={item.value}49 key={item.value}
48 keybindings={props.keybindings}50 keybindings={props.keybindings}
51 isActive={props.highlightedItemValue === item.value}
49 onExecuteItem={props.onExecuteItem}52 onExecuteItem={props.onExecuteItem}
50 />53 />
51 )}54 )}
...@@ -58,6 +61,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {...@@ -58,6 +61,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {
5861
59function CommandPaletteResultRow(props: {62function CommandPaletteResultRow(props: {
60 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;63 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
64 isActive: boolean;
61 keybindings: ResolvedKeybindingsConfig;65 keybindings: ResolvedKeybindingsConfig;
62 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;66 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
63}) {67}) {
...@@ -68,7 +72,10 @@ function CommandPaletteResultRow(props: {...@@ -68,7 +72,10 @@ function CommandPaletteResultRow(props: {
68 return (72 return (
69 <CommandItem73 <CommandItem
70 value={props.item.value}74 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 )}
72 onMouseDown={(event) => {79 onMouseDown={(event) => {
73 event.preventDefault();80 event.preventDefault();
74 }}81 }}
apps/web/src/components/Sidebar.tsx+12-258
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 ArrowUpDownIcon,3 ArrowUpDownIcon,
4 ChevronRightIcon,4 ChevronRightIcon,
5 CloudIcon,5 CloudIcon,
6 FolderIcon,
7 GitPullRequestIcon,6 GitPullRequestIcon,
8 PlusIcon,7 PlusIcon,
9 SearchIcon,8 SearchIcon,
...@@ -32,9 +31,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-...@@ -32,9 +31,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
32import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";31import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
33import { CSS } from "@dnd-kit/utilities";32import { CSS } from "@dnd-kit/utilities";
34import {33import {
35 DEFAULT_MODEL_BY_PROVIDER,
36 type DesktopUpdateState,34 type DesktopUpdateState,
37 type EnvironmentId,
38 ProjectId,35 ProjectId,
39 type ScopedProjectRef,36 type ScopedProjectRef,
40 type ScopedThreadRef,37 type ScopedThreadRef,
...@@ -58,7 +55,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary";...@@ -58,7 +55,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary";
58import { isElectron } from "../env";55import { isElectron } from "../env";
59import { APP_STAGE_LABEL, APP_VERSION } from "../branding";56import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
60import { isTerminalFocused } from "../lib/terminalFocus";57import { isTerminalFocused } from "../lib/terminalFocus";
61import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";58import { isMacPlatform, newCommandId } from "../lib/utils";
62import {59import {
63 selectProjectByRef,60 selectProjectByRef,
64 selectProjectsAcrossEnvironments,61 selectProjectsAcrossEnvironments,
...@@ -122,7 +119,7 @@ import {...@@ -122,7 +119,7 @@ import {
122 SidebarTrigger,119 SidebarTrigger,
123} from "./ui/sidebar";120} from "./ui/sidebar";
124import { useThreadSelectionStore } from "../threadSelectionStore";121import { useThreadSelectionStore } from "../threadSelectionStore";
125import { isNonEmpty as isNonEmptyString } from "effect/String";122import { useCommandPaletteStore } from "../commandPaletteStore";
126import {123import {
127 getSidebarThreadIdsToPrewarm,124 getSidebarThreadIdsToPrewarm,
128 resolveAdjacentThreadId,125 resolveAdjacentThreadId,
...@@ -2033,20 +2030,7 @@ interface SidebarProjectsContentProps {...@@ -2033,20 +2030,7 @@ interface SidebarProjectsContentProps {
2033 projectSortOrder: SidebarProjectSortOrder;2030 projectSortOrder: SidebarProjectSortOrder;
2034 threadSortOrder: SidebarThreadSortOrder;2031 threadSortOrder: SidebarThreadSortOrder;
2035 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];2032 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
2036 shouldShowProjectPathEntry: boolean;2033 openAddProject: () => void;
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;
2050 isManualProjectSorting: boolean;2034 isManualProjectSorting: boolean;
2051 projectDnDSensors: ReturnType<typeof useSensors>;2035 projectDnDSensors: ReturnType<typeof useSensors>;
2052 projectCollisionDetection: CollisionDetection;2036 projectCollisionDetection: CollisionDetection;
...@@ -2085,20 +2069,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2085,20 +2069,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2085 projectSortOrder,2069 projectSortOrder,
2086 threadSortOrder,2070 threadSortOrder,
2087 updateSettings,2071 updateSettings,
2088 shouldShowProjectPathEntry,2072 openAddProject,
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,
2102 isManualProjectSorting,2073 isManualProjectSorting,
2103 projectDnDSensors,2074 projectDnDSensors,
2104 projectCollisionDetection,2075 projectCollisionDetection,
...@@ -2137,26 +2108,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2137,26 +2108,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2137 },2108 },
2138 [updateSettings],2109 [updateSettings],
2139 );2110 );
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
2161 return (2112 return (
2162 <SidebarContent className="gap-0">2113 <SidebarContent className="gap-0">
...@@ -2223,68 +2174,19 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2223,68 +2174,19 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2223 render={2174 render={
2224 <button2175 <button
2225 type="button"2176 type="button"
2226 aria-label={shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}2177 aria-label="Add project"
2227 aria-pressed={shouldShowProjectPathEntry}2178 data-testid="sidebar-add-project-trigger"
2228 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"2179 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}
2230 />2181 />
2231 }2182 }
2232 >2183 >
2233 <PlusIcon2184 <PlusIcon className="size-3.5" />
2234 className={`size-3.5 transition-transform duration-150 ${
2235 shouldShowProjectPathEntry ? "rotate-45" : "rotate-0"
2236 }`}
2237 />
2238 </TooltipTrigger>2185 </TooltipTrigger>
2239 <TooltipPopup side="right">2186 <TooltipPopup side="right">Add project</TooltipPopup>
2240 {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
2241 </TooltipPopup>
2242 </Tooltip>2187 </Tooltip>
2243 </div>2188 </div>
2244 </div>2189 </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
2289 {isManualProjectSorting ? (2191 {isManualProjectSorting ? (
2290 <DndContext2192 <DndContext
...@@ -2359,7 +2261,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2359,7 +2261,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2359 </SidebarMenu>2261 </SidebarMenu>
2360 )}2262 )}
23612263
2362 {projectsLength === 0 && !shouldShowProjectPathEntry && (2264 {projectsLength === 0 && (
2363 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">2265 <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60">
2364 No projects yet2266 No projects yet
2365 </div>2267 </div>
...@@ -2372,7 +2274,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2372,7 +2274,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2372export default function Sidebar() {2274export default function Sidebar() {
2373 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));2275 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
2374 const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));2276 const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
2375 const activeEnvironmentId = useStore((store) => store.activeEnvironmentId);
2376 const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);2277 const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
2377 const projectOrder = useUiStateStore((store) => store.projectOrder);2278 const projectOrder = useUiStateStore((store) => store.projectOrder);
2378 const reorderProjects = useUiStateStore((store) => store.reorderProjects);2279 const reorderProjects = useUiStateStore((store) => store.reorderProjects);
...@@ -2381,7 +2282,6 @@ export default function Sidebar() {...@@ -2381,7 +2282,6 @@ export default function Sidebar() {
2381 const isOnSettings = pathname.startsWith("/settings");2282 const isOnSettings = pathname.startsWith("/settings");
2382 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);2283 const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder);
2383 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);2284 const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder);
2384 const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode);
2385 const { updateSettings } = useUpdateSettings();2285 const { updateSettings } = useUpdateSettings();
2386 const { handleNewThread } = useNewThreadHandler();2286 const { handleNewThread } = useNewThreadHandler();
2387 const { archiveThread, deleteThread } = useThreadActions();2287 const { archiveThread, deleteThread } = useThreadActions();
...@@ -2391,12 +2291,7 @@ export default function Sidebar() {...@@ -2391,12 +2291,7 @@ export default function Sidebar() {
2391 });2291 });
2392 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;2292 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
2393 const keybindings = useServerKeybindings();2293 const keybindings = useServerKeybindings();
2394 const [addingProject, setAddingProject] = useState(false);2294 const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject);
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);
2400 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<2295 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
2401 ReadonlySet<string>2296 ReadonlySet<string>
2402 >(() => new Set());2297 >(() => new Set());
...@@ -2408,10 +2303,7 @@ export default function Sidebar() {...@@ -2408,10 +2303,7 @@ export default function Sidebar() {
2408 const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size);2303 const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size);
2409 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);2304 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
2410 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);2305 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
2411 const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
2412 const platform = navigator.platform;2306 const platform = navigator.platform;
2413 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
2414 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
2415 const primaryEnvironmentId = usePrimaryEnvironmentId();2307 const primaryEnvironmentId = usePrimaryEnvironmentId();
2416 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);2308 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
2417 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);2309 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
...@@ -2574,131 +2466,6 @@ export default function Sidebar() {...@@ -2574,131 +2466,6 @@ export default function Sidebar() {
2574 const newThreadShortcutLabel =2466 const newThreadShortcutLabel =
2575 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??2467 shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ??
2576 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);2468 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
2703 const navigateToThread = useCallback(2470 const navigateToThread = useCallback(
2704 (threadRef: ScopedThreadRef) => {2471 (threadRef: ScopedThreadRef) => {
...@@ -3212,20 +2979,7 @@ export default function Sidebar() {...@@ -3212,20 +2979,7 @@ export default function Sidebar() {
3212 projectSortOrder={sidebarProjectSortOrder}2979 projectSortOrder={sidebarProjectSortOrder}
3213 threadSortOrder={sidebarThreadSortOrder}2980 threadSortOrder={sidebarThreadSortOrder}
3214 updateSettings={updateSettings}2981 updateSettings={updateSettings}
3215 shouldShowProjectPathEntry={shouldShowProjectPathEntry}2982 openAddProject={openAddProjectCommandPalette}
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}
3229 isManualProjectSorting={isManualProjectSorting}2983 isManualProjectSorting={isManualProjectSorting}
3230 projectDnDSensors={projectDnDSensors}2984 projectDnDSensors={projectDnDSensors}
3231 projectCollisionDetection={projectCollisionDetection}2985 projectCollisionDetection={projectCollisionDetection}
apps/web/src/components/settings/SettingsPanels.tsx+32
...@@ -444,6 +444,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -444,6 +444,9 @@ export function useSettingsRestore(onRestored?: () => void) {
444 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode444 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
445 ? ["New thread mode"]445 ? ["New thread mode"]
446 : []),446 : []),
447 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
448 ? ["Add project base directory"]
449 : []),
447 ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive450 ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
448 ? ["Archive confirmation"]451 ? ["Archive confirmation"]
449 : []),452 : []),
...@@ -458,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -458,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
458 isGitWritingModelDirty,461 isGitWritingModelDirty,
459 settings.confirmThreadArchive,462 settings.confirmThreadArchive,
460 settings.confirmThreadDelete,463 settings.confirmThreadDelete,
464 settings.addProjectBaseDirectory,
461 settings.defaultThreadEnvMode,465 settings.defaultThreadEnvMode,
462 settings.diffWordWrap,466 settings.diffWordWrap,
463 settings.enableAssistantStreaming,467 settings.enableAssistantStreaming,
...@@ -919,6 +923,34 @@ export function GeneralSettingsPanel() {...@@ -919,6 +923,34 @@ export function GeneralSettingsPanel() {
919 }923 }
920 />924 />
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
922 <SettingsRow954 <SettingsRow
923 title="Archive confirmation"955 title="Archive confirmation"
924 description="Require a second click on the inline archive action before a thread is archived."956 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";...@@ -3,6 +3,8 @@ import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
3import type { WsRpcClient } from "./rpc/wsRpcClient";3import type { WsRpcClient } from "./rpc/wsRpcClient";
4import { readEnvironmentConnection } from "./environments/runtime";4import { readEnvironmentConnection } from "./environments/runtime";
55
6const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>();
7
6export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {8export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
7 return {9 return {
8 terminal: {10 terminal: {
...@@ -18,6 +20,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {...@@ -18,6 +20,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
18 searchEntries: rpcClient.projects.searchEntries,20 searchEntries: rpcClient.projects.searchEntries,
19 writeFile: rpcClient.projects.writeFile,21 writeFile: rpcClient.projects.writeFile,
20 },22 },
23 filesystem: {
24 browse: rpcClient.filesystem.browse,
25 },
21 git: {26 git: {
22 pull: rpcClient.git.pull,27 pull: rpcClient.git.pull,
23 refreshStatus: rpcClient.git.refreshStatus,28 refreshStatus: rpcClient.git.refreshStatus,
...@@ -52,6 +57,11 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi...@@ -52,6 +57,11 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi
52 return undefined;57 return undefined;
53 }58 }
5459
60 const overriddenApi = environmentApiOverridesForTests.get(environmentId);
61 if (overriddenApi) {
62 return overriddenApi;
63 }
64
55 const connection = readEnvironmentConnection(environmentId);65 const connection = readEnvironmentConnection(environmentId);
56 return connection ? createEnvironmentApi(connection.client) : undefined;66 return connection ? createEnvironmentApi(connection.client) : undefined;
57}67}
...@@ -63,3 +73,14 @@ export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentA...@@ -63,3 +73,14 @@ export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentA
63 }73 }
64 return api;74 return api;
65}75}
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 = {...@@ -51,6 +51,9 @@ const rpcClientMock = {
51 searchEntries: vi.fn(),51 searchEntries: vi.fn(),
52 writeFile: vi.fn(),52 writeFile: vi.fn(),
53 },53 },
54 filesystem: {
55 browse: vi.fn(),
56 },
54 shell: {57 shell: {
55 openInEditor: vi.fn(),58 openInEditor: vi.fn(),
56 },59 },
...@@ -419,6 +422,25 @@ describe("wsApi", () => {...@@ -419,6 +422,25 @@ describe("wsApi", () => {
419 });422 });
420 });423 });
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
422 it("forwards full-thread diff requests to the orchestration RPC", async () => {444 it("forwards full-thread diff requests to the orchestration RPC", async () => {
423 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });445 rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" });
424 const { createEnvironmentApi } = await import("./environmentApi");446 const { createEnvironmentApi } = await import("./environmentApi");
...@@ -481,6 +503,19 @@ describe("wsApi", () => {...@@ -481,6 +503,19 @@ describe("wsApi", () => {
481 expect(showContextMenu).toHaveBeenCalledWith(items, undefined);503 expect(showContextMenu).toHaveBeenCalledWith(items, undefined);
482 });504 });
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
484 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {519 it("falls back to the browser context menu helper when the desktop bridge is missing", async () => {
485 showContextMenuFallbackMock.mockResolvedValue("rename");520 showContextMenuFallbackMock.mockResolvedValue("rename");
486 const { createLocalApi } = await import("./localApi");521 const { createLocalApi } = await import("./localApi");
apps/web/src/localApi.ts+2-2
...@@ -29,9 +29,9 @@ let cachedApi: LocalApi | undefined;...@@ -29,9 +29,9 @@ let cachedApi: LocalApi | undefined;
29export function createLocalApi(rpcClient: WsRpcClient): LocalApi {29export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
30 return {30 return {
31 dialogs: {31 dialogs: {
32 pickFolder: async () => {32 pickFolder: async (options) => {
33 if (!window.desktopBridge) return null;33 if (!window.desktopBridge) return null;
34 return window.desktopBridge.pickFolder();34 return window.desktopBridge.pickFolder(options);
35 },35 },
36 confirm: async (message) => {36 confirm: async (message) => {
37 if (window.desktopBridge) {37 if (window.desktopBridge) {
apps/web/src/rpc/wsRpcClient.ts+6
...@@ -68,6 +68,9 @@ export interface WsRpcClient {...@@ -68,6 +68,9 @@ export interface WsRpcClient {
68 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;68 readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>;
69 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;69 readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>;
70 };70 };
71 readonly filesystem: {
72 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
73 };
71 readonly shell: {74 readonly shell: {
72 readonly openInEditor: (input: {75 readonly openInEditor: (input: {
73 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];76 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
...@@ -145,6 +148,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -145,6 +148,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
145 writeFile: (input) =>148 writeFile: (input) =>
146 transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),149 transport.request((client) => client[WS_METHODS.projectsWriteFile](input)),
147 },150 },
151 filesystem: {
152 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
153 },
148 shell: {154 shell: {
149 openInEditor: (input) =>155 openInEditor: (input) =>
150 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),156 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";...@@ -13,4 +13,5 @@ export * from "./git";
13export * from "./orchestration";13export * from "./orchestration";
14export * from "./editor";14export * from "./editor";
15export * from "./project";15export * from "./project";
16export * from "./filesystem";
16export * from "./rpc";17export * from "./rpc";
packages/contracts/src/ipc.ts+10-2
...@@ -18,6 +18,7 @@ import type {...@@ -18,6 +18,7 @@ import type {
18 GitStatusResult,18 GitStatusResult,
19 GitCreateBranchResult,19 GitCreateBranchResult,
20} from "./git";20} from "./git";
21import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem";
21import type {22import type {
22 ProjectSearchEntriesInput,23 ProjectSearchEntriesInput,
23 ProjectSearchEntriesResult,24 ProjectSearchEntriesResult,
...@@ -140,6 +141,10 @@ export interface DesktopServerExposureState {...@@ -140,6 +141,10 @@ export interface DesktopServerExposureState {
140 advertisedHost: string | null;141 advertisedHost: string | null;
141}142}
142143
144export interface PickFolderOptions {
145 initialPath?: string | null;
146}
147
143export interface DesktopBridge {148export interface DesktopBridge {
144 getAppBranding: () => DesktopAppBranding | null;149 getAppBranding: () => DesktopAppBranding | null;
145 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;150 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
...@@ -154,7 +159,7 @@ export interface DesktopBridge {...@@ -154,7 +159,7 @@ export interface DesktopBridge {
154 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;159 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;
155 getServerExposureState: () => Promise<DesktopServerExposureState>;160 getServerExposureState: () => Promise<DesktopServerExposureState>;
156 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;161 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
157 pickFolder: () => Promise<string | null>;162 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
158 confirm: (message: string) => Promise<boolean>;163 confirm: (message: string) => Promise<boolean>;
159 setTheme: (theme: DesktopTheme) => Promise<void>;164 setTheme: (theme: DesktopTheme) => Promise<void>;
160 showContextMenu: <T extends string>(165 showContextMenu: <T extends string>(
...@@ -183,7 +188,7 @@ export interface DesktopBridge {...@@ -183,7 +188,7 @@ export interface DesktopBridge {
183 */188 */
184export interface LocalApi {189export interface LocalApi {
185 dialogs: {190 dialogs: {
186 pickFolder: () => Promise<string | null>;191 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
187 confirm: (message: string) => Promise<boolean>;192 confirm: (message: string) => Promise<boolean>;
188 };193 };
189 shell: {194 shell: {
...@@ -239,6 +244,9 @@ export interface EnvironmentApi {...@@ -239,6 +244,9 @@ export interface EnvironmentApi {
239 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;244 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
240 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;245 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
241 };246 };
247 filesystem: {
248 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
249 };
242 git: {250 git: {
243 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;251 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
244 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;252 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", ()...@@ -95,6 +95,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
95 assert.strictEqual(parsed.projectId, "project-1");95 assert.strictEqual(parsed.projectId, "project-1");
96 assert.strictEqual(parsed.title, "Project Title");96 assert.strictEqual(parsed.title, "Project Title");
97 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");97 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");
98 assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined);
98 assert.deepStrictEqual(parsed.defaultModelSelection, {99 assert.deepStrictEqual(parsed.defaultModelSelection, {
99 provider: "codex",100 provider: "codex",
100 model: "gpt-5.2",101 model: "gpt-5.2",
...@@ -102,6 +103,22 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()...@@ -102,6 +103,22 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
102 }),103 }),
103);104);
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
105it.effect("decodes historical project.created payloads with a default provider", () =>122it.effect("decodes historical project.created payloads with a default provider", () =>
106 Effect.gen(function* () {123 Effect.gen(function* () {
107 const parsed = yield* decodeProjectCreatedPayload({124 const parsed = yield* decodeProjectCreatedPayload({
packages/contracts/src/orchestration.ts+1
...@@ -402,6 +402,7 @@ export const ProjectCreateCommand = Schema.Struct({...@@ -402,6 +402,7 @@ export const ProjectCreateCommand = Schema.Struct({
402 projectId: ProjectId,402 projectId: ProjectId,
403 title: TrimmedNonEmptyString,403 title: TrimmedNonEmptyString,
404 workspaceRoot: TrimmedNonEmptyString,404 workspaceRoot: TrimmedNonEmptyString,
405 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
405 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),406 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
406 createdAt: IsoDateTime,407 createdAt: IsoDateTime,
407});408});
packages/contracts/src/rpc.ts+11
...@@ -4,6 +4,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup";...@@ -4,6 +4,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
5import { OpenError, OpenInEditorInput } from "./editor";5import { OpenError, OpenInEditorInput } from "./editor";
6import { AuthAccessStreamEvent } from "./auth";6import { AuthAccessStreamEvent } from "./auth";
7import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError } from "./filesystem";
7import {8import {
8 GitActionProgressEvent,9 GitActionProgressEvent,
9 GitCheckoutInput,10 GitCheckoutInput,
...@@ -83,6 +84,9 @@ export const WS_METHODS = {...@@ -83,6 +84,9 @@ export const WS_METHODS = {
83 // Shell methods84 // Shell methods
84 shellOpenInEditor: "shell.openInEditor",85 shellOpenInEditor: "shell.openInEditor",
8586
87 // Filesystem methods
88 filesystemBrowse: "filesystem.browse",
89
86 // Git methods90 // Git methods
87 gitPull: "git.pull",91 gitPull: "git.pull",
88 gitRefreshStatus: "git.refreshStatus",92 gitRefreshStatus: "git.refreshStatus",
...@@ -165,6 +169,12 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {...@@ -165,6 +169,12 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
165 error: OpenError,169 error: OpenError,
166});170});
167171
172export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
173 payload: FilesystemBrowseInput,
174 success: FilesystemBrowseResult,
175 error: FilesystemBrowseError,
176});
177
168export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, {178export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, {
169 payload: GitStatusInput,179 payload: GitStatusInput,
170 success: GitStatusStreamEvent,180 success: GitStatusStreamEvent,
...@@ -350,6 +360,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -350,6 +360,7 @@ export const WsRpcGroup = RpcGroup.make(
350 WsProjectsSearchEntriesRpc,360 WsProjectsSearchEntriesRpc,
351 WsProjectsWriteFileRpc,361 WsProjectsWriteFileRpc,
352 WsShellOpenInEditorRpc,362 WsShellOpenInEditorRpc,
363 WsFilesystemBrowseRpc,
353 WsSubscribeGitStatusRpc,364 WsSubscribeGitStatusRpc,
354 WsGitPullRpc,365 WsGitPullRpc,
355 WsGitRefreshStatusRpc,366 WsGitRefreshStatusRpc,
packages/contracts/src/settings.ts+2
...@@ -84,6 +84,7 @@ export const ServerSettings = Schema.Struct({...@@ -84,6 +84,7 @@ export const ServerSettings = Schema.Struct({
84 defaultThreadEnvMode: ThreadEnvMode.pipe(84 defaultThreadEnvMode: ThreadEnvMode.pipe(
85 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),85 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
86 ),86 ),
87 addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
87 textGenerationModelSelection: ModelSelection.pipe(88 textGenerationModelSelection: ModelSelection.pipe(
88 Schema.withDecodingDefault(89 Schema.withDecodingDefault(
89 Effect.succeed({90 Effect.succeed({
...@@ -168,6 +169,7 @@ const ClaudeSettingsPatch = Schema.Struct({...@@ -168,6 +169,7 @@ const ClaudeSettingsPatch = Schema.Struct({
168export const ServerSettingsPatch = Schema.Struct({169export const ServerSettingsPatch = Schema.Struct({
169 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),170 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
170 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),171 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
172 addProjectBaseDirectory: Schema.optionalKey(Schema.String),
171 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),173 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
172 observability: Schema.optionalKey(174 observability: Schema.optionalKey(
173 Schema.Struct({175 Schema.Struct({
packages/shared/package.json+4
...@@ -59,6 +59,10 @@...@@ -59,6 +59,10 @@
59 "./qrCode": {59 "./qrCode": {
60 "types": "./src/qrCode.ts",60 "types": "./src/qrCode.ts",
61 "import": "./src/qrCode.ts"61 "import": "./src/qrCode.ts"
62 },
63 "./path": {
64 "types": "./src/path.ts",
65 "import": "./src/path.ts"
62 }66 }
63 },67 },
64 "scripts": {68 "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}