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