- 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 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 93 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 94 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 94 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 95 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 95 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 96 | const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace"; | ||
| 96 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 97 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 97 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 98 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 98 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | 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 | const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); | 119 | const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); |
| 119 | const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); | 120 | const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); |
| 120 | const DESKTOP_SCHEME = "t3"; | 121 | const DESKTOP_SCHEME = "t3"; |
| 122 | const OPEN_WORKSPACE_ARG = "--t3-open-path"; | ||
| 121 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); | 123 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 122 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 124 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 123 | // Dev-only SSH launcher override. Set this to an absolute path on the SSH host | 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 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); | 241 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); |
| 240 | let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); | 242 | let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); |
| 241 | let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; | 243 | let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; |
| 244 | let pendingOpenWorkspacePath = resolveOpenWorkspaceArg(process.argv.slice(1)); | ||
| 242 | 245 | ||
| 243 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; | 246 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 244 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); | 247 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); |
| @@ -471,6 +474,23 @@ function formatErrorMessage(error: unknown): string { · 226 unmodified lines | |||
| 471 | return String(error); | 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 | function getSafeExternalUrl(rawUrl: unknown): string | null { | 494 | function getSafeExternalUrl(rawUrl: unknown): string | null { |
| 475 | if (typeof rawUrl !== "string" || rawUrl.length === 0) { | 495 | if (typeof rawUrl !== "string" || rawUrl.length === 0) { |
| 476 | return null; | 496 | return null; |
| @@ -567,6 +587,32 @@ function ensureInitialBackendWindowOpen(): void { · 90 unmodified lines | |||
| 567 | backendInitialWindowOpenInFlight = nextOpen; | 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 | function writeDesktopStreamChunk( | 616 | function writeDesktopStreamChunk( |
| 571 | streamName: "stdout" | "stderr", | 617 | streamName: "stdout" | "stderr", |
| 572 | chunk: unknown, | 618 | chunk: unknown, |
| @@ -1464,6 +1510,8 @@ function startBackend(): void { · 891 unmodified lines | |||
| 1464 | return; | 1510 | return; |
| 1465 | } | 1511 | } |
| 1466 | 1512 | ||
| 1513 | const bootstrapWorkspacePath = pendingOpenWorkspacePath; | ||
| 1514 | pendingOpenWorkspacePath = null; | ||
| 1467 | const captureBackendLogs = !isDevelopment; | 1515 | const captureBackendLogs = !isDevelopment; |
| 1468 | const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { | 1516 | const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { |
| 1469 | cwd: resolveBackendCwd(), | 1517 | cwd: resolveBackendCwd(), |
| @@ -1487,6 +1535,9 @@ function startBackend(): void { · 17 unmodified lines | |||
| 1487 | t3Home: BASE_DIR, | 1535 | t3Home: BASE_DIR, |
| 1488 | host: backendBindHost, | 1536 | host: backendBindHost, |
| 1489 | desktopBootstrapToken: backendBootstrapToken, | 1537 | desktopBootstrapToken: backendBootstrapToken, |
| 1538 | ...(bootstrapWorkspacePath | ||
| 1539 | ? { cwd: bootstrapWorkspacePath, autoBootstrapProjectFromCwd: true } | ||
| 1540 | : {}), | ||
| 1490 | tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, | 1541 | tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, |
| 1491 | tailscaleServePort: desktopSettings.tailscaleServePort, | 1542 | tailscaleServePort: desktopSettings.tailscaleServePort, |
| 1492 | ...(backendObservabilitySettings.otlpTracesUrl | 1543 | ...(backendObservabilitySettings.otlpTracesUrl |
| @@ -2135,6 +2186,12 @@ function createWindow(): BrowserWindow { · 642 unmodified lines | |||
| 2135 | void window.loadURL(backendHttpUrl); | 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 | window.on("closed", () => { | 2195 | window.on("closed", () => { |
| 2139 | desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( | 2196 | desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( |
| 2140 | "SSH authentication was cancelled because the app window closed.", | 2197 | "SSH authentication was cancelled because the app window closed.", |
| @@ -2234,38 +2291,59 @@ app.on("before-quit", () => { · 93 unmodified lines | |||
| 2234 | restoreStdIoCapture?.(); | 2291 | restoreStdIoCapture?.(); |
| 2235 | }); | 2292 | }); |
| 2236 | 2293 | ||
| 2237 | app | 2294 | const hasSingleInstanceLock = app.requestSingleInstanceLock(); |
| 2238 | .whenReady() | 2295 | if (!hasSingleInstanceLock) { |
| 2239 | .then(() => { | 2296 | app.quit(); |
| 2240 | writeDesktopLogHeader("app ready"); | 2297 | } else { |
| 2241 | configureAppIdentity(); | 2298 | app.on("second-instance", (_event, commandLine) => { |
| 2242 | configureApplicationMenu(); | 2299 | const workspacePath = resolveOpenWorkspaceArg(commandLine); |
| 2243 | registerDesktopProtocol(); | 2300 | if (workspacePath) { |
| 2244 | configureAutoUpdater(); | 2301 | dispatchOpenWorkspace(workspacePath); |
| 2245 | void bootstrap().catch((error) => { | 2302 | return; |
| 2246 | if (isBackendReadinessAborted(error) && isQuitting) { | 2303 | } |
| 2247 | return; | ||
| 2248 | } | ||
| 2249 | handleFatalStartupError("bootstrap", error); | ||
| 2250 | }); | ||
| 2251 | 2304 | ||
| 2252 | app.on("activate", () => { | 2305 | const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; |
| 2253 | const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | 2306 | if (existingWindow) { |
| 2254 | if (existingWindow) { | 2307 | revealWindow(existingWindow); |
| 2255 | revealWindow(existingWindow); | 2308 | return; |
| 2256 | return; | 2309 | } |
| 2257 | } | 2310 | |
| 2258 | if (isDevelopment) { | 2311 | ensureInitialBackendWindowOpen(); |
| 2259 | mainWindow = createWindow(); | ||
| 2260 | return; | ||
| 2261 | } | ||
| 2262 | ensureInitialBackendWindowOpen(); | ||
| 2263 | }); | ||
| 2264 | }) | ||
| 2265 | .catch((error) => { | ||
| 2266 | handleFatalStartupError("whenReady", error); | ||
| 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 | app.on("window-all-closed", () => { | 2347 | app.on("window-all-closed", () => { |
| 2270 | if (process.platform !== "darwin" && !isQuitting) { | 2348 | if (process.platform !== "darwin" && !isQuitting) { |
| 2271 | app.quit(); | 2349 | app.quit(); |
| @@ -7,6 +7,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; · 6 unmodified lines | |||
| 7 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 7 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 8 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 8 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace"; | ||
| 10 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 11 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 12 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | 13 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; |
| @@ -128,6 +129,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 115 unmodified lines | |||
| 128 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); | 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 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), | 143 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), |
| 132 | setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), | 144 | setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), |
| 133 | checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), | 145 | checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), |
| @@ -65,6 +65,7 @@ import { · 64 unmodified lines | |||
| 65 | } from "./serverRuntimeState.ts"; | 65 | } from "./serverRuntimeState.ts"; |
| 66 | import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; | 66 | import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; |
| 67 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 67 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 68 | import { openDesktopAppOrPrompt } from "./desktopAppLauncher.ts"; | ||
| 68 | 69 | ||
| 69 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | 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 | Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), | 140 | Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), |
| 140 | Flag.optional, | 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 | const EnvServerConfig = Config.all({ | 148 | const EnvServerConfig = Config.all({ |
| 144 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), | 149 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), |
| @@ -1150,11 +1155,21 @@ const runServerCommand = ( · 1005 unmodified lines | |||
| 1150 | readonly forceAutoBootstrapProjectFromCwd?: boolean; | 1155 | readonly forceAutoBootstrapProjectFromCwd?: boolean; |
| 1151 | }, | 1156 | }, |
| 1152 | ) => | 1157 | ) => |
| 1153 | Effect.gen(function* () { | 1158 | Effect.service(GlobalFlag.LogLevel).pipe( |
| 1154 | const logLevel = yield* GlobalFlag.LogLevel; | 1159 | Effect.flatMap((logLevel) => resolveServerConfig(flags, logLevel, options)), |
| 1155 | const config = yield* resolveServerConfig(flags, logLevel, options); | 1160 | Effect.flatMap((config) => Effect.provideService(ServerConfig, config)(runServer)), |
| 1156 | return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | 1161 | ); |
| 1157 | }); | 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 | const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( | 1174 | const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( |
| 1160 | Command.withDescription("Run the T3 Code server."), | 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 | Command.withDescription("Run the T3 Code server."), | 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 | Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), | 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 | showContextMenu: vi.fn().mockResolvedValue(null), | 378 | showContextMenu: vi.fn().mockResolvedValue(null), |
| 379 | openExternal: vi.fn().mockResolvedValue(true), | 379 | openExternal: vi.fn().mockResolvedValue(true), |
| 380 | onMenuAction: () => () => {}, | 380 | onMenuAction: () => () => {}, |
| 381 | onOpenWorkspace: () => () => {}, | ||
| 381 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), | 382 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), |
| 382 | setUpdateChannel: | 383 | setUpdateChannel: |
| 383 | overrides?.setUpdateChannel ?? | 384 | overrides?.setUpdateChannel ?? |
| @@ -225,6 +225,7 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 224 unmodified lines | |||
| 225 | showContextMenu: async () => null, | 225 | showContextMenu: async () => null, |
| 226 | openExternal: async () => true, | 226 | openExternal: async () => true, |
| 227 | onMenuAction: () => () => undefined, | 227 | onMenuAction: () => () => undefined, |
| 228 | onOpenWorkspace: () => () => undefined, | ||
| 228 | getUpdateState: async () => { | 229 | getUpdateState: async () => { |
| 229 | throw new Error("getUpdateState not implemented in test"); | 230 | throw new Error("getUpdateState not implemented in test"); |
| 230 | }, | 231 | }, |
| @@ -1,5 +1,11 @@ | |||
| 1 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | 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 | import { | 9 | import { |
| 4 | Outlet, | 10 | Outlet, |
| 5 | createRootRouteWithContext, | 11 | createRootRouteWithContext, |
| @@ -28,7 +34,9 @@ import { · 22 unmodified lines | |||
| 28 | } from "../components/ui/toast"; | 34 | } from "../components/ui/toast"; |
| 29 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | 35 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 30 | import { readLocalApi } from "../localApi"; | 36 | import { readLocalApi } from "../localApi"; |
| 37 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 31 | import { useSettings } from "../hooks/useSettings"; | 38 | import { useSettings } from "../hooks/useSettings"; |
| 39 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | ||
| 32 | import { | 40 | import { |
| 33 | deriveLogicalProjectKeyFromSettings, | 41 | deriveLogicalProjectKeyFromSettings, |
| 34 | derivePhysicalProjectKeyFromPath, | 42 | derivePhysicalProjectKeyFromPath, |
| @@ -41,8 +49,12 @@ import { · 6 unmodified lines | |||
| 41 | useServerConfigUpdatedSubscription, | 49 | useServerConfigUpdatedSubscription, |
| 42 | useServerWelcomeSubscription, | 50 | useServerWelcomeSubscription, |
| 43 | } from "../rpc/serverState"; | 51 | } from "../rpc/serverState"; |
| 44 | import { useStore } from "../store"; | 52 | import { selectSidebarThreadsForProjectRef, useStore } from "../store"; |
| 45 | import { useUiStateStore } from "../uiStateStore"; | 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 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; | 58 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 47 | import { | 59 | import { |
| 48 | ensureEnvironmentConnectionBootstrapped, | 60 | ensureEnvironmentConnectionBootstrapped, |
| @@ -136,6 +148,7 @@ function RootRouteView() { · 87 unmodified lines | |||
| 136 | <SshPasswordPromptDialog /> | 148 | <SshPasswordPromptDialog /> |
| 137 | <HostedStaticEnvironmentBootstrap /> | 149 | <HostedStaticEnvironmentBootstrap /> |
| 138 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} | 150 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
| 151 | {primaryEnvironmentAuthenticated ? <DesktopOpenWorkspaceBootstrap /> : null} | ||
| 139 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} | 152 | {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} |
| 140 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} | 153 | {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} |
| 141 | {primaryEnvironmentAuthenticated ? ( | 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 | function HostedStaticEnvironmentBootstrap() { | 270 | function HostedStaticEnvironmentBootstrap() { |
| 152 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( | 271 | const savedEnvironmentCount = useSavedEnvironmentRegistryStore( |
| 153 | (state) => Object.keys(state.byId).length, | 272 | (state) => Object.keys(state.byId).length, |
| @@ -242,6 +242,7 @@ export interface DesktopBridge { · 241 unmodified lines | |||
| 242 | ) => Promise<T | null>; | 242 | ) => Promise<T | null>; |
| 243 | openExternal: (url: string) => Promise<boolean>; | 243 | openExternal: (url: string) => Promise<boolean>; |
| 244 | onMenuAction: (listener: (action: string) => void) => () => void; | 244 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 245 | onOpenWorkspace: (listener: (workspacePath: string) => void) => () => void; | ||
| 245 | getUpdateState: () => Promise<DesktopUpdateState>; | 246 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 246 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; | 247 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; |
| 247 | checkForUpdate: () => Promise<DesktopUpdateCheckResult>; | 248 | checkForUpdate: () => Promise<DesktopUpdateCheckResult>; |