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 @@...@@ -1,6 +1,15 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
1import { assert, describe, it } from "@effect/vitest";5import { assert, describe, it } from "@effect/vitest";
26
3import { launchDetached, resolveEditorLaunch } from "./open";7import {
8 isCommandAvailable,
9 launchDetached,
10 resolveAvailableEditors,
11 resolveEditorLaunch,
12} from "./open";
4import { Effect } from "effect";13import { Effect } from "effect";
5import { assertSuccess } from "@effect/vitest/utils";14import { assertSuccess } from "@effect/vitest/utils";
615
...@@ -111,3 +120,50 @@ describe("launchDetached", () => {...@@ -111,3 +120,50 @@ describe("launchDetached", () => {
111 }),120 }),
112 );121 );
113});122});
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 @@...@@ -7,6 +7,8 @@
7 * @module Open7 * @module Open
8 */8 */
9import { spawn } from "node:child_process";9import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";
11import { delimiter, extname, join } from "node:path";
1012
11import { EDITORS, type EditorId } from "@t3tools/contracts";13import { EDITORS, type EditorId } from "@t3tools/contracts";
12import { ServiceMap, Schema, Effect, Layer } from "effect";14import { ServiceMap, Schema, Effect, Layer } from "effect";
...@@ -30,12 +32,122 @@ interface EditorLaunch {...@@ -30,12 +32,122 @@ interface EditorLaunch {
30 readonly args: ReadonlyArray<string>;32 readonly args: ReadonlyArray<string>;
31}33}
3234
35interface CommandAvailabilityOptions {
36 readonly platform?: NodeJS.Platform;
37 readonly env?: NodeJS.ProcessEnv;
38}
39
33const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;40const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
3441
35function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {42function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
36 return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);43 return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);
37}44}
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
39/**151/**
40 * OpenShape - Service API for browser and editor launch actions.152 * OpenShape - Service API for browser and editor launch actions.
41 */153 */
...@@ -81,14 +193,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* (...@@ -81,14 +193,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* (
81 return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });193 return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
82 }194 }
83195
84 switch (platform) {196 return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
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 }
92});197});
93198
94export const launchDetached = (launch: EditorLaunch) =>199export const launchDetached = (launch: EditorLaunch) =>
apps/server/src/wsServer.test.ts+24
...@@ -13,6 +13,7 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve...@@ -13,6 +13,7 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve
1313
14import {14import {
15 DEFAULT_TERMINAL_ID,15 DEFAULT_TERMINAL_ID,
16 EDITORS,
16 EventId,17 EventId,
17 ORCHESTRATION_WS_CHANNELS,18 ORCHESTRATION_WS_CHANNELS,
18 ORCHESTRATION_WS_METHODS,19 ORCHESTRATION_WS_METHODS,
...@@ -340,6 +341,15 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon...@@ -340,6 +341,15 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
340}341}
341342
342const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);343const 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
344describe("WebSocket Server", () => {354describe("WebSocket Server", () => {
345 let server: Http.Server | null = null;355 let server: Http.Server | null = null;
...@@ -726,7 +736,9 @@ describe("WebSocket Server", () => {...@@ -726,7 +736,9 @@ describe("WebSocket Server", () => {
726 keybindingsConfigPath: keybindingsPath,736 keybindingsConfigPath: keybindingsPath,
727 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,737 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
728 issues: [],738 issues: [],
739 availableEditors: expect.any(Array),
729 });740 });
741 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
730 });742 });
731743
732 it("bootstraps default keybindings file when missing", async () => {744 it("bootstraps default keybindings file when missing", async () => {
...@@ -749,7 +761,9 @@ describe("WebSocket Server", () => {...@@ -749,7 +761,9 @@ describe("WebSocket Server", () => {
749 keybindingsConfigPath: keybindingsPath,761 keybindingsConfigPath: keybindingsPath,
750 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,762 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
751 issues: [],763 issues: [],
764 availableEditors: expect.any(Array),
752 });765 });
766 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
753767
754 const persistedConfig = JSON.parse(768 const persistedConfig = JSON.parse(
755 fs.readFileSync(keybindingsPath, "utf8"),769 fs.readFileSync(keybindingsPath, "utf8"),
...@@ -782,7 +796,9 @@ describe("WebSocket Server", () => {...@@ -782,7 +796,9 @@ describe("WebSocket Server", () => {
782 message: expect.stringContaining("expected JSON array"),796 message: expect.stringContaining("expected JSON array"),
783 },797 },
784 ],798 ],
799 availableEditors: expect.any(Array),
785 });800 });
801 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
786 expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");802 expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json");
787 });803 });
788804
...@@ -814,6 +830,7 @@ describe("WebSocket Server", () => {...@@ -814,6 +830,7 @@ describe("WebSocket Server", () => {
814 keybindingsConfigPath: string;830 keybindingsConfigPath: string;
815 keybindings: ResolvedKeybindingsConfig;831 keybindings: ResolvedKeybindingsConfig;
816 issues: Array<{ kind: string; index?: number; message: string }>;832 issues: Array<{ kind: string; index?: number; message: string }>;
833 availableEditors: unknown;
817 };834 };
818 expect(result.cwd).toBe("/my/workspace");835 expect(result.cwd).toBe("/my/workspace");
819 expect(result.keybindingsConfigPath).toBe(keybindingsPath);836 expect(result.keybindingsConfigPath).toBe(keybindingsPath);
...@@ -832,6 +849,7 @@ describe("WebSocket Server", () => {...@@ -832,6 +849,7 @@ describe("WebSocket Server", () => {
832 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);849 expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length);
833 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);850 expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true);
834 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);851 expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true);
852 expectAvailableEditors(result.availableEditors);
835 });853 });
836854
837 it("pushes server.configUpdated issues when keybindings file changes", async () => {855 it("pushes server.configUpdated issues when keybindings file changes", async () => {
...@@ -929,7 +947,9 @@ describe("WebSocket Server", () => {...@@ -929,7 +947,9 @@ describe("WebSocket Server", () => {
929 keybindingsConfigPath: keybindingsPath,947 keybindingsConfigPath: keybindingsPath,
930 keybindings: compileKeybindings(persistedConfig),948 keybindings: compileKeybindings(persistedConfig),
931 issues: [],949 issues: [],
950 availableEditors: expect.any(Array),
932 });951 });
952 expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors);
933 });953 });
934954
935 it("upserts keybinding rules and updates cached server config", async () => {955 it("upserts keybinding rules and updates cached server config", async () => {
...@@ -974,7 +994,11 @@ describe("WebSocket Server", () => {...@@ -974,7 +994,11 @@ describe("WebSocket Server", () => {
974 keybindingsConfigPath: keybindingsPath,994 keybindingsConfigPath: keybindingsPath,
975 keybindings: compileKeybindings(persistedConfig),995 keybindings: compileKeybindings(persistedConfig),
976 issues: [],996 issues: [],
997 availableEditors: expect.any(Array),
977 });998 });
999 expectAvailableEditors(
1000 (configResponse.result as { availableEditors: unknown }).availableEditors,
1001 );
978 });1002 });
9791003
980 it("returns error for unknown methods", async () => {1004 it("returns error for unknown methods", async () => {
apps/server/src/wsServer.ts+3-1
...@@ -54,7 +54,7 @@ import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReac...@@ -54,7 +54,7 @@ import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReac
54import { ProviderService } from "./provider/Services/ProviderService";54import { ProviderService } from "./provider/Services/ProviderService";
55import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";55import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
56import { clamp } from "effect/Number";56import { clamp } from "effect/Number";
57import { Open } from "./open";57import { Open, resolveAvailableEditors } from "./open";
58import { ServerConfig } from "./config";58import { ServerConfig } from "./config";
59import { GitCore } from "./git/Services/GitCore.ts";59import { GitCore } from "./git/Services/GitCore.ts";
60import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";60import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
...@@ -193,6 +193,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -193,6 +193,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
193 logWebSocketEvents,193 logWebSocketEvents,
194 autoBootstrapProjectFromCwd,194 autoBootstrapProjectFromCwd,
195 } = serverConfig;195 } = serverConfig;
196 const availableEditors = resolveAvailableEditors();
196197
197 const gitManager = yield* GitManager;198 const gitManager = yield* GitManager;
198 const terminalManager = yield* TerminalManager;199 const terminalManager = yield* TerminalManager;
...@@ -734,6 +735,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -734,6 +735,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
734 keybindingsConfigPath,735 keybindingsConfigPath,
735 keybindings: keybindingsConfig.keybindings,736 keybindings: keybindingsConfig.keybindings,
736 issues: keybindingsConfig.issues,737 issues: keybindingsConfig.issues,
738 availableEditors,
737 };739 };
738740
739 case WS_METHODS.serverUpsertKeybinding: {741 case WS_METHODS.serverUpsertKeybinding: {
apps/web/src/components/BranchToolbar.logic.test.ts+1-58
...@@ -1,67 +1,10 @@...@@ -1,67 +1,10 @@
1import { ThreadId, type GitBranch } from "@t3tools/contracts";1import type { GitBranch } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
3import {3import {
4 dedupeRemoteBranchesWithLocalMatches,4 dedupeRemoteBranchesWithLocalMatches,
5 deriveLocalBranchNameFromRemoteRef,5 deriveLocalBranchNameFromRemoteRef,
6 deriveSyncedLocalBranch,
7} from "./BranchToolbar.logic";6} 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
65describe("deriveLocalBranchNameFromRemoteRef", () => {8describe("deriveLocalBranchNameFromRemoteRef", () => {
66 it("strips the remote prefix from a remote ref", () => {9 it("strips the remote prefix from a remote ref", () => {
67 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");10 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
apps/web/src/components/BranchToolbar.logic.ts+1-28
...@@ -1,12 +1,4 @@...@@ -1,12 +1,4 @@
1import type { GitBranch, ThreadId } from "@t3tools/contracts";1import type { GitBranch } 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}
102
11export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {3export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
12 const firstSeparatorIndex = branchName.indexOf("/");4 const firstSeparatorIndex = branchName.indexOf("/");
...@@ -55,22 +47,3 @@ export function dedupeRemoteBranchesWithLocalMatches(...@@ -55,22 +47,3 @@ export function dedupeRemoteBranchesWithLocalMatches(
55 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));47 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
56 });48 });
57}49}
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 =...@@ -147,6 +147,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
147const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];147const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
148const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];148const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
149const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];149const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
150const EMPTY_AVAILABLE_EDITORS: EditorId[] = [];
150const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;151const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
151const SCRIPT_TERMINAL_COLS = 120;152const SCRIPT_TERMINAL_COLS = 120;
152const SCRIPT_TERMINAL_ROWS = 30;153const SCRIPT_TERMINAL_ROWS = 30;
...@@ -869,10 +870,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -869,10 +870,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
869 );870 );
870 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";871 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
871 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));872 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
872 const keybindingsQuery = useQuery({873 const serverConfigQuery = useQuery(serverConfigQueryOptions());
873 ...serverConfigQueryOptions(),
874 select: (config) => config.keybindings,
875 });
876 const workspaceEntriesQuery = useQuery(874 const workspaceEntriesQuery = useQuery(
877 projectSearchEntriesQueryOptions({875 projectSearchEntriesQueryOptions({
878 cwd: gitCwd,876 cwd: gitCwd,
...@@ -944,7 +942,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -944,7 +942,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
944 () => new Set(nonPersistedComposerImageIds),942 () => new Set(nonPersistedComposerImageIds),
945 [nonPersistedComposerImageIds],943 [nonPersistedComposerImageIds],
946 );944 );
947 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;945 const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
946 const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
948 const activeProjectCwd = activeProject?.cwd ?? null;947 const activeProjectCwd = activeProject?.cwd ?? null;
949 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;948 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
950 const threadTerminalRuntimeEnv = useMemo(() => {949 const threadTerminalRuntimeEnv = useMemo(() => {
...@@ -2486,6 +2485,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2486,6 +2485,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2486 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null2485 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
2487 }2486 }
2488 keybindings={keybindings}2487 keybindings={keybindings}
2488 availableEditors={availableEditors}
2489 diffToggleShortcutLabel={diffPanelShortcutLabel}2489 diffToggleShortcutLabel={diffPanelShortcutLabel}
2490 gitCwd={gitCwd}2490 gitCwd={gitCwd}
2491 diffOpen={diffOpen}2491 diffOpen={diffOpen}
...@@ -2899,6 +2899,7 @@ interface ChatHeaderProps {...@@ -2899,6 +2899,7 @@ interface ChatHeaderProps {
2899 activeProjectScripts: ProjectScript[] | undefined;2899 activeProjectScripts: ProjectScript[] | undefined;
2900 preferredScriptId: string | null;2900 preferredScriptId: string | null;
2901 keybindings: ResolvedKeybindingsConfig;2901 keybindings: ResolvedKeybindingsConfig;
2902 availableEditors: ReadonlyArray<EditorId>;
2902 diffToggleShortcutLabel: string | null;2903 diffToggleShortcutLabel: string | null;
2903 gitCwd: string | null;2904 gitCwd: string | null;
2904 diffOpen: boolean;2905 diffOpen: boolean;
...@@ -2915,6 +2916,7 @@ const ChatHeader = memo(function ChatHeader({...@@ -2915,6 +2916,7 @@ const ChatHeader = memo(function ChatHeader({
2915 activeProjectScripts,2916 activeProjectScripts,
2916 preferredScriptId,2917 preferredScriptId,
2917 keybindings,2918 keybindings,
2919 availableEditors,
2918 diffToggleShortcutLabel,2920 diffToggleShortcutLabel,
2919 gitCwd,2921 gitCwd,
2920 diffOpen,2922 diffOpen,
...@@ -2951,7 +2953,11 @@ const ChatHeader = memo(function ChatHeader({...@@ -2951,7 +2953,11 @@ const ChatHeader = memo(function ChatHeader({
2951 />2953 />
2952 )}2954 )}
2953 {activeProjectName && (2955 {activeProjectName && (
2954 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />2956 <OpenInPicker
2957 keybindings={keybindings}
2958 availableEditors={availableEditors}
2959 activeThreadId={activeThreadId}
2960 />
2955 )}2961 )}
2956 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}2962 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
2957 <Tooltip>2963 <Tooltip>
...@@ -3663,9 +3669,11 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {...@@ -3663,9 +3669,11 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
36633669
3664const OpenInPicker = memo(function OpenInPicker({3670const OpenInPicker = memo(function OpenInPicker({
3665 keybindings,3671 keybindings,
3672 availableEditors,
3666 activeThreadId,3673 activeThreadId,
3667}: {3674}: {
3668 keybindings: ResolvedKeybindingsConfig;3675 keybindings: ResolvedKeybindingsConfig;
3676 availableEditors: ReadonlyArray<EditorId>;
3669 activeThreadId: ThreadId | null;3677 activeThreadId: ThreadId | null;
3670}) {3678}) {
3671 const [lastEditor, setLastEditor] = useState<EditorId>(() => {3679 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
...@@ -3673,28 +3681,45 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3673,28 +3681,45 @@ const OpenInPicker = memo(function OpenInPicker({
3673 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;3681 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
3674 });3682 });
36753683
3676 const options = [3684 const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>(
3677 {3685 () => [
3678 label: "Cursor",3686 {
3679 Icon: CursorIcon,3687 label: "Cursor",
3680 value: "cursor",3688 Icon: CursorIcon,
3681 },3689 value: "cursor",
3682 {3690 },
3683 label: "VS Code",3691 {
3684 Icon: VisualStudioCode,3692 label: "VS Code",
3685 value: "vscode",3693 Icon: VisualStudioCode,
3686 },3694 value: "vscode",
3687 {3695 },
3688 label: isMacPlatform(navigator.platform)3696 {
3689 ? "Finder"3697 label: isMacPlatform(navigator.platform)
3690 : isWindowsPlatform(navigator.platform)3698 ? "Finder"
3691 ? "Explorer"3699 : isWindowsPlatform(navigator.platform)
3692 : "Files",3700 ? "Explorer"
3693 Icon: FolderClosedIcon,3701 : "Files",
3694 value: "file-manager",3702 Icon: FolderClosedIcon,
3695 },3703 value: "file-manager",
3696 ] satisfies { label: string; Icon: Icon; value: EditorId }[];3704 },
3697 const primaryOption = options.find(({ value }) => value === lastEditor);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
3699 const activeThread = useStore((store) =>3724 const activeThread = useStore((store) =>
3700 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,3725 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
...@@ -3708,13 +3733,14 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3708,13 +3733,14 @@ const OpenInPicker = memo(function OpenInPicker({
3708 (editorId: EditorId | null) => {3733 (editorId: EditorId | null) => {
3709 const api = readNativeApi();3734 const api = readNativeApi();
3710 if (!api || !activeProject) return;3735 if (!api || !activeProject) return;
3711 const editor = editorId ?? lastEditor;3736 const editor = editorId ?? effectiveEditor;
3737 if (!editor) return;
3712 const cwd = activeThread?.worktreePath ?? activeProject.cwd;3738 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
3713 void api.shell.openInEditor(cwd, editor);3739 void api.shell.openInEditor(cwd, editor);
3714 localStorage.setItem(LAST_EDITOR_KEY, editor);3740 localStorage.setItem(LAST_EDITOR_KEY, editor);
3715 setLastEditor(editor);3741 setLastEditor(editor);
3716 },3742 },
3717 [activeProject, activeThread, lastEditor, setLastEditor],3743 [activeProject, activeThread, effectiveEditor, setLastEditor],
3718 );3744 );
37193745
3720 const openFavoriteEditorShortcutLabel = useMemo(3746 const openFavoriteEditorShortcutLabel = useMemo(
...@@ -3727,18 +3753,24 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3727,18 +3753,24 @@ const OpenInPicker = memo(function OpenInPicker({
3727 const api = readNativeApi();3753 const api = readNativeApi();
3728 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;3754 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
3729 if (!api || !activeProject) return;3755 if (!api || !activeProject) return;
3756 if (!effectiveEditor) return;
37303757
3731 e.preventDefault();3758 e.preventDefault();
3732 const cwd = activeThread?.worktreePath ?? activeProject.cwd;3759 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
3733 void api.shell.openInEditor(cwd, lastEditor);3760 void api.shell.openInEditor(cwd, effectiveEditor);
3734 };3761 };
3735 window.addEventListener("keydown", handler);3762 window.addEventListener("keydown", handler);
3736 return () => window.removeEventListener("keydown", handler);3763 return () => window.removeEventListener("keydown", handler);
3737 }, [activeProject, activeThread, keybindings, lastEditor]);3764 }, [activeProject, activeThread, keybindings, effectiveEditor]);
37383765
3739 return (3766 return (
3740 <Group aria-label="Subscription actions">3767 <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 >
3742 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}3774 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}
3743 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">3775 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
3744 Open3776 Open
...@@ -3750,11 +3782,12 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3750,11 +3782,12 @@ const OpenInPicker = memo(function OpenInPicker({
3750 <ChevronDownIcon aria-hidden="true" className="size-4" />3782 <ChevronDownIcon aria-hidden="true" className="size-4" />
3751 </MenuTrigger>3783 </MenuTrigger>
3752 <MenuPopup align="end">3784 <MenuPopup align="end">
3785 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}
3753 {options.map(({ label, Icon, value }) => (3786 {options.map(({ label, Icon, value }) => (
3754 <MenuItem key={value} onClick={() => openInEditor(value)}>3787 <MenuItem key={value} onClick={() => openInEditor(value)}>
3755 <Icon aria-hidden="true" className="text-muted-foreground" />3788 <Icon aria-hidden="true" className="text-muted-foreground" />
3756 {label}3789 {label}
3757 {value === lastEditor && openFavoriteEditorShortcutLabel && (3790 {value === effectiveEditor && openFavoriteEditorShortcutLabel && (
3758 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>3791 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
3759 )}3792 )}
3760 </MenuItem>3793 </MenuItem>
apps/web/src/components/Icons.tsx+95-102
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { SVGProps } from "react";1import { type SVGProps, useId } from "react";
22
3export type Icon = React.FC<SVGProps<SVGSVGElement>>;3export type Icon = React.FC<SVGProps<SVGSVGElement>>;
44
...@@ -20,109 +20,102 @@ export const CursorIcon: Icon = (props) => (...@@ -20,109 +20,102 @@ export const CursorIcon: Icon = (props) => (
20 </svg>20 </svg>
21);21);
2222
23export const VisualStudioCode: Icon = (props) => (23export const VisualStudioCode: Icon = (props) => {
24 <svg {...props} fill="none" viewBox="0 0 100 100">24 const id = useId();
25 <mask25 const maskId = `${id}-vscode-a`;
26 id="vscode__a"26 const topShadowFilterId = `${id}-vscode-b`;
27 width="100"27 const sideShadowFilterId = `${id}-vscode-c`;
28 height="100"28 const overlayGradientId = `${id}-vscode-d`;
29 x="0"29
30 y="0"30 return (
31 maskUnits="userSpaceOnUse"31 <svg {...props} fill="none" viewBox="0 0 100 100">
32 >32 <mask id={maskId} width="100" height="100" x="0" y="0" maskUnits="userSpaceOnUse">
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)">
46 <path33 <path
47 fill="#007ACC"34 fill="#fff"
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"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"
49 />38 />
50 </g>39 </mask>
51 <g filter="url(#vscode__c)">40 <g mask={`url(#${maskId})`}>
52 <path41 <path
53 fill="#1F9CF0"42 fill="#0065A9"
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"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"
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"
88 />44 />
89 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />45 <g filter={`url(#${topShadowFilterId})`}>
90 </filter>46 <path
91 <filter47 fill="#007ACC"
92 id="vscode__c"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"
93 width="47.917"49 />
94 height="116.151"50 </g>
95 x="60.417"51 <g filter={`url(#${sideShadowFilterId})`}>
96 y="-8.076"52 <path
97 colorInterpolationFilters="sRGB"53 fill="#1F9CF0"
98 filterUnits="userSpaceOnUse"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"
99 >55 />
100 <feFlood floodOpacity="0" result="BackgroundImageFix" />56 </g>
101 <feColorMatrix57 <path
102 in="SourceAlpha"58 fill={`url(#${overlayGradientId})`}
103 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"59 fillRule="evenodd"
104 />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"
105 <feOffset />61 clipRule="evenodd"
106 <feGaussianBlur stdDeviation="4.167" />62 opacity=".25"
107 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />63 style={{ mixBlendMode: "overlay" }}
108 <feBlend
109 in2="BackgroundImageFix"
110 mode="overlay"
111 result="effect1_dropShadow"
112 />64 />
113 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />65 </g>
114 </filter>66 <defs>
115 <linearGradient67 <filter
116 id="vscode__d"68 id={topShadowFilterId}
117 x1="49.939"69 width="116.727"
118 x2="49.939"70 height="92.246"
119 y1=".258"71 x="-8.394"
120 y2="99.742"72 y="15.829"
121 gradientUnits="userSpaceOnUse"73 colorInterpolationFilters="sRGB"
122 >74 filterUnits="userSpaceOnUse"
123 <stop stopColor="#fff" />75 >
124 <stop offset="1" stopColor="#fff" stopOpacity="0" />76 <feFlood floodOpacity="0" result="BackgroundImageFix" />
125 </linearGradient>77 <feColorMatrix
126 </defs>78 in="SourceAlpha"
127 </svg>79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
128);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 @@...@@ -1,6 +1,7 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { TrimmedNonEmptyString } from "./baseSchemas";
3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
4import { EditorId } from "./editor";
45
5export const KeybindingsMalformedConfigIssue = Schema.Struct({6export const KeybindingsMalformedConfigIssue = Schema.Struct({
6 kind: Schema.Literal("keybindings.malformed-config"),7 kind: Schema.Literal("keybindings.malformed-config"),
...@@ -29,6 +30,7 @@ export const ServerConfig = Schema.Struct({...@@ -29,6 +30,7 @@ export const ServerConfig = Schema.Struct({
29 keybindingsConfigPath: TrimmedNonEmptyString,30 keybindingsConfigPath: TrimmedNonEmptyString,
30 keybindings: ResolvedKeybindingsConfig,31 keybindings: ResolvedKeybindingsConfig,
31 issues: ServerConfigIssues,32 issues: ServerConfigIssues,
33 availableEditors: Schema.Array(EditorId),
32});34});
33export type ServerConfig = typeof ServerConfig.Type;35export type ServerConfig = typeof ServerConfig.Type;
3436