- launch the installed desktop app from `t3 --app` - forward workspace paths from Electron to the web UI - bootstrap new projects when opening a missing workspace
Unified · Split
| @@ -93,6 +93,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; · 92 unmodified lines | ||
| 93 | 93 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 94 | 94 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 95 | 95 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 96 | +const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace"; | |
| 96 | 97 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 97 | 98 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 98 | 99 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; |
| @@ -118,6 +119,7 @@ const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); · 19 unmodified lines | ||
| 118 | 119 | const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); |
| 119 | 120 | const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); |
| 120 | 121 | const DESKTOP_SCHEME = "t3"; |
| 122 | +const OPEN_WORKSPACE_ARG = "--t3-open-path"; | |
| 121 | 123 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 122 | 124 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 123 | 125 | // Dev-only SSH launcher override. Set this to an absolute path on the SSH host |
| @@ -239,6 +241,7 @@ let restoreStdIoCapture: (() => void) | null = null; · 115 unmodified lines | ||
| 239 | 241 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); |
| 240 | 242 | let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); |
| 241 | 243 | let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; |
| 244 | +let pendingOpenWorkspacePath = resolveOpenWorkspaceArg(process.argv.slice(1)); | |
| 242 | 245 | |
| 243 | 246 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 244 | 247 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); |
| @@ -471,6 +474,23 @@ function formatErrorMessage(error: unknown): string { · 226 unmodified lines | ||
| 471 | 474 | return String(error); |
| 472 | 475 | } |
| 473 | 476 | |
| 477 | +function resolveOpenWorkspaceArg(argv: ReadonlyArray<string>): string | null { | |
| 478 | + for (let index = 0; index < argv.length; index += 1) { | |
| 479 | + const arg = argv[index]; | |
| 480 | + if (arg === OPEN_WORKSPACE_ARG) { | |
| 481 | + const workspacePath = argv[index + 1]?.trim(); | |
| 482 | + return workspacePath ? Path.resolve(workspacePath) : null; | |
| 483 | + } | |
| 484 | + | |
| 485 | + if (arg?.startsWith(`${OPEN_WORKSPACE_ARG}=`)) { | |
| 486 | + const workspacePath = arg.slice(OPEN_WORKSPACE_ARG.length + 1).trim(); | |
| 487 | + return workspacePath ? Path.resolve(workspacePath) : null; | |
| 488 | + } | |
| 489 | + } | |
| 490 | + | |
| 491 | + return null; | |
| 492 | +} | |
| 493 | + | |
| 474 | 494 | function getSafeExternalUrl(rawUrl: unknown): string | null { |
| 475 | 495 | if (typeof rawUrl !== "string" || rawUrl.length === 0) { |
| 476 | 496 | return null; |
| @@ -567,6 +587,32 @@ function ensureInitialBackendWindowOpen(): void { · 90 unmodified lines | ||
| 567 | 587 | backendInitialWindowOpenInFlight = nextOpen; |
| 568 | 588 | } |
| 569 | 589 | |
| 590 | +function sendOpenWorkspace(window: BrowserWindow, workspacePath: string): void { | |
| 591 | + const send = () => { | |
| 592 | + if (window.isDestroyed()) return; | |
| 593 | + window.webContents.send(OPEN_WORKSPACE_CHANNEL, workspacePath); | |
| 594 | + revealWindow(window); | |
| 595 | + }; | |
| 596 | + | |
| 597 | + if (window.webContents.isLoadingMainFrame()) { | |
| 598 | + window.webContents.once("did-finish-load", send); | |
| 599 | + return; | |
| 600 | + } | |
| 601 | + | |
| 602 | + send(); | |
| 603 | +} | |
| 604 | + | |
| 605 | +function dispatchOpenWorkspace(workspacePath: string): void { | |
| 606 | + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; | |
| 607 | + if (existingWindow) { | |
| 608 | + sendOpenWorkspace(existingWindow, workspacePath); | |
| 609 | + return; | |
| 610 | + } | |
| 611 | + | |
| 612 | + pendingOpenWorkspacePath = workspacePath; | |
| 613 | + ensureInitialBackendWindowOpen(); | |
| 614 | +} | |
| 615 | + | |
| 570 | 616 | function writeDesktopStreamChunk( |
| 571 | 617 | streamName: "stdout" | "stderr", |
| 572 | 618 | chunk: unknown, |
| @@ -1464,6 +1510,8 @@ function startBackend(): void { · 891 unmodified lines | ||
| 1464 | 1510 | return; |
| 1465 | 1511 | } |
| 1466 | 1512 | |
| 1513 | + const bootstrapWorkspacePath = pendingOpenWorkspacePath; | |
| 1514 | + pendingOpenWorkspacePath = null; | |
| 1467 | 1515 | const captureBackendLogs = !isDevelopment; |
| 1468 | 1516 | const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { |
| 1469 | 1517 | cwd: resolveBackendCwd(), |
| @@ -1487,6 +1535,9 @@ function startBackend(): void { · 17 unmodified lines | ||
| 1487 | 1535 | t3Home: BASE_DIR, |
| 1488 | 1536 | host: backendBindHost, |
| 1489 | 1537 | desktopBootstrapToken: backendBootstrapToken, |
| 1538 | + ...(bootstrapWorkspacePath | |
| 1539 | + ? { cwd: bootstrapWorkspacePath, autoBootstrapProjectFromCwd: true } | |
| 1540 | + : {}), | |
| 1490 | 1541 | tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, |
| 1491 | 1542 | tailscaleServePort: desktopSettings.tailscaleServePort, |
| 1492 | 1543 | ...(backendObservabilitySettings.otlpTracesUrl |
| @@ -2135,6 +2186,12 @@ function createWindow(): BrowserWindow { · 642 unmodified lines | ||
| 2135 | 2186 | void window.loadURL(backendHttpUrl); |
| 2136 | 2187 | } |
| 2137 | 2188 | |
| 2189 | + if (pendingOpenWorkspacePath) { | |
| 2190 | + const workspacePath = pendingOpenWorkspacePath; | |
| 2191 | + pendingOpenWorkspacePath = null; | |
| 2192 | + sendOpenWorkspace(window, workspacePath); | |
| 2193 | + } | |
| 2194 | + | |
| 2138 | 2195 | window.on("closed", () => { |
| 2139 | 2196 | desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( |
| 2140 | 2197 | "SSH authentication was cancelled because the app window closed.", |
| @@ -2234,38 +2291,59 @@ app.on("before-quit", () => { · 93 unmodified lines | ||
| 2234 | 2291 | restoreStdIoCapture?.(); |
| 2235 | 2292 | }); |
| 2236 | 2293 | |
| 2237 | −app | |
| 2238 | − .whenReady() | |
| 2239 | − .then(() => { | |
| 2240 | − writeDesktopLogHeader("app ready"); | |
| 2241 | − configureAppIdentity(); | |
| 2242 | − configureApplicationMenu(); | |
| 2243 | − registerDesktopProtocol(); | |
| 2244 | − configureAutoUpdater(); | |
| 2245 | − void bootstrap().catch((error) => { | |
| 2246 | − if (isBackendReadinessAborted(error) && isQuitting) { | |
| 2247 | − return; | |
| 2248 | − } | |
| 2249 | − handleFatalStartupError("bootstrap", error); | |
| 2250 | − }); | |
| 2294 | +const hasSingleInstanceLock = app.requestSingleInstanceLock(); | |
| 2295 | +if (!hasSingleInstanceLock) { | |
| 2296 | + app.quit(); | |
| 2297 | +} else { | |
| 2298 | + app.on("second-instance", (_event, commandLine) => { | |
| 2299 | + const workspacePath = resolveOpenWorkspaceArg(commandLine); | |
| 2300 | + if (workspacePath) { | |
| 2301 | + dispatchOpenWorkspace(workspacePath); | |
| 2302 | + return; | |
| 2303 | + } | |
| 2251 | 2304 | |
| 2252 | − app.on("activate", () => { | |
| 2253 | − const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 2254 | − if (existingWindow) { | |
| 2255 | − revealWindow(existingWindow); | |
| 2256 | − return; | |
| 2257 | − } | |
| 2258 | − if (isDevelopment) { | |
| 2259 | − mainWindow = createWindow(); | |
| 2260 | − return; | |
| 2261 | − } | |
| 2262 | − ensureInitialBackendWindowOpen(); | |
| 2263 | − }); | |
| 2264 | − }) | |
| 2265 | − .catch((error) => { | |
| 2266 | − handleFatalStartupError("whenReady", error); | |
| 2305 | + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 2306 | + if (existingWindow) { | |
| 2307 | + revealWindow(existingWindow); | |
| 2308 | + return; | |
| 2309 | + } | |
| 2310 | + | |
| 2311 | + ensureInitialBackendWindowOpen(); | |
| 2267 | 2312 | }); |
| 2268 | 2313 | |
| 2314 | + app | |
| 2315 | + .whenReady() | |
| 2316 | + .then(() => { | |
| 2317 | + writeDesktopLogHeader("app ready"); | |
| 2318 | + configureAppIdentity(); | |
| 2319 | + configureApplicationMenu(); | |
| 2320 | + registerDesktopProtocol(); | |
| 2321 | + configureAutoUpdater(); | |
| 2322 | + void bootstrap().catch((error) => { | |
| 2323 | + if (isBackendReadinessAborted(error) && isQuitting) { | |
| 2324 | + return; | |
| 2325 | + } | |
| 2326 | + handleFatalStartupError("bootstrap", error); | |
| 2327 | + }); | |
| 2328 | + | |
| 2329 | + app.on("activate", () => { | |
| 2330 | + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 2331 | + if (existingWindow) { | |
| 2332 | + revealWindow(existingWindow); | |
| 2333 | + return; | |
| 2334 | + } | |
| 2335 | + if (isDevelopment) { | |
| 2336 | + mainWindow = createWindow(); | |
| 2337 | + return; | |
| 2338 | + } | |
| 2339 | + ensureInitialBackendWindowOpen(); | |
| 2340 | + }); | |
| 2341 | + }) | |
| 2342 | + .catch((error) => { | |
| 2343 | + handleFatalStartupError("whenReady", error); | |
| 2344 | + }); | |
| 2345 | +} | |
| 2346 | + | |
| 2269 | 2347 | app.on("window-all-closed", () => { |
| 2270 | 2348 | if (process.platform !== "darwin" && !isQuitting) { |
| 2271 | 2349 | app.quit(); |
| @@ -7,6 +7,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; · 6 unmodified lines | ||
| 7 | 7 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 8 | 8 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 9 | 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | +const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace"; | |
| 10 | 11 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 11 | 12 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | 13 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; |
| @@ -128,6 +129,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 115 unmodified lines | ||
| 128 | 129 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); |
| 129 | 130 | }; |
| 130 | 131 | }, |
| 132 | + onOpenWorkspace: (listener) => { | |
| 133 | + const wrappedListener = (_event: Electron.IpcRendererEvent, workspacePath: unknown) => { | |
| 134 | + if (typeof workspacePath !== "string" || workspacePath.length === 0) return; | |
| 135 | + listener(workspacePath); | |
| 136 | + }; | |
| 137 | + | |
| 138 | + ipcRenderer.on(OPEN_WORKSPACE_CHANNEL, wrappedListener); | |
| 139 | + return () => { | |
| 140 | + ipcRenderer.removeListener(OPEN_WORKSPACE_CHANNEL, wrappedListener); | |
| 141 | + }; | |
| 142 | + }, | |
| 131 | 143 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), |
| 132 | 144 | setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), |
| 133 | 145 | checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), |
| @@ -65,6 +65,7 @@ import { · 64 unmodified lines | ||
| 65 | 65 | } from "./serverRuntimeState.ts"; |
| 66 | 66 | import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; |
| 67 | 67 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 68 | +import { openDesktopAppOrPrompt } from "./desktopAppLauncher.ts"; | |
| 68 | 69 | |
| 69 | 70 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); |
| 70 | 71 | |
| @@ -139,6 +140,10 @@ const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( · 68 unmodified lines | ||
| 139 | 140 | Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), |
| 140 | 141 | Flag.optional, |
| 141 | 142 | ); |
| 143 | +const appFlag = Flag.boolean("app").pipe( | |
| 144 | + Flag.withDescription("Open or focus the installed T3 Code desktop app."), | |
| 145 | + Flag.withDefault(false), | |
| 146 | +); | |
| 142 | 147 | |
| 143 | 148 | const EnvServerConfig = Config.all({ |
| 144 | 149 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), |
| @@ -1150,11 +1155,21 @@ const runServerCommand = ( · 1005 unmodified lines | ||
| 1150 | 1155 | readonly forceAutoBootstrapProjectFromCwd?: boolean; |
| 1151 | 1156 | }, |
| 1152 | 1157 | ) => |
| 1153 | − Effect.gen(function* () { | |
| 1154 | − const logLevel = yield* GlobalFlag.LogLevel; | |
| 1155 | − const config = yield* resolveServerConfig(flags, logLevel, options); | |
| 1156 | − return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | |
| 1157 | − }); | |
| 1158 | + Effect.service(GlobalFlag.LogLevel).pipe( | |
| 1159 | + Effect.flatMap((logLevel) => resolveServerConfig(flags, logLevel, options)), | |
| 1160 | + Effect.flatMap((config) => Effect.provideService(ServerConfig, config)(runServer)), | |
| 1161 | + ); | |
| 1162 | + | |
| 1163 | +const runDesktopCommand = (flags: CliServerFlags) => | |
| 1164 | + Effect.service(GlobalFlag.LogLevel).pipe( | |
| 1165 | + Effect.flatMap((logLevel) => | |
| 1166 | + resolveServerConfig(flags, logLevel, { forceAutoBootstrapProjectFromCwd: true }), | |
| 1167 | + ), | |
| 1168 | + Effect.flatMap((config) => openDesktopAppOrPrompt(process.platform, config.cwd)), | |
| 1169 | + Effect.flatMap((result) => | |
| 1170 | + result._tag === "use-web-ui" ? runServerCommand(flags) : Effect.void, | |
| 1171 | + ), | |
| 1172 | + ); | |
| 1158 | 1173 | |
| 1159 | 1174 | const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( |
| 1160 | 1175 | Command.withDescription("Run the T3 Code server."), |
| @@ -1173,8 +1188,8 @@ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe · 12 unmodified lines | ||
| 1173 | 1188 | ), |
| 1174 | 1189 | ); |
| 1175 | 1190 | |
| 1176 | −export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( | |
| 1191 | +export const cli = Command.make("t3", { ...sharedServerCommandFlags, app: appFlag }).pipe( | |
| 1177 | 1192 | Command.withDescription("Run the T3 Code server."), |
| 1178 | − Command.withHandler((flags) => runServerCommand(flags)), | |
| 1193 | + Command.withHandler((flags) => (flags.app ? runDesktopCommand(flags) : runServerCommand(flags))), | |
| 1179 | 1194 | Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), |
| 1180 | 1195 | ); |
| @@ -0,0 +1,216 @@ | ||
| 1 | +import { | |
| 2 | + Console, | |
| 3 | + Effect, | |
| 4 | + FileSystem, | |
| 5 | + Layer, | |
| 6 | + Option, | |
| 7 | + Path, | |
| 8 | + Queue, | |
| 9 | + Sink, | |
| 10 | + Stream, | |
| 11 | + Terminal, | |
| 12 | +} from "effect"; | |
| 13 | +import { TestConsole } from "effect/testing"; | |
| 14 | +import { describe, expect, it } from "@effect/vitest"; | |
| 15 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 16 | + | |
| 17 | +import { | |
| 18 | + DESKTOP_RELEASES_URL, | |
| 19 | + desktopAppLaunchCommands, | |
| 20 | + openDesktopAppOrPrompt, | |
| 21 | +} from "./desktopAppLauncher.ts"; | |
| 22 | + | |
| 23 | +const makeUserInput = (name: string): Terminal.UserInput => ({ | |
| 24 | + input: Option.some(name), | |
| 25 | + key: { name, ctrl: false, meta: false, shift: false }, | |
| 26 | +}); | |
| 27 | + | |
| 28 | +const makeTerminalLayer = (keys: ReadonlyArray<string>) => | |
| 29 | + Layer.effect( | |
| 30 | + Terminal.Terminal, | |
| 31 | + Effect.gen(function* () { | |
| 32 | + const input = yield* Queue.unbounded<Terminal.UserInput>(); | |
| 33 | + yield* Queue.offerAll(input, keys.map(makeUserInput)); | |
| 34 | + | |
| 35 | + return Terminal.make({ | |
| 36 | + columns: Effect.succeed(80), | |
| 37 | + display: (text) => Console.log(text), | |
| 38 | + readInput: Effect.succeed(Queue.asDequeue(input)), | |
| 39 | + readLine: Effect.succeed(""), | |
| 40 | + }); | |
| 41 | + }), | |
| 42 | + ); | |
| 43 | + | |
| 44 | +const makeHandle = (exitCode: number) => | |
| 45 | + ChildProcessSpawner.makeHandle({ | |
| 46 | + pid: ChildProcessSpawner.ProcessId(1), | |
| 47 | + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | |
| 48 | + isRunning: Effect.succeed(false), | |
| 49 | + kill: () => Effect.void, | |
| 50 | + unref: Effect.succeed(Effect.void), | |
| 51 | + stdin: Sink.drain, | |
| 52 | + stdout: Stream.empty, | |
| 53 | + stderr: Stream.empty, | |
| 54 | + all: Stream.empty, | |
| 55 | + getInputFd: () => Sink.drain, | |
| 56 | + getOutputFd: () => Stream.empty, | |
| 57 | + }); | |
| 58 | + | |
| 59 | +const makeSpawnerLayer = ( | |
| 60 | + calls: Array<{ command: string; args: ReadonlyArray<string> }>, | |
| 61 | + exitCodes: ReadonlyArray<number>, | |
| 62 | +) => | |
| 63 | + Layer.succeed( | |
| 64 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 65 | + ChildProcessSpawner.make((command) => | |
| 66 | + Effect.sync(() => { | |
| 67 | + const standardCommand = command as ChildProcess.StandardCommand; | |
| 68 | + calls.push({ command: standardCommand.command, args: standardCommand.args }); | |
| 69 | + return makeHandle(exitCodes[calls.length - 1] ?? 0); | |
| 70 | + }), | |
| 71 | + ), | |
| 72 | + ); | |
| 73 | + | |
| 74 | +const makeTestLayer = ( | |
| 75 | + calls: Array<{ command: string; args: ReadonlyArray<string> }>, | |
| 76 | + exitCodes: ReadonlyArray<number>, | |
| 77 | + keys: ReadonlyArray<string> = [], | |
| 78 | +) => | |
| 79 | + Layer.mergeAll( | |
| 80 | + TestConsole.layer, | |
| 81 | + FileSystem.layerNoop({}), | |
| 82 | + Path.layer, | |
| 83 | + makeTerminalLayer(keys), | |
| 84 | + makeSpawnerLayer(calls, exitCodes), | |
| 85 | + ); | |
| 86 | + | |
| 87 | +describe("desktopAppLaunchCommands", () => { | |
| 88 | + it("tries Alpha and Nightly app names on macOS", () => { | |
| 89 | + expect(desktopAppLaunchCommands("darwin")).toEqual([ | |
| 90 | + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, | |
| 91 | + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, | |
| 92 | + ]); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it("passes the workspace path through macOS app launches", () => { | |
| 96 | + expect(desktopAppLaunchCommands("darwin", "/repo/app")).toEqual([ | |
| 97 | + { | |
| 98 | + command: "open", | |
| 99 | + args: ["-a", "T3 Code (Alpha)", "--args", "--t3-open-path", "/repo/app"], | |
| 100 | + }, | |
| 101 | + { | |
| 102 | + command: "open", | |
| 103 | + args: ["-a", "T3 Code (Nightly)", "--args", "--t3-open-path", "/repo/app"], | |
| 104 | + }, | |
| 105 | + ]); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it("tries the Linux desktop entry before the executable", () => { | |
| 109 | + expect(desktopAppLaunchCommands("linux")).toEqual([ | |
| 110 | + { command: "gtk-launch", args: ["t3code.desktop"] }, | |
| 111 | + { command: "t3code", args: [] }, | |
| 112 | + ]); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it("uses the Linux executable directly when passing a workspace path", () => { | |
| 116 | + expect(desktopAppLaunchCommands("linux", "/repo/app")).toEqual([ | |
| 117 | + { command: "t3code", args: ["--t3-open-path", "/repo/app"] }, | |
| 118 | + ]); | |
| 119 | + }); | |
| 120 | +}); | |
| 121 | + | |
| 122 | +describe("openDesktopAppOrPrompt", () => { | |
| 123 | + it.effect("opens the installed app without prompting for releases", () => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 126 | + | |
| 127 | + const result = yield* openDesktopAppOrPrompt("darwin", "/repo/app").pipe( | |
| 128 | + Effect.provide(makeTestLayer(calls, [0])), | |
| 129 | + ); | |
| 130 | + const logs = yield* TestConsole.logLines; | |
| 131 | + | |
| 132 | + expect(result).toEqual({ _tag: "opened-app" }); | |
| 133 | + expect(calls).toEqual([ | |
| 134 | + { | |
| 135 | + command: "open", | |
| 136 | + args: ["-a", "T3 Code (Alpha)", "--args", "--t3-open-path", "/repo/app"], | |
| 137 | + }, | |
| 138 | + ]); | |
| 139 | + expect(logs).toContain("Opened T3 Code."); | |
| 140 | + }), | |
| 141 | + ); | |
| 142 | + | |
| 143 | + it.effect("opens Nightly when Alpha is not installed", () => | |
| 144 | + Effect.gen(function* () { | |
| 145 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 146 | + | |
| 147 | + const result = yield* openDesktopAppOrPrompt("darwin").pipe( | |
| 148 | + Effect.provide(makeTestLayer(calls, [1, 0])), | |
| 149 | + ); | |
| 150 | + const logs = yield* TestConsole.logLines; | |
| 151 | + | |
| 152 | + expect(result).toEqual({ _tag: "opened-app" }); | |
| 153 | + expect(calls).toEqual([ | |
| 154 | + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, | |
| 155 | + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, | |
| 156 | + ]); | |
| 157 | + expect(logs).toContain("Opened T3 Code."); | |
| 158 | + }), | |
| 159 | + ); | |
| 160 | + | |
| 161 | + it.effect("prompts and opens GitHub releases when app launch fails", () => | |
| 162 | + Effect.gen(function* () { | |
| 163 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 164 | + | |
| 165 | + const result = yield* openDesktopAppOrPrompt("darwin").pipe( | |
| 166 | + Effect.provide(makeTestLayer(calls, [1, 1, 0], ["enter"])), | |
| 167 | + ); | |
| 168 | + const logs = yield* TestConsole.logLines; | |
| 169 | + | |
| 170 | + expect(result).toEqual({ _tag: "opened-releases" }); | |
| 171 | + expect(calls).toEqual([ | |
| 172 | + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, | |
| 173 | + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, | |
| 174 | + { command: "open", args: [DESKTOP_RELEASES_URL] }, | |
| 175 | + ]); | |
| 176 | + expect(logs.some((line) => String(line).includes("Yes, open github"))).toBe(true); | |
| 177 | + expect(logs).toContain(`Opened ${DESKTOP_RELEASES_URL}`); | |
| 178 | + }), | |
| 179 | + ); | |
| 180 | + | |
| 181 | + it.effect("returns use-web-ui without opening releases when selected", () => | |
| 182 | + Effect.gen(function* () { | |
| 183 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 184 | + | |
| 185 | + const result = yield* openDesktopAppOrPrompt("darwin").pipe( | |
| 186 | + Effect.provide(makeTestLayer(calls, [1, 1], ["down", "enter"])), | |
| 187 | + ); | |
| 188 | + const logs = yield* TestConsole.logLines; | |
| 189 | + | |
| 190 | + expect(result).toEqual({ _tag: "use-web-ui" }); | |
| 191 | + expect(calls).toEqual([ | |
| 192 | + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, | |
| 193 | + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, | |
| 194 | + ]); | |
| 195 | + expect(logs).toContain("Starting the web UI."); | |
| 196 | + }), | |
| 197 | + ); | |
| 198 | + | |
| 199 | + it.effect("prints the releases URL without opening a browser when exiting", () => | |
| 200 | + Effect.gen(function* () { | |
| 201 | + const calls: Array<{ command: string; args: ReadonlyArray<string> }> = []; | |
| 202 | + | |
| 203 | + const result = yield* openDesktopAppOrPrompt("darwin").pipe( | |
| 204 | + Effect.provide(makeTestLayer(calls, [1, 1], ["down", "down", "enter"])), | |
| 205 | + ); | |
| 206 | + const logs = yield* TestConsole.logLines; | |
| 207 | + | |
| 208 | + expect(result).toEqual({ _tag: "exit" }); | |
| 209 | + expect(calls).toEqual([ | |
| 210 | + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, | |
| 211 | + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, | |
| 212 | + ]); | |
| 213 | + expect(logs).toContain(`Download T3 Code from ${DESKTOP_RELEASES_URL}`); | |
| 214 | + }), | |
| 215 | + ); | |
| 216 | +}); | |
| @@ -0,0 +1,264 @@ | ||
| 1 | +import { Console, Data, Duration, Effect, Option, Stream } from "effect"; | |
| 2 | +import { Prompt } from "effect/unstable/cli"; | |
| 3 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 4 | + | |
| 5 | +const DESKTOP_APP_NAMES = ["T3 Code (Alpha)", "T3 Code (Nightly)"] as const; | |
| 6 | +const DESKTOP_APP_NAME = "T3 Code"; | |
| 7 | +const DESKTOP_LINUX_DESKTOP_ENTRY = "t3code.desktop"; | |
| 8 | +const DESKTOP_LINUX_EXECUTABLE = "t3code"; | |
| 9 | +export const DESKTOP_RELEASES_URL = "https://github.com/pingdotgg/t3code/releases/latest"; | |
| 10 | + | |
| 11 | +type Platform = NodeJS.Platform; | |
| 12 | + | |
| 13 | +export interface DesktopLaunchCommand { | |
| 14 | + readonly command: string; | |
| 15 | + readonly args: ReadonlyArray<string>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export class DesktopAppLaunchError extends Data.TaggedError("DesktopAppLaunchError")<{ | |
| 19 | + readonly command: string; | |
| 20 | + readonly message: string; | |
| 21 | + readonly exitCode?: number | undefined; | |
| 22 | + readonly cause?: unknown; | |
| 23 | +}> {} | |
| 24 | + | |
| 25 | +export type DesktopAppFallbackChoice = "open-github" | "use-web-ui" | "exit"; | |
| 26 | + | |
| 27 | +export interface DesktopAppFallbackPromptChoice { | |
| 28 | + readonly title: string; | |
| 29 | + readonly value: DesktopAppFallbackChoice; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export const desktopAppFallbackPromptChoices: ReadonlyArray<DesktopAppFallbackPromptChoice> = [ | |
| 33 | + { title: "Yes, open github", value: "open-github" }, | |
| 34 | + { title: "No, use web UI", value: "use-web-ui" }, | |
| 35 | + { title: "No, exit", value: "exit" }, | |
| 36 | +]; | |
| 37 | + | |
| 38 | +export type DesktopAppLaunchResult = | |
| 39 | + | { readonly _tag: "opened-app" } | |
| 40 | + | { readonly _tag: "opened-releases" } | |
| 41 | + | { readonly _tag: "use-web-ui" } | |
| 42 | + | { readonly _tag: "exit" }; | |
| 43 | + | |
| 44 | +const desktopAppLaunchArgs = (workspacePath: string | undefined): ReadonlyArray<string> => | |
| 45 | + workspacePath ? ["--t3-open-path", workspacePath] : []; | |
| 46 | + | |
| 47 | +function quotePowerShellSingle(value: string): string { | |
| 48 | + return `'${value.replaceAll("'", "''")}'`; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function desktopAppLaunchCommands( | |
| 52 | + platform: Platform, | |
| 53 | + workspacePath?: string, | |
| 54 | +): ReadonlyArray<DesktopLaunchCommand> { | |
| 55 | + const appArgs = desktopAppLaunchArgs(workspacePath); | |
| 56 | + if (platform === "darwin") { | |
| 57 | + return DESKTOP_APP_NAMES.map((appName) => ({ | |
| 58 | + command: "open", | |
| 59 | + args: appArgs.length > 0 ? ["-a", appName, "--args", ...appArgs] : ["-a", appName], | |
| 60 | + })); | |
| 61 | + } | |
| 62 | + | |
| 63 | + if (platform === "win32") { | |
| 64 | + const argumentList = | |
| 65 | + appArgs.length > 0 | |
| 66 | + ? ` -ArgumentList @(${appArgs.map(quotePowerShellSingle).join(", ")})` | |
| 67 | + : ""; | |
| 68 | + const script = [ | |
| 69 | + "$names = @('T3 Code (Alpha)', 'T3 Code (Nightly)', 'T3 Code')", | |
| 70 | + "$roots = @($env:LOCALAPPDATA, $env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ }", | |
| 71 | + '$candidates = foreach ($root in $roots) { foreach ($name in $names) { Join-Path (Join-Path $root \'Programs\') (Join-Path $name "$name.exe"); Join-Path $root (Join-Path $name "$name.exe") } }', | |
| 72 | + "$target = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1", | |
| 73 | + "if (-not $target) { exit 1 }", | |
| 74 | + `Start-Process -FilePath $target${argumentList}`, | |
| 75 | + ].join("; "); | |
| 76 | + | |
| 77 | + return [ | |
| 78 | + { | |
| 79 | + command: "powershell.exe", | |
| 80 | + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], | |
| 81 | + }, | |
| 82 | + ]; | |
| 83 | + } | |
| 84 | + | |
| 85 | + if (platform === "linux") { | |
| 86 | + if (appArgs.length > 0) { | |
| 87 | + return [{ command: DESKTOP_LINUX_EXECUTABLE, args: appArgs }]; | |
| 88 | + } | |
| 89 | + | |
| 90 | + return [ | |
| 91 | + { command: "gtk-launch", args: [DESKTOP_LINUX_DESKTOP_ENTRY] }, | |
| 92 | + { command: DESKTOP_LINUX_EXECUTABLE, args: [] }, | |
| 93 | + ]; | |
| 94 | + } | |
| 95 | + | |
| 96 | + return []; | |
| 97 | +} | |
| 98 | + | |
| 99 | +function openReleasesCommand(platform: Platform): DesktopLaunchCommand { | |
| 100 | + if (platform === "darwin") { | |
| 101 | + return { command: "open", args: [DESKTOP_RELEASES_URL] }; | |
| 102 | + } | |
| 103 | + | |
| 104 | + if (platform === "win32") { | |
| 105 | + return { | |
| 106 | + command: "powershell.exe", | |
| 107 | + args: [ | |
| 108 | + "-NoProfile", | |
| 109 | + "-ExecutionPolicy", | |
| 110 | + "Bypass", | |
| 111 | + "-Command", | |
| 112 | + `Start-Process ${JSON.stringify(DESKTOP_RELEASES_URL)}`, | |
| 113 | + ], | |
| 114 | + }; | |
| 115 | + } | |
| 116 | + | |
| 117 | + return { command: "xdg-open", args: [DESKTOP_RELEASES_URL] }; | |
| 118 | +} | |
| 119 | + | |
| 120 | +function commandLabel(command: string, args: ReadonlyArray<string>): string { | |
| 121 | + return [command, ...args].join(" "); | |
| 122 | +} | |
| 123 | + | |
| 124 | +const runDesktopCommand = ( | |
| 125 | + command: string, | |
| 126 | + args: ReadonlyArray<string>, | |
| 127 | +): Effect.Effect<void, DesktopAppLaunchError, ChildProcessSpawner.ChildProcessSpawner> => | |
| 128 | + Effect.gen(function* () { | |
| 129 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 130 | + const label = commandLabel(command, args); | |
| 131 | + | |
| 132 | + const exitCode = yield* Effect.scoped( | |
| 133 | + Effect.gen(function* () { | |
| 134 | + const child = yield* spawner | |
| 135 | + .spawn( | |
| 136 | + ChildProcess.make(command, [...args], { | |
| 137 | + env: process.env, | |
| 138 | + }), | |
| 139 | + ) | |
| 140 | + .pipe( | |
| 141 | + Effect.mapError( | |
| 142 | + (cause) => | |
| 143 | + new DesktopAppLaunchError({ | |
| 144 | + command: label, | |
| 145 | + message: `Failed to run ${label}.`, | |
| 146 | + cause, | |
| 147 | + }), | |
| 148 | + ), | |
| 149 | + ); | |
| 150 | + | |
| 151 | + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | |
| 152 | + | |
| 153 | + const [, , code] = yield* Effect.all( | |
| 154 | + [Stream.runDrain(child.stdout), Stream.runDrain(child.stderr), child.exitCode], | |
| 155 | + { concurrency: "unbounded" }, | |
| 156 | + ).pipe( | |
| 157 | + Effect.mapError( | |
| 158 | + (cause) => | |
| 159 | + new DesktopAppLaunchError({ | |
| 160 | + command: label, | |
| 161 | + message: `Failed to collect result for ${label}.`, | |
| 162 | + cause, | |
| 163 | + }), | |
| 164 | + ), | |
| 165 | + ); | |
| 166 | + | |
| 167 | + return code; | |
| 168 | + }).pipe( | |
| 169 | + Effect.timeoutOption(Duration.millis(10_000)), | |
| 170 | + Effect.flatMap((result) => | |
| 171 | + Option.match(result, { | |
| 172 | + onSome: Effect.succeed, | |
| 173 | + onNone: () => | |
| 174 | + Effect.fail( | |
| 175 | + new DesktopAppLaunchError({ | |
| 176 | + command: label, | |
| 177 | + message: `${label} timed out.`, | |
| 178 | + }), | |
| 179 | + ), | |
| 180 | + }), | |
| 181 | + ), | |
| 182 | + ), | |
| 183 | + ); | |
| 184 | + | |
| 185 | + if (exitCode !== 0) { | |
| 186 | + return yield* new DesktopAppLaunchError({ | |
| 187 | + command: label, | |
| 188 | + message: `${label} exited with code ${exitCode}.`, | |
| 189 | + exitCode, | |
| 190 | + }); | |
| 191 | + } | |
| 192 | + }); | |
| 193 | + | |
| 194 | +const chooseFallback = ( | |
| 195 | + message: string, | |
| 196 | +): Effect.Effect<DesktopAppFallbackChoice, DesktopAppLaunchError, Prompt.Environment> => | |
| 197 | + Prompt.select({ | |
| 198 | + message, | |
| 199 | + choices: desktopAppFallbackPromptChoices, | |
| 200 | + }).pipe( | |
| 201 | + Prompt.run, | |
| 202 | + Effect.mapError( | |
| 203 | + (cause) => | |
| 204 | + new DesktopAppLaunchError({ | |
| 205 | + command: "desktop fallback prompt", | |
| 206 | + message: "Desktop fallback prompt was cancelled.", | |
| 207 | + cause, | |
| 208 | + }), | |
| 209 | + ), | |
| 210 | + ); | |
| 211 | + | |
| 212 | +function tryLaunchDesktopApp( | |
| 213 | + platform: Platform, | |
| 214 | + workspacePath: string | undefined, | |
| 215 | +): Effect.Effect<boolean, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 216 | + return Effect.gen(function* () { | |
| 217 | + for (const launchCommand of desktopAppLaunchCommands(platform, workspacePath)) { | |
| 218 | + const launched = yield* runDesktopCommand(launchCommand.command, launchCommand.args).pipe( | |
| 219 | + Effect.as(true), | |
| 220 | + Effect.catchCause(() => Effect.succeed(false)), | |
| 221 | + ); | |
| 222 | + if (launched) { | |
| 223 | + return true; | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + return false; | |
| 228 | + }); | |
| 229 | +} | |
| 230 | + | |
| 231 | +export function openDesktopAppOrPrompt( | |
| 232 | + platform: Platform, | |
| 233 | + workspacePath?: string, | |
| 234 | +): Effect.Effect< | |
| 235 | + DesktopAppLaunchResult, | |
| 236 | + DesktopAppLaunchError, | |
| 237 | + ChildProcessSpawner.ChildProcessSpawner | Prompt.Environment | |
| 238 | +> { | |
| 239 | + return Effect.gen(function* () { | |
| 240 | + if (yield* tryLaunchDesktopApp(platform, workspacePath)) { | |
| 241 | + yield* Console.log(`Opened ${DESKTOP_APP_NAME}.`); | |
| 242 | + return { _tag: "opened-app" } satisfies DesktopAppLaunchResult; | |
| 243 | + } | |
| 244 | + | |
| 245 | + const fallbackChoice = yield* chooseFallback( | |
| 246 | + `${DESKTOP_APP_NAME} does not appear to be installed.`, | |
| 247 | + ); | |
| 248 | + | |
| 249 | + if (fallbackChoice === "use-web-ui") { | |
| 250 | + yield* Console.log("Starting the web UI."); | |
| 251 | + return { _tag: "use-web-ui" } satisfies DesktopAppLaunchResult; | |
| 252 | + } | |
| 253 | + | |
| 254 | + if (fallbackChoice === "exit") { | |
| 255 | + yield* Console.log(`Download ${DESKTOP_APP_NAME} from ${DESKTOP_RELEASES_URL}`); | |
| 256 | + return { _tag: "exit" } satisfies DesktopAppLaunchResult; | |
| 257 | + } | |
| 258 | + | |
| 259 | + const command = openReleasesCommand(platform); | |
| 260 | + yield* runDesktopCommand(command.command, command.args); | |
| 261 | + yield* Console.log(`Opened ${DESKTOP_RELEASES_URL}`); | |
| 262 | + return { _tag: "opened-releases" } satisfies DesktopAppLaunchResult; | |
| 263 | + }); | |
| 264 | +} | |
| @@ -378,6 +378,7 @@ const createDesktopBridgeStub = (overrides?: { · 377 unmodified lines | ||
| 378 | 378 | showContextMenu: vi.fn().mockResolvedValue(null), |
| 379 | 379 | openExternal: vi.fn().mockResolvedValue(true), |
| 380 | 380 | onMenuAction: () => () => {}, |
| 381 | + onOpenWorkspace: () => () => {}, | |
| 381 | 382 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), |
| 382 | 383 | setUpdateChannel: |
| 383 | 384 | overrides?.setUpdateChannel ?? |
| @@ -225,6 +225,7 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 224 unmodified lines | ||
| 225 | 225 | showContextMenu: async () => null, |
| 226 | 226 | openExternal: async () => true, |
| 227 | 227 | onMenuAction: () => () => undefined, |
| 228 | + onOpenWorkspace: () => () => undefined, | |
| 228 | 229 | getUpdateState: async () => { |
| 229 | 230 | throw new Error("getUpdateState not implemented in test"); |
| 230 | 231 | }, |
| @@ -1,5 +1,11 @@ | ||
| 1 | −import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | |
| 2 | −import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | |
| 1 | +import { | |
| 2 | + DEFAULT_MODEL, | |
| 3 | + DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 4 | + DEFAULT_RUNTIME_MODE, | |
| 5 | + ProviderInstanceId, | |
| 6 | + type ServerLifecycleWelcomePayload, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import { scopedProjectKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 3 | 9 | import { |
| 4 | 10 | Outlet, |
| 5 | 11 | createRootRouteWithContext, |
| @@ -28,7 +34,9 @@ import { · 22 unmodified lines | ||
| 28 | 34 | } from "../components/ui/toast"; |
| 29 | 35 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 30 | 36 | import { readLocalApi } from "../localApi"; |
| 37 | +import { readEnvironmentApi } from "../environmentApi"; | |
| 31 | 38 | import { useSettings } from "../hooks/useSettings"; |
| 39 | +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | |
| 32 | 40 | import { |
| 33 | 41 | deriveLogicalProjectKeyFromSettings, |
| 34 | 42 | derivePhysicalProjectKeyFromPath, |
| @@ -41,8 +49,12 @@ import { · 6 unmodified lines | ||
| 41 | 49 | useServerConfigUpdatedSubscription, |
| 42 | 50 | useServerWelcomeSubscription, |
| 43 | 51 | } from "../rpc/serverState"; |
| 44 | −import { useStore } from "../store"; | |
| 52 | +import { selectSidebarThreadsForProjectRef, useStore } from "../store"; | |
| 45 | 53 | import { useUiStateStore } from "../uiStateStore"; |
| 54 | +import { getLatestThreadForProject } from "../lib/threadSort"; | |
| 55 | +import { inferProjectTitleFromPath, findProjectByPath } from "../lib/projectPaths"; | |
| 56 | +import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; | |
| 57 | +import { buildThreadRouteParams } from "../threadRoutes"; | |
| 46 | 58 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 47 | 59 | import { |
| 48 | 60 | ensureEnvironmentConnectionBootstrapped, |
| @@ -136,6 +148,7 @@ function RootRouteView() { · 87 unmodified lines | ||
| 136 | 148 | <SshPasswordPromptDialog /> |
| 137 | 149 | <HostedStaticEnvironmentBootstrap /> |
| 138 | 150 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 151 | + {primaryEnvironmentAuthenticated ? <DesktopOpenWorkspaceBootstrap /> : null} | |
| 139 | 152 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} |
| 140 | 153 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} |
| 141 | 154 | {primaryEnvironmentAuthenticated ? ( |
| @@ -148,6 +161,112 @@ function RootRouteView() { · 6 unmodified lines | ||
| 148 | 161 | ); |
| 149 | 162 | } |
| 150 | 163 | |
| 164 | +function DesktopOpenWorkspaceBootstrap() { | |
| 165 | + const navigate = useNavigate(); | |
| 166 | + const serverConfig = useServerConfig(); | |
| 167 | + const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); | |
| 168 | + const defaultThreadEnvMode = useSettings((settings) => settings.defaultThreadEnvMode); | |
| 169 | + const { handleNewThread } = useNewThreadHandler(); | |
| 170 | + | |
| 171 | + const openWorkspace = useEffectEvent(async (rawWorkspacePath: string) => { | |
| 172 | + const workspacePath = rawWorkspacePath.trim(); | |
| 173 | + if (!workspacePath) return; | |
| 174 | + | |
| 175 | + const environmentId = | |
| 176 | + serverConfig?.environment.environmentId ?? getPrimaryKnownEnvironment()?.environmentId; | |
| 177 | + if (!environmentId) return; | |
| 178 | + | |
| 179 | + await ensureEnvironmentConnectionBootstrapped(environmentId); | |
| 180 | + const api = readEnvironmentApi(environmentId); | |
| 181 | + if (!api) return; | |
| 182 | + | |
| 183 | + const environmentState = useStore.getState().environmentStateById[environmentId]; | |
| 184 | + const projects = Object.values(environmentState?.projectById ?? {}); | |
| 185 | + const existingProject = findProjectByPath(projects, workspacePath); | |
| 186 | + | |
| 187 | + const openThread = async (projectId: (typeof projects)[number]["id"]) => { | |
| 188 | + const projectRef = scopeProjectRef(environmentId, projectId); | |
| 189 | + const latestThread = getLatestThreadForProject( | |
| 190 | + selectSidebarThreadsForProjectRef(useStore.getState(), projectRef), | |
| 191 | + projectId, | |
| 192 | + sidebarThreadSortOrder, | |
| 193 | + ); | |
| 194 | + if (latestThread) { | |
| 195 | + await navigate({ | |
| 196 | + to: "/$environmentId/$threadId", | |
| 197 | + params: buildThreadRouteParams( | |
| 198 | + scopeThreadRef(latestThread.environmentId, latestThread.id), | |
| 199 | + ), | |
| 200 | + }); | |
| 201 | + return; | |
| 202 | + } | |
| 203 | + | |
| 204 | + await handleNewThread(projectRef, { | |
| 205 | + envMode: defaultThreadEnvMode, | |
| 206 | + }); | |
| 207 | + }; | |
| 208 | + | |
| 209 | + if (existingProject) { | |
| 210 | + await openThread(existingProject.id); | |
| 211 | + return; | |
| 212 | + } | |
| 213 | + | |
| 214 | + const createdAt = new Date().toISOString(); | |
| 215 | + const projectId = newProjectId(); | |
| 216 | + const threadId = newThreadId(); | |
| 217 | + const defaultModelSelection = { | |
| 218 | + instanceId: ProviderInstanceId.make("codex"), | |
| 219 | + model: DEFAULT_MODEL, | |
| 220 | + }; | |
| 221 | + | |
| 222 | + await api.orchestration.dispatchCommand({ | |
| 223 | + type: "project.create", | |
| 224 | + commandId: newCommandId(), | |
| 225 | + projectId, | |
| 226 | + title: inferProjectTitleFromPath(workspacePath), | |
| 227 | + workspaceRoot: workspacePath, | |
| 228 | + createWorkspaceRootIfMissing: true, | |
| 229 | + defaultModelSelection, | |
| 230 | + createdAt, | |
| 231 | + }); | |
| 232 | + await api.orchestration.dispatchCommand({ | |
| 233 | + type: "thread.create", | |
| 234 | + commandId: newCommandId(), | |
| 235 | + threadId, | |
| 236 | + projectId, | |
| 237 | + title: "New thread", | |
| 238 | + modelSelection: defaultModelSelection, | |
| 239 | + runtimeMode: DEFAULT_RUNTIME_MODE, | |
| 240 | + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 241 | + branch: null, | |
| 242 | + worktreePath: null, | |
| 243 | + createdAt, | |
| 244 | + }); | |
| 245 | + await navigate({ | |
| 246 | + to: "/$environmentId/$threadId", | |
| 247 | + params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), | |
| 248 | + }); | |
| 249 | + }); | |
| 250 | + | |
| 251 | + useEffect(() => { | |
| 252 | + const unsubscribe = window.desktopBridge?.onOpenWorkspace?.((workspacePath) => { | |
| 253 | + void openWorkspace(workspacePath).catch((error) => { | |
| 254 | + toastManager.add( | |
| 255 | + stackedThreadToast({ | |
| 256 | + type: "error", | |
| 257 | + title: "Unable to open workspace", | |
| 258 | + description: error instanceof Error ? error.message : "Unknown error.", | |
| 259 | + }), | |
| 260 | + ); | |
| 261 | + }); | |
| 262 | + }); | |
| 263 | + | |
| 264 | + return unsubscribe ?? undefined; | |
| 265 | + }, []); | |
| 266 | + | |
| 267 | + return null; | |
| 268 | +} | |
| 269 | + | |
| 151 | 270 | function HostedStaticEnvironmentBootstrap() { |
| 152 | 271 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( |
| 153 | 272 | (state) => Object.keys(state.byId).length, |
| @@ -242,6 +242,7 @@ export interface DesktopBridge { · 241 unmodified lines | ||
| 242 | 242 | ) => Promise<T | null>; |
| 243 | 243 | openExternal: (url: string) => Promise<boolean>; |
| 244 | 244 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 245 | + onOpenWorkspace: (listener: (workspacePath: string) => void) => () => void; | |
| 245 | 246 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 246 | 247 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; |
| 247 | 248 | checkForUpdate: () => Promise<DesktopUpdateCheckResult>; |