diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 8adb3e1a68fb8848d525a70a5581d73dcc23a1f7..1889c851562b340b1b1ed05670bdea10b7068472 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -1,6 +1,15 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { assert, describe, it } from "@effect/vitest"; -import { launchDetached, resolveEditorLaunch } from "./open"; +import { + isCommandAvailable, + launchDetached, + resolveAvailableEditors, + resolveEditorLaunch, +} from "./open"; import { Effect } from "effect"; import { assertSuccess } from "@effect/vitest/utils"; @@ -111,3 +120,50 @@ describe("launchDetached", () => { }), ); }); + +describe("isCommandAvailable", () => { + function withTempDir(run: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-")); + try { + run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + it("resolves win32 commands with PATHEXT", () => { + withTempDir((dir) => { + fs.writeFileSync(path.join(dir, "code.CMD"), "@echo off\r\n", "utf8"); + const env = { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); + }); + }); + + it("returns false when a command is not on PATH", () => { + const env = { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); + }); +}); + +describe("resolveAvailableEditors", () => { + it("returns only editors whose launch commands are available", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-editors-")); + try { + fs.writeFileSync(path.join(dir, "cursor.CMD"), "@echo off\r\n", "utf8"); + fs.writeFileSync(path.join(dir, "explorer.EXE"), "MZ", "utf8"); + const editors = resolveAvailableEditors("win32", { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); + assert.deepEqual(editors, ["cursor", "file-manager"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index dcd4e736d3bbfd5681badb94ec606d26e7b5b0d2..b15ec72de36d72176dde8c577ab295caebca9ccc 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,6 +7,8 @@ * @module Open */ import { spawn } from "node:child_process"; +import { accessSync, constants, statSync } from "node:fs"; +import { delimiter, extname, join } from "node:path"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; @@ -30,12 +32,122 @@ interface EditorLaunch { readonly args: ReadonlyArray; } +interface CommandAvailabilityOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +} + const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target); } +function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { + switch (platform) { + case "darwin": + return "open"; + case "win32": + return "explorer"; + default: + return "xdg-open"; + } +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return env.PATH ?? env.Path ?? env.path ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolveCommandCandidates( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): ReadonlyArray { + if (platform !== "win32") return [command]; + if (extname(command).length > 0) return [command]; + + const candidates = [command]; + for (const extension of resolveWindowsPathExtensions(env)) { + candidates.push(`${command}${extension}`); + candidates.push(`${command}${extension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return false; + if (platform === "win32") return true; + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function isCommandAvailable( + command: string, + options: CommandAvailabilityOptions = {}, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const commandCandidates = resolveCommandCandidates(command, platform, env); + + if (command.includes("/") || command.includes("\\")) { + return commandCandidates.some((candidate) => isExecutableFile(candidate, platform)); + } + + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) return false; + const pathEntries = pathValue + .split(delimiter) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + if (isExecutableFile(join(pathEntry, candidate), platform)) { + return true; + } + } + } + return false; +} + +export function resolveAvailableEditors( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ReadonlyArray { + const available: EditorId[] = []; + + for (const editor of EDITORS) { + const command = editor.command ?? fileManagerCommandForPlatform(platform); + if (isCommandAvailable(command, { platform, env })) { + available.push(editor.id); + } + } + + return available; +} + /** * OpenShape - Service API for browser and editor launch actions. */ @@ -81,14 +193,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); } - switch (platform) { - case "darwin": - return { command: "open", args: [input.cwd] }; - case "win32": - return { command: "explorer", args: [input.cwd] }; - default: - return { command: "xdg-open", args: [input.cwd] }; - } + return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; }); export const launchDetached = (launch: EditorLaunch) => diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 1a278e2d327282fcfd6f9a7597afedf42bffb0e8..f288332f862ac512e526c2b7f05b38a2dbeb1002 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -13,6 +13,7 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve import { DEFAULT_TERMINAL_ID, + EDITORS, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -340,6 +341,15 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon } const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); +const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); + +function expectAvailableEditors(value: unknown): void { + expect(Array.isArray(value)).toBe(true); + for (const editorId of value as unknown[]) { + expect(typeof editorId).toBe("string"); + expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true); + } +} describe("WebSocket Server", () => { let server: Http.Server | null = null; @@ -726,7 +736,9 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, issues: [], + availableEditors: expect.any(Array), }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); it("bootstraps default keybindings file when missing", async () => { @@ -749,7 +761,9 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: DEFAULT_RESOLVED_KEYBINDINGS, issues: [], + availableEditors: expect.any(Array), }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); const persistedConfig = JSON.parse( fs.readFileSync(keybindingsPath, "utf8"), @@ -782,7 +796,9 @@ describe("WebSocket Server", () => { message: expect.stringContaining("expected JSON array"), }, ], + availableEditors: expect.any(Array), }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); }); @@ -814,6 +830,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: string; keybindings: ResolvedKeybindingsConfig; issues: Array<{ kind: string; index?: number; message: string }>; + availableEditors: unknown; }; expect(result.cwd).toBe("/my/workspace"); expect(result.keybindingsConfigPath).toBe(keybindingsPath); @@ -832,6 +849,7 @@ describe("WebSocket Server", () => { expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); + expectAvailableEditors(result.availableEditors); }); it("pushes server.configUpdated issues when keybindings file changes", async () => { @@ -929,7 +947,9 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: compileKeybindings(persistedConfig), issues: [], + availableEditors: expect.any(Array), }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); it("upserts keybinding rules and updates cached server config", async () => { @@ -974,7 +994,11 @@ describe("WebSocket Server", () => { keybindingsConfigPath: keybindingsPath, keybindings: compileKeybindings(persistedConfig), issues: [], + availableEditors: expect.any(Array), }); + expectAvailableEditors( + (configResponse.result as { availableEditors: unknown }).availableEditors, + ); }); it("returns error for unknown methods", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 3f2c5734d268dad185563811431e143dff27911a..9e5c4e788670ac72ff28926a54387e832f078785 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -54,7 +54,7 @@ import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReac import { ProviderService } from "./provider/Services/ProviderService"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; -import { Open } from "./open"; +import { Open, resolveAvailableEditors } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; @@ -193,6 +193,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; + const availableEditors = resolveAvailableEditors(); const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; @@ -734,6 +735,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, + availableEditors, }; case WS_METHODS.serverUpsertKeybinding: { diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 31339930d23e231a7f9ebc22ca346a047d19832c..58d24845a1a7ca94ab96f007c73ff773b769173d 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,67 +1,10 @@ -import { ThreadId, type GitBranch } from "@t3tools/contracts"; +import type { GitBranch } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, - deriveSyncedLocalBranch, } from "./BranchToolbar.logic"; -const branches: GitBranch[] = [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: null, - }, - { - name: "feature/demo", - current: false, - isDefault: false, - worktreePath: null, - }, -]; - -describe("deriveSyncedLocalBranch", () => { - it("syncs to git current branch in local mode", () => { - const threadId = ThreadId.makeUnsafe("thread-1"); - const result = deriveSyncedLocalBranch({ - activeThreadId: threadId, - activeWorktreePath: null, - envMode: "local", - activeThreadBranch: "feature/demo", - queryBranches: branches, - }); - - expect(result).toBe("main"); - }); - - it("does not sync when creating a new worktree", () => { - const threadId = ThreadId.makeUnsafe("thread-1"); - const result = deriveSyncedLocalBranch({ - activeThreadId: threadId, - activeWorktreePath: null, - envMode: "worktree", - activeThreadBranch: "feature/demo", - queryBranches: branches, - }); - - expect(result).toBeNull(); - }); - - it("does not sync when thread already targets a worktree path", () => { - const threadId = ThreadId.makeUnsafe("thread-1"); - const result = deriveSyncedLocalBranch({ - activeThreadId: threadId, - activeWorktreePath: "/tmp/repo/worktrees/feature-demo", - envMode: "local", - activeThreadBranch: "feature/demo", - queryBranches: branches, - }); - - expect(result).toBeNull(); - }); -}); - describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 7985033b200973aeee84a43c3818b69d612756d1..030f735321279c2179e0613fd81dfc79b7c12ea7 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,12 +1,4 @@ -import type { GitBranch, ThreadId } from "@t3tools/contracts"; - -interface DeriveSyncedLocalBranchInput { - activeThreadId: ThreadId | undefined; - activeWorktreePath: string | null; - envMode: "local" | "worktree"; - activeThreadBranch: string | null; - queryBranches: ReadonlyArray | undefined; -} +import type { GitBranch } from "@t3tools/contracts"; export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { const firstSeparatorIndex = branchName.indexOf("/"); @@ -55,22 +47,3 @@ export function dedupeRemoteBranchesWithLocalMatches( return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } - -export function deriveSyncedLocalBranch({ - activeThreadId, - activeWorktreePath, - envMode, - activeThreadBranch, - queryBranches, -}: DeriveSyncedLocalBranchInput): string | null { - if (!activeThreadId || activeWorktreePath || envMode !== "local") { - return null; - } - - const currentBranch = queryBranches?.find((branch) => branch.current); - if (!currentBranch || currentBranch.name === activeThreadBranch) { - return null; - } - - return currentBranch.name; -} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index df5f5fd1de751db1fa53c5c77161b095e01f170f..a651adf43fb79da52585ef3af14a237a5979dd5a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -147,6 +147,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; +const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -869,10 +870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const keybindingsQuery = useQuery({ - ...serverConfigQueryOptions(), - select: (config) => config.keybindings, - }); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -944,7 +942,8 @@ export default function ChatView({ threadId }: ChatViewProps) { () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); - const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { @@ -2486,6 +2485,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } keybindings={keybindings} + availableEditors={availableEditors} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} diffOpen={diffOpen} @@ -2899,6 +2899,7 @@ interface ChatHeaderProps { activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; diffOpen: boolean; @@ -2915,6 +2916,7 @@ const ChatHeader = memo(function ChatHeader({ activeProjectScripts, preferredScriptId, keybindings, + availableEditors, diffToggleShortcutLabel, gitCwd, diffOpen, @@ -2951,7 +2953,11 @@ const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && ( - + )} {activeProjectName && } @@ -3663,9 +3669,11 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { const OpenInPicker = memo(function OpenInPicker({ keybindings, + availableEditors, activeThreadId, }: { keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; activeThreadId: ThreadId | null; }) { const [lastEditor, setLastEditor] = useState(() => { @@ -3673,28 +3681,45 @@ const OpenInPicker = memo(function OpenInPicker({ return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); - const options = [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: isMacPlatform(navigator.platform) - ? "Finder" - : isWindowsPlatform(navigator.platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ] satisfies { label: string; Icon: Icon; value: EditorId }[]; - const primaryOption = options.find(({ value }) => value === lastEditor); + const allOptions = useMemo>( + () => [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: isMacPlatform(navigator.platform) + ? "Finder" + : isWindowsPlatform(navigator.platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ], + [], + ); + const options = useMemo( + () => allOptions.filter((option) => availableEditors.includes(option.value)), + [allOptions, availableEditors], + ); + + const effectiveEditor = options.some((option) => option.value === lastEditor) + ? lastEditor + : (options[0]?.value ?? null); + const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + + useEffect(() => { + if (!effectiveEditor || effectiveEditor === lastEditor) return; + localStorage.setItem(LAST_EDITOR_KEY, effectiveEditor); + setLastEditor(effectiveEditor); + }, [effectiveEditor, lastEditor]); const activeThread = useStore((store) => activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, @@ -3708,13 +3733,14 @@ const OpenInPicker = memo(function OpenInPicker({ (editorId: EditorId | null) => { const api = readNativeApi(); if (!api || !activeProject) return; - const editor = editorId ?? lastEditor; + const editor = editorId ?? effectiveEditor; + if (!editor) return; const cwd = activeThread?.worktreePath ?? activeProject.cwd; void api.shell.openInEditor(cwd, editor); localStorage.setItem(LAST_EDITOR_KEY, editor); setLastEditor(editor); }, - [activeProject, activeThread, lastEditor, setLastEditor], + [activeProject, activeThread, effectiveEditor, setLastEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -3727,18 +3753,24 @@ const OpenInPicker = memo(function OpenInPicker({ const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !activeProject) return; + if (!effectiveEditor) return; e.preventDefault(); const cwd = activeThread?.worktreePath ?? activeProject.cwd; - void api.shell.openInEditor(cwd, lastEditor); + void api.shell.openInEditor(cwd, effectiveEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [activeProject, activeThread, keybindings, lastEditor]); + }, [activeProject, activeThread, keybindings, effectiveEditor]); return ( -