authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 16:35:30-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 18:53:21-08:00
logb3de2bd926bf9e5f381d02cdcc32b85c39f7c4ed
tree3d2aabe84c198c7d6ae3d7a9523b16bb3a1adb74
parentb81ec02984c92c9506c7859ab5c967f3b8afae1d

Expose available editors and gate Open In options

- Add server-side command detection and return `availableEditors` in server config - Update Chat header Open In picker to show only installed editors and handle empty state - Extend server/web tests and contracts for `availableEditors`; remove unused branch-sync logic

9 files changed, 363 insertions(+), 232 deletions(-)

apps/server/src/open.test.ts+57-1
......@@ -1,6 +1,15 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
15import { assert, describe, it } from "@effect/vitest";
26
3import { launchDetached, resolveEditorLaunch } from "./open";
7import {
8 isCommandAvailable,
9 launchDetached,
10 resolveAvailableEditors,
11 resolveEditorLaunch,
12} from "./open";
413import { Effect } from "effect";
514import { assertSuccess } from "@effect/vitest/utils";
615
......@@ -111,3 +120,50 @@ describe("launchDetached", () => {
111120 }),
112121 );
113122});
123
124describe("isCommandAvailable", () => {
125 function withTempDir(run: (dir: string) => void): void {
126 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-"));
127 try {
128 run(dir);
129 } finally {
130 fs.rmSync(dir, { recursive: true, force: true });
131 }
132 }
133
134 it("resolves win32 commands with PATHEXT", () => {
135 withTempDir((dir) => {
136 fs.writeFileSync(path.join(dir, "code.CMD"), "@echo off\r\n", "utf8");
137 const env = {
138 PATH: dir,
139 PATHEXT: ".COM;.EXE;.BAT;.CMD",
140 } satisfies NodeJS.ProcessEnv;
141 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
142 });
143 });
144
145 it("returns false when a command is not on PATH", () => {
146 const env = {
147 PATH: "",
148 PATHEXT: ".COM;.EXE;.BAT;.CMD",
149 } satisfies NodeJS.ProcessEnv;
150 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
151 });
152});
153
154describe("resolveAvailableEditors", () => {
155 it("returns only editors whose launch commands are available", () => {
156 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-editors-"));
157 try {
158 fs.writeFileSync(path.join(dir, "cursor.CMD"), "@echo off\r\n", "utf8");
159 fs.writeFileSync(path.join(dir, "explorer.EXE"), "MZ", "utf8");
160 const editors = resolveAvailableEditors("win32", {
161 PATH: dir,
162 PATHEXT: ".COM;.EXE;.BAT;.CMD",
163 });
164 assert.deepEqual(editors, ["cursor", "file-manager"]);
165 } finally {
166 fs.rmSync(dir, { recursive: true, force: true });
167 }
168 });
169});
apps/server/src/open.ts+113-8
......@@ -7,6 +7,8 @@
77 * @module Open
88 */
99import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";
11import { delimiter, extname, join } from "node:path";
1012
1113import { EDITORS, type EditorId } from "@t3tools/contracts";
1214import { ServiceMap, Schema, Effect, Layer } from "effect";
......@@ -30,12 +32,122 @@ interface EditorLaunch {
3032 readonly args: ReadonlyArray<string>;
3133}
3234
35interface CommandAvailabilityOptions {
36 readonly platform?: NodeJS.Platform;
37 readonly env?: NodeJS.ProcessEnv;
38}
39
3340const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
3441
3542function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
3643 return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);
3744}
3845
46function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
47 switch (platform) {
48 case "darwin":
49 return "open";
50 case "win32":
51 return "explorer";
52 default:
53 return "xdg-open";
54 }
55}
56
57function stripWrappingQuotes(value: string): string {
58 return value.replace(/^"+|"+$/g, "");
59}
60
61function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
62 return env.PATH ?? env.Path ?? env.path ?? "";
63}
64
65function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
66 const rawValue = env.PATHEXT;
67 const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
68 if (!rawValue) return fallback;
69
70 const parsed = rawValue
71 .split(";")
72 .map((entry) => entry.trim())
73 .filter((entry) => entry.length > 0)
74 .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
75 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
76}
77
78function resolveCommandCandidates(
79 command: string,
80 platform: NodeJS.Platform,
81 env: NodeJS.ProcessEnv,
82): ReadonlyArray<string> {
83 if (platform !== "win32") return [command];
84 if (extname(command).length > 0) return [command];
85
86 const candidates = [command];
87 for (const extension of resolveWindowsPathExtensions(env)) {
88 candidates.push(`${command}${extension}`);
89 candidates.push(`${command}${extension.toLowerCase()}`);
90 }
91 return Array.from(new Set(candidates));
92}
93
94function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
95 try {
96 const stat = statSync(filePath);
97 if (!stat.isFile()) return false;
98 if (platform === "win32") return true;
99 accessSync(filePath, constants.X_OK);
100 return true;
101 } catch {
102 return false;
103 }
104}
105
106export function isCommandAvailable(
107 command: string,
108 options: CommandAvailabilityOptions = {},
109): boolean {
110 const platform = options.platform ?? process.platform;
111 const env = options.env ?? process.env;
112 const commandCandidates = resolveCommandCandidates(command, platform, env);
113
114 if (command.includes("/") || command.includes("\\")) {
115 return commandCandidates.some((candidate) => isExecutableFile(candidate, platform));
116 }
117
118 const pathValue = resolvePathEnvironmentVariable(env);
119 if (pathValue.length === 0) return false;
120 const pathEntries = pathValue
121 .split(delimiter)
122 .map((entry) => stripWrappingQuotes(entry.trim()))
123 .filter((entry) => entry.length > 0);
124
125 for (const pathEntry of pathEntries) {
126 for (const candidate of commandCandidates) {
127 if (isExecutableFile(join(pathEntry, candidate), platform)) {
128 return true;
129 }
130 }
131 }
132 return false;
133}
134
135export function resolveAvailableEditors(
136 platform: NodeJS.Platform = process.platform,
137 env: NodeJS.ProcessEnv = process.env,
138): ReadonlyArray<EditorId> {
139 const available: EditorId[] = [];
140
141 for (const editor of EDITORS) {
142 const command = editor.command ?? fileManagerCommandForPlatform(platform);
143 if (isCommandAvailable(command, { platform, env })) {
144 available.push(editor.id);
145 }
146 }
147
148 return available;
149}
150
39151/**
40152 * OpenShape - Service API for browser and editor launch actions.
41153 */
......@@ -81,14 +193,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* (
81193 return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
82194 }
83195
84 switch (platform) {
85 case "darwin":
86 return { command: "open", args: [input.cwd] };
87 case "win32":
88 return { command: "explorer", args: [input.cwd] };
89 default:
90 return { command: "xdg-open", args: [input.cwd] };
91 }
196 return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
92197});
93198
94199export const launchDetached = (launch: EditorLaunch) =>
apps/server/src/wsServer.test.ts+24
......@@ -13,6 +13,7 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve
1313
1414import {
1515 DEFAULT_TERMINAL_ID,
16 EDITORS,
1617 EventId,
1718 ORCHESTRATION_WS_CHANNELS,
1819 ORCHESTRATION_WS_METHODS,
......@@ -340,6 +341,15 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
340341}
341342
342343const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
344const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id));
345
346function expectAvailableEditors(value: unknown): void {
347 expect(Array.isArray(value)).toBe(true);
348 for (const editorId of value as unknown[]) {
349 expect(typeof editorId).toBe("string");
350 expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true);
351 }
352}
343353
344354describe("WebSocket Server", () => {
345355 let server: Http.Server | null = null;
......@@ -726,7 +736,9 @@ describe("WebSocket Server", () => {
726736 keybindingsConfigPath: keybindingsPath,
727737 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
728738 issues: [],
739 availableEditors: expect.any(Array),
729740 });
741 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
730742 });
731743
732744 it("bootstraps default keybindings file when missing", async () => {
......@@ -749,7 +761,9 @@ describe("WebSocket Server", () => {
749761 keybindingsConfigPath: keybindingsPath,
750762 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
751763 issues: [],
764 availableEditors: expect.any(Array),
752765 });
766 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
753767
754768 const persistedConfig = JSON.parse(
755769 fs.readFileSync(keybindingsPath, "utf8"),
......@@ -782,7 +796,9 @@ describe("WebSocket Server", () => {
782796 message: expect.stringContaining("expected JSON array"),
783797 },
784798 ],
799 availableEditors: expect.any(Array),
785800 });
801 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
786802 expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
787803 });
788804
......@@ -814,6 +830,7 @@ describe("WebSocket Server", () => {
814830 keybindingsConfigPath: string;
815831 keybindings: ResolvedKeybindingsConfig;
816832 issues: Array<{ kind: string; index?: number; message: string }>;
833 availableEditors: unknown;
817834 };
818835 expect(result.cwd).toBe("/my/workspace");
819836 expect(result.keybindingsConfigPath).toBe(keybindingsPath);
......@@ -832,6 +849,7 @@ describe("WebSocket Server", () => {
832849 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
833850 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
834851 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
852 expectAvailableEditors(result.availableEditors);
835853 });
836854
837855 it("pushes server.configUpdated issues when keybindings file changes", async () => {
......@@ -929,7 +947,9 @@ describe("WebSocket Server", () => {
929947 keybindingsConfigPath: keybindingsPath,
930948 keybindings: compileKeybindings(persistedConfig),
931949 issues: [],
950 availableEditors: expect.any(Array),
932951 });
952 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
933953 });
934954
935955 it("upserts keybinding rules and updates cached server config", async () => {
......@@ -974,7 +994,11 @@ describe("WebSocket Server", () => {
974994 keybindingsConfigPath: keybindingsPath,
975995 keybindings: compileKeybindings(persistedConfig),
976996 issues: [],
997 availableEditors: expect.any(Array),
977998 });
999 expectAvailableEditors(
1000 (configResponse.result as { availableEditors: unknown }).availableEditors,
1001 );
9781002 });
9791003
9801004 it("returns error for unknown methods", async () => {
apps/server/src/wsServer.ts+3-1
......@@ -54,7 +54,7 @@ import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReac
5454import { ProviderService } from "./provider/Services/ProviderService";
5555import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
5656import { clamp } from "effect/Number";
57import { Open } from "./open";
57import { Open, resolveAvailableEditors } from "./open";
5858import { ServerConfig } from "./config";
5959import { GitCore } from "./git/Services/GitCore.ts";
6060import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
......@@ -193,6 +193,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
193193 logWebSocketEvents,
194194 autoBootstrapProjectFromCwd,
195195 } = serverConfig;
196 const availableEditors = resolveAvailableEditors();
196197
197198 const gitManager = yield* GitManager;
198199 const terminalManager = yield* TerminalManager;
......@@ -734,6 +735,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
734735 keybindingsConfigPath,
735736 keybindings: keybindingsConfig.keybindings,
736737 issues: keybindingsConfig.issues,
738 availableEditors,
737739 };
738740
739741 case WS_METHODS.serverUpsertKeybinding: {
apps/web/src/components/BranchToolbar.logic.test.ts+1-58
......@@ -1,67 +1,10 @@
1import { ThreadId, type GitBranch } from "@t3tools/contracts";
1import type { GitBranch } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33import {
44 dedupeRemoteBranchesWithLocalMatches,
55 deriveLocalBranchNameFromRemoteRef,
6 deriveSyncedLocalBranch,
76} from "./BranchToolbar.logic";
87
9const branches: GitBranch[] = [
10 {
11 name: "main",
12 current: true,
13 isDefault: true,
14 worktreePath: null,
15 },
16 {
17 name: "feature/demo",
18 current: false,
19 isDefault: false,
20 worktreePath: null,
21 },
22];
23
24describe("deriveSyncedLocalBranch", () => {
25 it("syncs to git current branch in local mode", () => {
26 const threadId = ThreadId.makeUnsafe("thread-1");
27 const result = deriveSyncedLocalBranch({
28 activeThreadId: threadId,
29 activeWorktreePath: null,
30 envMode: "local",
31 activeThreadBranch: "feature/demo",
32 queryBranches: branches,
33 });
34
35 expect(result).toBe("main");
36 });
37
38 it("does not sync when creating a new worktree", () => {
39 const threadId = ThreadId.makeUnsafe("thread-1");
40 const result = deriveSyncedLocalBranch({
41 activeThreadId: threadId,
42 activeWorktreePath: null,
43 envMode: "worktree",
44 activeThreadBranch: "feature/demo",
45 queryBranches: branches,
46 });
47
48 expect(result).toBeNull();
49 });
50
51 it("does not sync when thread already targets a worktree path", () => {
52 const threadId = ThreadId.makeUnsafe("thread-1");
53 const result = deriveSyncedLocalBranch({
54 activeThreadId: threadId,
55 activeWorktreePath: "/tmp/repo/worktrees/feature-demo",
56 envMode: "local",
57 activeThreadBranch: "feature/demo",
58 queryBranches: branches,
59 });
60
61 expect(result).toBeNull();
62 });
63});
64
658describe("deriveLocalBranchNameFromRemoteRef", () => {
669 it("strips the remote prefix from a remote ref", () => {
6710 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts+1-28
......@@ -1,12 +1,4 @@
1import type { GitBranch, ThreadId } from "@t3tools/contracts";
2
3interface DeriveSyncedLocalBranchInput {
4 activeThreadId: ThreadId | undefined;
5 activeWorktreePath: string | null;
6 envMode: "local" | "worktree";
7 activeThreadBranch: string | null;
8 queryBranches: ReadonlyArray<GitBranch> | undefined;
9}
1import type { GitBranch } from "@t3tools/contracts";
102
113export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
124 const firstSeparatorIndex = branchName.indexOf("/");
......@@ -55,22 +47,3 @@ export function dedupeRemoteBranchesWithLocalMatches(
5547 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
5648 });
5749}
58
59export function deriveSyncedLocalBranch({
60 activeThreadId,
61 activeWorktreePath,
62 envMode,
63 activeThreadBranch,
64 queryBranches,
65}: DeriveSyncedLocalBranchInput): string | null {
66 if (!activeThreadId || activeWorktreePath || envMode !== "local") {
67 return null;
68 }
69
70 const currentBranch = queryBranches?.find((branch) => branch.current);
71 if (!currentBranch || currentBranch.name === activeThreadBranch) {
72 return null;
73 }
74
75 return currentBranch.name;
76}
apps/web/src/components/ChatView.tsx+67-34
......@@ -147,6 +147,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
147147const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
148148const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
149149const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
150const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
150151const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
151152const SCRIPT_TERMINAL_COLS = 120;
152153const SCRIPT_TERMINAL_ROWS = 30;
......@@ -869,10 +870,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
869870 );
870871 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
871872 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
872 const keybindingsQuery = useQuery({
873 ...serverConfigQueryOptions(),
874 select: (config) => config.keybindings,
875 });
873 const serverConfigQuery = useQuery(serverConfigQueryOptions());
876874 const workspaceEntriesQuery = useQuery(
877875 projectSearchEntriesQueryOptions({
878876 cwd: gitCwd,
......@@ -944,7 +942,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
944942 () => new Set(nonPersistedComposerImageIds),
945943 [nonPersistedComposerImageIds],
946944 );
947 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
945 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
946 const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
948947 const activeProjectCwd = activeProject?.cwd ?? null;
949948 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
950949 const threadTerminalRuntimeEnv = useMemo(() => {
......@@ -2486,6 +2485,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24862485 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
24872486 }
24882487 keybindings={keybindings}
2488 availableEditors={availableEditors}
24892489 diffToggleShortcutLabel={diffPanelShortcutLabel}
24902490 gitCwd={gitCwd}
24912491 diffOpen={diffOpen}
......@@ -2899,6 +2899,7 @@ interface ChatHeaderProps {
28992899 activeProjectScripts: ProjectScript[] | undefined;
29002900 preferredScriptId: string | null;
29012901 keybindings: ResolvedKeybindingsConfig;
2902 availableEditors: ReadonlyArray<EditorId>;
29022903 diffToggleShortcutLabel: string | null;
29032904 gitCwd: string | null;
29042905 diffOpen: boolean;
......@@ -2915,6 +2916,7 @@ const ChatHeader = memo(function ChatHeader({
29152916 activeProjectScripts,
29162917 preferredScriptId,
29172918 keybindings,
2919 availableEditors,
29182920 diffToggleShortcutLabel,
29192921 gitCwd,
29202922 diffOpen,
......@@ -2951,7 +2953,11 @@ const ChatHeader = memo(function ChatHeader({
29512953 />
29522954 )}
29532955 {activeProjectName && (
2954 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
2956 <OpenInPicker
2957 keybindings={keybindings}
2958 availableEditors={availableEditors}
2959 activeThreadId={activeThreadId}
2960 />
29552961 )}
29562962 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
29572963 <Tooltip>
......@@ -3663,9 +3669,11 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
36633669
36643670const OpenInPicker = memo(function OpenInPicker({
36653671 keybindings,
3672 availableEditors,
36663673 activeThreadId,
36673674}: {
36683675 keybindings: ResolvedKeybindingsConfig;
3676 availableEditors: ReadonlyArray<EditorId>;
36693677 activeThreadId: ThreadId | null;
36703678}) {
36713679 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
......@@ -3673,28 +3681,45 @@ const OpenInPicker = memo(function OpenInPicker({
36733681 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
36743682 });
36753683
3676 const options = [
3677 {
3678 label: "Cursor",
3679 Icon: CursorIcon,
3680 value: "cursor",
3681 },
3682 {
3683 label: "VS Code",
3684 Icon: VisualStudioCode,
3685 value: "vscode",
3686 },
3687 {
3688 label: isMacPlatform(navigator.platform)
3689 ? "Finder"
3690 : isWindowsPlatform(navigator.platform)
3691 ? "Explorer"
3692 : "Files",
3693 Icon: FolderClosedIcon,
3694 value: "file-manager",
3695 },
3696 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
3697 const primaryOption = options.find(({ value }) => value === lastEditor);
3684 const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>(
3685 () => [
3686 {
3687 label: "Cursor",
3688 Icon: CursorIcon,
3689 value: "cursor",
3690 },
3691 {
3692 label: "VS Code",
3693 Icon: VisualStudioCode,
3694 value: "vscode",
3695 },
3696 {
3697 label: isMacPlatform(navigator.platform)
3698 ? "Finder"
3699 : isWindowsPlatform(navigator.platform)
3700 ? "Explorer"
3701 : "Files",
3702 Icon: FolderClosedIcon,
3703 value: "file-manager",
3704 },
3705 ],
3706 [],
3707 );
3708 const options = useMemo(
3709 () => allOptions.filter((option) => availableEditors.includes(option.value)),
3710 [allOptions, availableEditors],
3711 );
3712
3713 const effectiveEditor = options.some((option) => option.value === lastEditor)
3714 ? lastEditor
3715 : (options[0]?.value ?? null);
3716 const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;
3717
3718 useEffect(() => {
3719 if (!effectiveEditor || effectiveEditor === lastEditor) return;
3720 localStorage.setItem(LAST_EDITOR_KEY, effectiveEditor);
3721 setLastEditor(effectiveEditor);
3722 }, [effectiveEditor, lastEditor]);
36983723
36993724 const activeThread = useStore((store) =>
37003725 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
......@@ -3708,13 +3733,14 @@ const OpenInPicker = memo(function OpenInPicker({
37083733 (editorId: EditorId | null) => {
37093734 const api = readNativeApi();
37103735 if (!api || !activeProject) return;
3711 const editor = editorId ?? lastEditor;
3736 const editor = editorId ?? effectiveEditor;
3737 if (!editor) return;
37123738 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
37133739 void api.shell.openInEditor(cwd, editor);
37143740 localStorage.setItem(LAST_EDITOR_KEY, editor);
37153741 setLastEditor(editor);
37163742 },
3717 [activeProject, activeThread, lastEditor, setLastEditor],
3743 [activeProject, activeThread, effectiveEditor, setLastEditor],
37183744 );
37193745
37203746 const openFavoriteEditorShortcutLabel = useMemo(
......@@ -3727,18 +3753,24 @@ const OpenInPicker = memo(function OpenInPicker({
37273753 const api = readNativeApi();
37283754 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
37293755 if (!api || !activeProject) return;
3756 if (!effectiveEditor) return;
37303757
37313758 e.preventDefault();
37323759 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
3733 void api.shell.openInEditor(cwd, lastEditor);
3760 void api.shell.openInEditor(cwd, effectiveEditor);
37343761 };
37353762 window.addEventListener("keydown", handler);
37363763 return () => window.removeEventListener("keydown", handler);
3737 }, [activeProject, activeThread, keybindings, lastEditor]);
3764 }, [activeProject, activeThread, keybindings, effectiveEditor]);
37383765
37393766 return (
37403767 <Group aria-label="Subscription actions">
3741 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>
3768 <Button
3769 size="xs"
3770 variant="outline"
3771 disabled={!effectiveEditor || !activeProject}
3772 onClick={() => openInEditor(effectiveEditor)}
3773 >
37423774 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}
37433775 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
37443776 Open
......@@ -3750,11 +3782,12 @@ const OpenInPicker = memo(function OpenInPicker({
37503782 <ChevronDownIcon aria-hidden="true" className="size-4" />
37513783 </MenuTrigger>
37523784 <MenuPopup align="end">
3785 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}
37533786 {options.map(({ label, Icon, value }) => (
37543787 <MenuItem key={value} onClick={() => openInEditor(value)}>
37553788 <Icon aria-hidden="true" className="text-muted-foreground" />
37563789 {label}
3757 {value === lastEditor && openFavoriteEditorShortcutLabel && (
3790 {value === effectiveEditor && openFavoriteEditorShortcutLabel && (
37583791 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
37593792 )}
37603793 </MenuItem>
apps/web/src/components/Icons.tsx+95-102
......@@ -1,4 +1,4 @@
1import type { SVGProps } from "react";
1import { type SVGProps, useId } from "react";
22
33export type Icon = React.FC<SVGProps<SVGSVGElement>>;
44
......@@ -20,109 +20,102 @@ export const CursorIcon: Icon = (props) => (
2020 </svg>
2121);
2222
23export const VisualStudioCode: Icon = (props) => (
24 <svg {...props} fill="none" viewBox="0 0 100 100">
25 <mask
26 id="vscode__a"
27 width="100"
28 height="100"
29 x="0"
30 y="0"
31 maskUnits="userSpaceOnUse"
32 >
33 <path
34 fill="#fff"
35 fillRule="evenodd"
36 d="M70.912 99.317a6.223 6.223 0 0 0 4.96-.19l20.589-9.907A6.25 6.25 0 0 0 100 83.587V16.413a6.25 6.25 0 0 0-3.54-5.632L75.874.874a6.226 6.226 0 0 0-7.104 1.21L29.355 38.04 12.187 25.01a4.162 4.162 0 0 0-5.318.236l-5.506 5.009a4.168 4.168 0 0 0-.004 6.162L16.247 50 1.36 63.583a4.168 4.168 0 0 0 .004 6.162l5.506 5.01a4.162 4.162 0 0 0 5.318.236l17.168-13.032L68.77 97.917a6.217 6.217 0 0 0 2.143 1.4ZM75.015 27.3 45.11 50l29.906 22.701V27.3Z"
37 clipRule="evenodd"
38 />
39 </mask>
40 <g mask="url(#vscode__a)">
41 <path
42 fill="#0065A9"
43 d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
44 />
45 <g filter="url(#vscode__b)">
23export const VisualStudioCode: Icon = (props) => {
24 const id = useId();
25 const maskId = `${id}-vscode-a`;
26 const topShadowFilterId = `${id}-vscode-b`;
27 const sideShadowFilterId = `${id}-vscode-c`;
28 const overlayGradientId = `${id}-vscode-d`;
29
30 return (
31 <svg {...props} fill="none" viewBox="0 0 100 100">
32 <mask id={maskId} width="100" height="100" x="0" y="0" maskUnits="userSpaceOnUse">
4633 <path
47 fill="#007ACC"
48 d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
34 fill="#fff"
35 fillRule="evenodd"
36 d="M70.912 99.317a6.223 6.223 0 0 0 4.96-.19l20.589-9.907A6.25 6.25 0 0 0 100 83.587V16.413a6.25 6.25 0 0 0-3.54-5.632L75.874.874a6.226 6.226 0 0 0-7.104 1.21L29.355 38.04 12.187 25.01a4.162 4.162 0 0 0-5.318.236l-5.506 5.009a4.168 4.168 0 0 0-.004 6.162L16.247 50 1.36 63.583a4.168 4.168 0 0 0 .004 6.162l5.506 5.01a4.162 4.162 0 0 0 5.318.236l17.168-13.032L68.77 97.917a6.217 6.217 0 0 0 2.143 1.4ZM75.015 27.3 45.11 50l29.906 22.701V27.3Z"
37 clipRule="evenodd"
4938 />
50 </g>
51 <g filter="url(#vscode__c)">
39 </mask>
40 <g mask={`url(#${maskId})`}>
5241 <path
53 fill="#1F9CF0"
54 d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
55 />
56 </g>
57 <path
58 fill="url(#vscode__d)"
59 fillRule="evenodd"
60 d="M70.851 99.317a6.224 6.224 0 0 0 4.96-.19L96.4 89.22a6.25 6.25 0 0 0 3.54-5.633V16.413a6.25 6.25 0 0 0-3.54-5.632L75.812.874a6.226 6.226 0 0 0-7.104 1.21L29.294 38.04 12.126 25.01a4.162 4.162 0 0 0-5.317.236l-5.507 5.009a4.168 4.168 0 0 0-.004 6.162L16.186 50 1.298 63.583a4.168 4.168 0 0 0 .004 6.162l5.507 5.009a4.162 4.162 0 0 0 5.317.236L29.294 61.96l39.414 35.958a6.218 6.218 0 0 0 2.143 1.4ZM74.954 27.3 45.048 50l29.906 22.701V27.3Z"
61 clipRule="evenodd"
62 opacity=".25"
63 style={{ mixBlendMode: "overlay" }}
64 />
65 </g>
66 <defs>
67 <filter
68 id="vscode__b"
69 width="116.727"
70 height="92.246"
71 x="-8.394"
72 y="15.829"
73 colorInterpolationFilters="sRGB"
74 filterUnits="userSpaceOnUse"
75 >
76 <feFlood floodOpacity="0" result="BackgroundImageFix" />
77 <feColorMatrix
78 in="SourceAlpha"
79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
80 />
81 <feOffset />
82 <feGaussianBlur stdDeviation="4.167" />
83 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
84 <feBlend
85 in2="BackgroundImageFix"
86 mode="overlay"
87 result="effect1_dropShadow"
42 fill="#0065A9"
43 d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
8844 />
89 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
90 </filter>
91 <filter
92 id="vscode__c"
93 width="47.917"
94 height="116.151"
95 x="60.417"
96 y="-8.076"
97 colorInterpolationFilters="sRGB"
98 filterUnits="userSpaceOnUse"
99 >
100 <feFlood floodOpacity="0" result="BackgroundImageFix" />
101 <feColorMatrix
102 in="SourceAlpha"
103 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
104 />
105 <feOffset />
106 <feGaussianBlur stdDeviation="4.167" />
107 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
108 <feBlend
109 in2="BackgroundImageFix"
110 mode="overlay"
111 result="effect1_dropShadow"
45 <g filter={`url(#${topShadowFilterId})`}>
46 <path
47 fill="#007ACC"
48 d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
49 />
50 </g>
51 <g filter={`url(#${sideShadowFilterId})`}>
52 <path
53 fill="#1F9CF0"
54 d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
55 />
56 </g>
57 <path
58 fill={`url(#${overlayGradientId})`}
59 fillRule="evenodd"
60 d="M70.851 99.317a6.224 6.224 0 0 0 4.96-.19L96.4 89.22a6.25 6.25 0 0 0 3.54-5.633V16.413a6.25 6.25 0 0 0-3.54-5.632L75.812.874a6.226 6.226 0 0 0-7.104 1.21L29.294 38.04 12.126 25.01a4.162 4.162 0 0 0-5.317.236l-5.507 5.009a4.168 4.168 0 0 0-.004 6.162L16.186 50 1.298 63.583a4.168 4.168 0 0 0 .004 6.162l5.507 5.009a4.162 4.162 0 0 0 5.317.236L29.294 61.96l39.414 35.958a6.218 6.218 0 0 0 2.143 1.4ZM74.954 27.3 45.048 50l29.906 22.701V27.3Z"
61 clipRule="evenodd"
62 opacity=".25"
63 style={{ mixBlendMode: "overlay" }}
11264 />
113 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
114 </filter>
115 <linearGradient
116 id="vscode__d"
117 x1="49.939"
118 x2="49.939"
119 y1=".258"
120 y2="99.742"
121 gradientUnits="userSpaceOnUse"
122 >
123 <stop stopColor="#fff" />
124 <stop offset="1" stopColor="#fff" stopOpacity="0" />
125 </linearGradient>
126 </defs>
127 </svg>
128);
65 </g>
66 <defs>
67 <filter
68 id={topShadowFilterId}
69 width="116.727"
70 height="92.246"
71 x="-8.394"
72 y="15.829"
73 colorInterpolationFilters="sRGB"
74 filterUnits="userSpaceOnUse"
75 >
76 <feFlood floodOpacity="0" result="BackgroundImageFix" />
77 <feColorMatrix
78 in="SourceAlpha"
79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
80 />
81 <feOffset />
82 <feGaussianBlur stdDeviation="4.167" />
83 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
84 <feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow" />
85 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
86 </filter>
87 <filter
88 id={sideShadowFilterId}
89 width="47.917"
90 height="116.151"
91 x="60.417"
92 y="-8.076"
93 colorInterpolationFilters="sRGB"
94 filterUnits="userSpaceOnUse"
95 >
96 <feFlood floodOpacity="0" result="BackgroundImageFix" />
97 <feColorMatrix
98 in="SourceAlpha"
99 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
100 />
101 <feOffset />
102 <feGaussianBlur stdDeviation="4.167" />
103 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
104 <feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow" />
105 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
106 </filter>
107 <linearGradient
108 id={overlayGradientId}
109 x1="49.939"
110 x2="49.939"
111 y1=".258"
112 y2="99.742"
113 gradientUnits="userSpaceOnUse"
114 >
115 <stop stopColor="#fff" />
116 <stop offset="1" stopColor="#fff" stopOpacity="0" />
117 </linearGradient>
118 </defs>
119 </svg>
120 );
121};
packages/contracts/src/server.ts+2
......@@ -1,6 +1,7 @@
11import { Schema } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas";
33import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
4import { EditorId } from "./editor";
45
56export const KeybindingsMalformedConfigIssue = Schema.Struct({
67 kind: Schema.Literal("keybindings.malformed-config"),
......@@ -29,6 +30,7 @@ export const ServerConfig = Schema.Struct({
2930 keybindingsConfigPath: TrimmedNonEmptyString,
3031 keybindings: ResolvedKeybindingsConfig,
3132 issues: ServerConfigIssues,
33 availableEditors: Schema.Array(EditorId),
3234});
3335export type ServerConfig = typeof ServerConfig.Type;
3436