| author | |
| committer | |
| log | b3de2bd926bf9e5f381d02cdcc32b85c39f7c4ed |
| tree | 3d2aabe84c198c7d6ae3d7a9523b16bb3a1adb74 |
| parent | b81ec02984c92c9506c7859ab5c967f3b8afae1d |
- 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 logic9 files changed, 363 insertions(+), 232 deletions(-)
apps/server/src/open.test.ts+57-1| ... | @@ -1,6 +1,15 @@ | ... | @@ -1,6 +1,15 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | 5 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 6 | ||
| 3 | import { launchDetached, resolveEditorLaunch } from "./open"; | 7 | import { |
| 8 | isCommandAvailable, | ||
| 9 | launchDetached, | ||
| 10 | resolveAvailableEditors, | ||
| 11 | resolveEditorLaunch, | ||
| 12 | } from "./open"; | ||
| 4 | import { Effect } from "effect"; | 13 | import { Effect } from "effect"; |
| 5 | import { assertSuccess } from "@effect/vitest/utils"; | 14 | import { assertSuccess } from "@effect/vitest/utils"; |
| 6 | 15 | ||
| ... | @@ -111,3 +120,50 @@ describe("launchDetached", () => { | ... | @@ -111,3 +120,50 @@ describe("launchDetached", () => { |
| 111 | }), | 120 | }), |
| 112 | ); | 121 | ); |
| 113 | }); | 122 | }); |
| 123 | |||
| 124 | describe("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 | |||
| 154 | describe("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 Open | 7 | * @module Open |
| 8 | */ | 8 | */ |
| 9 | import { spawn } from "node:child_process"; | 9 | import { spawn } from "node:child_process"; |
| 10 | import { accessSync, constants, statSync } from "node:fs"; | ||
| 11 | import { delimiter, extname, join } from "node:path"; | ||
| 10 | 12 | ||
| 11 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | 13 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 12 | import { ServiceMap, Schema, Effect, Layer } from "effect"; | 14 | import { 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 | } |
| 32 | 34 | ||
| 35 | interface CommandAvailabilityOptions { | ||
| 36 | readonly platform?: NodeJS.Platform; | ||
| 37 | readonly env?: NodeJS.ProcessEnv; | ||
| 38 | } | ||
| 39 | |||
| 33 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 40 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 34 | 41 | ||
| 35 | function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { | 42 | function 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 | } |
| 38 | 45 | ||
| 46 | function 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 | |||
| 57 | function stripWrappingQuotes(value: string): string { | ||
| 58 | return value.replace(/^"+|"+$/g, ""); | ||
| 59 | } | ||
| 60 | |||
| 61 | function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { | ||
| 62 | return env.PATH ?? env.Path ?? env.path ?? ""; | ||
| 63 | } | ||
| 64 | |||
| 65 | function 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 | |||
| 78 | function 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 | |||
| 94 | function 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 | |||
| 106 | export 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 | |||
| 135 | export 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 | } |
| 83 | 195 | ||
| 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 | }); |
| 93 | 198 | ||
| 94 | export const launchDetached = (launch: EditorLaunch) => | 199 | export 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 |
| 13 | 13 | ||
| 14 | import { | 14 | import { |
| 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 | } |
| 341 | 342 | ||
| 342 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); | 343 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); |
| 344 | const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); | ||
| 345 | |||
| 346 | function 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 | } | ||
| 343 | 353 | ||
| 344 | describe("WebSocket Server", () => { | 354 | describe("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 | }); |
| 731 | 743 | ||
| 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); | ||
| 753 | 767 | ||
| 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 | }); |
| 788 | 804 | ||
| ... | @@ -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 | }); |
| 836 | 854 | ||
| 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 | }); |
| 934 | 954 | ||
| 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 | }); |
| 979 | 1003 | ||
| 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 |
| 54 | import { ProviderService } from "./provider/Services/ProviderService"; | 54 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 55 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | 55 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 56 | import { clamp } from "effect/Number"; | 56 | import { clamp } from "effect/Number"; |
| 57 | import { Open } from "./open"; | 57 | import { Open, resolveAvailableEditors } from "./open"; |
| 58 | import { ServerConfig } from "./config"; | 58 | import { ServerConfig } from "./config"; |
| 59 | import { GitCore } from "./git/Services/GitCore.ts"; | 59 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 60 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 60 | import { 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(); | ||
| 196 | 197 | ||
| 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 | }; |
| 738 | 740 | ||
| 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 @@ |
| 1 | import { ThreadId, type GitBranch } from "@t3tools/contracts"; | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | 4 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | deriveLocalBranchNameFromRemoteRef, | 5 | deriveLocalBranchNameFromRemoteRef, |
| 6 | deriveSyncedLocalBranch, | ||
| 7 | } from "./BranchToolbar.logic"; | 6 | } from "./BranchToolbar.logic"; |
| 8 | 7 | ||
| 9 | const 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 | |||
| 24 | describe("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 | |||
| 65 | describe("deriveLocalBranchNameFromRemoteRef", () => { | 8 | describe("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 @@ |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | |||
| 3 | interface DeriveSyncedLocalBranchInput { | ||
| 4 | activeThreadId: ThreadId | undefined; | ||
| 5 | activeWorktreePath: string | null; | ||
| 6 | envMode: "local" | "worktree"; | ||
| 7 | activeThreadBranch: string | null; | ||
| 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; | ||
| 9 | } | ||
| 10 | 2 | ||
| 11 | export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { | 3 | export 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 | |||
| 59 | export 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 = |
| 147 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; | 147 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 148 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 148 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 149 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | 149 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 150 | const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; | ||
| 150 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | 151 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 151 | const SCRIPT_TERMINAL_COLS = 120; | 152 | const SCRIPT_TERMINAL_COLS = 120; |
| 152 | const SCRIPT_TERMINAL_ROWS = 30; | 153 | const 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) : null | 2485 | 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: { |
| 3663 | 3669 | ||
| 3664 | const OpenInPicker = memo(function OpenInPicker({ | 3670 | const 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 | }); |
| 3675 | 3683 | ||
| 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]); | ||
| 3698 | 3723 | ||
| 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 | ); |
| 3719 | 3745 | ||
| 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; | ||
| 3730 | 3757 | ||
| 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]); |
| 3738 | 3765 | ||
| 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 | Open | 3776 | 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 @@ |
| 1 | import type { SVGProps } from "react"; | 1 | import { type SVGProps, useId } from "react"; |
| 2 | 2 | ||
| 3 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; | 3 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; |
| 4 | 4 | ||
| ... | @@ -20,109 +20,102 @@ export const CursorIcon: Icon = (props) => ( | ... | @@ -20,109 +20,102 @@ export const CursorIcon: Icon = (props) => ( |
| 20 | </svg> | 20 | </svg> |
| 21 | ); | 21 | ); |
| 22 | 22 | ||
| 23 | export const VisualStudioCode: Icon = (props) => ( | 23 | export const VisualStudioCode: Icon = (props) => { |
| 24 | <svg {...props} fill="none" viewBox="0 0 100 100"> | 24 | const id = useId(); |
| 25 | <mask | 25 | 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 | <path | 33 | <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 | <path | 41 | <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 | <filter | 47 | 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 | <feColorMatrix | 57 | <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 | <linearGradient | 67 | <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 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; | 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | import { EditorId } from "./editor"; | ||
| 4 | 5 | ||
| 5 | export const KeybindingsMalformedConfigIssue = Schema.Struct({ | 6 | export 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 | }); |
| 33 | export type ServerConfig = typeof ServerConfig.Type; | 35 | export type ServerConfig = typeof ServerConfig.Type; |
| 34 | 36 |