Open History to see the last commit
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 readonly remoteScheme?: string;
14 readonly jetbrainsProductCode?: string;
15};
16
17export const EDITORS = [
18 {
19 id: "cursor",
20 label: "Cursor",
21 commands: ["cursor"],
22 // File and workspace opens must target the IDE even when the Agents Window is active.
23 baseArgs: ["--classic"],
24 launchStyle: "goto",
25 remoteScheme: "cursor",
26 },
27 { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" },
28 { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" },
29 {
30 id: "vscode",
31 label: "VS Code",
32 commands: ["code"],
33 launchStyle: "goto",
34 remoteScheme: "vscode",
35 },
36 {
37 id: "vscode-insiders",
38 label: "VS Code Insiders",
39 commands: ["code-insiders"],
40 launchStyle: "goto",
41 remoteScheme: "vscode-insiders",
42 },
43 {
44 id: "vscodium",
45 label: "VSCodium",
46 commands: ["codium"],
47 launchStyle: "goto",
48 remoteScheme: "vscodium",
49 },
50 {
51 id: "zed",
52 label: "Zed",
53 commands: ["zed", "zeditor"],
54 launchStyle: "direct-path",
55 remoteScheme: "zed",
56 },
57 {
58 id: "antigravity",
59 label: "Antigravity",
60 // The IDE ships antigravity-ide; agy is the standalone CLI.
61 commands: ["antigravity-ide", "agy-ide"],
62 launchStyle: "goto",
63 },
64 {
65 id: "idea",
66 label: "IntelliJ IDEA",
67 commands: ["idea"],
68 launchStyle: "line-column",
69 jetbrainsProductCode: "IU",
70 },
71 {
72 id: "aqua",
73 label: "Aqua",
74 commands: ["aqua"],
75 launchStyle: "line-column",
76 jetbrainsProductCode: "QA",
77 },
78 {
79 id: "clion",
80 label: "CLion",
81 commands: ["clion"],
82 launchStyle: "line-column",
83 jetbrainsProductCode: "CL",
84 },
85 {
86 id: "datagrip",
87 label: "DataGrip",
88 commands: ["datagrip"],
89 launchStyle: "line-column",
90 jetbrainsProductCode: "DB",
91 },
92 {
93 id: "dataspell",
94 label: "DataSpell",
95 commands: ["dataspell"],
96 launchStyle: "line-column",
97 jetbrainsProductCode: "DS",
98 },
99 {
100 id: "goland",
101 label: "GoLand",
102 commands: ["goland"],
103 launchStyle: "line-column",
104 jetbrainsProductCode: "GO",
105 },
106 {
107 id: "phpstorm",
108 label: "PhpStorm",
109 commands: ["phpstorm"],
110 launchStyle: "line-column",
111 jetbrainsProductCode: "PS",
112 },
113 {
114 id: "pycharm",
115 label: "PyCharm",
116 commands: ["pycharm"],
117 launchStyle: "line-column",
118 jetbrainsProductCode: "PY",
119 },
120 {
121 id: "rider",
122 label: "Rider",
123 commands: ["rider"],
124 launchStyle: "line-column",
125 jetbrainsProductCode: "RD",
126 },
127 {
128 id: "rubymine",
129 label: "RubyMine",
130 commands: ["rubymine"],
131 launchStyle: "line-column",
132 jetbrainsProductCode: "RM",
133 },
134 {
135 id: "rustrover",
136 label: "RustRover",
137 commands: ["rustrover"],
138 launchStyle: "line-column",
139 jetbrainsProductCode: "RR",
140 },
141 {
142 id: "webstorm",
143 label: "WebStorm",
144 commands: ["webstorm"],
145 launchStyle: "line-column",
146 jetbrainsProductCode: "WS",
147 },
148 { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" },
149] as const satisfies ReadonlyArray<EditorDefinition>;
150
151export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
152export type EditorId = typeof EditorId.Type;
153
154export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]);
155export type FileManagerRevealKind = typeof FileManagerRevealKind.Type;
156
157export const LaunchEditorInput = Schema.Struct({
158 cwd: TrimmedNonEmptyString,
159 editor: EditorId,
160 /** Workspace the target belongs to; JetBrains IDEs open positioned files in this project. */
161 projectRoot: Schema.optional(TrimmedNonEmptyString),
162 /** Only for file-manager; requires the server's shellRevealInFileManager capability. */
163 reveal: Schema.optional(Schema.Boolean),
164});
165export type LaunchEditorInput = typeof LaunchEditorInput.Type;
166
167const remoteSchemeOf = (editor: EditorDefinition): string | undefined =>
168 editor.remoteScheme ?? (editor.jetbrainsProductCode === undefined ? undefined : "jetbrains");
169
170export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) =>
171 remoteSchemeOf(editor) !== undefined ? [editor.id] : [],
172);
173
174export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
175 const editor = EDITORS.find((candidate) => candidate.id === id);
176 return editor === undefined ? undefined : remoteSchemeOf(editor);
177};
178
179// `goto` editors share VS Code's `file:line:col` addressing, so their remote schemes also open
180// a local file as `<scheme>://file/<absolute path>[:line[:col]]`. Zed and JetBrains differ.
181export const LOCAL_FILE_LINK_SCHEMES: ReadonlyArray<string> = EDITORS.flatMap((editor) =>
182 editor.launchStyle === "goto" && "remoteScheme" in editor ? [editor.remoteScheme] : [],
183);
184
185export const isLocalEditorFileUrl = (url: URL): boolean =>
186 LOCAL_FILE_LINK_SCHEMES.includes(url.protocol.slice(0, -1)) &&
187 url.host === "file" &&
188 url.username.length === 0 &&
189 url.password.length === 0 &&
190 url.pathname.length > 1;
191
192export const buildRemoteOpenUrl = (input: {
193 readonly editor: EditorId;
194 readonly host: string;
195 readonly username?: string;
196 readonly absolutePath: string;
197 readonly isFile?: boolean;
198}): string | undefined => {
199 const scheme = remoteSchemeForEditor(input.editor);
200 if (scheme === undefined) {
201 return undefined;
202 }
203 const editor = EDITORS.find((candidate) => candidate.id === input.editor);
204 if (editor !== undefined && "jetbrainsProductCode" in editor) {
205 const params = new URLSearchParams({
206 h: input.host,
207 launchIde: "true",
208 ideHint: editor.jetbrainsProductCode,
209 projectHint: input.absolutePath.replaceAll("\\", "/"),
210 });
211 if (input.username !== undefined) {
212 params.set("u", input.username);
213 }
214 return `${scheme}://gateway/ssh/environment?${params.toString()}`;
215 }
216 // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.
217 const posixPath = input.absolutePath.replaceAll("\\", "/");
218 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
219 const encodedHost = encodeURIComponent(input.host);
220 if (input.editor === "zed") {
221 // Zed roots paths on the system drive, so omit the Windows drive prefix.
222 const zedPath = /^[Cc]:[\\/]/.test(input.absolutePath) ? rootedPath.slice(3) : rootedPath;
223 const encodedZedPath = zedPath.split("/").map(encodeURIComponent).join("/");
224 // Zed reads this segment as ssh URL userinfo, so the `@` stays literal.
225 const zedDestination =
226 input.username === undefined
227 ? encodedHost
228 : `${encodeURIComponent(input.username)}@${encodedHost}`;
229 return `${scheme}://ssh/${zedDestination}${encodedZedPath}`;
230 }
231 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");
232 const plainDestination =
233 input.username === undefined ? input.host : `${input.username}@${input.host}`;
234 // Remote-SSH splits the decoded authority on "+", so a destination containing one must use its
235 // hex-encoded JSON form instead.
236 const destination = plainDestination.includes("+")
237 ? Array.from(
238 new TextEncoder().encode(JSON.stringify({ hostName: input.host, user: input.username })),
239 (byte) => byte.toString(16).padStart(2, "0"),
240 ).join("")
241 : plainDestination;
242 // VS Code opens a bare vscode-remote path as a folder; a trailing position makes it open the file.
243 return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(destination)}${encodedPath}${input.isFile ? ":1:1" : ""}`;
244};
245
246export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]);
247export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type;
248
249export const RemoteOpenTarget = Schema.Struct({
250 kind: RemoteOpenTargetKind,
251 host: TrimmedNonEmptyString,
252 username: Schema.optionalKey(TrimmedNonEmptyString),
253});
254export type RemoteOpenTarget = typeof RemoteOpenTarget.Type;
255
256export class ExternalLauncherUnknownEditorError extends Schema.TaggedError<ExternalLauncherUnknownEditorError>()(
257 "ExternalLauncherUnknownEditorError",
258 {
259 editor: Schema.String,
260 },
261) {
262 override get message(): string {
263 return `Unknown editor: ${this.editor}`;
264 }
265}
266
267export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedError<ExternalLauncherUnsupportedEditorError>()(
268 "ExternalLauncherUnsupportedEditorError",
269 {
270 editor: EditorId,
271 },
272) {
273 override get message(): string {
274 return `Unsupported editor: ${this.editor}`;
275 }
276}
277
278export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError<ExternalLauncherCommandNotFoundError>()(
279 "ExternalLauncherCommandNotFoundError",
280 {
281 editor: EditorId,
282 command: Schema.String,
283 },
284) {
285 override get message(): string {
286 return `Editor command not found: ${this.command}`;
287 }
288}
289
290export class ExternalLauncherUnsupportedTargetError extends Schema.TaggedError<ExternalLauncherUnsupportedTargetError>()(
291 "ExternalLauncherUnsupportedTargetError",
292 {
293 editor: EditorId,
294 },
295) {
296 override get message(): string {
297 return `${this.editor} cannot open a path containing line breaks or double quotes`;
298 }
299}
300
301const ExternalLauncherSpawnFields = {
302 command: Schema.String,
303 args: Schema.Array(Schema.String),
304 cause: Schema.Defect(),
305};
306
307export class ExternalLauncherBrowserSpawnError extends Schema.TaggedError<ExternalLauncherBrowserSpawnError>()(
308 "ExternalLauncherBrowserSpawnError",
309 {
310 ...ExternalLauncherSpawnFields,
311 target: Schema.String,
312 },
313) {
314 override get message(): string {
315 return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`;
316 }
317}
318
319export class ExternalLauncherEditorSpawnError extends Schema.TaggedError<ExternalLauncherEditorSpawnError>()(
320 "ExternalLauncherEditorSpawnError",
321 {
322 ...ExternalLauncherSpawnFields,
323 editor: EditorId,
324 target: Schema.String,
325 },
326) {
327 override get message(): string {
328 return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`;
329 }
330}
331
332export const ExternalLauncherError = Schema.Union([
333 ExternalLauncherUnknownEditorError,
334 ExternalLauncherUnsupportedEditorError,
335 ExternalLauncherCommandNotFoundError,
336 ExternalLauncherUnsupportedTargetError,
337 ExternalLauncherBrowserSpawnError,
338 ExternalLauncherEditorSpawnError,
339]);
340export type ExternalLauncherError = typeof ExternalLauncherError.Type;