feat(startup): wire startup repo path from CLI to UI

The app now supports opening a repository at startup by
reading a --repo argument and passing the path to the UI.
A new startup repository state is managed in the backend and
exposed via a tauri command and event, enabling the frontend
to auto-open the repository when ready. The UI adds a small
startup flow with retry handling to ensure the path is
consumed once available.
- Reads --repo or --repo=PATH and resolves relative paths
- Signals UI to open startup repo via open-startup-repository
- Frontend retries opening the repo until it succeeds
This commit is contained in:
2026-08-16 15:54:54 +02:00
parent 25d7f27d7a
commit 993179c25e
2 changed files with 126 additions and 3 deletions
+86 -3
View File
@@ -31,9 +31,79 @@ use git::{
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote, update_remote,
}; };
use tauri::Manager; use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
struct StartupRepository(Mutex<Option<String>>);
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
let mut args = args.into_iter();
let mut repository = None;
while let Some(arg) = args.next() {
if arg == "--repo" {
repository = args.next();
break;
}
if let Some(path) = arg.strip_prefix("--repo=") {
repository = Some(path.to_string());
break;
}
if !arg.starts_with('-') {
repository = Some(arg);
break;
}
}
repository.filter(|path| !path.trim().is_empty()).map(|path| {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
.to_string_lossy()
.into_owned()
})
}
#[cfg(test)]
mod startup_repository_tests {
use super::repository_path_from_args;
use std::path::Path;
#[test]
fn accepts_direct_relative_repository_path() {
let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
}
#[test]
fn accepts_repo_option() {
let path = repository_path_from_args(
["--repo".to_string(), "/projects/repo".to_string()],
Path::new("/home/user"),
);
assert_eq!(path.as_deref(), Some("/projects/repo"));
}
#[test]
fn accepts_repo_equals_option() {
let path = repository_path_from_args(
["--repo=projects/repo".to_string()],
Path::new("/home/user"),
);
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
}
}
#[tauri::command]
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
state.0.lock().ok()?.take()
}
struct ConsoleLogger; struct ConsoleLogger;
impl log::Log for ConsoleLogger { impl log::Log for ConsoleLogger {
@@ -96,8 +166,19 @@ async fn main() {
return; return;
} }
let startup_repository = repository_path_from_args(
std::env::args().skip(1),
&std::env::current_dir().unwrap_or_default(),
);
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| { .plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
if let Some(path) = repository_path_from_args(args.into_iter().skip(1), Path::new(&cwd)) {
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
*pending = Some(path);
}
let _ = app.emit("open-startup-repository", ());
}
#[cfg(desktop)] #[cfg(desktop)]
let _ = app let _ = app
.get_webview_window("main") .get_webview_window("main")
@@ -112,6 +193,7 @@ async fn main() {
}) })
.build(), .build(),
) )
.manage(StartupRepository(Mutex::new(startup_repository)))
.manage(SearchCancellationState::default()) .manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new()) .manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init()); .plugin(tauri_plugin_dialog::init());
@@ -226,7 +308,8 @@ async fn main() {
close_splashscreen, close_splashscreen,
set_telemetry_enabled, set_telemetry_enabled,
emit_frontend_log, emit_frontend_log,
emit_frontend_span emit_frontend_span,
take_startup_repository
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+40
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
@@ -275,6 +276,10 @@
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
let repoPath = ""; let repoPath = "";
let startupReady = false;
let pendingStartupRepoPath = "";
let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined;
let unlistenStartupRepository: (() => void) | undefined;
let activeRepoPath = ""; let activeRepoPath = "";
let activeView: AppView = "management"; let activeView: AppView = "management";
let repoTabs: RepoTab[] = []; let repoTabs: RepoTab[] = [];
@@ -543,6 +548,14 @@
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)"); themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange); themeMediaQuery.addEventListener("change", handleSystemThemeChange);
void runStartupSequence(); void runStartupSequence();
void listen("open-startup-repository", () => {
void receiveStartupRepository();
}).then((unlisten) => {
if (appShuttingDown) unlisten();
else unlistenStartupRepository = unlisten;
}).catch(() => {
// Browser preview has no Tauri event system.
});
void refreshDetectedExternalTools(); void refreshDetectedExternalTools();
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; }); void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
window.addEventListener("beforeunload", handleAppShutdown); window.addEventListener("beforeunload", handleAppShutdown);
@@ -565,7 +578,9 @@
window.removeEventListener("beforeunload", handleAppShutdown); window.removeEventListener("beforeunload", handleAppShutdown);
window.removeEventListener("pagehide", handleAppShutdown); window.removeEventListener("pagehide", handleAppShutdown);
unlistenCloseRequested?.(); unlistenCloseRequested?.();
unlistenStartupRepository?.();
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer); if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
if (startupRepositoryRetryTimer) clearTimeout(startupRepositoryRetryTimer);
Object.values(errorAutoHideStates).forEach((state) => { Object.values(errorAutoHideStates).forEach((state) => {
if (state?.timer) clearTimeout(state.timer); if (state?.timer) clearTimeout(state.timer);
}); });
@@ -642,6 +657,8 @@
if (remainingSplashTime > 0) await wait(remainingSplashTime); if (remainingSplashTime > 0) await wait(remainingSplashTime);
await closeStartupSplashscreen(); await closeStartupSplashscreen();
startupReady = true;
await receiveStartupRepository();
startBackgroundTimers(); startBackgroundTimers();
// Remote access can take seconds (offline networks, SSH negotiation, // Remote access can take seconds (offline networks, SSH negotiation,
// credential helpers). It must never hold the startup screen hostage. // credential helpers). It must never hold the startup screen hostage.
@@ -650,6 +667,29 @@
} }
} }
async function receiveStartupRepository() {
try {
const path = await tracedInvoke<string | null>("take_startup_repository");
if (path) pendingStartupRepoPath = path;
} catch {
return;
}
if (!startupReady || !pendingStartupRepoPath) return;
if (isBusy) {
if (!startupRepositoryRetryTimer) {
startupRepositoryRetryTimer = setTimeout(() => {
startupRepositoryRetryTimer = undefined;
void receiveStartupRepository();
}, 100);
}
return;
}
const path = pendingStartupRepoPath;
pendingStartupRepoPath = "";
await openRepo(path);
}
async function fetchOpenRepositoriesDuringStartup() { async function fetchOpenRepositoriesDuringStartup() {
const paths = uniqueRepoPaths(repoTabs.map((tab) => tab.path)); const paths = uniqueRepoPaths(repoTabs.map((tab) => tab.path));
if (paths.length === 0 || backgroundFetchInFlight) return; if (paths.length === 0 || backgroundFetchInFlight) return;