diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 022215da291..987cad34089 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,7 +18,7 @@ import { safeStorage, shell, } from "electron"; -import type { MenuItemConstructorOptions } from "electron"; +import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { ClientSettings, DesktopTheme, @@ -126,6 +126,32 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; + +function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { + if (typeof rawOptions !== "object" || rawOptions === null) { + return undefined; + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return undefined; + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return undefined; + } + + if (trimmedPath === "~") { + return OS.homedir(); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Path.join(OS.homedir(), trimmedPath.slice(2)); + } + + return Path.resolve(trimmedPath); +} const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; const TITLEBAR_HEIGHT = 40; @@ -1641,15 +1667,16 @@ function registerIpcHandlers(): void { }); ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { + ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + const defaultPath = resolvePickFolderDefaultPath(rawOptions); + const openDialogOptions: OpenDialogOptions = { + properties: ["openDirectory", "createDirectory"], + ...(defaultPath ? { defaultPath } : {}), + }; const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); + ? await dialog.showOpenDialog(owner, openDialogOptions) + : await dialog.showOpenDialog(openDialogOptions); if (result.canceled) return null; return result.filePaths[0] ?? null; }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a34a01da30f..a6756048725 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -53,7 +53,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), - pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), + pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 69863b7f0ad..177a23ec001 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -28,10 +28,31 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ), ); + const normalizeProjectWorkspaceRootForCreate = ( + workspaceRoot: string, + createIfMissing: boolean | undefined, + ) => + workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { + createIfMissing: createIfMissing === true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: cause.message, + }), + ), + ); + if (command.type === "project.create") { return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( + command.workspaceRoot, + command.createWorkspaceRootIfMissing, + ), + createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 276bc1d9b40..b2c16abb427 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2039,6 +2039,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-create-" }); + const missingWorkspaceRoot = path.join(parentDir, "nested", "new-project"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-missing-root"), + projectId: ProjectId.make("project-create-missing-root"), + title: "New Project", + workspaceRoot: missingWorkspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt: new Date().toISOString(), + }), + ), + ); + const stat = yield* fs.stat(missingWorkspaceRoot); + + assert.isAtLeast(response.sequence, 0); + assert.equal(stat.type, "Directory"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 289bc689617..dcf3fa189ef 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -176,12 +176,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsService; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: " ~/Development ", observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }); + assert.equal(next.addProjectBaseDirectory, "~/Development"); assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -215,6 +217,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -230,6 +233,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 09f6905ce98..85b43ab37f6 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -68,6 +68,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb return yield* workspaceEntries.search(input); }); +const appendSeparator = (input: string) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${process.platform === "win32" ? "\\" : "/"}`; + it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { afterEach(() => { vi.restoreAllMocks(); @@ -275,4 +280,85 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); }); + + describe("browse", () => { + it.effect("returns matching directories and excludes files", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); + yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); + yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); + yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); + + const result = yield* workspaceEntries.browse({ + partialPath: path.join(cwd, "alp"), + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [ + { name: "alpha", fullPath: path.join(cwd, "alpha") }, + { name: "alpine", fullPath: path.join(cwd, "alpine") }, + ], + }); + }), + ); + + it.effect("shows dot directories in directory mode and hidden-prefix mode", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); + yield* writeTextFile(cwd, ".config/settings.json", "{}"); + yield* writeTextFile(cwd, "config/settings.json", "{}"); + + const directoryResult = yield* workspaceEntries.browse({ + partialPath: appendSeparator(cwd), + }); + const hiddenPrefixResult = yield* workspaceEntries.browse({ + partialPath: `${appendSeparator(cwd)}.c`, + }); + + expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); + expect(hiddenPrefixResult).toEqual({ + parentPath: cwd, + entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], + }); + }), + ); + + it.effect("supports relative paths when cwd is provided", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); + yield* writeTextFile(cwd, "packages/pkg.json", "{}"); + + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./pack", + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], + }); + }), + ); + + it.effect("rejects relative paths without cwd", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + + const error = yield* workspaceEntries + .browse({ + partialPath: "./src", + }) + .pipe(Effect.flip); + + expect(error.detail).toBe("Relative filesystem browse paths require a current project."); + }), + ); + }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index c4d3c3c81f3..7cb16b652be 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,9 +1,11 @@ +import * as OS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; -import { type ProjectEntry } from "@t3tools/contracts"; +import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { insertRankedSearchResult, normalizeSearchQuery, @@ -14,6 +16,7 @@ import { import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries, + WorkspaceEntriesBrowseError, WorkspaceEntriesError, type WorkspaceEntriesShape, } from "../Services/WorkspaceEntries.ts"; @@ -52,6 +55,16 @@ function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); } +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + function parentPathOf(input: string): string | undefined { const separatorIndex = input.lastIndexOf("/"); if (separatorIndex === -1) { @@ -129,6 +142,36 @@ function directoryAncestorsOf(relativePath: string): string[] { return directories; } +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + pathService: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return pathService.resolve(expandHomePath(input.partialPath, pathService)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); + }); + export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; const gitOption = yield* Effect.serviceOption(GitCore); @@ -379,6 +422,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + + return { + parentPath, + entries: dirents + .filter( + (dirent) => + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")), + ) + .map((dirent) => ({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); @@ -415,6 +498,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ); return { + browse, invalidate, search, } satisfies WorkspaceEntriesShape; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts index d02a5929d27..13658e9c1ae 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts @@ -58,6 +58,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("creates missing directories when createIfMissing is enabled", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const missingPath = path.join(cwd, "nested", "new-project"); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { + createIfMissing: true, + }); + const stat = yield* fileSystem.stat(resolved); + + expect(resolved).toBe(missingPath); + expect(stat.type).toBe("Directory"); + }), + ); + it.effect("rejects file paths", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index fa7a90cf07d..f19bb3624d8 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { WorkspacePaths, WorkspacePathOutsideRootError, + WorkspaceRootCreateFailedError, WorkspaceRootNotDirectoryError, WorkspaceRootNotExistsError, type WorkspacePathsShape, @@ -29,11 +30,25 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", - )(function* (workspaceRoot) { + )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - const workspaceStat = yield* fileSystem + let workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat && options?.createIfMissing) { + yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( + Effect.mapError( + () => + new WorkspaceRootCreateFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + }), + ), + ); + workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ workspaceRoot, diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index c0011a9dc9a..e546bf4c5d1 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -9,7 +9,12 @@ import { Schema, Context } from "effect"; import type { Effect } from "effect"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( "WorkspaceEntriesError", @@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + /** * WorkspaceEntriesShape - Service API for workspace entry search and cache * invalidation. */ export interface WorkspaceEntriesShape { + /** + * Browse matching directories for the provided partial path. + */ + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts index 86cef907359..1cd016284a4 100644 --- a/apps/server/src/workspace/Services/WorkspacePaths.ts +++ b/apps/server/src/workspace/Services/WorkspacePaths.ts @@ -21,6 +21,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( + "WorkspaceRootCreateFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -47,6 +59,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect; + options?: { readonly createIfMissing?: boolean }, + ) => Effect.Effect< + string, + WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + >; /** * Resolve a relative path within a validated workspace root. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3214cef806e..96b5b54d71b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -17,6 +17,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, + FilesystemBrowseError, ThreadId, type TerminalEvent, WS_METHODS, @@ -768,6 +769,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", }), + [WS_METHODS.filesystemBrowse]: (input) => + observeRpcEffect( + WS_METHODS.filesystemBrowse, + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + message: cause.detail, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeGitStatus]: (input) => observeRpcStream( WS_METHODS.subscribeGitStatus, diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts index 4f291d5a480..04b25529f2f 100644 --- a/apps/web/src/commandPaletteStore.ts +++ b/apps/web/src/commandPaletteStore.ts @@ -1,13 +1,32 @@ import { create } from "zustand"; +interface CommandPaletteOpenIntent { + kind: "add-project"; + requestId: number; +} + interface CommandPaletteStore { open: boolean; + openIntent: CommandPaletteOpenIntent | null; setOpen: (open: boolean) => void; toggleOpen: () => void; + openAddProject: () => void; + clearOpenIntent: () => void; } export const useCommandPaletteStore = create((set) => ({ open: false, - setOpen: (open) => set({ open }), - toggleOpen: () => set((state) => ({ open: !state.open })), + openIntent: null, + setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), + toggleOpen: () => + set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), + openAddProject: () => + set((state) => ({ + open: true, + openIntent: { + kind: "add-project", + requestId: (state.openIntent?.requestId ?? 0) + 1, + }, + })), + clearOpenIntent: () => set({ openIntent: null }), })); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f127047b711..1774a15e3ec 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -5,6 +5,7 @@ import { EventId, ORCHESTRATION_WS_METHODS, EnvironmentId, + type EnvironmentApi, type MessageId, type OrchestrationReadModel, type ProjectId, @@ -31,6 +32,16 @@ import { render } from "vitest-browser-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { + __resetEnvironmentApiOverridesForTests, + __setEnvironmentApiOverrideForTests, +} from "../environmentApi"; +import { + resetSavedEnvironmentRegistryStoreForTests, + resetSavedEnvironmentRuntimeStoreForTests, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, removeInlineTerminalContextPlaceholder, @@ -62,6 +73,7 @@ const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as Thre const PROJECT_ID = "project-1" as ProjectId; const SECOND_PROJECT_ID = "project-2" as ProjectId; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const 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 const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; +const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; interface TestFixture { snapshot: OrchestrationReadModel; @@ -172,6 +185,32 @@ function createBaseServerConfig(): ServerConfig { }; } +function createMockEnvironmentApi(input: { + browse: EnvironmentApi["filesystem"]["browse"]; + dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; +}): EnvironmentApi { + return { + terminal: {} as EnvironmentApi["terminal"], + projects: {} as EnvironmentApi["projects"], + filesystem: { + browse: input.browse, + }, + git: {} as EnvironmentApi["git"], + orchestration: { + dispatchCommand: input.dispatchCommand, + getTurnDiff: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getTurnDiff"], + getFullThreadDiff: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], + subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], + subscribeThread: (() => () => + undefined) as EnvironmentApi["orchestration"]["subscribeThread"], + }, + }; +} + function createUserMessage(options: { id: MessageId; text: string; @@ -1383,6 +1422,44 @@ async function waitForCommandPaletteShortcutLabel(): Promise { ); } +async function waitForCommandPaletteInput(placeholder: string): Promise { + return waitForElement( + () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null, + `Command palette input with placeholder "${placeholder}" did not render.`, + ); +} + +function getCommandPaletteLegendEntries(): string[] { + const footer = document.querySelector('[data-slot="command-footer"]'); + if (!footer) { + return []; + } + + return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]')) + .map((group) => + Array.from(group.children) + .map((child) => child.textContent?.trim() ?? "") + .filter((value) => value.length > 0) + .join(" "), + ) + .filter((value) => value.length > 0); +} + +async function dispatchInputKey( + input: HTMLInputElement, + init: Pick, +): Promise { + input.focus(); + input.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }), + ); + await waitForLayout(); +} + async function mountChatView(options: { viewport: ViewportSpec; snapshot: OrchestrationReadModel; @@ -1524,6 +1601,10 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; + __resetEnvironmentApiOverridesForTests(); + resetSavedEnvironmentRegistryStoreForTests(); + resetSavedEnvironmentRuntimeStoreForTests(); + Reflect.deleteProperty(window, "desktopBridge"); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, @@ -1533,6 +1614,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); useCommandPaletteStore.setState({ open: false, + openIntent: null, }); useStore.setState({ activeEnvironmentId: null, @@ -4079,6 +4161,746 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("adds a project from browse mode with Enter when no directory is highlighted", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId, + targetText: "command palette add project enter", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [ + { name: "alpha", fullPath: "~/Development/alpha" }, + { name: "beta", fullPath: "~/Development/beta" }, + ], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); + await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); + + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development", + title: "Development", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project with Enter.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens add project browse mode from the sidebar add button", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId, + targetText: "sidebar add project trigger", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + + await page.getByTestId("sidebar-add-project-trigger").click(); + + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("starts add project browse mode from the configured base directory", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId, + targetText: "sidebar add project custom base directory", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + addProjectBaseDirectory: "~/Development", + }, + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [{ name: "codething", fullPath: "~/Development/codething" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + + await page.getByTestId("sidebar-add-project-trigger").click(); + + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/Development/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && + request.partialPath === "~/Development/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows create-folder affordances for missing project paths", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId, + targetText: "command palette create missing project", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Desktop/") { + return { + parentPath: "~/Desktop/", + entries: [{ name: "existing", fullPath: "~/Desktop/existing" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Desktop", fullPath: "~/Desktop" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + const palette = page.getByTestId("command-palette"); + await page.getByTestId("sidebar-add-project-trigger").click(); + + await expect.element(palette).toBeInTheDocument(); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); + + await expect + .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) + .toBeInTheDocument(); + await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + createWorkspaceRootIfMissing?: boolean; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Desktop/fresh-project", + title: "fresh-project", + createWorkspaceRootIfMissing: true, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("does not show create affordances for an existing directory with a trailing slash", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId, + targetText: "command palette existing trailing directory", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/codex/") { + return { + parentPath: "~/Development/codex/", + entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + const palette = page.getByTestId("command-palette"); + await page.getByTestId("sidebar-add-project-trigger").click(); + + await expect.element(palette).toBeInTheDocument(); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && + request.partialPath === "~/Development/codex/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + await expect + .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) + .not.toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development/codex", + title: "codex", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("selects an environment before browsing when multiple environments are available", async () => { + const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { + if (partialPath === "~/workspaces/") { + return { + parentPath: "~/workspaces/", + entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "workspaces", fullPath: "~/workspaces" }], + }; + }); + const remoteDispatchMock = vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })); + + __setEnvironmentApiOverrideForTests( + REMOTE_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: remoteBrowseMock, + dispatchCommand: remoteDispatchMock, + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, + targetText: "command palette add project multi env", + }), + }); + + try { + await waitForServerConfigToApply(); + useSavedEnvironmentRegistryStore.getState().upsert({ + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + httpBaseUrl: "https://staging.example.test", + wsBaseUrl: "wss://staging.example.test/ws", + createdAt: NOW_ISO, + lastConnectedAt: NOW_ISO, + }); + useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { + connectionState: "connected", + authState: "authenticated", + descriptor: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + }, + serverConfig: { + ...fixture.serverConfig, + environment: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + }, + settings: { + ...fixture.serverConfig.settings, + addProjectBaseDirectory: "~/workspaces", + }, + }, + connectedAt: NOW_ISO, + }); + + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("This device", { exact: true }).first()) + .toBeInTheDocument(); + await palette.getByText("Staging", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/workspaces/"); + + await vi.waitFor( + () => { + expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); + await vi.waitFor( + () => { + expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + expect(remoteDispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "project.create", + workspaceRoot: "~/workspaces", + title: "workspaces", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a remote project.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("picks a local project from the native file manager", async () => { + const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId, + targetText: "command palette add project file manager", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Applications/") { + return { + parentPath: "~/Applications/", + entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Applications", fullPath: "~/Applications" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + window.desktopBridge = { + pickFolder, + setTheme: vi.fn().mockResolvedValue(undefined), + } as unknown as NonNullable; + + await page.getByTestId("sidebar-add-project-trigger").click(); + + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await browseInput.fill("~/Applications/access"); + + const fileManagerLabel = isMacPlatform(navigator.platform) + ? "Open in Finder" + : navigator.platform.toLowerCase().startsWith("win") + ? "Open in Explorer" + : "Open in Files"; + await palette.getByRole("button", { name: fileManagerLabel }).click(); + + await vi.waitFor( + () => { + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "/Users/julius/Projects/finder-picked", + title: "finder-picked", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project from the native file manager.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId, + targetText: "command palette add project mod enter", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [ + { name: "alpha", fullPath: "~/Development/alpha" }, + { name: "beta", fullPath: "~/Development/beta" }, + ], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); + await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "ArrowDown" }); + + const addButtonLabel = isMacPlatform(navigator.platform) + ? "Add (\u2318 Enter)" + : "Add (Ctrl Enter)"; + await vi.waitFor( + () => { + const legendEntries = getCommandPaletteLegendEntries(); + expect(legendEntries).toContain("Enter Select"); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect + .element(palette.getByRole("button", { name: addButtonLabel })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { + key: "Enter", + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development", + title: "Development", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project with Mod+Enter.", + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps project-context thread matches available when searching by project name", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 7524ef859d8..3e2f1ec890c 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,4 @@ -import { type KeybindingCommand } from "@t3tools/contracts"; +import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; @@ -45,7 +45,39 @@ export interface CommandPaletteView { readonly initialQuery?: string; } -export type CommandPaletteMode = "root" | "submenu"; +export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; + +export function filterBrowseEntries(input: { + browseEntries: ReadonlyArray; + browseFilterQuery: string; + highlightedItemValue: string | null; +}): { + filteredEntries: FilesystemBrowseEntry[]; + highlightedEntry: FilesystemBrowseEntry | null; + exactEntry: FilesystemBrowseEntry | null; +} { + const lowerFilter = input.browseFilterQuery.toLowerCase(); + const showHidden = input.browseFilterQuery.startsWith("."); + + const filteredEntries = input.browseEntries.filter( + (entry) => + entry.name.toLowerCase().startsWith(lowerFilter) && + (showHidden || !entry.name.startsWith(".")), + ); + + let highlightedEntry: FilesystemBrowseEntry | null = null; + if (input.highlightedItemValue?.startsWith("browse:")) { + const highlightedPath = input.highlightedItemValue.slice("browse:".length); + highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; + } + + const exactEntry = + input.browseFilterQuery.length > 0 + ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) + : null; + + return { filteredEntries, highlightedEntry, exactEntry }; +} export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); @@ -230,10 +262,56 @@ export function filterCommandPaletteGroups(input: { }); } +export function buildBrowseGroups(input: { + browseEntries: ReadonlyArray; + browseQuery: string; + canBrowseUp: boolean; + upIcon: ReactNode; + directoryIcon: ReactNode; + browseUp: () => void; + browseTo: (name: string) => void; +}): CommandPaletteGroup[] { + const items: CommandPaletteActionItem[] = []; + + if (input.canBrowseUp) { + items.push({ + kind: "action", + value: "browse:up", + searchTerms: [input.browseQuery, ".."], + title: "..", + icon: input.upIcon, + keepOpen: true, + run: async () => { + input.browseUp(); + }, + }); + } + + for (const entry of input.browseEntries) { + items.push({ + kind: "action", + value: `browse:${entry.fullPath}`, + searchTerms: [input.browseQuery, entry.fullPath, entry.name], + title: entry.name, + icon: input.directoryIcon, + keepOpen: true, + run: async () => { + input.browseTo(entry.name); + }, + }); + } + + return [{ value: "directories", label: "Directories", items }]; +} + export function getCommandPaletteMode(input: { currentView: CommandPaletteView | null; + isBrowsing: boolean; }): CommandPaletteMode { - return input.currentView ? "submenu" : "root"; + if (input.currentView) { + return input.isBrowsing ? "submenu-browse" : "submenu"; + } + return input.isBrowsing ? "root-browse" : "root"; } export function buildRootGroups(input: { @@ -258,7 +336,11 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str switch (mode) { case "root": return "Search commands, projects, and threads..."; + case "root-browse": + return "Enter project path (e.g. ~/projects/my-app)"; case "submenu": return "Search..."; + case "submenu-browse": + return "Enter path (e.g. ~/projects/my-app)"; } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4028043f196..fbbeda10139 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,17 +1,27 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { ProjectId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL_BY_PROVIDER, + type EnvironmentId, + type FilesystemBrowseResult, + type ProjectId, +} from "@t3tools/contracts"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, + CornerLeftUpIcon, + FolderIcon, + FolderPlusIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; import { + useCallback, useDeferredValue, useEffect, useMemo, @@ -22,15 +32,37 @@ import { } from "react"; import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { readEnvironmentApi } from "../environmentApi"; +import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; +import { readLocalApi } from "../localApi"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, + findProjectByPath, + getBrowseDirectoryPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; -import { cn } from "../lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectsAcrossEnvironments, selectSidebarThreadsAcrossEnvironments, @@ -40,18 +72,21 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { ADDON_ICON_CLASS, + buildBrowseGroups, buildProjectActionItems, buildRootGroups, buildThreadActionItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, + filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; +import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { ProjectFavicon } from "./ProjectFavicon"; import { useServerKeybindings } from "../rpc/serverState"; @@ -64,11 +99,44 @@ import { CommandInput, CommandPanel, } from "./ui/command"; +import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { toastManager } from "./ui/toast"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const BROWSE_STALE_TIME_MS = 30_000; + +function getLocalFileManagerName(platform: string): string { + if (isMacPlatform(platform)) { + return "Finder"; + } + if (isWindowsPlatform(platform)) { + return "Explorer"; + } + return "Files"; +} + +function getEnvironmentBrowsePlatform(os: string | null | undefined): string { + if (os === "windows") { + return "Win32"; + } + if (os === "darwin") { + return "MacIntel"; + } + if (os === "linux") { + return "Linux"; + } + return typeof navigator === "undefined" ? "" : navigator.platform; +} + +interface AddProjectEnvironmentOption { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; +} + export function CommandPalette({ children }: { children: ReactNode }) { const open = useCommandPaletteStore((store) => store.open); const setOpen = useCommandPaletteStore((store) => store.setOpen); @@ -136,10 +204,14 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); const setOpen = useCommandPaletteStore((store) => store.setOpen); + const openIntent = useCommandPaletteStore((store) => store.openIntent); + const clearOpenIntent = useCommandPaletteStore((store) => store.clearOpenIntent); const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); + const queryClient = useQueryClient(); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); const settings = useSettings(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); @@ -148,15 +220,199 @@ function OpenCommandPaletteDialog() { const keybindings = useServerKeybindings(); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; - const paletteMode = getCommandPaletteMode({ currentView }); + const [browseGeneration, setBrowseGeneration] = useState(0); + const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( + null, + ); + const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + + const addProjectEnvironmentOptions = useMemo(() => { + const options: AddProjectEnvironmentOption[] = []; + const seenEnvironmentIds = new Set(); + + if (primaryEnvironmentId) { + seenEnvironmentIds.add(primaryEnvironmentId); + options.push({ + environmentId: primaryEnvironmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: true, + environmentId: primaryEnvironmentId, + runtimeLabel: primaryEnvironmentLabel, + }), + isPrimary: true, + }); + } + + for (const record of Object.values(savedEnvironmentRegistry)) { + if (seenEnvironmentIds.has(record.environmentId)) { + continue; + } + const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; + options.push({ + environmentId: record.environmentId, + label: resolveEnvironmentOptionLabel({ + isPrimary: false, + environmentId: record.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: record.label, + }), + isPrimary: false, + }); + } + + options.sort((left, right) => { + if (left.isPrimary !== right.isPrimary) { + return left.isPrimary ? -1 : 1; + } + return left.label.localeCompare(right.label); + }); + + return options; + }, [ + primaryEnvironmentId, + primaryEnvironmentLabel, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; + const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; + const browseEnvironmentPlatform = useMemo(() => { + const os = + browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId + ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) + : browseEnvironmentId + ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? + savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform + .os ?? + null) + : null; + return getEnvironmentBrowsePlatform(os); + }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const isBrowsing = isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); + const getAddProjectInitialQueryForEnvironment = useCallback( + (environmentId: EnvironmentId | null): string => { + const environmentSettings = + environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId + ? settings + : environmentId + ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings + : null; + const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; + if (baseDirectory.length === 0) { + return "~/"; + } + return ensureBrowseDirectoryPath(baseDirectory); + }, + [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + ); + + const projectCwdById = useMemo( + () => new Map(projects.map((project) => [project.id, project.cwd])), + [projects], + ); const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.name])), [projects], ); const activeThreadId = activeThread?.id; + const currentProjectEnvironmentId = + activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; + const currentProjectCwd = currentProjectId + ? (projectCwdById.get(currentProjectId) ?? null) + : null; + const currentProjectCwdForBrowse = + browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId + ? currentProjectCwd + : null; + const relativePathNeedsActiveProject = + isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; + const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; + const browseFilterQuery = + isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; + + const fetchBrowseResult = useCallback( + async (partialPath: string): Promise => { + if (!browseEnvironmentId) return null; + const api = readEnvironmentApi(browseEnvironmentId); + if (!api) return null; + return api.filesystem.browse({ + partialPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse], + ); + + const { data: browseResult, isPending: isBrowsePending } = useQuery({ + queryKey: [ + "filesystemBrowse", + browseEnvironmentId, + browseDirectoryPath, + currentProjectCwdForBrowse, + ], + queryFn: () => fetchBrowseResult(browseDirectoryPath), + staleTime: BROWSE_STALE_TIME_MS, + enabled: + isBrowsing && + browseDirectoryPath.length > 0 && + browseEnvironmentId !== null && + !relativePathNeedsActiveProject, + }); + const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; + const { + filteredEntries: filteredBrowseEntries, + highlightedEntry: highlightedBrowseEntry, + exactEntry: exactBrowseEntry, + } = useMemo( + () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), + [browseEntries, browseFilterQuery, highlightedItemValue], + ); + + const prefetchBrowsePath = useCallback( + (partialPath: string) => { + void queryClient.prefetchQuery({ + queryKey: [ + "filesystemBrowse", + browseEnvironmentId, + partialPath, + currentProjectCwdForBrowse, + ], + queryFn: () => fetchBrowseResult(partialPath), + staleTime: BROWSE_STALE_TIME_MS, + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], + ); + + // Prefetch the parent and the most likely next child so browse navigation + // stays warm without scanning every child directory in large trees. + useEffect(() => { + if (!isBrowsing || filteredBrowseEntries.length === 0) return; + + if (canNavigateUp(query)) { + prefetchBrowsePath(getBrowseParentPath(query)!); + } + + const nextChild = highlightedBrowseEntry ?? exactBrowseEntry; + if (nextChild) { + prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name)); + } + }, [ + exactBrowseEntry, + filteredBrowseEntries.length, + highlightedBrowseEntry, + isBrowsing, + prefetchBrowsePath, + query, + ]); const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { @@ -259,30 +515,117 @@ function OpenCommandPaletteDialog() { ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); - function pushView(item: CommandPaletteSubmenuItem): void { + function pushPaletteView(view: CommandPaletteView): void { setViewStack((previousViews) => [ ...previousViews, { - addonIcon: item.addonIcon, - groups: item.groups, - ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), }, ]); - setQuery(item.initialQuery ?? ""); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + } + + function pushView(item: CommandPaletteSubmenuItem): void { + pushPaletteView({ + addonIcon: item.addonIcon, + groups: item.groups, + ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), + }); } function popView(): void { + if (viewStack.length <= 1) { + setAddProjectEnvironmentId(null); + } setViewStack((previousViews) => previousViews.slice(0, -1)); + setHighlightedItemValue(null); setQuery(""); } function handleQueryChange(nextQuery: string): void { + setHighlightedItemValue(null); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); } } + const startAddProjectBrowse = useCallback( + (environmentId: EnvironmentId): void => { + setAddProjectEnvironmentId(environmentId); + pushPaletteView({ + addonIcon: , + groups: [], + initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), + }); + }, + [getAddProjectInitialQueryForEnvironment], + ); + + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( + (option) => ({ + kind: "action", + value: `action:add-project:environment:${option.environmentId}`, + searchTerms: [option.label, option.environmentId, option.isPrimary ? "this device" : ""], + title: option.label, + description: option.isPrimary ? "This device" : option.environmentId, + icon: , + keepOpen: true, + run: async () => { + startAddProjectBrowse(option.environmentId); + }, + }), + ); + + const addProjectEnvironmentGroups = useMemo( + () => [ + { + value: "environments", + label: "Environments", + items: addProjectEnvironmentItems, + }, + ], + [addProjectEnvironmentItems], + ); + + const openAddProjectFlow = useCallback(() => { + if (addProjectEnvironmentOptions.length > 1) { + pushPaletteView({ + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + return; + } + + const environmentId = defaultAddProjectEnvironmentId; + if (!environmentId) { + toastManager.add({ + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + }); + return; + } + + startAddProjectBrowse(environmentId); + }, [ + addProjectEnvironmentGroups, + addProjectEnvironmentOptions.length, + defaultAddProjectEnvironmentId, + startAddProjectBrowse, + ]); + + useEffect(() => { + if (openIntent?.kind !== "add-project") { + return; + } + clearOpenIntent(); + openAddProjectFlow(); + }, [clearOpenIntent, openAddProjectFlow, openIntent]); + const actionItems: Array = []; if (projects.length > 0) { @@ -325,6 +668,30 @@ function OpenCommandPaletteDialog() { }); } + if (addProjectEnvironmentOptions.length > 1) { + actionItems.push({ + kind: "submenu", + value: "action:add-project", + searchTerms: ["add project", "folder", "directory", "browse", "environment"], + title: "Add project", + icon: , + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + } else { + actionItems.push({ + kind: "action", + value: "action:add-project", + searchTerms: ["add project", "folder", "directory", "browse"], + title: "Add project", + icon: , + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:settings", @@ -339,7 +706,7 @@ function OpenCommandPaletteDialog() { const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const activeGroups = currentView ? currentView.groups : rootGroups; - const displayedGroups = filterCommandPaletteGroups({ + const filteredGroups = filterCommandPaletteGroups({ activeGroups, query: deferredQuery, isInSubmenu: currentView !== null, @@ -347,10 +714,206 @@ function OpenCommandPaletteDialog() { threadSearchItems: allThreadItems, }); + const handleAddProject = useCallback( + async (rawCwd: string) => { + if (!browseEnvironmentId) return; + const api = readEnvironmentApi(browseEnvironmentId); + if (!api) return; + + if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: "Windows-style paths are only supported on Windows.", + }); + return; + } + + if (isExplicitRelativeProjectPath(rawCwd.trim()) && !currentProjectCwdForBrowse) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: "Relative paths require an active project.", + }); + return; + } + + const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse); + if (cwd.length === 0) return; + + const existing = findProjectByPath( + projects.filter((project) => project.environmentId === browseEnvironmentId), + cwd, + ); + if (existing) { + const latestThread = getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === existing.environmentId), + existing.id, + settings.sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(latestThread.environmentId, latestThread.id), + ), + }); + } else { + await handleNewThread(scopeProjectRef(existing.environmentId, existing.id), { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + } + setOpen(false); + return; + } + + try { + const projectId = newProjectId(); + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + createdAt: new Date().toISOString(), + }); + await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + setOpen(false); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, + [ + browseEnvironmentId, + browseEnvironmentPlatform, + currentProjectCwdForBrowse, + handleNewThread, + navigate, + projects, + setOpen, + settings.defaultThreadEnvMode, + settings.sidebarThreadSortOrder, + threads, + ], + ); + + function browseTo(name: string): void { + const nextQuery = appendBrowsePathSegment(query, name); + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + } + + function browseUp(): void { + const parentPath = getBrowseParentPath(query); + if (parentPath === null) { + return; + } + + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + } + + // Resolve the add-project path from browse data when available. When the + // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the + // directory itself. Otherwise the user typed a partial leaf name, so we need + // the exact browse entry's fullPath or fall back to the raw query. + const resolvedAddProjectPath = hasTrailingPathSeparator(query) + ? (browseResult?.parentPath ?? query.trim()) + : (exactBrowseEntry?.fullPath ?? query.trim()); + + const canBrowseUp = + isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); + + const browseGroups = buildBrowseGroups({ + browseEntries: filteredBrowseEntries, + browseQuery: query, + canBrowseUp, + upIcon: , + directoryIcon: , + browseUp, + browseTo, + }); + + let displayedGroups = filteredGroups; + if (isBrowsing) { + displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; + } + const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); - const isSubmenu = paletteMode === "submenu"; + const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; + const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; + const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; + const willCreateProjectPath = + canSubmitBrowsePath && + !isBrowsePending && + query.trim().length > 0 && + !hasHighlightedBrowseItem && + (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); + const useMetaForMod = isMacPlatform(navigator.platform); + const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; + const submitActionLabel = willCreateProjectPath ? "Create & Add" : "Add"; + const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"; + const fileManagerName = getLocalFileManagerName(navigator.platform); + const canOpenProjectFromFileManager = + isBrowsing && + browseEnvironmentId !== null && + primaryEnvironmentId !== null && + browseEnvironmentId === primaryEnvironmentId && + typeof window !== "undefined" && + window.desktopBridge !== undefined; + const fileManagerInitialPath = useMemo(() => { + if (!canOpenProjectFromFileManager) { + return undefined; + } + + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) { + return undefined; + } + + const initialPath = hasTrailingPathSeparator(query) + ? (browseResult?.parentPath ?? trimmedQuery) + : browseDirectoryPath || trimmedQuery; + + const resolvedPath = resolveProjectPathForDispatch(initialPath, currentProjectCwdForBrowse); + return resolvedPath.length > 0 ? resolvedPath : undefined; + }, [ + browseDirectoryPath, + browseResult?.parentPath, + canOpenProjectFromFileManager, + currentProjectCwdForBrowse, + query, + ]); + + function isPrimaryModifierPressed(event: KeyboardEvent): boolean { + return useMetaForMod ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + } function handleKeyDown(event: KeyboardEvent): void { + const shouldSubmitBrowsePath = + canSubmitBrowsePath && + event.key === "Enter" && + (!hasHighlightedBrowseItem || isPrimaryModifierPressed(event)); + + if (shouldSubmitBrowsePath) { + event.preventDefault(); + void handleAddProject(resolvedAddProjectPath); + return; + } + if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); @@ -376,6 +939,38 @@ function OpenCommandPaletteDialog() { }); } + const handleOpenProjectFromFileManager = useCallback(async () => { + if (!canOpenProjectFromFileManager || isPickingProjectFolder) { + return; + } + const api = readLocalApi(); + if (!api) { + return; + } + + setIsPickingProjectFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder( + fileManagerInitialPath ? { initialPath: fileManagerInitialPath } : undefined, + ); + } catch { + // Ignore picker failures and leave the palette open. + setIsPickingProjectFolder(false); + return; + } + setIsPickingProjectFolder(false); + if (!pickedPath) { + return; + } + await handleAddProject(pickedPath); + }, [ + canOpenProjectFromFileManager, + fileManagerInitialPath, + handleAddProject, + isPickingProjectFolder, + ]); + return ( { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} onValueChange={handleQueryChange} value={query} > - - - - ), - } - : {})} - onKeyDown={handleKeyDown} - /> +
+ + + + ), + } + : isBrowsing && !isSubmenu + ? { + startAddon: , + } + : {})} + onKeyDown={handleKeyDown} + /> + {isBrowsing ? ( + + ) : null} +
@@ -436,10 +1075,12 @@ function OpenCommandPaletteDialog() { Navigate - - Enter - Select - + {!canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + + Enter + Select + + ) : null} {isSubmenu ? ( Backspace @@ -451,6 +1092,19 @@ function OpenCommandPaletteDialog() { Close + {canOpenProjectFromFileManager ? ( + + ) : null}
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 72700471bac..e2841d58805 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -14,10 +14,12 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { cn } from "~/lib/utils"; interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; + highlightedItemValue?: string | null; isActionsOnly: boolean; keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; @@ -46,6 +48,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { item={item} key={item.value} keybindings={props.keybindings} + isActive={props.highlightedItemValue === item.value} onExecuteItem={props.onExecuteItem} /> )} @@ -58,6 +61,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { function CommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + isActive: boolean; keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; }) { @@ -68,7 +72,10 @@ function CommandPaletteResultRow(props: { return ( { event.preventDefault(); }} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc2da70e208..cff71cf62ac 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3,7 +3,6 @@ import { ArrowUpDownIcon, ChevronRightIcon, CloudIcon, - FolderIcon, GitPullRequestIcon, PlusIcon, SearchIcon, @@ -32,9 +31,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, - type EnvironmentId, ProjectId, type ScopedProjectRef, type ScopedThreadRef, @@ -58,7 +55,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, @@ -122,7 +119,7 @@ import { SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, @@ -2033,20 +2030,7 @@ interface SidebarProjectsContentProps { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; updateSettings: ReturnType["updateSettings"]; - shouldShowProjectPathEntry: boolean; - handleStartAddProject: () => void; - isElectron: boolean; - isPickingFolder: boolean; - isAddingProject: boolean; - handlePickFolder: () => Promise; - addProjectInputRef: React.RefObject; - addProjectError: string | null; - newCwd: string; - setNewCwd: React.Dispatch>; - setAddProjectError: React.Dispatch>; - handleAddProject: () => void; - setAddingProject: React.Dispatch>; - canAddProject: boolean; + openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; @@ -2085,20 +2069,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectSortOrder, threadSortOrder, updateSettings, - shouldShowProjectPathEntry, - handleStartAddProject, - isElectron, - isPickingFolder, - isAddingProject, - handlePickFolder, - addProjectInputRef, - addProjectError, - newCwd, - setNewCwd, - setAddProjectError, - handleAddProject, - setAddingProject, - canAddProject, + openAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2137,26 +2108,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); - const handleAddProjectInputChange = useCallback( - (event: React.ChangeEvent) => { - setNewCwd(event.target.value); - setAddProjectError(null); - }, - [setAddProjectError, setNewCwd], - ); - const handleAddProjectInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }, - [handleAddProject, setAddProjectError, setAddingProject], - ); - const handleBrowseForFolderClick = useCallback(() => { - void handlePickFolder(); - }, [handlePickFolder]); return ( @@ -2223,68 +2174,19 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( render={ - )} -
- - -
- {addProjectError && ( -

- {addProjectError} -

- )} - - )} {isManualProjectSorting ? ( )} - {projectsLength === 0 && !shouldShowProjectPathEntry && ( + {projectsLength === 0 && (
No projects yet
@@ -2372,7 +2274,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -2381,7 +2282,6 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2391,12 +2291,7 @@ export default function Sidebar() { }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); - const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); - const addProjectInputRef = useRef(null); + const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); @@ -2408,10 +2303,7 @@ export default function Sidebar() { const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -2574,131 +2466,6 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - const focusMostRecentThreadForProject = useCallback( - (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(projectRef.environmentId, projectRef.projectId), - ); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreads( - (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), - sidebarThreadSortOrder, - )[0]; - if (!latestThread) return; - - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), - }); - }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], - ); - - const addProjectFromInput = useCallback( - async (rawCwd: string) => { - const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; - const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; - if (!api) return; - - setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); - setAddingProject(false); - }; - - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject({ - environmentId: existing.environmentId, - projectId: existing.id, - }); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - try { - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt: new Date().toISOString(), - }); - if (activeEnvironmentId !== null) { - await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { - envMode: defaultThreadEnvMode, - }).catch(() => undefined); - } - } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; - setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } - return; - } - finishAddingProject(); - }, - [ - focusMostRecentThreadForProject, - activeEnvironmentId, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - defaultThreadEnvMode, - ], - ); - - const handleAddProject = () => { - void addProjectFromInput(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readLocalApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromInput(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; - - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { @@ -3212,20 +2979,7 @@ export default function Sidebar() { projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} updateSettings={updateSettings} - shouldShowProjectPathEntry={shouldShowProjectPathEntry} - handleStartAddProject={handleStartAddProject} - isElectron={isElectron} - isPickingFolder={isPickingFolder} - isAddingProject={isAddingProject} - handlePickFolder={handlePickFolder} - addProjectInputRef={addProjectInputRef} - addProjectError={addProjectError} - newCwd={newCwd} - setNewCwd={setNewCwd} - setAddProjectError={setAddProjectError} - handleAddProject={handleAddProject} - setAddingProject={setAddingProject} - canAddProject={canAddProject} + openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} projectDnDSensors={projectDnDSensors} projectCollisionDetection={projectCollisionDetection} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5a389d01d18..1d297add3c5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -444,6 +444,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory + ? ["Add project base directory"] + : []), ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ["Archive confirmation"] : []), @@ -458,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { isGitWritingModelDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, @@ -919,6 +923,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ addProjectBaseDirectory: event.target.value })} + placeholder="~/" + spellCheck={false} + aria-label="Add project base directory" + /> + } + /> + (); + export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { return { terminal: { @@ -18,6 +20,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { searchEntries: rpcClient.projects.searchEntries, writeFile: rpcClient.projects.writeFile, }, + filesystem: { + browse: rpcClient.filesystem.browse, + }, git: { pull: rpcClient.git.pull, refreshStatus: rpcClient.git.refreshStatus, @@ -52,6 +57,11 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi return undefined; } + const overriddenApi = environmentApiOverridesForTests.get(environmentId); + if (overriddenApi) { + return overriddenApi; + } + const connection = readEnvironmentConnection(environmentId); return connection ? createEnvironmentApi(connection.client) : undefined; } @@ -63,3 +73,14 @@ export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentA } return api; } + +export function __setEnvironmentApiOverrideForTests( + environmentId: EnvironmentId, + api: EnvironmentApi, +): void { + environmentApiOverridesForTests.set(environmentId, api); +} + +export function __resetEnvironmentApiOverridesForTests(): void { + environmentApiOverridesForTests.clear(); +} diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts new file mode 100644 index 00000000000..7989622e1d5 --- /dev/null +++ b/apps/web/src/lib/projectPaths.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBrowsePathSegment, + canNavigateUp, + getBrowseDirectoryPath, + findProjectByPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "./projectPaths"; + +describe("projectPaths", () => { + it("normalizes trailing separators for dispatch and comparison", () => { + expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app"); + expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app"); + }); + + it("normalizes windows-style paths for comparison", () => { + expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo"); + expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo"); + }); + + it("finds existing projects even when the input formatting differs", () => { + const existing = findProjectByPath( + [ + { id: "project-1", cwd: "/repo/app" }, + { id: "project-2", cwd: "C:\\Work\\Repo" }, + ], + "C:/Work/Repo/", + ); + + expect(existing?.id).toBe("project-2"); + }); + + it("infers project titles from normalized paths", () => { + expect(inferProjectTitleFromPath("/repo/app/")).toBe("app"); + expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo"); + expect(inferProjectTitleFromPath("/home/user\\project/")).toBe("user\\project"); + }); + + it("detects browse queries across supported path styles", () => { + expect(isFilesystemBrowseQuery(".")).toBe(false); + expect(isFilesystemBrowseQuery("..")).toBe(false); + expect(isFilesystemBrowseQuery("./")).toBe(true); + expect(isFilesystemBrowseQuery("../")).toBe(true); + expect(isFilesystemBrowseQuery("~/projects")).toBe(true); + expect(isFilesystemBrowseQuery("..\\docs")).toBe(true); + expect(isFilesystemBrowseQuery("notes")).toBe(false); + }); + + it("only treats windows-style paths as browse queries on windows", () => { + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false); + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false); + }); + + it("detects explicit relative project paths", () => { + expect(isExplicitRelativeProjectPath(".")).toBe(true); + expect(isExplicitRelativeProjectPath("..")).toBe(true); + expect(isExplicitRelativeProjectPath("./docs")).toBe(true); + expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true); + expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false); + }); + + it("resolves explicit relative paths against the current project", () => { + expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app"); + expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo"); + expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs"); + expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs"); + expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo"); + expect(resolveProjectPathForDispatch("./docs", "/home/user\\project")).toBe( + "/home/user\\project/docs", + ); + }); + + it("navigates browse paths with matching separators", () => { + expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/"); + expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\"); + expect(appendBrowsePathSegment("/home/user\\project/", "docs")).toBe( + "/home/user\\project/docs/", + ); + expect(getBrowseParentPath("/repo/src/")).toBe("/repo/"); + expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\"); + expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull(); + expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\"); + expect(getBrowseParentPath("C:\\")).toBeNull(); + expect(getBrowseParentPath("/home/user\\project/docs/")).toBe("/home/user\\project/"); + }); + + it("detects browse path boundaries", () => { + expect(hasTrailingPathSeparator("/repo/src/")).toBe(true); + expect(hasTrailingPathSeparator("/repo/src")).toBe(false); + expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/"); + expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/"); + expect(getBrowseLeafPathSegment("/repo/src")).toBe("src"); + expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs"); + expect(getBrowseDirectoryPath("/home/user\\project/docs")).toBe("/home/user\\project/"); + expect(getBrowseLeafPathSegment("/home/user\\project/docs")).toBe("docs"); + }); + + it("only allows browse-up after entering a directory", () => { + expect(canNavigateUp("~/repo")).toBe(false); + expect(canNavigateUp("~/a")).toBe(false); + expect(canNavigateUp("~/repo/")).toBe(true); + expect(canNavigateUp("\\\\server\\share\\")).toBe(false); + expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 00000000000..4b823dff933 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,259 @@ +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "@t3tools/shared/path"; +import { isWindowsPlatform } from "./utils"; + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function getAbsolutePathKind(value: string): "unix" | "windows" | null { + if (isWindowsDrivePath(value) || isUncPath(value)) { + return "windows"; + } + + if (value.startsWith("/")) { + return "unix"; + } + + return null; +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + + const trimmed = + getAbsolutePathKind(value) === "unix" + ? value.replace(/\/+$/g, "") + : value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +function preferredPathSeparator(value: string): "/" | "\\" { + const absolutePathKind = getAbsolutePathKind(value); + if (absolutePathKind === "windows") { + return "\\"; + } + if (absolutePathKind === "unix") { + return "/"; + } + + return value.includes("\\") ? "\\" : "/"; +} + +export function hasTrailingPathSeparator(value: string): boolean { + return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); +} + +export { isExplicitRelativePath as isExplicitRelativeProjectPath }; + +function splitPathSegments(value: string, separator: "/" | "\\"): string[] { + return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); +} + +function getLastPathSeparatorIndex(value: string): number { + if (getAbsolutePathKind(value) === "unix") { + return value.lastIndexOf("/"); + } + + return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); +} + +function splitAbsolutePath(value: string): { + root: string; + separator: "/" | "\\"; + segments: string[]; +} | null { + if (isWindowsDrivePath(value)) { + const root = `${value.slice(0, 2)}\\`; + const segments = splitPathSegments(value.slice(root.length), "\\"); + return { root, separator: "\\", segments }; + } + if (isUncPath(value)) { + const segments = splitPathSegments(value, "\\"); + const [server, share, ...rest] = segments; + if (!server || !share) { + return null; + } + return { + root: `\\\\${server}\\${share}\\`, + separator: "\\", + segments: rest, + }; + } + if (value.startsWith("/")) { + return { + root: "/", + separator: "/", + segments: splitPathSegments(value.slice(1), "/"), + }; + } + return null; +} + +export function isFilesystemBrowseQuery( + value: string, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + const allowWindowsPaths = isWindowsPlatform(platform); + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || + value.startsWith("/") || + value.startsWith("~/") || + (allowWindowsPaths && isWindowsAbsolutePath(value)) + ); +} + +export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { + return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { + const trimmedValue = value.trim(); + if (!isExplicitRelativePath(trimmedValue) || !cwd) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); + if (!absoluteBase) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const nextSegments = [...absoluteBase.segments]; + for (const segment of trimmedValue.split(/[\\/]+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + nextSegments.pop(); + continue; + } + nextSegments.push(segment); + } + + const joinedPath = nextSegments.join(absoluteBase.separator); + if (joinedPath.length === 0) { + return normalizeProjectPathForDispatch(absoluteBase.root); + } + + return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +} + +export function findProjectByPath( + projects: ReadonlyArray, + candidatePath: string, +): T | undefined { + const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); + if (normalizedCandidate.length === 0) { + return undefined; + } + + return projects.find( + (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, + ); +} + +export function inferProjectTitleFromPath(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + const absolutePath = splitAbsolutePath(normalized); + if (absolutePath) { + return absolutePath.segments.findLast(Boolean) ?? normalized; + } + + const segments = normalized.split(/[/\\]/); + return segments.findLast(Boolean) ?? normalized; +} + +export function appendBrowsePathSegment(currentPath: string, segment: string): string { + const separator = preferredPathSeparator(currentPath); + return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; +} + +export function getBrowseLeafPathSegment(currentPath: string): string { + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + return currentPath.slice(lastSeparatorIndex + 1); +} + +export function getBrowseDirectoryPath(currentPath: string): string { + if (hasTrailingPathSeparator(currentPath)) { + return currentPath; + } + + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + if (lastSeparatorIndex < 0) { + return currentPath; + } + + return currentPath.slice(0, lastSeparatorIndex + 1); +} + +export function ensureBrowseDirectoryPath(currentPath: string): string { + const trimmed = currentPath.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + if (hasTrailingPathSeparator(trimmed)) { + return trimmed; + } + + return `${trimmed}${preferredPathSeparator(trimmed)}`; +} + +export function getBrowseParentPath(currentPath: string): string | null { + const trimmed = trimTrailingPathSeparators(currentPath); + const absolutePath = splitAbsolutePath(trimmed); + if (absolutePath) { + if (absolutePath.segments.length === 0) { + return null; + } + + if (absolutePath.segments.length === 1) { + return absolutePath.root; + } + + const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); + return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; + } + + const separator = preferredPathSeparator(currentPath); + const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); + + if (lastSeparatorIndex < 0) { + return null; + } + + if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}${separator}`; + } + + return trimmed.slice(0, lastSeparatorIndex + 1); +} + +export function canNavigateUp(currentPath: string): boolean { + return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 8586f1d3bf9..06b163137b4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -51,6 +51,9 @@ const rpcClientMock = { searchEntries: vi.fn(), writeFile: vi.fn(), }, + filesystem: { + browse: vi.fn(), + }, shell: { openInEditor: vi.fn(), }, @@ -419,6 +422,25 @@ describe("wsApi", () => { }); }); + it("forwards filesystem browse requests to the RPC client", async () => { + rpcClientMock.filesystem.browse.mockResolvedValue({ + parentPath: "/tmp/project/", + entries: [], + }); + const { createEnvironmentApi } = await import("./environmentApi"); + + const api = createEnvironmentApi(rpcClientMock as never); + await api.filesystem.browse({ + partialPath: "/tmp/project/", + cwd: "/tmp/project", + }); + + expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ + partialPath: "/tmp/project/", + cwd: "/tmp/project", + }); + }); + it("forwards full-thread diff requests to the orchestration RPC", async () => { rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); const { createEnvironmentApi } = await import("./environmentApi"); @@ -481,6 +503,19 @@ describe("wsApi", () => { expect(showContextMenu).toHaveBeenCalledWith(items, undefined); }); + it("forwards folder picker options to the desktop bridge", async () => { + const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); + getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder }); + + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(rpcClientMock as never); + + await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe( + "/tmp/project", + ); + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" }); + }); + it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); const { createLocalApi } = await import("./localApi"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 40ed014580a..4401b5b778e 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -29,9 +29,9 @@ let cachedApi: LocalApi | undefined; export function createLocalApi(rpcClient: WsRpcClient): LocalApi { return { dialogs: { - pickFolder: async () => { + pickFolder: async (options) => { if (!window.desktopBridge) return null; - return window.desktopBridge.pickFolder(); + return window.desktopBridge.pickFolder(options); }, confirm: async (message) => { if (window.desktopBridge) { diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 42c2c64bd06..b67be32dc78 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -68,6 +68,9 @@ export interface WsRpcClient { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; }; + readonly filesystem: { + readonly browse: RpcUnaryMethod; + }; readonly shell: { readonly openInEditor: (input: { readonly cwd: Parameters[0]; @@ -145,6 +148,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), }, + filesystem: { + browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), + }, shell: { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts new file mode 100644 index 00000000000..41b1eb2b6f4 --- /dev/null +++ b/packages/contracts/src/filesystem.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const FILESYSTEM_PATH_MAX_LENGTH = 512; + +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; + +export class FilesystemBrowseError extends Schema.TaggedErrorClass()( + "FilesystemBrowseError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f12cf80d57a..0f2327d25a0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -13,4 +13,5 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./filesystem"; export * from "./rpc"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7f33cd17650..c2d68133015 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -18,6 +18,7 @@ import type { GitStatusResult, GitCreateBranchResult, } from "./git"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -140,6 +141,10 @@ export interface DesktopServerExposureState { advertisedHost: string | null; } +export interface PickFolderOptions { + initialPath?: string | null; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -154,7 +159,7 @@ export interface DesktopBridge { removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; - pickFolder: () => Promise; + pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( @@ -183,7 +188,7 @@ export interface DesktopBridge { */ export interface LocalApi { dialogs: { - pickFolder: () => Promise; + pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; }; shell: { @@ -239,6 +244,9 @@ export interface EnvironmentApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; + filesystem: { + browse: (input: FilesystemBrowseInput) => Promise; + }; git: { listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 53e84f1b98b..ad46e380a38 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -95,6 +95,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () assert.strictEqual(parsed.projectId, "project-1"); assert.strictEqual(parsed.title, "Project Title"); assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); + assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined); assert.deepStrictEqual(parsed.defaultModelSelection, { provider: "codex", model: "gpt-5.2", @@ -102,6 +103,22 @@ it.effect("trims branded ids and command string fields at decode boundaries", () }), ); +it.effect("decodes project.create with createWorkspaceRootIfMissing enabled", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreateCommand({ + type: "project.create", + commandId: "cmd-1", + projectId: "project-1", + title: "Project Title", + workspaceRoot: "/tmp/workspace", + createWorkspaceRootIfMissing: true, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(parsed.createWorkspaceRootIfMissing, true); + }), +); + it.effect("decodes historical project.created payloads with a default provider", () => Effect.gen(function* () { const parsed = yield* decodeProjectCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0c26a42256c..2745cdcdb79 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -402,6 +402,7 @@ export const ProjectCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b3809d96587..ebdab2c45d3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -4,6 +4,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor"; import { AuthAccessStreamEvent } from "./auth"; +import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError } from "./filesystem"; import { GitActionProgressEvent, GitCheckoutInput, @@ -83,6 +84,9 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + // Filesystem methods + filesystemBrowse: "filesystem.browse", + // Git methods gitPull: "git.pull", gitRefreshStatus: "git.refreshStatus", @@ -165,6 +169,12 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); +export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { + payload: FilesystemBrowseInput, + success: FilesystemBrowseResult, + error: FilesystemBrowseError, +}); + export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, success: GitStatusStreamEvent, @@ -350,6 +360,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, + WsFilesystemBrowseRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, WsGitRefreshStatusRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee568..fe432e098b0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -84,6 +84,7 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -168,6 +169,7 @@ const ClaudeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + addProjectBaseDirectory: Schema.optionalKey(Schema.String), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ diff --git a/packages/shared/package.json b/packages/shared/package.json index ed65cbeaf3c..fe11f2e315e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,10 @@ "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" + }, + "./path": { + "types": "./src/path.ts", + "import": "./src/path.ts" } }, "scripts": { diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts new file mode 100644 index 00000000000..912e1e13d75 --- /dev/null +++ b/packages/shared/src/path.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "./path"; + +describe("path helpers", () => { + it("detects windows drive paths", () => { + expect(isWindowsDrivePath("C:\\repo")).toBe(true); + expect(isWindowsDrivePath("D:/repo")).toBe(true); + expect(isWindowsDrivePath("/repo")).toBe(false); + }); + + it("detects UNC paths", () => { + expect(isUncPath("\\\\server\\share\\repo")).toBe(true); + expect(isUncPath("C:\\repo")).toBe(false); + }); + + it("detects windows absolute paths", () => { + expect(isWindowsAbsolutePath("C:\\repo")).toBe(true); + expect(isWindowsAbsolutePath("\\\\server\\share\\repo")).toBe(true); + expect(isWindowsAbsolutePath("./repo")).toBe(false); + }); + + it("detects explicit relative paths", () => { + expect(isExplicitRelativePath(".")).toBe(true); + expect(isExplicitRelativePath("..")).toBe(true); + expect(isExplicitRelativePath("./repo")).toBe(true); + expect(isExplicitRelativePath("..\\repo")).toBe(true); + expect(isExplicitRelativePath("~/repo")).toBe(false); + }); +}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts new file mode 100644 index 00000000000..2bb2ca0238d --- /dev/null +++ b/packages/shared/src/path.ts @@ -0,0 +1,22 @@ +export function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +export function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +export function isWindowsAbsolutePath(value: string): boolean { + return isUncPath(value) || isWindowsDrivePath(value); +} + +export function isExplicitRelativePath(value: string): boolean { + return ( + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +}