From 5a014af8ae08abb8eed8aa50eef5e7edf2326d6f Mon Sep 17 00:00:00 2001 From: Elis Mar Einarsson Date: Thu, 30 Jul 2026 11:52:35 +0200 Subject: [PATCH 1/5] Keep installed editors visible when discovery times out --- .../src/process/externalLauncher.test.ts | 195 ++++++++++++++++- apps/server/src/process/externalLauncher.ts | 171 +++++++++++++-- apps/server/src/server.test.ts | 89 +++++++- apps/server/src/ws.ts | 42 +++- .../src/components/chat/OpenInPicker.test.ts | 17 ++ apps/web/src/components/chat/OpenInPicker.tsx | 15 +- packages/contracts/src/server.ts | 1 + packages/shared/src/shell.test.ts | 197 ++++++++++++++++++ packages/shared/src/shell.ts | 141 +++++++++++++ 9 files changed, 828 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/chat/OpenInPicker.test.ts diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c..e85f9f169ca 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,16 +1,25 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import { + CommandAvailability, + type CommandAvailabilityChecker, + isCommandAvailable, + SpawnExecutableResolution, +} from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { @@ -36,6 +45,7 @@ const testLayer = (input: { readonly platform: NodeJS.Platform; readonly env?: Record; readonly resolveExecutable?: (command: string) => string | undefined; + readonly commandAvailability?: CommandAvailabilityChecker; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; }) => { @@ -60,6 +70,7 @@ const testLayer = (input: { SpawnExecutableResolution, (command) => input.resolveExecutable?.(command) ?? command, ), + Layer.succeed(CommandAvailability, input.commandAvailability ?? isCommandAvailable), ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); }; @@ -138,7 +149,7 @@ it.effect("discovers editors through the service API", () => yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); - const editors = yield* Effect.gen(function* () { + const discovery = yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; return yield* launcher.resolveAvailableEditors(); }).pipe( @@ -150,11 +161,187 @@ it.effect("discovers editors through the service API", () => ), ); - assert.equal(editors.includes("vscode"), true); - assert.equal(editors.includes("file-manager"), true); + assert.equal(discovery.complete, true); + assert.equal(discovery.editors.includes("vscode"), true); + assert.equal(discovery.editors.includes("file-manager"), true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("keeps responsive editors when other discovery probes time out", () => { + const discoveryTimeout = Duration.seconds(3); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const discoveryFiber = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(discoveryTimeout); + + const discovery = yield* Fiber.join(discoveryFiber); + assert.deepEqual(discovery, { + editors: ["vscode", "file-manager"], + complete: false, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + TestClock.layer(), + testLayer({ + platform: "linux", + env: { PATH: "/bin" }, + commandAvailability: (command) => + command === "code" || command === "xdg-open" ? Effect.succeed(true) : Effect.never, + }), + ), + ), + ); +}); + +it.effect("preserves the last complete editor list when a cached refresh is incomplete", () => { + const discoveryTimeout = Duration.seconds(3); + let incomplete = false; + let codeChecks = 0; + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const [initialDiscovery, concurrentDiscovery] = yield* Effect.all( + [launcher.resolveAvailableEditors(), launcher.resolveAvailableEditors()], + { concurrency: "unbounded" }, + ); + assert.deepEqual(initialDiscovery, { + editors: ["vscode", "file-manager"], + complete: true, + }); + assert.deepEqual(concurrentDiscovery, initialDiscovery); + assert.equal(codeChecks, 1); + + incomplete = true; + yield* TestClock.adjust(Duration.minutes(1)); + + const refreshFiber = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust(discoveryTimeout); + + const refreshedDiscovery = yield* Fiber.join(refreshFiber); + assert.deepEqual(refreshedDiscovery, { + editors: initialDiscovery.editors, + complete: false, + }); + assert.equal(codeChecks, 2); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + TestClock.layer(), + testLayer({ + platform: "linux", + env: { PATH: "/bin" }, + commandAvailability: (command) => { + if (command === "code") { + codeChecks += 1; + } + if (command === "code" || command === "xdg-open") { + return Effect.succeed(true); + } + return incomplete ? Effect.never : Effect.succeed(false); + }, + }), + ), + ), + ); +}); + +it.effect("does not cache an incomplete first discovery", () => { + const discoveryTimeout = Duration.seconds(3); + let stalled = true; + let codeChecks = 0; + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const initialFiber = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(discoveryTimeout); + + assert.deepEqual(yield* Fiber.join(initialFiber), { + editors: [], + complete: false, + }); + + stalled = false; + const retryDiscovery = yield* launcher.resolveAvailableEditors(); + assert.deepEqual(retryDiscovery, { + editors: ["vscode", "file-manager"], + complete: true, + }); + assert.equal(codeChecks, 2); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + TestClock.layer(), + testLayer({ + platform: "linux", + env: { PATH: "/bin" }, + commandAvailability: (command) => { + if (command === "code") { + codeChecks += 1; + } + return stalled + ? Effect.never + : Effect.succeed(command === "code" || command === "xdg-open"); + }, + }), + ), + ), + ); +}); + +it.effect("keeps shared discovery alive when its first caller is interrupted", () => { + let codeChecks = 0; + + return Effect.gen(function* () { + const lookupStarted = yield* Deferred.make(); + const releaseLookup = yield* Deferred.make(); + + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const firstCaller = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped); + + yield* Deferred.await(lookupStarted); + yield* Fiber.interrupt(firstCaller); + + const secondCaller = yield* launcher.resolveAvailableEditors().pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseLookup, undefined); + + assert.deepEqual(yield* Fiber.join(secondCaller), { + editors: ["vscode", "file-manager"], + complete: true, + }); + assert.equal(codeChecks, 1); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: "/bin" }, + commandAvailability: (command) => { + if (command === "code") { + codeChecks += 1; + } + return ( + command === "code" ? Deferred.succeed(lookupStarted, undefined) : Effect.void + ).pipe( + Effect.andThen(Deferred.await(releaseLookup)), + Effect.as(command === "code" || command === "xdg-open"), + ); + }, + }), + ), + ); + }).pipe(Effect.scoped); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 9c2f0e417d3..41b2d324fc3 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -18,15 +18,24 @@ import { type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + discoverAvailableCommands, + isCommandAvailable, + resolveSpawnCommand, +} from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -63,6 +72,32 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +export interface EditorDiscoveryResult { + readonly editors: ReadonlyArray; + readonly complete: boolean; +} + +interface CompleteEditorDiscovery { + readonly editors: ReadonlyArray; + readonly expiresAtMillis: number; +} + +interface EditorDiscoveryState { + readonly lastKnown: ReadonlyArray; + readonly complete: Option.Option; + readonly inFlight: Option.Option>; +} + +type EditorDiscoveryClaim = + | { + readonly _tag: "Ready"; + readonly result: EditorDiscoveryResult; + } + | { + readonly _tag: "Pending"; + readonly deferred: Deferred.Deferred; + }; + const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -79,6 +114,9 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; +const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(3); +const EDITOR_DISCOVERY_CACHE_TTL_MILLIS = Duration.toMillis(Duration.minutes(1)); + const compactEnv = (input: Record>): NodeJS.ProcessEnv => Object.fromEntries( Object.entries(input).flatMap(([key, value]) => @@ -263,27 +301,33 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const available: EditorId[] = []; - - for (const editor of EDITORS) { - if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailable(command, { env })) { - available.push(editor.id); - } - continue; - } - - const command = yield* resolveAvailableCommand(editor.commands, env); - if (Option.isSome(command)) { - available.push(editor.id); - } - } +): Effect.fn.Return { + const fileManagerCommand = fileManagerCommandForPlatform(platform); + const discovery = yield* discoverAvailableCommands( + [...EDITORS.flatMap((editor) => editor.commands ?? []), fileManagerCommand], + { + env, + timeout: EDITOR_DISCOVERY_TIMEOUT, + }, + ); - return available; + return { + editors: EDITORS.flatMap((editor) => { + const editorCommands = editor.commands ?? [fileManagerCommand]; + return editorCommands.some((command) => discovery.available.has(command)) ? [editor.id] : []; + }), + complete: discovery.complete, + }; }); +function mergeEditorLists( + current: ReadonlyArray, + previous: ReadonlyArray, +): ReadonlyArray { + const available = new Set([...current, ...previous]); + return EDITORS.flatMap((editor) => (available.has(editor.id) ? [editor.id] : [])); +} + const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( target: string, ) { @@ -304,7 +348,7 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit export class ExternalLauncher extends Context.Service< ExternalLauncher, { - readonly resolveAvailableEditors: () => Effect.Effect>; + readonly resolveAvailableEditors: () => Effect.Effect; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -430,10 +474,16 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); -export const make = Effect.gen(function* () { +export const make = Effect.fn("externalLauncher.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const discoveryLock = yield* Semaphore.make(1); + const discoveryState = yield* Ref.make({ + lastKnown: [], + complete: Option.none(), + inFlight: Option.none(), + }); const provideCommandResolutionServices = ( effect: Effect.Effect, @@ -443,8 +493,83 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const runEditorDiscovery = Effect.fn("externalLauncher.runEditorDiscovery")(function* ( + pending: Deferred.Deferred, + ) { + const discovery = yield* provideCommandResolutionServices(resolveAvailableEditors()); + const nowMillis = yield* Clock.currentTimeMillis; + const editors = yield* Ref.modify(discoveryState, (state) => { + const resolved = discovery.complete + ? discovery.editors + : mergeEditorLists(discovery.editors, state.lastKnown); + + return [ + resolved, + { + lastKnown: resolved, + complete: discovery.complete + ? Option.some({ + editors: resolved, + expiresAtMillis: nowMillis + EDITOR_DISCOVERY_CACHE_TTL_MILLIS, + }) + : state.complete, + inFlight: state.inFlight, + }, + ]; + }); + + yield* Deferred.succeed(pending, { + editors, + complete: discovery.complete, + }); + yield* Ref.update(discoveryState, (state) => ({ + ...state, + inFlight: Option.filter(state.inFlight, (current) => current !== pending), + })); + }); + + const claimEditorDiscovery = Effect.fn("externalLauncher.claimEditorDiscovery")(function* () { + const nowMillis = yield* Clock.currentTimeMillis; + const state = yield* Ref.get(discoveryState); + const complete = Option.filter(state.complete, (cached) => nowMillis < cached.expiresAtMillis); + + if (Option.isSome(complete)) { + return { + _tag: "Ready", + result: { + editors: complete.value.editors, + complete: true, + }, + } satisfies EditorDiscoveryClaim; + } + if (Option.isSome(state.inFlight)) { + return { + _tag: "Pending", + deferred: state.inFlight.value, + } satisfies EditorDiscoveryClaim; + } + + const pending = yield* Deferred.make(); + yield* Ref.set(discoveryState, { + ...state, + inFlight: Option.some(pending), + }); + yield* Effect.forkDetach(runEditorDiscovery(pending), { startImmediately: true }); + return { + _tag: "Pending", + deferred: pending, + } satisfies EditorDiscoveryClaim; + }); + + const cachedAvailableEditors = Effect.fn("externalLauncher.cachedAvailableEditors")(function* () { + const claim = yield* discoveryLock.withPermit( + claimEditorDiscovery().pipe(Effect.uninterruptible), + ); + return claim._tag === "Ready" ? claim.result : yield* Deferred.await(claim.deferred); + }); + return ExternalLauncher.of({ - resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), + resolveAvailableEditors: () => cachedAvailableEditors(), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -460,4 +585,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(ExternalLauncher, make); +export const layer = Layer.effect(ExternalLauncher, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fff71dbb4e7..e2ab8b58e08 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -590,7 +590,11 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ExternalLauncher.ExternalLauncher)({ - resolveAvailableEditors: () => Effect.succeed([]), + resolveAvailableEditors: () => + Effect.succeed({ + editors: [], + complete: true, + }), ...options?.layers?.externalLauncher, }), ), @@ -3890,11 +3894,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); + assert.equal(response.availableEditorsComplete, true); assert.equal(response.shellResumeCompletionMarker, true); assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("preserves incomplete editor discovery in server config", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => + Effect.succeed({ + editors: [], + complete: false, + }), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.deepEqual(response.availableEditors, []); + assert.equal(response.availableEditorsComplete, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -3906,9 +3941,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* TestClock.adjust(Duration.seconds(5)); - const availableEditors = yield* Fiber.join(responseFiber); + const editorDiscovery = yield* Fiber.join(responseFiber); yield* Deferred.await(discoveryInterrupted); - assert.deepEqual(availableEditors, []); + assert.deepEqual(editorDiscovery, { + editors: [], + complete: false, + }); }), ); @@ -4467,6 +4505,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("retries incomplete editor discovery in the server config stream", () => + Effect.gen(function* () { + let discoveryCalls = 0; + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => + Effect.sync(() => { + discoveryCalls += 1; + return discoveryCalls === 1 + ? { + editors: [], + complete: false, + } + : { + editors: [EditorId.make("vscode")], + complete: true, + }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [initial, refreshed] = Array.from(events); + assert.equal(initial?.type, "snapshot"); + if (initial?.type === "snapshot") { + assert.deepEqual(initial.config.availableEditors, []); + assert.equal(initial.config.availableEditorsComplete, false); + } + assert.equal(refreshed?.type, "snapshot"); + if (refreshed?.type === "snapshot") { + assert.deepEqual(refreshed.config.availableEditors, [EditorId.make("vscode")]); + assert.equal(refreshed.config.availableEditorsComplete, true); + } + assert.equal(discoveryCalls, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 06888ef3f70..1b5ac2d2e6c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,13 +124,21 @@ const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchComma const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); +const EDITOR_DISCOVERY_RETRY_DELAY = Duration.seconds(1); -export const resolveAvailableEditorsForConfig = ( - discovery: Effect.Effect, E, R>, +export const resolveAvailableEditorsForConfig = ( + discovery: Effect.Effect, ) => discovery.pipe( Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), - Effect.map(Option.getOrElse(() => [])), + Effect.map( + Option.getOrElse( + (): ExternalLauncher.EditorDiscoveryResult => ({ + editors: [], + complete: false, + }), + ), + ), ); function unexpectedCompatibilityError(error: never): never { @@ -977,6 +985,9 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const editorDiscovery = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); return { environment, @@ -986,9 +997,8 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), + availableEditors: editorDiscovery.editors, + availableEditorsComplete: editorDiscovery.complete, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, @@ -1999,16 +2009,30 @@ const makeWsRpcLayer = ( .refresh() .pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + const initialConfig = yield* loadServerConfig; + const editorDiscoveryRetry = + initialConfig.availableEditorsComplete === false + ? Stream.fromEffect( + Effect.sleep(EDITOR_DISCOVERY_RETRY_DELAY).pipe( + Effect.andThen(loadServerConfig), + Effect.map((config) => ({ + version: 1 as const, + type: "snapshot" as const, + config, + })), + ), + ) + : Stream.empty; const liveUpdates = Stream.merge( - keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + editorDiscoveryRetry, + Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), ); return Stream.concat( Stream.make({ version: 1 as const, type: "snapshot" as const, - config: yield* loadServerConfig, + config: initialConfig, }), liveUpdates, ); diff --git a/apps/web/src/components/chat/OpenInPicker.test.ts b/apps/web/src/components/chat/OpenInPicker.test.ts new file mode 100644 index 00000000000..cc7eab5fbbe --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveEmptyEditorMessage } from "./OpenInPicker"; + +describe("resolveEmptyEditorMessage", () => { + it("does not report incomplete discovery as no installed editors", () => { + expect(resolveEmptyEditorMessage(false)).toBe("Couldn’t check for installed editors"); + }); + + it("treats missing discovery metadata from older servers as unknown", () => { + expect(resolveEmptyEditorMessage(undefined)).toBe("Couldn’t check for installed editors"); + }); + + it("reports no installed editors after complete empty discovery", () => { + expect(resolveEmptyEditorMessage(true)).toBe("No installed editors found"); + }); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index de5a2f7cfff..a0a60acb6b5 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; @@ -32,6 +33,7 @@ import { WebStormIcon, } from "../JetBrainsIcons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { serverEnvironment } from "~/state/server"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -183,6 +185,12 @@ function getOpenInIconClass(kind: OpenInOption["kind"]) { return cn(kind === "brand" ? "text-foreground opacity-100" : "text-muted-foreground"); } +export function resolveEmptyEditorMessage(discoveryComplete: boolean | undefined): string { + return discoveryComplete === true + ? "No installed editors found" + : "Couldn’t check for installed editors"; +} + export const OpenInPicker = memo(function OpenInPicker({ environmentId, keybindings, @@ -198,6 +206,7 @@ export const OpenInPicker = memo(function OpenInPicker({ compact?: boolean; enableShortcut?: boolean; }) { + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( @@ -295,7 +304,11 @@ export const OpenInPicker = memo(function OpenInPicker({