1import * as Schema from "effect/Schema";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3
4export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]);
5export type EditorLaunchStyle = typeof EditorLaunchStyle.Type;
6
7type EditorDefinition = {
8 readonly id: string;
9 readonly label: string;
10 readonly commands: readonly [string, ...string[]] | null;
11 readonly baseArgs?: readonly string[];
12 readonly launchStyle: EditorLaunchStyle;
13 /**
14 * URL scheme for editors that support VS Code's remote deep links
15 * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code
16 * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own
17 * `zed://ssh/<host><path>` shape.
18 */
19 readonly remoteScheme?: string;
20 /**
21 * JetBrains product code (`IU`, `PY`, ...). JetBrains IDEs open remote
22 * projects through the Toolbox App's `jetbrains://gateway/ssh/...` link,
23 * which uses the code to choose the IDE backend.
24 */
25 readonly jetbrainsProductCode?: string;
26};
27
28export const EDITORS = [
29 {
30 id: "cursor",
31 label: "Cursor",
32 commands: ["cursor"],
33 // File and workspace opens must target the IDE even when the Agents Window is active.
34 baseArgs: ["--classic"],
35 launchStyle: "goto",
36 remoteScheme: "cursor",
37 },
38 { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" },
39 { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" },
40 {
41 id: "vscode",
42 label: "VS Code",
43 commands: ["code"],
44 launchStyle: "goto",
45 remoteScheme: "vscode",
46 },
47 {
48 id: "vscode-insiders",
49 label: "VS Code Insiders",
50 commands: ["code-insiders"],
51 launchStyle: "goto",
52 remoteScheme: "vscode-insiders",
53 },
54 {
55 id: "vscodium",
56 label: "VSCodium",
57 commands: ["codium"],
58 launchStyle: "goto",
59 remoteScheme: "vscodium",
60 },
61 {
62 id: "zed",
63 label: "Zed",
64 commands: ["zed", "zeditor"],
65 launchStyle: "direct-path",
66 remoteScheme: "zed",
67 },
68 {
69 id: "antigravity",
70 label: "Antigravity",
71 // `agy` is the standalone Antigravity CLI, not the IDE. The IDE bundle
72 // ships `antigravity-ide`, so it comes first for install-folder lookups.
73 commands: ["antigravity-ide", "agy-ide"],
74 launchStyle: "goto",
75 },
76 {
77 id: "idea",
78 label: "IntelliJ IDEA",
79 commands: ["idea"],
80 launchStyle: "line-column",
81 jetbrainsProductCode: "IU",
82 },
83 {
84 id: "aqua",
85 label: "Aqua",
86 commands: ["aqua"],
87 launchStyle: "line-column",
88 jetbrainsProductCode: "QA",
89 },
90 {
91 id: "clion",
92 label: "CLion",
93 commands: ["clion"],
94 launchStyle: "line-column",
95 jetbrainsProductCode: "CL",
96 },
97 {
98 id: "datagrip",
99 label: "DataGrip",
100 commands: ["datagrip"],
101 launchStyle: "line-column",
102 jetbrainsProductCode: "DB",
103 },
104 {
105 id: "dataspell",
106 label: "DataSpell",
107 commands: ["dataspell"],
108 launchStyle: "line-column",
109 jetbrainsProductCode: "DS",
110 },
111 {
112 id: "goland",
113 label: "GoLand",
114 commands: ["goland"],
115 launchStyle: "line-column",
116 jetbrainsProductCode: "GO",
117 },
118 {
119 id: "phpstorm",
120 label: "PhpStorm",
121 commands: ["phpstorm"],
122 launchStyle: "line-column",
123 jetbrainsProductCode: "PS",
124 },
125 {
126 id: "pycharm",
127 label: "PyCharm",
128 commands: ["pycharm"],
129 launchStyle: "line-column",
130 jetbrainsProductCode: "PY",
131 },
132 {
133 id: "rider",
134 label: "Rider",
135 commands: ["rider"],
136 launchStyle: "line-column",
137 jetbrainsProductCode: "RD",
138 },
139 {
140 id: "rubymine",
141 label: "RubyMine",
142 commands: ["rubymine"],
143 launchStyle: "line-column",
144 jetbrainsProductCode: "RM",
145 },
146 {
147 id: "rustrover",
148 label: "RustRover",
149 commands: ["rustrover"],
150 launchStyle: "line-column",
151 jetbrainsProductCode: "RR",
152 },
153 {
154 id: "webstorm",
155 label: "WebStorm",
156 commands: ["webstorm"],
157 launchStyle: "line-column",
158 jetbrainsProductCode: "WS",
159 },
160 { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" },
161] as const satisfies ReadonlyArray<EditorDefinition>;
162
163export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
164export type EditorId = typeof EditorId.Type;
165
166export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]);
167export type FileManagerRevealKind = typeof FileManagerRevealKind.Type;
168
169export const LaunchEditorInput = Schema.Struct({
170 cwd: TrimmedNonEmptyString,
171 editor: EditorId,
172 /** Reveal (select) `cwd` in the file manager instead of opening it. Only
173 honored by the "file-manager" editor; clients must check the server's
174 `shellRevealInFileManager` config flag before sending this. */
175 reveal: Schema.optional(Schema.Boolean),
176});
177export type LaunchEditorInput = typeof LaunchEditorInput.Type;
178
179const remoteSchemeOf = (editor: EditorDefinition): string | undefined =>
180 editor.remoteScheme ?? (editor.jetbrainsProductCode === undefined ? undefined : "jetbrains");
181
182/** Editors that can open a remote workspace via an SSH deep link. */
183export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) =>
184 remoteSchemeOf(editor) !== undefined ? [editor.id] : [],
185);
186
187export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
188 const editor = EDITORS.find((candidate) => candidate.id === id);
189 return editor === undefined ? undefined : remoteSchemeOf(editor);
190};
191
192/**
193 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
194 * takes `zed://ssh/<host><path>`, JetBrains IDEs a Toolbox App
195 * `jetbrains://gateway/ssh/environment?...` link) that opens `absolutePath` on
196 * `host` in the local editor over SSH. Returns undefined for editors without
197 * remote deep-link support.
198 */
199export const buildRemoteOpenUrl = (input: {
200 readonly editor: EditorId;
201 readonly host: string;
202 readonly absolutePath: string;
203}): string | undefined => {
204 const scheme = remoteSchemeForEditor(input.editor);
205 if (scheme === undefined) {
206 return undefined;
207 }
208 const editor = EDITORS.find((candidate) => candidate.id === input.editor);
209 if (editor !== undefined && "jetbrainsProductCode" in editor) {
210 // Like the VS Code link, no user or port: the SSH config entry for `host`
211 // supplies them. A bare product code lets Toolbox pick the backend build.
212 const params = new URLSearchParams({
213 h: input.host,
214 launchIde: "true",
215 ideHint: editor.jetbrainsProductCode,
216 projectHint: input.absolutePath.replaceAll("\\", "/"),
217 });
218 return `${scheme}://gateway/ssh/environment?${params.toString()}`;
219 }
220 // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.
221 const posixPath = input.absolutePath.replaceAll("\\", "/");
222 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
223 const encodedHost = encodeURIComponent(input.host);
224 if (input.editor === "zed") {
225 // Zed's remote server resolves a rooted path on the system drive, so a
226 // Windows `C:\Users\x` must become `/Users/x` (verified in #8938). Other
227 // drives are untested and kept as is rather than silently remapped, and a
228 // POSIX path that happens to start with `/C:` is left alone.
229 const zedPath = /^[Cc]:[\\/]/.test(input.absolutePath) ? rootedPath.slice(3) : rootedPath;
230 const encodedZedPath = zedPath.split("/").map(encodeURIComponent).join("/");
231 return `${scheme}://ssh/${encodedHost}${encodedZedPath}`;
232 }
233 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");
234 return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`;
235};
236
237/**
238 * SSH hostnames an environment advertises for remote open links. Reachability
239 * is client-side; the server only advertises names that resolve to itself and
240 * gates them on a local sshd listen check. Ordered most-reachable first
241 * (tailnet MagicDNS name, then mDNS `<hostname>.local`).
242 */
243export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]);
244export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type;
245
246export const RemoteOpenTarget = Schema.Struct({
247 kind: RemoteOpenTargetKind,
248 host: TrimmedNonEmptyString,
249});
250export type RemoteOpenTarget = typeof RemoteOpenTarget.Type;
251
252export class ExternalLauncherUnknownEditorError extends Schema.TaggedError<ExternalLauncherUnknownEditorError>()(
253 "ExternalLauncherUnknownEditorError",
254 {
255 editor: Schema.String,
256 },
257) {
258 override get message(): string {
259 return `Unknown editor: ${this.editor}`;
260 }
261}
262
263export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedError<ExternalLauncherUnsupportedEditorError>()(
264 "ExternalLauncherUnsupportedEditorError",
265 {
266 editor: EditorId,
267 },
268) {
269 override get message(): string {
270 return `Unsupported editor: ${this.editor}`;
271 }
272}
273
274export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError<ExternalLauncherCommandNotFoundError>()(
275 "ExternalLauncherCommandNotFoundError",
276 {
277 editor: EditorId,
278 command: Schema.String,
279 },
280) {
281 override get message(): string {
282 return `Editor command not found: ${this.command}`;
283 }
284}
285
286/** The target path cannot reach the editor intact, so it was not started. */
287export class ExternalLauncherUnsupportedTargetError extends Schema.TaggedError<ExternalLauncherUnsupportedTargetError>()(
288 "ExternalLauncherUnsupportedTargetError",
289 {
290 editor: EditorId,
291 },
292) {
293 override get message(): string {
294 return `${this.editor} cannot open a path containing line breaks or double quotes`;
295 }
296}
297
298const ExternalLauncherSpawnFields = {
299 command: Schema.String,
300 args: Schema.Array(Schema.String),
301 cause: Schema.Defect(),
302};
303
304export class ExternalLauncherBrowserSpawnError extends Schema.TaggedError<ExternalLauncherBrowserSpawnError>()(
305 "ExternalLauncherBrowserSpawnError",
306 {
307 ...ExternalLauncherSpawnFields,
308 target: Schema.String,
309 },
310) {
311 override get message(): string {
312 return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`;
313 }
314}
315
316export class ExternalLauncherEditorSpawnError extends Schema.TaggedError<ExternalLauncherEditorSpawnError>()(
317 "ExternalLauncherEditorSpawnError",
318 {
319 ...ExternalLauncherSpawnFields,
320 editor: EditorId,
321 target: Schema.String,
322 },
323) {
324 override get message(): string {
325 return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`;
326 }
327}
328
329export const ExternalLauncherError = Schema.Union([
330 ExternalLauncherUnknownEditorError,
331 ExternalLauncherUnsupportedEditorError,
332 ExternalLauncherCommandNotFoundError,
333 ExternalLauncherUnsupportedTargetError,
334 ExternalLauncherBrowserSpawnError,
335 ExternalLauncherEditorSpawnError,
336]);
337export type ExternalLauncherError = typeof ExternalLauncherError.Type;