const EXTENSION_ID: &str = "LeanCTX.lean-ctx";
pub(crate) enum EditorOpen {
Handed(&'static str),
NeedsExtension(&'static str),
NoEditor,
}
#[derive(Clone, Copy)]
struct Editor {
label: &'static str,
bin: &'static str,
scheme: &'static str,
ext_dir: &'static str,
}
const CURSOR: Editor = Editor {
label: "Cursor",
bin: "cursor",
scheme: "cursor",
ext_dir: ".cursor/extensions",
};
const WINDSURF: Editor = Editor {
label: "Windsurf",
bin: "windsurf",
scheme: "windsurf",
ext_dir: ".windsurf/extensions",
};
const VSCODIUM: Editor = Editor {
label: "VSCodium",
bin: "codium",
scheme: "vscodium",
ext_dir: ".vscode-oss/extensions",
};
const INSIDERS: Editor = Editor {
label: "VS Code Insiders",
bin: "code-insiders",
scheme: "vscode-insiders",
ext_dir: ".vscode-insiders/extensions",
};
const VSCODE: Editor = Editor {
label: "VS Code",
bin: "code",
scheme: "vscode",
ext_dir: ".vscode/extensions",
};
fn classify(
askpass: &str,
bundle: &str,
term: &str,
cursor_trace: bool,
vscode_injection: bool,
) -> Option<Editor> {
let bundle = bundle.to_ascii_lowercase();
let bundle_family = bundle.starts_with("com.microsoft.vscode")
|| bundle.starts_with("com.todesktop.")
|| bundle.contains("vscodium")
|| bundle.contains("windsurf")
|| bundle.contains("visualstudio.code");
let in_family = vscode_injection
|| cursor_trace
|| !askpass.is_empty()
|| term.eq_ignore_ascii_case("vscode")
|| bundle_family;
if !in_family {
return None;
}
let hay = format!("{} {bundle}", askpass.to_ascii_lowercase());
if cursor_trace || hay.contains("cursor") || bundle.starts_with("com.todesktop.") {
Some(CURSOR)
} else if hay.contains("windsurf") {
Some(WINDSURF)
} else if hay.contains("vscodium") || hay.contains("codium") {
Some(VSCODIUM)
} else if hay.contains("insiders") {
Some(INSIDERS)
} else {
Some(VSCODE)
}
}
fn detect_editor() -> Option<Editor> {
classify(
&std::env::var("VSCODE_GIT_ASKPASS_MAIN").unwrap_or_default(),
&std::env::var("__CFBundleIdentifier").unwrap_or_default(),
&std::env::var("TERM_PROGRAM").unwrap_or_default(),
std::env::var_os("CURSOR_TRACE_ID").is_some(),
std::env::var_os("VSCODE_INJECTION").is_some(),
)
}
fn extension_installed(editor: Editor) -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
let needle = EXTENSION_ID.to_ascii_lowercase();
for dir in [
home.join(editor.ext_dir),
home.join(".vscode-server/extensions"),
] {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
if entry
.file_name()
.to_string_lossy()
.to_ascii_lowercase()
.starts_with(&needle)
{
return true;
}
}
}
false
}
fn bin_on_path(bin: &str) -> bool {
let which = if cfg!(windows) { "where" } else { "which" };
std::process::Command::new(which)
.arg(bin)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn run_opener(bin: &str, args: &[&str]) -> bool {
std::process::Command::new(bin)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn os_open(uri: &str) -> bool {
#[cfg(target_os = "macos")]
let ok = run_opener("open", &[uri]);
#[cfg(target_os = "linux")]
let ok = run_opener("xdg-open", &[uri]);
#[cfg(target_os = "windows")]
let ok = run_opener("cmd", &["/C", "start", "", uri]);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let ok = {
let _ = uri;
false
};
ok
}
fn issue_deep_link(editor: Editor) -> bool {
let uri = format!("{}://{}/dashboard", editor.scheme, EXTENSION_ID);
if bin_on_path(editor.bin) && run_opener(editor.bin, &["--open-url", uri.as_str()]) {
return true;
}
os_open(&uri)
}
pub(crate) fn open_in_editor() -> EditorOpen {
let Some(editor) = detect_editor() else {
return EditorOpen::NoEditor;
};
if !extension_installed(editor) {
return EditorOpen::NeedsExtension(editor.label);
}
if issue_deep_link(editor) {
EditorOpen::Handed(editor.label)
} else {
EditorOpen::NeedsExtension(editor.label)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_outside_an_editor_is_none() {
assert!(classify("", "", "Apple_Terminal", false, false).is_none());
assert!(classify("", "", "iTerm.app", false, false).is_none());
assert!(classify("", "", "", false, false).is_none());
}
#[test]
fn classify_detects_each_fork() {
let scheme = |a: &str, t: &str, cur: bool| classify(a, "", t, cur, false).map(|e| e.scheme);
assert_eq!(
scheme(
"/Applications/Cursor.app/Contents/.../askpass.js",
"vscode",
false
),
Some("cursor")
);
assert_eq!(scheme("", "vscode", true), Some("cursor"));
assert_eq!(
scheme("/Applications/Windsurf.app/.../askpass.js", "vscode", false),
Some("windsurf")
);
assert_eq!(
scheme("/Applications/VSCodium.app/.../askpass.js", "vscode", false),
Some("vscodium")
);
assert_eq!(
scheme(
"/Applications/Visual Studio Code.app/.../askpass.js",
"vscode",
false
),
Some("vscode")
);
}
#[test]
fn classify_insiders_wins_over_generic_code() {
let e = classify(
"/Applications/Visual Studio Code - Insiders.app/.../askpass.js",
"",
"vscode",
false,
false,
)
.expect("in editor family");
assert_eq!(e.scheme, "vscode-insiders");
assert_eq!(e.bin, "code-insiders");
}
#[test]
fn classify_falls_back_to_vscode_on_bare_term_program() {
let e = classify("", "", "vscode", false, false).expect("in editor family");
assert_eq!(e.scheme, "vscode");
assert_eq!(e.bin, "code");
}
#[test]
fn classify_uses_injection_signal_when_term_program_missing() {
assert!(classify("", "", "", false, true).is_some());
}
#[test]
fn classify_detects_via_macos_bundle_id() {
let scheme = |b: &str| classify("", b, "", false, false).map(|e| e.scheme);
assert_eq!(scheme("com.todesktop.230313mzl4w4u92"), Some("cursor"));
assert_eq!(scheme("com.microsoft.VSCode"), Some("vscode"));
assert_eq!(
scheme("com.microsoft.VSCodeInsiders"),
Some("vscode-insiders")
);
assert!(classify("", "com.apple.Terminal", "", false, false).is_none());
}
}