Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
{
"identifier": "shell:allow-execute",
"allow": [
Expand Down
7 changes: 6 additions & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! the OS-keychain secret storage the engine consumes via its environment.

mod engine;
mod repo;
mod secrets;

use engine::EngineManager;
Expand Down Expand Up @@ -50,6 +51,7 @@ fn list_secret_keys(app: tauri::AppHandle) -> Result<Vec<String>, String> {
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
// Start the engine sidecar up front so the UI has an endpoint as
// soon as it loads. A startup failure aborts the app with a clear
Expand All @@ -67,7 +69,10 @@ pub fn run() {
restart_engine,
set_secret,
delete_secret,
list_secret_keys
list_secret_keys,
repo::pick_directory,
repo::check_git_repo,
repo::which_commands
])
.build(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
98 changes: 98 additions & 0 deletions desktop/src-tauri/src/repo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! Repository selection + local environment checks (desktop only).
//!
//! These commands back the Start screen's "select a repo" flow and the provider
//! onboarding panel: a native folder picker, a fast "is this a git repo?" probe,
//! and detection of which coding/CLI tools are installed. They run in the
//! trusted Rust shell (not the webview) so they can touch the filesystem and
//! PATH directly; the engine still re-validates a selected repo server-side
//! before a run starts.

use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;

/// Opens the native folder picker and returns the chosen absolute path (or
/// `None` if the user cancelled). Blocking is fine here: Tauri runs command
/// handlers off the main thread, and the picker is modal by design.
#[tauri::command]
pub fn pick_directory(app: AppHandle) -> Result<Option<String>, String> {
let picked = app.dialog().file().blocking_pick_folder();
Ok(picked
.and_then(|p| p.into_path().ok())
.map(|p| p.to_string_lossy().to_string()))
}

/// True when `path` is inside a git working tree. Mirrors the engine's
/// `isGitRepo` so the UI can validate a selection before a run is started.
#[tauri::command]
pub fn check_git_repo(path: String) -> bool {
Command::new("git")
.args(["-C", &path, "rev-parse", "--is-inside-work-tree"])
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false)
}

/// For each requested tool name, reports whether it is found on PATH. Used by
/// the provider-onboarding panel to show "installed / missing" for tools like
/// `codex`, `kiro`, and `gh`.
#[tauri::command]
pub fn which_commands(names: Vec<String>) -> HashMap<String, bool> {
names
.into_iter()
.map(|n| {
let found = is_on_path(&n);
(n, found)
})
.collect()
}

/// Resolves whether `name` exists as an executable on PATH, without running it.
/// On Windows it also tries the usual executable extensions (PATHEXT).
fn is_on_path(name: &str) -> bool {
// An explicit path (contains a separator) is checked directly.
if name.contains('/') || name.contains('\\') {
return is_executable(Path::new(name));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".to_string())
.split(';')
.map(|s| s.to_string())
.collect()
} else {
vec![String::new()]
};
for dir in std::env::split_paths(&paths) {
for ext in &exts {
let candidate = dir.join(format!("{name}{ext}"));
if is_executable(&candidate) {
return true;
}
}
}
false
}

/// True when `path` is a regular file that is actually executable. On Unix a
/// plain readable file on PATH is not runnable, so we check the exec bits;
/// elsewhere (Windows) file existence plus a PATHEXT extension is sufficient.
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
30 changes: 30 additions & 0 deletions desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface StartRunBody {
env?: Record<string, string>;
sessionId?: string;
resume?: boolean;
/** absolute path to the local git repo the run should build against */
repoDir?: string;
}

export async function startRun(body: StartRunBody): Promise<string> {
Expand Down Expand Up @@ -177,3 +179,31 @@ export async function setSecret(key: string, value: string): Promise<void> {
export async function deleteSecret(key: string): Promise<void> {
await tauriInvoke<void>("delete_secret", { key });
}

// --- Repo selection + environment checks (Tauri only) ----------------------

/**
* Opens the native folder picker and returns the chosen absolute path, or null
* if the user cancelled. Only available in the desktop app; in a browser there
* is no filesystem access, so callers fall back to a manual path input.
*/
export async function pickDirectory(): Promise<string | null> {
if (!isTauri()) return null;
return tauriInvoke<string | null>("pick_directory");
}

/** Whether the given path is a git working tree (Tauri only; null = unknown). */
export async function checkGitRepo(path: string): Promise<boolean | null> {
if (!isTauri()) return null;
return tauriInvoke<boolean>("check_git_repo", { path });
}

/**
* Detects which of the named CLI tools are installed/on PATH (e.g. codex, kiro,
* gh). Used for provider onboarding ("installed / missing"). Returns an empty
* map in a browser, where local command detection isn't possible.
*/
export async function detectCommands(names: string[]): Promise<Record<string, boolean>> {
if (!isTauri()) return {};
return tauriInvoke<Record<string, boolean>>("which_commands", { names });
}
Loading
Loading