Add external editor/file picker, update theme

Introduce file/application pickers and external-editor integration, plus a visual refresh. Frontend: add UI for selecting/rescanning installed IDEs, custom editor input, "Open in File Explorer" and "Open in Code Editor" actions, clone destination browse, helper utilities, new icons, and many CSS theme/UX improvements (variables, shadows, scrollbars, selection, refined component styles). State: track installedIdes and scan status. Tauri API: expose browseDirectory, browseApplication and scanInstalledIdes, and wire UI handlers to call them. Backend: add InstalledIde struct and update tauri Cargo manifest and capabilities to allow dialogs. Overall improves editor/workflow integrations and modernizes the app styling.
This commit is contained in:
2026-05-10 21:26:03 +12:00
parent ac7fc231a0
commit 5e10750043
9 changed files with 815 additions and 104 deletions
+68
View File
@@ -1297,6 +1297,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
]
@@ -2254,6 +2255,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2920,6 +2922,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3675,6 +3701,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
+1
View File
@@ -23,4 +23,5 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
tauri-plugin-dialog = "2.7.1"
+1
View File
@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"opener:default"
]
}
+274
View File
@@ -126,6 +126,14 @@ struct GitCommitDetail {
diff: String,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct InstalledIde {
id: String,
name: String,
executable_path: String,
}
fn normalize_api_base_url(server_url: &str) -> Result<String, String> {
// Normalize user input so every backend consistently resolves to /api/v1.
let trimmed = server_url.trim().trim_end_matches('/');
@@ -240,6 +248,194 @@ fn validate_git_path(path: &str) -> Result<String, String> {
Ok(normalized)
}
fn add_installed_ide(
ides: &mut Vec<InstalledIde>,
seen: &mut HashSet<String>,
name: &str,
executable_path: PathBuf,
) {
if !executable_path.is_file() {
return;
}
let executable_path = executable_path.to_string_lossy().to_string();
let key = executable_path.to_lowercase();
if seen.insert(key) {
ides.push(InstalledIde {
id: format!(
"{}:{}",
name.to_lowercase().replace(' ', "-"),
executable_path
),
name: name.to_string(),
executable_path,
});
}
}
fn env_path(name: &str) -> Option<PathBuf> {
env::var_os(name).map(PathBuf::from)
}
fn add_relative_ide_candidate(
ides: &mut Vec<InstalledIde>,
seen: &mut HashSet<String>,
env_name: &str,
relative_path: &str,
name: &str,
) {
if let Some(base_path) = env_path(env_name) {
add_installed_ide(ides, seen, name, base_path.join(relative_path));
}
}
fn add_path_ide_candidate(
ides: &mut Vec<InstalledIde>,
seen: &mut HashSet<String>,
command_names: &[&str],
name: &str,
) {
if let Some(executable_path) = find_executable_on_path(command_names) {
add_installed_ide(ides, seen, name, executable_path);
}
}
fn find_executable_on_path(command_names: &[&str]) -> Option<PathBuf> {
let path_values = env::var_os("PATH")?;
let extensions: Vec<String> = if cfg!(windows) {
env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT".to_string())
.split(';')
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
} else {
vec!["".to_string()]
};
for dir in env::split_paths(&path_values) {
for command_name in command_names {
let command_path = Path::new(command_name);
let has_extension = command_path.extension().is_some();
let candidates = if has_extension {
vec![dir.join(command_name)]
} else {
extensions
.iter()
.map(|extension| dir.join(format!("{command_name}{extension}")))
.collect()
};
for candidate in candidates {
if candidate.is_file() {
return Some(candidate);
}
}
}
}
None
}
#[cfg(target_os = "windows")]
fn add_windows_ide_candidates(ides: &mut Vec<InstalledIde>, seen: &mut HashSet<String>) {
let candidates = [
(
"LOCALAPPDATA",
"Programs\\Microsoft VS Code\\Code.exe",
"Visual Studio Code",
),
(
"LOCALAPPDATA",
"Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe",
"Visual Studio Code Insiders",
),
("LOCALAPPDATA", "Programs\\Cursor\\Cursor.exe", "Cursor"),
(
"LOCALAPPDATA",
"Programs\\Windsurf\\Windsurf.exe",
"Windsurf",
),
(
"LOCALAPPDATA",
"Programs\\VSCodium\\VSCodium.exe",
"VSCodium",
),
("LOCALAPPDATA", "Programs\\Zed\\Zed.exe", "Zed"),
(
"ProgramFiles",
"Microsoft VS Code\\Code.exe",
"Visual Studio Code",
),
(
"ProgramFiles",
"Microsoft VS Code Insiders\\Code - Insiders.exe",
"Visual Studio Code Insiders",
),
("ProgramFiles", "Cursor\\Cursor.exe", "Cursor"),
("ProgramFiles", "Windsurf\\Windsurf.exe", "Windsurf"),
("ProgramFiles", "VSCodium\\VSCodium.exe", "VSCodium"),
(
"ProgramFiles",
"Sublime Text\\sublime_text.exe",
"Sublime Text",
),
("ProgramFiles", "Notepad++\\notepad++.exe", "Notepad++"),
("ProgramFiles(x86)", "Notepad++\\notepad++.exe", "Notepad++"),
];
for (env_name, relative_path, name) in candidates {
add_relative_ide_candidate(ides, seen, env_name, relative_path, name);
}
for env_name in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Some(jetbrains_root) = env_path(env_name).map(|path| path.join("JetBrains")) {
add_jetbrains_candidates(ides, seen, &jetbrains_root);
}
}
}
#[cfg(not(target_os = "windows"))]
fn add_windows_ide_candidates(_ides: &mut Vec<InstalledIde>, _seen: &mut HashSet<String>) {}
fn add_jetbrains_candidates(ides: &mut Vec<InstalledIde>, seen: &mut HashSet<String>, root: &Path) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let app_dir = entry.path();
if !app_dir.is_dir() {
continue;
}
let dir_name = app_dir
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("JetBrains IDE")
.to_string();
let candidates = [
("IntelliJ IDEA", "idea64.exe"),
("WebStorm", "webstorm64.exe"),
("PhpStorm", "phpstorm64.exe"),
("PyCharm", "pycharm64.exe"),
("Rider", "rider64.exe"),
("RustRover", "rustrover64.exe"),
("GoLand", "goland64.exe"),
];
for (label, exe) in candidates {
let label_lower = label.to_lowercase();
let name = if dir_name.to_lowercase().contains(label_lower.as_str()) {
dir_name.clone()
} else {
label.to_string()
};
add_installed_ide(ides, seen, &name, app_dir.join("bin").join(exe));
}
}
}
fn status_kind(x: &str, y: &str) -> String {
match (x, y) {
("?", "?") => "untracked",
@@ -1259,6 +1455,82 @@ fn open_in_external_editor(repo_path: String, editor_path: String) -> Result<(),
Ok(())
}
#[tauri::command]
fn scan_installed_ides() -> Result<Vec<InstalledIde>, String> {
let mut ides = Vec::new();
let mut seen = HashSet::new();
add_windows_ide_candidates(&mut ides, &mut seen);
#[cfg(target_os = "macos")]
{
let candidates = [
(
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
"Visual Studio Code",
),
(
"/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders",
"Visual Studio Code Insiders",
),
("/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "Cursor"),
("/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf", "Windsurf"),
("/Applications/VSCodium.app/Contents/Resources/app/bin/codium", "VSCodium"),
("/Applications/Zed.app/Contents/MacOS/zed", "Zed"),
("/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", "Sublime Text"),
];
for (path, name) in candidates {
add_installed_ide(&mut ides, &mut seen, name, PathBuf::from(path));
}
}
add_path_ide_candidate(
&mut ides,
&mut seen,
&["code", "Code.exe", "code.cmd"],
"Visual Studio Code",
);
add_path_ide_candidate(
&mut ides,
&mut seen,
&["code-insiders", "Code - Insiders.exe", "code-insiders.cmd"],
"Visual Studio Code Insiders",
);
add_path_ide_candidate(
&mut ides,
&mut seen,
&["cursor", "Cursor.exe", "cursor.cmd"],
"Cursor",
);
add_path_ide_candidate(
&mut ides,
&mut seen,
&["windsurf", "Windsurf.exe", "windsurf.cmd"],
"Windsurf",
);
add_path_ide_candidate(
&mut ides,
&mut seen,
&["codium", "VSCodium.exe", "codium.cmd"],
"VSCodium",
);
add_path_ide_candidate(&mut ides, &mut seen, &["zed", "Zed.exe"], "Zed");
add_path_ide_candidate(
&mut ides,
&mut seen,
&["subl", "sublime_text.exe"],
"Sublime Text",
);
ides.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then(a.executable_path.cmp(&b.executable_path))
});
Ok(ides)
}
#[tauri::command]
fn scan_local_repos(
roots: Option<Vec<String>>,
@@ -1558,6 +1830,7 @@ fn test_gitea_connection(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
git_clone,
@@ -1582,6 +1855,7 @@ pub fn run() {
commit_detail,
open_in_file_explorer,
open_in_external_editor,
scan_installed_ides,
scan_local_repos,
local_repo_branches,
local_repo_tree,