| 1 | import * as Schema from "effect/Schema"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | |
| 4 | export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); |
| 5 | export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; |
| 6 | |
| 7 | type 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 | |
| 28 | export 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 | |
| 163 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); |
| 164 | export type EditorId = typeof EditorId.Type; |
| 165 | |
| 166 | export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]); |
| 167 | export type FileManagerRevealKind = typeof FileManagerRevealKind.Type; |
| 168 | |
| 169 | export 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 | }); |
| 177 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; |
| 178 | |
| 179 | const 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. */ |
| 183 | export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) => |
| 184 | remoteSchemeOf(editor) !== undefined ? [editor.id] : [], |
| 185 | ); |
| 186 | |
| 187 | export 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 | */ |
| 199 | export 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 | */ |
| 243 | export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); |
| 244 | export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; |
| 245 | |
| 246 | export const RemoteOpenTarget = Schema.Struct({ |
| 247 | kind: RemoteOpenTargetKind, |
| 248 | host: TrimmedNonEmptyString, |
| 249 | }); |
| 250 | export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; |
| 251 | |
| 252 | export 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 | |
| 263 | export 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 | |
| 274 | export 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. */ |
| 287 | export 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 | |
| 298 | const ExternalLauncherSpawnFields = { |
| 299 | command: Schema.String, |
| 300 | args: Schema.Array(Schema.String), |
| 301 | cause: Schema.Defect(), |
| 302 | }; |
| 303 | |
| 304 | export 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 | |
| 316 | export 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 | |
| 329 | export const ExternalLauncherError = Schema.Union([ |
| 330 | ExternalLauncherUnknownEditorError, |
| 331 | ExternalLauncherUnsupportedEditorError, |
| 332 | ExternalLauncherCommandNotFoundError, |
| 333 | ExternalLauncherUnsupportedTargetError, |
| 334 | ExternalLauncherBrowserSpawnError, |
| 335 | ExternalLauncherEditorSpawnError, |
| 336 | ]); |
| 337 | export type ExternalLauncherError = typeof ExternalLauncherError.Type; |