From 53ef60c0877c8a894c3cbfd5b3a4e17d398178a8 Mon Sep 17 00:00:00 2001 From: florian Date: Mon, 13 Jul 2026 23:32:07 +0200 Subject: [PATCH 01/21] feat: clone-import, detach-from-remote, dir sizes + drag-out, VPN tunnel-up gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import a project straight from a GitHub/GitLab URL (`git_clone` lands the tree, then the ordinary import registers it in place), and reverse the extend flow with `detach_project_from_remote`. Remote hosts remember a default path (`remote_{list,get,set}_default_path`), and the "Save password" opt-in is now the same control in every remote menu — the Connect modal and the new-project / extend-to-remote dialogs share `useRemoteSession`. The keychain is keyed by host target, not project id, so the credential a create/extend dialog authenticated with is handed to that project's first pooled connect (`stashRemotePassword`, single-use, never on disk); connecting password-less would otherwise record `key_auth: true` on a password host. While a tunnel is up machine-wide, every project-scoped OpenVPN block collapses to a one-line notice pointing at the header, via the shared `useVpnSectionVisible` gate + `VpnTunnelUpNotice` — except a tunnel the dialog itself brought up, which stays expanded so its log and Disconnect remain where the user started it. File tree gains per-directory size breakdown (`dir_size_breakdown`) and drag-out to other apps (`start_file_drag` / `cancel_file_drag`). Gates: tsc clean, 1358 frontend tests, 915 Rust tests, privacy scan clear. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ag1A6YVQxujZkcnrWahjua --- CLAUDE.md | 23 +- Cargo.lock | 2 +- src-tauri/src/commands/apps.rs | 164 ++++++++ src-tauri/src/commands/fs.rs | 166 ++++++++ src-tauri/src/commands/git.rs | 263 +++++++++++- src-tauri/src/commands/git_hosting.rs | 9 + src-tauri/src/commands/projects.rs | 135 ++++++- src-tauri/src/commands/ssh.rs | 49 +++ src-tauri/src/lib.rs | 7 + src-tauri/src/services/git_peer.rs | 377 ++++++++++++++++-- src-tauri/src/services/remote_sync.rs | 56 +++ src-tauri/src/services/terminal_service.rs | 33 +- src-tauri/tests/services_tests.rs | 68 +++- src/CLAUDE.md | 2 +- src/__tests__/CloneImport.test.ts | 61 +++ src/__tests__/DetachFromRemote.test.ts | 141 +++++++ src/__tests__/FileTreeDirSize.test.tsx | 118 ++++++ src/__tests__/FileTreeNav.test.tsx | 14 +- src/__tests__/PillRunningIndicator.test.ts | 69 ++++ .../RemoteCreateCredentials.test.tsx | 207 ++++++++++ src/__tests__/RemoteVpnDisconnect.test.tsx | 19 +- src/__tests__/VpnAlreadyUpSections.test.tsx | 140 +++++++ src/components/common/VpnTunnelUpNotice.tsx | 23 ++ src/components/files/FileTree.tsx | 352 +++++++++++++--- src/components/layout/CenterPanel.tsx | 39 +- src/components/layout/ProjectSwitcher.tsx | 10 +- src/components/layout/RightPanel.tsx | 81 +++- src/components/layout/SettingsPanel.tsx | 103 ++++- src/components/layout/SettingsSubPanels.tsx | 135 +++++++ .../projects/ExtendToRemoteDialog.tsx | 18 +- src/components/projects/ProjectDialog.tsx | 160 +++++++- src/components/projects/ProjectPill.tsx | 59 ++- .../projects/RemoteConnectDialog.tsx | 16 +- .../projects/RemoteProjectSection.tsx | 109 ++++- src/components/projects/scaffold.ts | 41 ++ src/components/projects/useRemoteReconnect.ts | 29 +- src/components/projects/useRemoteSession.ts | 139 ++++++- src/components/tabs/TabBar.tsx | 18 +- src/stores/activity.ts | 74 ++-- src/stores/projects.ts | 59 ++- src/stores/tabs.ts | 67 ++++ src/stores/vpnStatus.ts | 17 + src/styles/themes.css | 107 ++++- 43 files changed, 3495 insertions(+), 284 deletions(-) create mode 100644 src/__tests__/CloneImport.test.ts create mode 100644 src/__tests__/DetachFromRemote.test.ts create mode 100644 src/__tests__/FileTreeDirSize.test.tsx create mode 100644 src/__tests__/RemoteCreateCredentials.test.tsx create mode 100644 src/__tests__/VpnAlreadyUpSections.test.tsx create mode 100644 src/components/common/VpnTunnelUpNotice.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 8e7881b..7d17996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,20 @@ Both list only the load-bearing files; the tree is the source of truth. **different notions of scope**, so marking a folder auto-sync is the one click that can haul a multi-GB tree into the mirror; the file tree prices the host subtree first (`sync_auto_preview`) and confirms when it is large. +- **Passwords are never persisted by default**, and the opt-in that persists them is + the same in every remote menu — the Connect modal *and* the new-project / + extend-to-remote dialogs (`useRemoteSession`, rendered by `RemoteProjectSection`). + It can be, because the keychain is keyed by **host target** (`ssh:user@host:port`) + and **config path**, never by project id: there is one saved credential per host and + per tunnel, whichever menu saved it. Hence the toggle is *pre-ticked* when the target + already has one (an untick is an explicit delete, so connecting with it unticked + would clear another project's saved password), and a blank password field means "use + the saved one", not "authenticate with nothing". The credential a create/extend + dialog authenticated with is also handed to that project's **first pooled connect** + (`stashRemotePassword`, single-use, never written to disk): connecting it + password-less would work — it rides the ControlMaster the dialog left up — but the + backend reads "no password given, none saved" as *key* auth and would record + `key_auth: true` on a password host, which auto-connect later believes. - A remote project connects **on demand** (the pill's connection lamp opens the `RemoteConnectDialog`) — *unless* it opts into `remote.auto_connect`, which connects it on launch and on activation and **never prompts**. The toggle is only @@ -112,7 +126,14 @@ Both list only the load-bearing files; the tree is the source of truth. a holder refcount — `releaseVpn` means a project logging out never pulls a tunnel out from under another project) and surfaced in the header by `VpnIndicator`, which is always present, lists every stored `.ovpn`, and can bring a tunnel **up or down with - no project behind it**. Every UI that can start a tunnel says so before it does. + no project behind it**. Every UI that can start a tunnel says so before it does — + and none of them offers a *second* one: while a tunnel is up machine-wide, every + project-scoped OpenVPN block (the Connect modal, and the SSH section the + new-project and extend-to-remote dialogs share) collapses to a one-line + "tunnel already up" notice pointing at the header, via the shared + `useVpnSectionVisible` gate + `VpnTunnelUpNotice`. The exception the gate keeps: + a tunnel *that* dialog itself brought up stays expanded, so its log and its + Disconnect remain where the user started it. Interactive (non-headless) tunnels are *armed* at command-build time — `interactive_connect_command` appends a `--writepid` Eldrun owns and registers it — so a tunnel typed into a terminal tab is as visible and as killable as a headless diff --git a/Cargo.lock b/Cargo.lock index 49e8c21..94a4d1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,7 +1129,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "eldrun" -version = "0.1.20" +version = "0.1.21" dependencies = [ "arboard", "base64 0.22.1", diff --git a/src-tauri/src/commands/apps.rs b/src-tauri/src/commands/apps.rs index 6865c8d..2c8ada0 100644 --- a/src-tauri/src/commands/apps.rs +++ b/src-tauri/src/commands/apps.rs @@ -618,6 +618,170 @@ pub fn drag_preview_icon() -> String { format!("data:image/png;base64,{b64}") } +#[cfg(target_os = "linux")] +thread_local! { + /// The in-flight GTK drag, so `cancel_file_drag` can abort it when the + /// cursor comes back into the window. GTK/GDK objects are not `Send`, and + /// every access is on the GTK main thread (sync Tauri commands and GTK + /// signal handlers both run there), so a thread-local is the right home. + static ACTIVE_DRAG: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + /// Set by `cancel_file_drag` so the `cancel` signal it provokes is + /// recognised as OURS: the gesture is being handed back to the in-app drag + /// and is NOT over, so it must not emit `FILE_DRAG_ENDED`. + static CANCEL_REQUESTED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Emitted when a native drag reaches its true end (dropped, failed, or +/// cancelled by the user/WM). The frontend keeps a gesture alive across the +/// handoff, so it needs to hear the end from the OS: once GTK owns the pointer, +/// the webview never sees the `pointerup` that would otherwise end it. +#[cfg(target_os = "linux")] +pub const FILE_DRAG_ENDED: &str = "eldrun:file-drag-ended"; + +/// Start a native OS drag-out of `paths` from the calling window (Linux/GTK). +/// +/// Replaces `tauri-plugin-drag`'s GTK path for the Ctrl-drag file export. That +/// path LOOKS right (OS drag icon, drop animation) but delivers an empty +/// payload to external targets: it disconnects its `drag-data-get` handler on +/// `drop-performed` — emitted at the instant of the drop, BEFORE the target +/// asynchronously requests the `text/uri-list` selection — so a browser's or +/// file manager's data request finds no provider and the drop silently does +/// nothing. It also builds `file://{path}` with no percent-encoding (a space +/// in the name yields an invalid URI strict consumers discard) and passes +/// `GDK_BUTTON1_MASK` (256) where GTK expects the button NUMBER (1). +/// +/// Here URIs are encoded canonically (`glib::filename_to_uri`) and teardown is +/// deferred to `dnd-finished` (the target has read the data), with `cancel` / +/// `drag-failed` covering the abort paths. Windows/macOS keep the plugin +/// (their OLE/NSDragging backends are sound). +/// +/// Deliberately a SYNC command: Tauri dispatches those on the main (GTK) +/// thread, which is exactly where every call below must run. +#[cfg(target_os = "linux")] +#[tauri::command] +pub fn start_file_drag(window: tauri::Window, paths: Vec) -> Result<(), String> { + use gtk::{gdk, glib, prelude::*}; + use std::cell::RefCell; + use std::rc::Rc; + + if paths.is_empty() { + return Err("start_file_drag: no paths".into()); + } + let uris: Vec = paths + .iter() + .map(|p| { + glib::filename_to_uri(p, None) + .map(|u| u.to_string()) + .map_err(|e| format!("start_file_drag: invalid path {p:?}: {e}")) + }) + .collect::>()?; + + let win = window.gtk_window().map_err(|e| e.to_string())?; + win.drag_source_set(gdk::ModifierType::BUTTON1_MASK, &[], gdk::DragAction::COPY); + win.drag_source_add_uri_targets(); + + // Window-level signal handlers live until an end-of-drag signal fires; + // the drag-data-get one MUST survive past the drop itself (see above). + let handler_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); + { + let uris = uris.clone(); + handler_ids + .borrow_mut() + .push(win.connect_drag_data_get(move |_, _, data, _, _| { + let refs: Vec<&str> = uris.iter().map(String::as_str).collect(); + data.set_uris(&refs); + })); + } + + let target_list = win + .drag_source_get_target_list() + .ok_or("start_file_drag: empty target list")?; + let context = win + .drag_begin_with_coordinates(&target_list, gdk::DragAction::COPY, 1, None, -1, -1) + .ok_or("start_file_drag: drag_begin refused (no pointer grab?)")?; + ACTIVE_DRAG.with(|c| *c.borrow_mut() = Some(context.clone())); + + // Idempotent teardown shared by every end-of-drag signal — a cancelled drag + // can raise more than one of them for the same gesture. `ours` marks the + // cancel WE asked for (handing the gesture back to the in-app drag): the + // GTK drag is over, but the user's gesture is not, so no end event. + let cleanup = { + let win = win.clone(); + let window = window.clone(); + let handler_ids = handler_ids.clone(); + move || { + for id in handler_ids.borrow_mut().drain(..) { + win.disconnect(id); + } + win.drag_source_unset(); + ACTIVE_DRAG.with(|c| *c.borrow_mut() = None); + let ours = CANCEL_REQUESTED.with(|f| f.replace(false)); + if !ours { + use tauri::Emitter; + let _ = window.emit(FILE_DRAG_ENDED, ()); + } + } + }; + { + let cleanup = cleanup.clone(); + context.connect_dnd_finished(move |_| cleanup()); + } + { + let cleanup = cleanup.clone(); + context.connect_cancel(move |_, _| cleanup()); + } + { + let cleanup = cleanup.clone(); + let id = win.connect_drag_failed(move |_, _, _| { + cleanup(); + // Proceed keeps GTK's snap-back animation for the failed drop. + glib::Propagation::Proceed + }); + handler_ids.borrow_mut().push(id); + } + + // Same OS drag icon the plugin path used (the app icon). + const ICON_PNG: &[u8] = include_bytes!("../../icons/128x128.png"); + let loader = gtk::gdk_pixbuf::PixbufLoader::new(); + if loader.write(ICON_PNG).and_then(|_| loader.close()).is_ok() { + if let Some(pixbuf) = loader.pixbuf() { + context.drag_set_icon_pixbuf(&pixbuf, 0, 0); + } + } + Ok(()) +} + +/// Abort the in-flight native drag, handing the still-held button back to the +/// in-app pointer drag (the cursor re-entered the Eldrun window, so the ghost +/// and hover take over again from the OS drag icon). A no-op when no native +/// drag is running, so the frontend can call it unconditionally. +#[cfg(target_os = "linux")] +#[tauri::command] +pub fn cancel_file_drag() { + use gtk::prelude::DragContextExtManual; + let Some(context) = ACTIVE_DRAG.with(|c| c.borrow().clone()) else { + return; + }; + // Read back in `cleanup` (via the `cancel` signal this raises) to tell our + // own abort apart from a real one. + CANCEL_REQUESTED.with(|f| f.set(true)); + context.drag_cancel(); +} + +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub fn cancel_file_drag() {} + +/// Non-Linux stub so the command registers everywhere; the frontend only +/// routes to it on Linux (Windows/macOS stay on `tauri-plugin-drag`). +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub fn start_file_drag(paths: Vec) -> Result<(), String> { + let _ = paths; + Err("start_file_drag is Linux-only; use tauri-plugin-drag".into()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmbedCapability { /// OS can host an embedded frameless window (X11 backend, not XWayland). diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 1eb4b4f..b172645 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -292,6 +292,111 @@ fn walk_dir_size(dir: &Path) -> u64 { total } +#[derive(Debug, Serialize)] +pub struct DirSizeBreakdown { + pub total: u64, + pub ignored: u64, +} + +/// Recursive byte size of a folder split into git-ignored vs +/// tracked/untracked content, so a mixed folder (e.g. source alongside a +/// build output dir) can show how much of its weight is ignored content. +/// Local projects only: splitting by ignore status needs a `git status` +/// process, and there is no cheap way to run one against a remote host's +/// index without a dedicated round trip per lazily-shown folder — a remote +/// folder (or one with no `.git`) just gets `ignored: 0`, same best-effort +/// fallback as [`dir_size`] on failure. +#[tauri::command] +pub async fn dir_size_breakdown(project_dir: String, rel_path: String) -> Result { + tokio::task::spawn_blocking(move || dir_size_breakdown_local(&project_dir, &rel_path)) + .await + .map_err(|e| e.to_string())? +} + +fn dir_size_breakdown_local(project_dir: &str, rel_path: &str) -> Result { + let root = canonical(project_dir)?; + let target = if rel_path.is_empty() { + root.clone() + } else { + canonical(&root.join(rel_path).to_string_lossy().to_string())? + }; + enforce_confinement(&root, &target)?; + + if crate::services::remote::remote_target_for_dir(project_dir).is_some() || !root.join(".git").exists() { + return Ok(DirSizeBreakdown { total: walk_dir_size(&target), ignored: 0 }); + } + + let ignored = ignored_paths_under(&root, rel_path); + let (total, ignored_bytes) = walk_dir_size_breakdown(&target, rel_path, &ignored); + Ok(DirSizeBreakdown { total, ignored: ignored_bytes }) +} + +/// The set of repo-root-relative paths (matching git's own porcelain output) +/// that `git status` reports as ignored (`!!`) anywhere under `rel_path`. +/// `--untracked-files=all` is what makes this useful: without it, git +/// collapses a wholly-ignored directory to one line for the directory itself +/// rather than recursing into it (see `commands::git::git_file_statuses`'s +/// `wholly_ignored` handling for the same quirk) — here we want every +/// individual ignored file, at any depth, so [`walk_dir_size_breakdown`] can +/// classify each file it visits by simple set membership. A failed/absent +/// `git` yields an empty set, so the breakdown degrades to "0 ignored" +/// rather than erroring. +fn ignored_paths_under(root: &Path, rel_path: &str) -> HashSet { + let mut args = vec!["status", "--porcelain", "--ignored", "--untracked-files=all"]; + if !rel_path.is_empty() { + args.push("--"); + args.push(rel_path); + } + let Ok(out) = crate::paths::command_no_window("git").args(&args).current_dir(root).output() else { + return HashSet::new(); + }; + let text = String::from_utf8_lossy(&out.stdout); + text.lines() + .filter_map(|line| { + if line.len() < 4 || &line[..2] != "!!" { + return None; + } + Some(line[3..].trim_matches('"').to_string()) + }) + .collect() +} + +/// Like [`walk_dir_size`], but also sums the subset of bytes whose +/// repo-root-relative path (`rel_prefix` + entry name, built up as recursion +/// descends) is in `ignored`. Directories themselves never appear in +/// `ignored` (git only reports leaf files under `--untracked-files=all`), so +/// this always recurses rather than short-circuiting on a directory match — +/// the leaf files inside eventually match individually regardless of depth. +fn walk_dir_size_breakdown(dir: &Path, rel_prefix: &str, ignored: &HashSet) -> (u64, u64) { + let mut total = 0u64; + let mut ignored_total = 0u64; + let Ok(rd) = fs::read_dir(dir) else { + return (0, 0); + }; + for entry in rd.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let child_rel = if rel_prefix.is_empty() { name } else { format!("{rel_prefix}/{name}") }; + if ft.is_dir() { + let (t, i) = walk_dir_size_breakdown(&entry.path(), &child_rel, ignored); + total = total.saturating_add(t); + ignored_total = ignored_total.saturating_add(i); + } else if ft.is_file() { + if let Ok(m) = entry.metadata() { + let bytes = m.len(); + total = total.saturating_add(bytes); + if ignored.contains(&child_rel) { + ignored_total = ignored_total.saturating_add(bytes); + } + } + } + } + (total, ignored_total) +} + /// Join a remote project root (`remote_path`) with a project-relative path, /// mirroring `ssh_exec::remote_subdir`-style joining: trim a trailing '/', then /// append the non-empty rel. Pure, so it is unit-tested without a live host. @@ -1798,6 +1903,67 @@ mod tests { ); } + // ── dir_size_breakdown ────────────────────────────────────────────────── + + fn git_available() -> bool { + crate::paths::command_no_window("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn init_repo(dir: &Path) { + let run = |args: &[&str]| { + assert!( + crate::paths::command_no_window("git") + .args(args) + .current_dir(dir) + .output() + .expect("git command should run") + .status + .success(), + "git {args:?} failed" + ); + }; + run(&["init"]); + run(&["config", "user.email", "test@example.com"]); + run(&["config", "user.name", "Test User"]); + } + + #[test] + fn dir_size_breakdown_splits_ignored_from_tracked() { + if !git_available() { + eprintln!("git not on PATH — skipping dir_size_breakdown_splits_ignored_from_tracked"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + init_repo(dir); + + std::fs::write(dir.join(".gitignore"), "whole/\n").unwrap(); + std::fs::create_dir(dir.join("src")).unwrap(); + std::fs::write(dir.join("src/main.rs"), "12345").unwrap(); // 5 bytes, tracked/untracked + std::fs::create_dir_all(dir.join("src/whole/sub")).unwrap(); + std::fs::write(dir.join("src/whole/a.txt"), "1234567890").unwrap(); // 10 bytes, ignored + std::fs::write(dir.join("src/whole/sub/b.txt"), "12345678901234567890").unwrap(); // 20 bytes, ignored + + let breakdown = dir_size_breakdown_local(&dir.to_string_lossy(), "src").expect("breakdown"); + assert_eq!(breakdown.total, 35); + assert_eq!(breakdown.ignored, 30); + } + + #[test] + fn dir_size_breakdown_no_git_yields_zero_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("a.txt"), "hello").unwrap(); + + let breakdown = dir_size_breakdown_local(&dir.to_string_lossy(), "").expect("breakdown"); + assert_eq!(breakdown.total, 5); + assert_eq!(breakdown.ignored, 0); + } + // ── copy_path / move_path ────────────────────────────────────────────── #[test] diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index 4b8b51b..b9ffada 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -277,7 +277,32 @@ fn git_file_statuses_blocking( return Ok(HashMap::new()); } - let out = run_git(target.as_ref(), &project_dir, &["status", "--porcelain", "--ignored"])?; + // A directory that is itself wholly ignored is collapsed by git into a + // single `!! rel_path/` line at every ancestor listing under the default + // `--untracked-files=normal` — so listing `rel_path` itself would come + // back with zero lines under its prefix, and its children would silently + // lose their "ignored" marker (rather than the folder just being empty of + // git-reportable changes, which is the far more common reason this map + // comes back empty). Detect that case up front via `check-ignore` and add + // `--untracked-files=all`, which makes git recurse into an ignored + // directory and report every file underneath individually instead of + // collapsing it, scoped to `rel_path` so this stays cheap even inside a + // huge folder like `node_modules`. Left off for the common case: it's a + // more expensive walk, and combined with the anti-bubble rule below it + // would actually stop a normal (non-wholly-ignored-ancestor) ignored + // subfolder from being recognized, since a deep file line like + // `sub/leaf.js` never equals its containing folder's own name. + let wholly_ignored = !rel_path.is_empty() + && run_git(target.as_ref(), &project_dir, &["check-ignore", "-q", "--", &rel_path]) + .map(|out| out.status.success()) + .unwrap_or(false); + + let status_args: Vec<&str> = if wholly_ignored { + vec!["status", "--porcelain", "--ignored", "--untracked-files=all", "--", &rel_path] + } else { + vec!["status", "--porcelain", "--ignored"] + }; + let out = run_git(target.as_ref(), &project_dir, &status_args)?; let porcelain = String::from_utf8_lossy(&out.stdout).into_owned(); // prefix used to filter entries under rel_path @@ -321,7 +346,12 @@ fn git_file_statuses_blocking( // top-level entry ignored when the ignored path IS that entry — else a // single ignored child would drag the whole folder into the gitignored // section. Other statuses still bubble up so a dir reflects its changes. - if status == "ignored" && rel.trim_end_matches('/') != top { + // Skipped when `rel_path` itself is already known to be wholly + // ignored: every line here is one of its descendants, so there is no + // "is this ONE child ignored or is the whole folder" ambiguity to + // guard against, and a deep file's top-level parent must still be + // marked ignored even though the two paths never match exactly. + if status == "ignored" && !wholly_ignored && rel.trim_end_matches('/') != top { return; } @@ -546,6 +576,15 @@ fn git_unpushed_commits_blocking(project_dir: String) -> Result, Str Ok(text.lines().filter(|l| !l.is_empty()).map(|l| l.to_string()).collect()) } +/// Ephemeral inline credential helper that answers an https challenge with the +/// effective token. The token is read from the child's env INSIDE the snippet, so +/// it never lands in argv or on disk. Always passed after a leading empty +/// `credential.helper=`, which clears any system helper (e.g. GCM) so only ours +/// runs. Harmless for SSH remotes — git won't call an http helper. Shared by +/// `git_push` and `git_clone`. +const TOKEN_CREDENTIAL_HELPER: &str = + "credential.helper=!f() { test \"$1\" = get && echo username=x-access-token && echo \"password=$ELDRUN_GIT_TOKEN\"; }; f"; + #[tauri::command] pub async fn git_push(project_dir: String, project_id: Option) -> Result { run_off_thread(move || git_push_blocking(project_dir, project_id)).await @@ -566,18 +605,9 @@ fn git_push_blocking(project_dir: String, project_id: Option) -> Result< let mut cmd = crate::paths::command_no_window("git"); cmd.current_dir(&project_dir); if let Some(tok) = token.as_deref() { - // Authenticate an https push with the effective token via an ephemeral - // inline credential helper. The token is read from the child's env INSIDE - // the helper snippet, so it never lands in argv or on disk. The leading - // empty `credential.helper=` clears any system helper (e.g. GCM) so only - // ours runs. Harmless for SSH remotes — git won't call an http helper. - cmd.args([ - "-c", - "credential.helper=", - "-c", - "credential.helper=!f() { test \"$1\" = get && echo username=x-access-token && echo \"password=$ELDRUN_GIT_TOKEN\"; }; f", - "push", - ]); + // Authenticate an https push with the effective token (see + // TOKEN_CREDENTIAL_HELPER). + cmd.args(["-c", "credential.helper=", "-c", TOKEN_CREDENTIAL_HELPER, "push"]); cmd.env("ELDRUN_GIT_TOKEN", tok); cmd.env("GIT_TERMINAL_PROMPT", "0"); } else { @@ -593,6 +623,142 @@ fn git_push_blocking(project_dir: String, project_id: Option) -> Result< Ok(if stdout.is_empty() { stderr } else { stdout }) } +// ── Clone (import from GitHub/GitLab) ─────────────────────────────────────── + +/// Accept only the clone URL forms we actually mean to support: https/http, +/// `ssh://`, `git://`, and the scp-like `[user@]host:path`. This is a whitelist +/// on purpose — git also understands `ext::` (which *runs* the command) +/// and local paths, and neither belongs behind a "paste a repo URL" field. +pub(crate) fn validate_clone_url(url: &str) -> Result<(), String> { + let url = url.trim(); + let reject = || { + Err(format!( + "'{url}' is not a supported repository URL (expected https://…, ssh://… or git@host:owner/repo.git)" + )) + }; + if url.is_empty() { + return Err("Repository URL is empty".to_string()); + } + + let lower = url.to_ascii_lowercase(); + if let Some(scheme_end) = lower.find("://") { + // An explicit scheme must be one we support. `file://` is not a remote, + // and anything else is a transport we did not mean to expose. + return if ["https", "http", "ssh", "git"].contains(&&lower[..scheme_end]) { + Ok(()) + } else { + reject() + }; + } + + // scp-like: `[user@]host:path`. The host part carries no slash (that would be + // a local path) and no colon of its own — `transport::address` is git's + // transport-helper form, and `ext::` *runs* the command. + let Some(colon) = url.find(':') else { + return reject(); + }; + let (host, path) = (&url[..colon], &url[colon + 1..]); + if host.is_empty() + || host.contains('/') + || host.starts_with('-') + || path.is_empty() + || path.starts_with(':') + { + return reject(); + } + Ok(()) +} + +/// Turn git's own auth failure into the one sentence that tells the user what to +/// do about it. A private https repo with no token stored reads as "could not +/// read Username" / "Authentication failed", which explains nothing on its own. +fn clone_error(stderr: &str, had_token: bool, https: bool) -> String { + let lower = stderr.to_ascii_lowercase(); + let auth_failed = lower.contains("could not read username") + || lower.contains("authentication failed") + || lower.contains("terminal prompts disabled") + || lower.contains("repository not found"); + if auth_failed && https { + let hint = if had_token { + "The stored access token was rejected (or has no access to this repository) — check it in Settings → Git Hosting." + } else { + "For a private repository, add an access token in Settings → Git Hosting, or use an SSH URL." + }; + return format!("{}\n\n{hint}", stderr.trim()); + } + if auth_failed { + return format!( + "{}\n\nSSH authentication failed — make sure your key is loaded (ssh-agent) and the host is known.", + stderr.trim() + ); + } + stderr.trim().to_string() +} + +/// Clone `url` into `dest` and return `dest`. Used by the import dialog's +/// "Clone from GitHub/GitLab" source: the clone lands first, then the regular +/// `import_project` registers the resulting directory in place ("keep" mode). +/// +/// Auth: an https URL rides the global access token from Settings → Git Hosting +/// (via the same inline credential helper as `git_push`) when one is stored; an +/// SSH URL uses the user's own keys. Every interactive prompt git could raise is +/// disabled (`GIT_TERMINAL_PROMPT=0`, ssh `BatchMode=yes`) — Eldrun has no console +/// attached, so a prompt would be an invisible hang rather than a question. +#[tauri::command] +pub async fn git_clone(url: String, dest: String) -> Result { + run_off_thread(move || git_clone_blocking(url, dest)).await +} + +fn git_clone_blocking(url: String, dest: String) -> Result { + let url = url.trim().to_string(); + validate_clone_url(&url)?; + + let dest_path = PathBuf::from(&dest); + if dest_path.exists() { + let empty = std::fs::read_dir(&dest_path) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(false); + if !empty { + return Err(format!("Destination '{dest}' already exists and is not empty")); + } + } + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let token = crate::commands::git_hosting::global_git_token(); + let https = { + let lower = url.to_ascii_lowercase(); + lower.starts_with("https://") || lower.starts_with("http://") + }; + + let mut cmd = crate::paths::command_no_window("git"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); + if https { + if let Some(tok) = token.as_deref() { + cmd.args(["-c", "credential.helper=", "-c", TOKEN_CREDENTIAL_HELPER]); + cmd.env("ELDRUN_GIT_TOKEN", tok); + } + } + // `--` so a URL can never be read as an option. + cmd.args(["clone", "--", &url, &dest]); + + let out = cmd.output().map_err(|e| e.to_string())?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let raw = if stderr.trim().is_empty() { stdout } else { stderr }; + // A failed clone can still leave a partial directory behind; drop it so a + // retry isn't blocked by its own debris. + if dest_path.exists() { + let _ = std::fs::remove_dir_all(&dest_path); + } + return Err(clone_error(&raw, token.is_some(), https)); + } + Ok(dest) +} + // ── Git history & branches ────────────────────────────────────────────────── #[derive(serde::Serialize)] @@ -1504,6 +1670,19 @@ filename note.txt "whole/ should be ignored (got {:?})", statuses.get("whole") ); + + // Navigating INTO the wholly-ignored folder must still report its own + // children as ignored, not silently come back empty (the bug this + // test guards: plain `--ignored` collapses `whole/` to one line and + // never descends into it). + let inner = git_file_statuses_blocking(dir.to_string_lossy().to_string(), "whole".to_string()) + .expect("git_file_statuses on whole/ should succeed"); + assert_eq!( + inner.get("a.txt").map(String::as_str), + Some("ignored"), + "whole/a.txt should be reported as ignored when listing whole/ (got {:?})", + inner.get("a.txt") + ); } #[test] @@ -1641,4 +1820,60 @@ filename note.txt None ); } + + // ── validate_clone_url ───────────────────────────────────────────────── + + #[test] + fn validate_clone_url_accepts_supported_forms() { + for url in &[ + "https://github.com/owner/repo.git", + "https://gitlab.com/group/sub/repo", + "http://git.internal/owner/repo.git", + "ssh://git@github.com:22/owner/repo.git", + "git://git.internal/repo.git", + "git@github.com:owner/repo.git", + "gitlab.com:group/repo.git", + ] { + assert!(validate_clone_url(url).is_ok(), "should accept: {url}"); + } + // Surrounding whitespace (a pasted URL) is tolerated. + assert!(validate_clone_url(" https://github.com/o/r.git ").is_ok()); + } + + #[test] + fn validate_clone_url_rejects_unsupported_forms() { + for url in &[ + "", + " ", + // `ext::` runs a command — never behind a URL field. + "ext::sh -c 'touch /tmp/pwned'", + "file:///etc", + "/etc/passwd", + "../repo", + "just-a-name", + // Option injection, with and without the scp-like colon. + "--upload-pack=touch /tmp/pwned", + "--upload-pack=x:y", + ] { + assert!(validate_clone_url(url).is_err(), "should reject: {url}"); + } + } + + #[test] + fn clone_error_explains_missing_token_for_https() { + let msg = clone_error("fatal: could not read Username for 'https://github.com'", false, true); + assert!(msg.contains("Settings → Git Hosting"), "{msg}"); + + // A token IS stored but was rejected → point at the token, not at adding one. + let msg = clone_error("remote: Repository not found.", true, true); + assert!(msg.contains("was rejected"), "{msg}"); + + // SSH auth failure gets the key/agent hint instead. + let msg = clone_error("git@github.com: Permission denied (publickey).\nfatal: Authentication failed", false, false); + assert!(msg.contains("ssh-agent"), "{msg}"); + + // A non-auth failure is passed through unchanged (no misleading hint). + let msg = clone_error("fatal: destination path exists", false, true); + assert_eq!(msg, "fatal: destination path exists"); + } } diff --git a/src-tauri/src/commands/git_hosting.rs b/src-tauri/src/commands/git_hosting.rs index 63cc112..d1389b9 100644 --- a/src-tauri/src/commands/git_hosting.rs +++ b/src-tauri/src/commands/git_hosting.rs @@ -136,6 +136,15 @@ pub fn effective_git_creds(project_id: &str) -> (Option, Option) (profile_url, token) } +/// The global access token from Settings → Git Hosting, if one is stored. For +/// work that has no project to key a per-project override off yet — `git_clone` +/// runs *before* the project exists — so only the global connection applies. +pub fn global_git_token() -> Option { + read_settings() + .and_then(|s| s.git_token) + .filter(|t| !t.trim().is_empty()) +} + fn read_settings() -> Option { let path = storage::state_dir().join("settings.json"); if path.exists() { diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 14cdf9d..9f3c1e5 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -5,9 +5,12 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tauri::State; + use crate::paths; use crate::schema::project::{OpenVpnSpec, Project, RemoteSpec, SandboxSpec}; use crate::schema::projects::{ProjectEntry, ProjectsList}; +use crate::services::remote_sync::SyncManifestState; use crate::storage; /// Local per-project state directory for a **remote** project: @@ -20,6 +23,36 @@ fn remote_project_state_dir(id: &str) -> std::path::PathBuf { storage::state_dir().join("remote-projects").join(id) } +/// Drop the per-project state that is bound to **one specific host**: the byte-sync +/// manifest (`sync.json`) and the lockstep state (`git_peer.json`). Both are meaningless +/// — worse, actively wrong — once a project is pointed at a *different* host, or at none. +/// +/// The manifest is the dangerous one, and it is why detach→re-extend needs this at all. +/// Its entries carry `last_pull_ts`/`last_push_ts`, so against a fresh, empty host +/// `push_decision` sees `ever_synced = true` plus a missing file and returns `Stale` — it +/// **refuses to push**. Meanwhile `divergence` maps the failed host stat to "couldn't +/// check → don't flag", so the file tree paints the very same file **green**. Net: a +/// re-extended project whose auto-synced data silently never crosses, reported as fully +/// in sync. The classic false green. +/// +/// The state dir is keyed by project **id**, which a detach preserves — so a re-extend +/// lands back on the same directory and inherits whatever the detach left behind. +/// `remove_dir` (as detach used to call) only removes an *empty* dir, and this one never +/// is, so every one of these files survived. +/// +/// Evicting the in-memory cache is not optional: `ensure_loaded` is `or_insert_with`, so +/// a manifest already loaded this session is never re-read from disk — deleting the file +/// alone would just get the stale copy re-saved on the next pass. +/// +/// `local_loss.json` deliberately **survives**: it records what was destroyed in the +/// *local mirror*, the user may not have acknowledged it yet, and it is a fact about this +/// machine rather than about any host. +async fn clear_host_bound_state(project_id: &str, manifest: &SyncManifestState) { + let _ = fs::remove_file(crate::services::remote_sync::manifest_path(project_id)); + let _ = fs::remove_file(crate::services::git_peer::state_path(project_id)); + manifest.lock().await.remove(project_id); +} + /// Compute a `` leaf under `parent` for a remote (SSH) project's local /// mirror. `sanitize_name` keeps the folder readable; the `id` disambiguates a /// name-based path already taken by another remote project, so two hosts' `~/work` @@ -1056,14 +1089,25 @@ pub fn save_project(local_file: String, project: Project) -> Result<(), String> } /// Save only the tab layout — writes to both project.json and the session file. +/// +/// `allow_clear` licenses an EMPTY `tabs` to erase the saved layout. The frontend +/// sets it only for a scope it has actually hydrated and that genuinely holds no +/// tabs; every other empty save is a no-op. See `terminal_service`. #[tauri::command] pub fn save_tab_layout( local_file: String, tabs: Vec, groups: Option, sessions: Option, + allow_clear: bool, ) -> Result<(), String> { - crate::services::terminal_service::save_tab_layout(&local_file, &tabs, groups, sessions) + crate::services::terminal_service::save_tab_layout( + &local_file, + &tabs, + groups, + sessions, + allow_clear, + ) } #[tauri::command] @@ -2018,7 +2062,10 @@ pub struct ExtendProjectRemoteRequest { /// becomes a local state dir holding `project.json`. The user pushes files to the /// (empty) host later via the existing manual sync UI. #[tauri::command] -pub fn extend_project_to_remote(req: ExtendProjectRemoteRequest) -> Result { +pub async fn extend_project_to_remote( + req: ExtendProjectRemoteRequest, + manifest: State<'_, SyncManifestState>, +) -> Result { let list_path = storage::state_dir().join("projects.json"); let mut list: ProjectsList = if list_path.exists() { storage::read_json(&list_path).map_err(|e| e.to_string())? @@ -2072,6 +2119,15 @@ pub fn extend_project_to_remote(req: ExtendProjectRemoteRequest) -> Result Result Result { +pub async fn detach_project_from_remote( + project_id: String, + manifest: State<'_, SyncManifestState>, +) -> Result { let list_path = storage::state_dir().join("projects.json"); let mut list: ProjectsList = if list_path.exists() { storage::read_json(&list_path).map_err(|e| e.to_string())? @@ -2196,13 +2255,77 @@ pub fn detach_project_from_remote(project_id: String) -> Result { + let _ = crate::services::terminal_service::save_tab_layout( + &new_local_file, + tabs, + project.tab_groups.clone(), + None, + false, + ); + } + _ => { + if let Some(dir) = crate::services::terminal_service::eldrun_sessions_dir(&new_local_file) + { + let _ = std::fs::remove_file(dir.join("terminals.json")); + } + } + } + + // Drop everything that was bound to the host we are detaching from. Not merely + // hygiene: the project keeps its id, so a later "extend to remote" — the whole point + // of detaching, when the old path was wrong — lands on this same state dir and would + // otherwise inherit a byte-sync manifest whose bases describe the OLD host. See + // `clear_host_bound_state` for what that silently does to the new one. + clear_host_bound_state(&project_id, manifest.inner()).await; + + // Remove the old state-dir project.json, its session mirror, and then the state dir. + // `.eldrun/` is why the dir used to survive every detach: `remove_dir` is + // non-recursive, so it failed on a dir that still held the session mirror, and the + // project's id was left lying around under `remote-projects/` forever. + // + // The final `remove_dir` stays non-recursive ON PURPOSE — `local_loss.json` + // deliberately outlives a detach (it records what was destroyed in the *local* + // mirror, and the user may not have seen it yet). A dir that still holds it must + // survive; `remove_dir` succeeds only once the dir is genuinely empty, which is + // exactly that distinction. let _ = std::fs::remove_file(&state_local_file); - let _ = std::fs::remove_dir(remote_project_state_dir(&project_id)); + let _ = std::fs::remove_dir_all(state_dir.join(".eldrun")); + let _ = std::fs::remove_dir(&state_dir); // Update the projects.json entry in place, preserving every other extra key // (categories, git_provider, git_type, description, sandbox, …). diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index 2e41f46..47d027e 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -152,6 +152,55 @@ pub fn remote_remember_path(host: String, path: String) -> Result<(), String> { crate::storage::write_json(&remote_paths_path(), &store).map_err(|e| e.to_string()) } +/// File backing the per-host **standard** remote paths set from Settings' +/// "Remote Connections" panel — a `HashMap`, keyed case- +/// insensitively like `remote_paths.json`. Distinct from that file: this one +/// holds one explicit, user-chosen default per host, not an auto-remembered +/// recents list. +fn remote_host_defaults_path() -> std::path::PathBuf { + crate::storage::state_dir().join("remote_host_defaults.json") +} + +/// All per-host standard paths configured from Settings, for the "Remote +/// Connections" panel to list and edit. +#[tauri::command] +pub fn remote_list_default_paths() -> std::collections::HashMap { + crate::storage::read_json(&remote_host_defaults_path()).unwrap_or_default() +} + +/// The configured standard remote path for `host`, if any. Consulted when a +/// connect/browse flow would otherwise fall back to `ssh_default_dir`'s SSH +/// home-directory guess, so a host with a preferred working directory starts +/// there instead every time. +#[tauri::command] +pub fn remote_get_default_path(host: String) -> Option { + let store: std::collections::HashMap = + crate::storage::read_json(&remote_host_defaults_path()).unwrap_or_default(); + store.get(&host.to_lowercase()).cloned() +} + +/// Set (or, with a blank `path`, clear) the standard remote path for `host`. +/// Trims and validates a non-empty path the same way `remote_remember_path` +/// does, so it can't smuggle in an `ssh`/`sftp`-option-looking value or +/// control characters. +#[tauri::command] +pub fn remote_set_default_path(host: String, path: String) -> Result<(), String> { + let key = host.trim().to_lowercase(); + if key.is_empty() { + return Err("empty host".to_string()); + } + let mut store: std::collections::HashMap = + crate::storage::read_json(&remote_host_defaults_path()).unwrap_or_default(); + let trimmed = path.trim(); + if trimmed.is_empty() { + store.remove(&key); + } else { + crate::services::ssh_common::validate_arg("remote path", trimmed)?; + store.insert(key, trimmed.to_string()); + } + crate::storage::write_json(&remote_host_defaults_path(), &store).map_err(|e| e.to_string()) +} + /// Open a web URL in the user's default browser. Refuses anything that is not an /// `http(s)` URL so it cannot be turned into a launcher for arbitrary local files /// or schemes. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f05cb82..e020120 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -662,6 +662,9 @@ pub fn run() { commands::ssh::ssh_remember_address, commands::ssh::remote_list_paths, commands::ssh::remote_remember_path, + commands::ssh::remote_list_default_paths, + commands::ssh::remote_get_default_path, + commands::ssh::remote_set_default_path, commands::ssh::open_external_url, // OpenVPN tunnels for VPN-gated remote projects commands::openvpn::openvpn_connect, @@ -691,6 +694,7 @@ pub fn run() { commands::fs::list_dir, commands::fs::list_recent_downloads, commands::fs::dir_size, + commands::fs::dir_size_breakdown, commands::fs::list_dirs, commands::fs::list_project_endings, commands::fs::list_project_paths, @@ -750,6 +754,8 @@ pub fn run() { commands::apps::restore_open_apps, commands::apps::run_script_detached, commands::apps::drag_preview_icon, + commands::apps::start_file_drag, + commands::apps::cancel_file_drag, commands::apps::embed_capability, commands::apps::list_installed_apps, // Workspace / network @@ -777,6 +783,7 @@ pub fn run() { commands::git::git_generate_commit_message, commands::git::git_commit, commands::git::git_push, + commands::git::git_clone, commands::git::git_file_statuses, commands::git::git_unpushed_commits, commands::git::git_change_stats, diff --git a/src-tauri/src/services/git_peer.rs b/src-tauri/src/services/git_peer.rs index 08af07f..a18fb4e 100644 --- a/src-tauri/src/services/git_peer.rs +++ b/src-tauri/src/services/git_peer.rs @@ -286,11 +286,21 @@ pub fn parse_refs(stdout: &str) -> Vec { } /// Combine `git symbolic-ref --quiet --short HEAD` (branch, empty when detached) -/// and `git rev-parse HEAD` (sha, fails on an unborn HEAD) into a [`HeadRef`]. +/// and `git rev-parse --verify --quiet HEAD` (sha, empty on an unborn HEAD) into a +/// [`HeadRef`]. +/// +/// The sha must be a real object name or the head is [`HeadRef::Unborn`] — "not +/// empty" is NOT enough. A plain `git rev-parse HEAD` on an unborn repo prints the +/// literal string `HEAD` to **stdout** (and fails only via its exit status), so a +/// caller that forgets to check that status hands us `("master", "HEAD")`. Trusting +/// it built `Branch { name: "master", sha: "HEAD" }` — a branch that exists nowhere, +/// at a sha that is not a sha — which read as a legitimately-checked-out peer and so +/// masked the one state that actually needed repairing: a `git init`ed host that was +/// never checked out. Whatever the probe does, an unborn HEAD parses as unborn. pub fn parse_head(symbolic: &str, rev: &str) -> HeadRef { let branch = symbolic.trim(); let sha = rev.trim(); - if sha.is_empty() { + if !is_hex_sha(sha) { HeadRef::Unborn } else if branch.is_empty() { HeadRef::Detached { @@ -551,15 +561,24 @@ pub fn blocked_detail(branch: &str, stderr: &str) -> String { /// Contains no interpolation: it is a constant, so there is nothing here to inject /// into (the only variable — the project's remote path — is `shell_quote`d by /// [`ssh_exec::run_remote_script`], which `cd`s to it). +/// The batched host probe. Its **output** is its answer; its **exit status** means only +/// "the probe ran" — hence the trailing `|| true`. An unborn repo makes the final +/// `git log HEAD` exit 128, and letting that escape would make a legitimately-empty side +/// indistinguishable from a probe that could not run at all — the one confusion that must +/// never happen here, since "could not run" is what withholds a `reset --hard`. +/// +/// Every `rev-parse` inside must likewise report an unborn HEAD by *printing nothing* +/// (`--verify --quiet`), not by exiting non-zero: a bare `git rev-parse HEAD` prints the +/// literal `HEAD` to stdout and fails only in its status, which this script discards. const PROBE_SCRIPT: &str = "\ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then \ printf 'repo\\036'; \ git symbolic-ref --quiet --short HEAD 2>/dev/null; printf '\\036'; \ -git rev-parse HEAD 2>/dev/null; printf '\\036'; \ +git rev-parse --verify --quiet HEAD 2>/dev/null; printf '\\036'; \ git for-each-ref --format='%(objectname) %(refname:short)' refs/heads 2>/dev/null; printf '\\036'; \ git for-each-ref --format='%(objectname) %(refname:short)' refs/tags 2>/dev/null; printf '\\036'; \ git status --porcelain 2>/dev/null; printf '\\036'; \ -git log -1 --format=%s HEAD 2>/dev/null; \ +git log -1 --format=%s HEAD 2>/dev/null || true; \ else printf 'norepo'; fi"; /// Parse [`PROBE_SCRIPT`]'s output into a snapshot. `None` means the output was not @@ -684,19 +703,66 @@ pub fn head_mismatch(local: &PeerSnapshot, remote: &PeerSnapshot) -> Option bool { + snap.is_repo + && matches!( + snap.head, + Some(HeadRef::Branch { .. }) | Some(HeadRef::Detached { .. }) + ) +} + +/// What a pass should do with the two sides — the gate `reconcile` runs on. Pure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PairPlan { + /// Both sides seeded → the ordinary bidirectional transfer. + Sync, + /// Exactly one side seeded → initialize the other from it. `source_is_local` names + /// the authority; the *other* side is the one `git init` + `reset --hard` touches. + Pair { source_is_local: bool }, + /// Neither side holds a commit → there is nothing to move in either direction. + Nothing, +} + +/// Decide the pass from the two snapshots. Pure. +pub fn pair_plan(local: &PeerSnapshot, remote: &PeerSnapshot) -> PairPlan { + match (is_seeded(local), is_seeded(remote)) { + (true, true) => PairPlan::Sync, + (true, false) => PairPlan::Pair { source_is_local: true }, + (false, true) => PairPlan::Pair { source_is_local: false }, + (false, false) => PairPlan::Nothing, + } +} + /// Whether a pass may be skipped entirely (#28p D5). Deliberately narrow: only a /// **green** project whose two ref signatures are both unchanged early-outs. Any /// non-green state re-runs in full, so a manual Retry after the user clears a blocker /// (deleting an untracked collision moves no ref) is never answered from cache. Pure. +/// +/// `both_seeded` — not "both are repos": a half-seeded side must never be able to +/// early-out of the pass that would repair it. pub fn can_early_out( prior: &GitPeerState, local_sig: &str, remote_sig: &str, - both_repos: bool, + both_seeded: bool, forced: bool, ) -> bool { !forced - && both_repos + && both_seeded && prior.status == SyncStatus::Synchronized && prior.local_sig.as_deref() == Some(local_sig) && prior.remote_sig.as_deref() == Some(remote_sig) @@ -945,8 +1011,11 @@ fn probe_per_command(peer: &Peer) -> PeerSnapshot { .run(&["symbolic-ref", "--quiet", "--short", "HEAD"]) .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .unwrap_or_default(); + // `--verify --quiet` so an unborn HEAD yields *empty stdout* rather than the + // literal `HEAD` that a bare `rev-parse HEAD` prints — the two probes must agree + // on what unborn looks like, whichever one a peer happens to take. let rev = peer - .run(&["rev-parse", "HEAD"]) + .run(&["rev-parse", "--verify", "--quiet", "HEAD"]) .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) @@ -1766,6 +1835,7 @@ async fn init_pairing( } else { (Peer::Local(mirror_dir(project_id)), false) }; + let dest_label = if source_is_local { "remote host" } else { "local mirror" }; if let Peer::Local(dir) = &dest_peer { let _ = std::fs::create_dir_all(dir); } @@ -1784,35 +1854,74 @@ async fn init_pairing( } } - // `git init` the empty side (idempotent on an existing repo, but we only reach - // here when the dest is not yet a repo). + // The host root must exist before any remote git can run: every remote command is + // `cd '' && git …`, so a missing directory fails ALL of them — including + // the `git init` below. Eldrun only `mkdir -p`s the root at create/extend time, which + // leaves a host whose directory is later removed (or whose creation was refused back + // then — it is best-effort there) permanently unpairable. Idempotent, so re-pairing an + // existing host costs one cheap round trip. + if source_is_local { + crate::services::ssh_exec::remote_mkdir_p(spec) + .map_err(|e| format!("could not create '{}' on the host: {e}", spec.remote_path))?; + } + + // `git init` the empty side. Idempotent, and deliberately so: the dest may already be + // a repo here — a bare, never-checked-out one left behind by an earlier seed that died + // after this very step. Re-running init on it is a no-op and the pairing carries on. let out = dest_peer.run(&["init"])?; if !out.status.success() { return Err(format!( - "git init on {} failed: {}", - if source_is_local { "remote host" } else { "local mirror" }, + "git init on {dest_label} failed: {}", String::from_utf8_lossy(&out.stderr).trim() )); } // Transfer every ref from the authority into the freshly-init'd (empty) dest; // with no dest shas this is a full bundle and every branch/tag is a create. + // + // A failure here is fatal to the pairing and must NOT fall through to the checkout + // below: `reset --hard` would be aimed at a sha whose objects never arrived, and the + // `git init` above has *already run* — so swallowing this (as `let _ =` used to) left + // a dest that is a repo, holds no commit, and has an empty working tree, while + // reporting `Ok`. That is precisely the half-seeded state `is_seeded` now exists to + // recognize; reporting it means the user sees why, and the next pass retries the seed + // instead of settling into a permanent head mismatch. let dest = probe(&dest_peer); - let _ = - transfer_and_apply(pool, manifest, project_id, spec, to_remote, source, &dest, false).await; + transfer_and_apply(pool, manifest, project_id, spec, to_remote, source, &dest, false).await?; // Position HEAD + populate the working tree to match the source's HEAD. The // `reset --hard` here is only reached once `pairing_conflicts` has confirmed that // nothing it would clobber differs from what it is about to write (#28p D3). + // + // This is the ONLY place lockstep writes a dest's working tree at pairing time, so a + // silent failure here is the difference between a seeded peer and an empty directory + // with a `.git` in it. Checked, not swallowed. + let checked = |what: &str, out: Output| -> Result<(), String> { + if out.status.success() { + return Ok(()); + } + Err(format!( + "{what} on the {dest_label} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )) + }; match &source.head { Some(HeadRef::Branch { name, sha }) => { - let _ = dest_peer.run(&["symbolic-ref", "HEAD", &format!("refs/heads/{name}")]); - let _ = dest_peer.run(&["reset", "--hard", sha]); + checked( + &format!("pointing HEAD at '{name}'"), + dest_peer.run(&["symbolic-ref", "HEAD", &format!("refs/heads/{name}")])?, + )?; + checked( + &format!("checking out '{name}'"), + dest_peer.run(&["reset", "--hard", sha])?, + )?; } Some(HeadRef::Detached { sha }) => { - let _ = dest_peer.run(&["checkout", sha]); + checked("checking out the detached HEAD", dest_peer.run(&["checkout", sha])?)?; } // Unborn/None source → nothing committed yet; leave the empty repo unborn too. + // Unreachable via `pair_plan` (an unseeded side is never the source), kept as a + // total match rather than a panic. _ => {} } @@ -2056,6 +2165,7 @@ async fn reconcile_with( let local = probe(&Peer::Local(mirror_dir(project_id))); let remote = probe(&Peer::Remote(spec.clone())); let (local_sig, remote_sig) = (refs_signature(&local), refs_signature(&remote)); + let plan = pair_plan(&local, &remote); // #28p D5: nothing moved on either side and we were green → re-emit, skipping the // bundle round trip entirely. This is what stops a bare `git add` (which trips the @@ -2064,7 +2174,7 @@ async fn reconcile_with( &prior, &local_sig, &remote_sig, - local.is_repo && remote.is_repo, + matches!(plan, PairPlan::Sync), opts.forced, ) { return persist( @@ -2084,7 +2194,7 @@ async fn reconcile_with( let mut pairing_blocked: Option = None; let mut pairing_conflict: Option = None; - if local.is_repo && remote.is_repo { + if matches!(plan, PairPlan::Sync) { // Local → remote, then remote → local (each catches the side that is ahead). // A leg that *errors* (bundle unreadable, SFTP transfer died) must not be // discarded: dropping it leaves `diverged`/`blocked` empty, which computes to @@ -2114,9 +2224,11 @@ async fn reconcile_with( } Err(e) => blocked = blocked.or(Some(format!("Sync from the host failed: {e}"))), } - } else if local.is_repo != remote.is_repo { - // Exactly one side is a repo → initialize the other from it (initial pairing). - let source_is_local = local.is_repo; + } else if let PairPlan::Pair { source_is_local } = plan { + // Exactly one side holds a commit → initialize the other from it (initial + // pairing). NB "holds a commit", not "is a repo": a host left as a bare + // `git init` by a seed that died mid-way is an unpaired dest, not a peer, and + // this is the branch that finishes it (see `is_seeded`). // The dest is the side we would `git init` + `reset --hard`. If *its* probe // errored (the tree is there, but git couldn't confirm what it is), refuse: a // transient git/network failure must never license a wipe of a real repo. This @@ -2164,18 +2276,22 @@ async fn reconcile_with( }); } else { let source = if source_is_local { &local } else { &remote }; - if init_pairing(pool, manifest, project_id, spec, source_is_local, source) - .await - .is_ok() + match init_pairing(pool, manifest, project_id, spec, source_is_local, source).await { - // Pairing just materialized both sides to the same HEAD, so every - // tracked file is byte-identical across host and mirror. Seed the - // selective-sync manifest for them so the file tree shows them green - // (in sync) rather than red (untracked by SFTP sync) — there is - // nothing to transfer. This branch only fires at the pairing moment - // (exactly one side a repo), so it runs once and never re-selects a - // file the user later deselects. - seed_manifest_after_pairing(pool, manifest, project_id, spec).await; + Ok(()) => { + // Pairing just materialized both sides to the same HEAD, so every + // tracked file is byte-identical across host and mirror. Seed the + // selective-sync manifest for them so the file tree shows them green + // (in sync) rather than red (untracked by SFTP sync) — there is + // nothing to transfer. This branch only fires at the pairing moment + // (exactly one side seeded), so it runs once and never re-selects a + // file the user later deselects. + seed_manifest_after_pairing(pool, manifest, project_id, spec).await; + } + // A seed that failed part-way leaves the dest a bare, unseeded repo. + // Say so: it used to report `Ok` and settle into a permanent, baffling + // head mismatch. The dest stays unseeded, so the next pass retries. + Err(e) => pairing_blocked = Some(format!("Initial pairing failed: {e}")), } } } @@ -4182,4 +4298,201 @@ mod tests { assert_eq!(s.status, SyncStatus::Synchronized); assert!(s.pairing_conflict.is_none() && s.local_sig.is_none()); } + + // ── The half-seeded host: `git init` ran, the checkout never did ───────── + // + // Live failure (SimpleGNN, extend-local-to-remote): the host ended up holding a + // `.git` and an empty working tree, permanently, and the pill blamed a branch that + // existed on neither side ("the mirror is on 'transfer-learning', the host is on + // 'master'"). Three bugs in series; one test each, plus the gate that repairs it. + + /// Bug 1. `git rev-parse HEAD` on an unborn repo prints the literal `HEAD` to + /// **stdout** and reports the failure only in its exit status. `PROBE_SCRIPT` + /// discarded that status, so `parse_head` was handed `("master", "HEAD")` and built + /// `Branch { name: "master", sha: "HEAD" }` — a branch nobody has, at a sha that is + /// not a sha. `parse_head` now demands a real object name, so no probe, however + /// broken, can fabricate a checked-out branch again. + #[test] + fn parse_head_never_invents_a_branch_from_a_non_sha() { + assert_eq!(parse_head("master", "HEAD"), HeadRef::Unborn); + assert_eq!(parse_head("", "HEAD"), HeadRef::Unborn); + assert_eq!(parse_head("main", ""), HeadRef::Unborn); + assert_eq!(parse_head("main", "not-a-sha"), HeadRef::Unborn); + // …while a real head still parses exactly as before. + assert_eq!( + parse_head("main", "abc123"), + HeadRef::Branch { name: "main".into(), sha: "abc123".into() } + ); + assert_eq!(parse_head("", "abc123"), HeadRef::Detached { sha: "abc123".into() }); + } + + /// Bug 1, at the level that actually shipped. `parse_probe_block_tolerates_empty_ + /// sections_and_a_non_repo` already asserted "unborn → `HeadRef::Unborn`", and passed + /// throughout — because it *synthesized* the block by hand with an empty sha section, + /// encoding the very assumption that was false. So: drive the real `PROBE_SCRIPT` + /// through a real `sh` against a real `git init`ed repo, and read what git actually + /// prints. This is the test that would have caught it. + #[cfg(unix)] + #[test] + fn probe_script_reports_a_real_unborn_repo_as_unborn() { + if !git_available() { + eprintln!("git not on PATH — skipping probe_script_reports_a_real_unborn_repo"); + return; + } + let tmp = tempfile::tempdir().expect("tempdir"); + let dir = tmp.path(); + crate::paths::command_no_window("git") + .args(["init", "-q", "-b", "master", "."]) + .current_dir(dir) + .output() + .expect("git init"); + + let snap = parse_probe_block(&run_sh(dir, PROBE_SCRIPT)).expect("parses"); + assert!(snap.is_repo, "a bare `git init` IS a repo…"); + assert_eq!( + snap.head, + Some(HeadRef::Unborn), + "…but its HEAD is unborn — NOT `Branch {{ name: \"master\", sha: \"HEAD\" }}`" + ); + assert!(snap.branches.is_empty() && snap.head_subject.is_none()); + // The batched remote probe and the per-command local one must agree about what + // unborn looks like, or the bug simply moves to whichever path a peer takes. + assert_eq!(snap.head, probe_per_command(&Peer::Local(dir.to_path_buf())).head); + // And the whole point: this side is not seeded, so it is still pairable. + assert!(!is_seeded(&snap)); + } + + /// Bug 2. The pairing gate keyed on `is_repo`, but `git init` is pairing's own first + /// step — so a seed that died after it flipped the gate shut behind itself. The dest + /// was a repo forever after, "exactly one side is a repo" never fired again, and the + /// steady-state path only ever moves refs (`update-ref`); it never positions HEAD or + /// checks out. Hence: objects and refs on the host, empty tree, forever. + #[test] + fn a_bare_git_init_is_not_seeded_and_stays_pairable() { + let unborn = PeerSnapshot { is_repo: true, head: Some(HeadRef::Unborn), ..Default::default() }; + let seeded = PeerSnapshot { + is_repo: true, + head: Some(HeadRef::Branch { name: "transfer-learning".into(), sha: "a5b535d".into() }), + branches: vec![RefEntry { name: "transfer-learning".into(), sha: "a5b535d".into() }], + ..Default::default() + }; + let absent = PeerSnapshot::default(); + + assert!(!is_seeded(&absent), "not a repo"); + assert!(!is_seeded(&unborn), "a repo, but nothing is checked out"); + assert!(is_seeded(&seeded)); + assert!(is_seeded(&PeerSnapshot { + is_repo: true, + head: Some(HeadRef::Detached { sha: "abc123".into() }), + ..Default::default() + })); + + // The live case: mirror seeded, host `git init`ed and never checked out. + assert_eq!( + pair_plan(&seeded, &unborn), + PairPlan::Pair { source_is_local: true }, + "the half-seeded host must be re-paired, not treated as a peer" + ); + // Under the old `is_repo` gate this was `Sync` — which moved refs and never a + // single file, which is exactly how it wedged. + assert!(seeded.is_repo && unborn.is_repo, "…and both ARE repos, which is the trap"); + + assert_eq!(pair_plan(&seeded, &absent), PairPlan::Pair { source_is_local: true }); + assert_eq!(pair_plan(&absent, &seeded), PairPlan::Pair { source_is_local: false }); + assert_eq!(pair_plan(&seeded, &seeded), PairPlan::Sync); + assert_eq!(pair_plan(&absent, &absent), PairPlan::Nothing); + assert_eq!(pair_plan(&unborn, &unborn), PairPlan::Nothing, "nothing to move either way"); + } + + /// The nastiest shape of bug 2: the refs DID land (the steady-state pass creates them + /// with `update-ref`), so the host looks populated by every measure except the only + /// one that matters — whether anything is checked out. A gate that looked at branches + /// rather than HEAD would call this seeded and wedge exactly as before. + #[test] + fn a_host_with_refs_but_no_checkout_is_still_unseeded() { + let mirror = PeerSnapshot { + is_repo: true, + head: Some(HeadRef::Branch { name: "transfer-learning".into(), sha: "a5b535d".into() }), + branches: vec![RefEntry { name: "transfer-learning".into(), sha: "a5b535d".into() }], + ..Default::default() + }; + // Every ref arrived; HEAD is still unborn. Empty working tree. + let host = PeerSnapshot { + is_repo: true, + head: Some(HeadRef::Unborn), + branches: vec![ + RefEntry { name: "transfer-learning".into(), sha: "a5b535d".into() }, + RefEntry { name: "main".into(), sha: "64bd1c3".into() }, + ], + ..Default::default() + }; + assert!(!is_seeded(&host), "refs are not a checkout"); + assert_eq!(pair_plan(&mirror, &host), PairPlan::Pair { source_is_local: true }); + } + + /// Bug 3's other half: a half-seeded side must never be able to *early-out* of the + /// very pass that would repair it. `can_early_out` now gates on both sides being + /// seeded, not on both being repos. + #[test] + fn early_out_never_skips_a_half_seeded_side() { + let prior = GitPeerState { + status: SyncStatus::Synchronized, + local_sig: Some("sig".into()), + remote_sig: Some("sig".into()), + ..Default::default() + }; + assert!( + can_early_out(&prior, "sig", "sig", true, false), + "both seeded + green + unmoved → skip, as before" + ); + assert!( + !can_early_out(&prior, "sig", "sig", false, false), + "a side that was never checked out must always be re-examined" + ); + } + + /// The constraint on the repair: it must never cost the user a local file. When the + /// mirror is the unseeded side, pairing writes *it* — so the D3 collision guard is + /// what stands between `reset --hard` and the user's untracked work, and it must fail + /// CLOSED. A dest file that cannot be proven byte-identical to what the source would + /// write over it (`hash: None` — the hash could not be computed) is a conflict, which + /// blocks the pairing and raises the prompt instead of clobbering. + #[test] + fn pairing_into_the_mirror_refuses_to_clobber_unproven_local_files() { + let source: HashMap = [ + ("README.md".to_string(), Fingerprint { size: 10, hash: Some("aaa".into()) }), + ("src/a.rs".to_string(), Fingerprint { size: 20, hash: Some("bbb".into()) }), + ] + .into_iter() + .collect(); + + // Same path, same size, but the hash could not be established → NOT provably + // identical → refused rather than overwritten. + let unproven: HashMap = + [("README.md".to_string(), Fingerprint { size: 10, hash: None })].into_iter().collect(); + assert_eq!(pairing_collisions(&source, &unproven), vec!["README.md".to_string()]); + + // Differing content → refused. + let differs: HashMap = + [("src/a.rs".to_string(), Fingerprint { size: 20, hash: Some("zzz".into()) })] + .into_iter() + .collect(); + assert_eq!(pairing_collisions(&source, &differs), vec!["src/a.rs".to_string()]); + + // Byte-identical → adopting it is a no-op, so pairing proceeds (the intended + // "the mirror already holds these files" path). + let identical: HashMap = + [("README.md".to_string(), Fingerprint { size: 10, hash: Some("aaa".into()) })] + .into_iter() + .collect(); + assert!(pairing_collisions(&source, &identical).is_empty()); + + // A local file the source does not track is not a collision at all — and + // `reset --hard` never removes untracked paths, so it survives the pairing. + let untracked: HashMap = + [("data/big.npz".to_string(), Fingerprint { size: 999, hash: None })] + .into_iter() + .collect(); + assert!(pairing_collisions(&source, &untracked).is_empty()); + } } diff --git a/src-tauri/src/services/remote_sync.rs b/src-tauri/src/services/remote_sync.rs index 2d8ee13..7f04863 100644 --- a/src-tauri/src/services/remote_sync.rs +++ b/src-tauri/src/services/remote_sync.rs @@ -1079,4 +1079,60 @@ mod tests { assert!(!is_auto(&m, "README.md")); assert!(is_auto(&m, "vendor/keep/x.rs")); // explicit ON still wins } + + /// Why `commands::projects::clear_host_bound_state` must exist. + /// + /// A manifest entry is a claim about **one specific host**. Point the project at a + /// different one — detach, then extend to a corrected path, which is the normal way to + /// fix a wrong `remote_path` — and every base in it becomes a lie. The state dir is + /// keyed by project *id*, which detach preserves, so without an explicit purge the new + /// pairing inherits the old host's manifest wholesale. + /// + /// The two pure functions below then disagree about the same file in the worst + /// possible way, and this test pins both halves so the purge can never be quietly + /// dropped: + /// * `push_decision` sees `ever_synced` + a missing host file and calls it `Stale` — + /// a deletion to be resolved, not a file to send. So it **refuses to push**. + /// * `divergence` maps the same failed host stat to "couldn't check → don't flag", + /// so with the mirror untouched the file reads `(false, false)` — **green**. + /// + /// A file the tree reports as fully in sync, on a host that has never had it, which + /// byte-sync will never send. It would look like the sync simply worked. + #[test] + fn a_stale_manifest_against_a_fresh_host_is_a_false_green() { + // Synced against the OLD host, and untouched locally since. + let stale = SyncEntry { + selected: true, + auto_sync: true, + host_size: 10, + host_mtime: Some(100), + local_size: 10, + local_mtime: Some(100), + last_pull_ts: Some(1), + ..Default::default() + }; + let local_unchanged = Some((10u64, Some(100u64))); + + // The new host has never heard of this file. + assert_eq!( + push_decision(&stale, None), + PushDecision::Stale, + "refuses to push: it reads the absence as a host-side DELETION, not a new host" + ); + assert_eq!( + divergence(&stale, None, local_unchanged), + (false, false), + "…and paints it green while doing so" + ); + + // Cleared (as a fresh pairing must be), the very same file behaves correctly: it + // is simply a local file the host lacks, so it gets created there. + let fresh = SyncEntry { selected: true, auto_sync: true, ..Default::default() }; + assert_eq!(push_decision(&fresh, None), PushDecision::Safe); + assert_eq!( + divergence(&fresh, None, local_unchanged), + (false, true), + "local-only change → push, which is exactly the seed a new host needs" + ); + } } diff --git a/src-tauri/src/services/terminal_service.rs b/src-tauri/src/services/terminal_service.rs index 6af6f34..c904a10 100644 --- a/src-tauri/src/services/terminal_service.rs +++ b/src-tauri/src/services/terminal_service.rs @@ -11,13 +11,19 @@ use crate::storage; /// `groups` is the opaque split/group layout tree (None clears it). /// `sessions` is the opaque list of open agent-session UUIDs: `Some([])` clears /// it, `Some(list)` replaces it, and `None` leaves the stored value untouched. +/// +/// `allow_clear` is what makes an EMPTY `tabs` mean "the user closed every tab" +/// rather than "the caller had nothing loaded". Only the frontend can tell those +/// apart, and it only knows for a scope it actually hydrated — see the guard in +/// `write_terminal_session`. pub fn save_tab_layout( local_file: &str, tabs: &[TabEntry], groups: Option, sessions: Option, + allow_clear: bool, ) -> Result<(), String> { - write_terminal_session(local_file, tabs, 0, groups, sessions) + write_terminal_session(local_file, tabs, 0, groups, sessions, allow_clear) } /// Save tab layout with the active tab index. @@ -31,7 +37,10 @@ pub fn save_terminal_session( ) -> Result<(), String> { // The project-switch snapshot carries no session list; leave the persisted // UUIDs untouched (the active project's debounced save_tab_layout owns them). - write_terminal_session(local_file, tabs, active_tab_index, groups, None) + // It also never clears: a snapshot is a picture of what was in memory, and an + // empty one is far more likely to mean "this scope was never loaded" than + // "the user closed everything" — the debounced save owns that intent. + write_terminal_session(local_file, tabs, active_tab_index, groups, None, false) } fn write_terminal_session( @@ -40,7 +49,27 @@ fn write_terminal_session( active_tab_index: usize, groups: Option, sessions: Option, + allow_clear: bool, ) -> Result<(), String> { + // An empty layout is DESTRUCTIVE — it drops `tab_layout`/`tab_groups` from + // project.json *and* overwrites the `.eldrun` mirror with an empty one, in a + // single call. The two are written from the same array, so the mirror is not a + // backup: one empty save takes both copies, and a persisted agent tab's + // `sessionId` is the only handle on its conversation. + // + // That is fine when the user really did close every tab, and catastrophic + // otherwise — and "otherwise" is reachable, which is how SimpleGNN lost four + // tabs on detach: the frontend's debounced autosave persists the tab store's + // CURRENT scope into the ACTIVE project's `local_file`, two values it tracks + // independently. Detach swaps `local_file` (state dir → promoted mirror) under + // a store whose scope has not caught up, the per-scope tab filter correctly + // refuses to write another project's tabs into this file, and what lands is an + // empty list that reads exactly like a deliberate close-all. + // + // So an empty layout only clears when the caller states it means one. + if tabs.is_empty() && !allow_clear { + return Ok(()); + } let path = PathBuf::from(local_file); let mut project: crate::schema::project::Project = storage::read_json(&path).unwrap_or_default(); diff --git a/src-tauri/tests/services_tests.rs b/src-tauri/tests/services_tests.rs index b95b86e..7c27dcd 100644 --- a/src-tauri/tests/services_tests.rs +++ b/src-tauri/tests/services_tests.rs @@ -229,7 +229,7 @@ fn save_and_load_tab_layout_roundtrip() { }, ]; - terminal_service::save_tab_layout(&local_file_str, &tabs, None, None).unwrap(); + terminal_service::save_tab_layout(&local_file_str, &tabs, None, None, true).unwrap(); let loaded = terminal_service::load_tab_layout(&local_file_str); assert_eq!(loaded.len(), 2); @@ -269,7 +269,7 @@ fn save_tab_layout_round_trips_agent_session_id() { }, ]; - terminal_service::save_tab_layout(&local_file_str, &tabs, None, None).unwrap(); + terminal_service::save_tab_layout(&local_file_str, &tabs, None, None, true).unwrap(); let loaded = terminal_service::load_tab_layout(&local_file_str); assert_eq!(loaded.len(), 2); @@ -313,7 +313,7 @@ fn save_tab_layout_preserves_other_project_fields() { session_id: None, extra: Default::default(), }]; - terminal_service::save_tab_layout(&local_file_str, &tabs, None, None).unwrap(); + terminal_service::save_tab_layout(&local_file_str, &tabs, None, None, true).unwrap(); let reloaded: Project = eldrun_lib::storage::read_json(&local_file).unwrap(); assert_eq!(reloaded.id, "preserve-me"); @@ -336,7 +336,7 @@ fn save_tab_layout_persists_open_session_uuids() { let sessions = serde_json::json!([ { "sessionId": "11111111-1111-4111-8111-111111111111", "cmd": "claude", "label": "Claude" } ]); - terminal_service::save_tab_layout(&path_str, &[], None, Some(sessions.clone())).unwrap(); + terminal_service::save_tab_layout(&path_str, &[], None, Some(sessions.clone()), true).unwrap(); let reloaded: Project = eldrun_lib::storage::read_json(&local_file).unwrap(); assert_eq!(reloaded.open_tab_sessions, Some(sessions)); @@ -347,7 +347,7 @@ fn save_tab_layout_persists_open_session_uuids() { let reloaded: Project = eldrun_lib::storage::read_json(&local_file).unwrap(); assert!(reloaded.open_tab_sessions.is_some()); - terminal_service::save_tab_layout(&path_str, &[], None, Some(serde_json::json!([]))).unwrap(); + terminal_service::save_tab_layout(&path_str, &[], None, Some(serde_json::json!([])), true).unwrap(); let reloaded: Project = eldrun_lib::storage::read_json(&local_file).unwrap(); assert_eq!(reloaded.open_tab_sessions, None); } @@ -372,9 +372,8 @@ fn load_open_apps_returns_empty_when_none_saved() { assert!(loaded.is_empty()); } -#[test] -fn save_empty_tabs_clears_layout_field() { - let tmp = TempDir::new().unwrap(); +/// A project holding one saved tab, for the two empty-save tests below. +fn project_with_one_tab(tmp: &TempDir) -> String { let project = Project { id: "p".to_string(), name: "P".to_string(), @@ -384,20 +383,65 @@ fn save_empty_tabs_clears_layout_field() { label: "Old".to_string(), cmd: "bash".to_string(), cwd: "/tmp".to_string(), - session_id: None, + session_id: Some("11111111-1111-4111-8111-111111111111".to_string()), extra: Default::default(), }]), ..Default::default() }; - let local_file = write_project_json(tmp.path(), &project); - let path_str = local_file.to_string_lossy().to_string(); + write_project_json(tmp.path(), &project) + .to_string_lossy() + .to_string() +} - terminal_service::save_tab_layout(&path_str, &[], None, None).unwrap(); +#[test] +fn save_empty_tabs_clears_layout_field_when_clearing_is_allowed() { + let tmp = TempDir::new().unwrap(); + let path_str = project_with_one_tab(&tmp); + + // The user really did close every tab. + terminal_service::save_tab_layout(&path_str, &[], None, None, true).unwrap(); let loaded = terminal_service::load_tab_layout(&path_str); assert!(loaded.is_empty()); } +/// The SimpleGNN regression: an empty layout arriving from a caller that cannot +/// vouch for it must NOT erase the saved one. +/// +/// Detach swaps a project's `local_file` (remote state dir → promoted mirror) while +/// the tab store's scope has not caught up; the debounced autosave then persists the +/// wrong scope's tabs into that file, the per-scope filter drops every one of them as +/// foreign, and what arrives is `[]` — indistinguishable from a close-all, and fatal. +/// It took `tab_layout`, `tab_groups` and the `.eldrun` session mirror in one call, +/// and with them the `sessionId`s that were the only handle on three live Claude +/// conversations. Empty now clears only when the caller says it means one. +#[test] +fn save_empty_tabs_preserves_layout_when_clearing_is_not_allowed() { + let tmp = TempDir::new().unwrap(); + let path_str = project_with_one_tab(&tmp); + + terminal_service::save_tab_layout(&path_str, &[], None, None, false).unwrap(); + + let loaded = terminal_service::load_tab_layout(&path_str); + assert_eq!(loaded.len(), 1, "an unvouched empty save must not erase tabs"); + assert_eq!(loaded[0].key, "old"); + assert_eq!( + loaded[0].session_id.as_deref(), + Some("11111111-1111-4111-8111-111111111111"), + "the agent's session id is the handle on its conversation" + ); + + // …and it must not have emptied the `.eldrun` mirror either — that file WINS on + // load, so clobbering it would lose the tabs even with project.json intact. + let sessions_dir = terminal_service::eldrun_sessions_dir(&path_str).unwrap(); + let mirror = sessions_dir.join("terminals.json"); + if mirror.exists() { + let session: eldrun_lib::schema::session::TerminalSession = + eldrun_lib::storage::read_json(&mirror).unwrap(); + assert_eq!(session.tab_layout.len(), 1); + } +} + // ── terminal_service: .eldrun/sessions/ ─────────────────────────────────── #[test] diff --git a/src/CLAUDE.md b/src/CLAUDE.md index a2dd0a9..b9a026e 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -26,7 +26,7 @@ workflow); see `src-tauri/CLAUDE.md` for the backend file map. | `DetachedApp.tsx` | Root component for a popped-out/detached subwindow (#42). | | `ProjectSwitcher.tsx` | Thin composition root: pill strip + search/dialog/settings wiring. Re-exports scaffold helpers. | | `ProjectSearch.tsx` *(in `projects/`)* | Inactive-project/box search box + results popover. | -| `ProjectDialog.tsx` *(in `projects/`)* | New/Import project dialog incl. SSH + OpenVPN + scaffold-fill sub-flows. | +| `ProjectDialog.tsx` *(in `projects/`)* | New/Import project dialog incl. SSH + OpenVPN + scaffold-fill sub-flows. An import's source is a local folder **or** a GitHub/GitLab clone (`git_clone` lands the tree, then the ordinary import registers it in place). | | `SettingsPanel.tsx` | Settings dialog + sub-panels (theme/git/layout, global apps, file-type apps, Ollama, shortcuts, help). | | `RightPanel.tsx` | File-tree overlay panel (git status/history). | | `VpnPasswordPrompt.tsx` | Modal prompting for an OpenVPN password on activation. | diff --git a/src/__tests__/CloneImport.test.ts b/src/__tests__/CloneImport.test.ts new file mode 100644 index 0000000..d62541f --- /dev/null +++ b/src/__tests__/CloneImport.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { isCloneUrl, repoNameFromCloneUrl, sanitizeName } from "../components/projects/scaffold"; + +// The import dialog's "GitHub / GitLab repository (clone)" source. `isCloneUrl` +// gates the Import button; the backend's `validate_clone_url` is the real guard +// and these cases are kept in step with its Rust tests. +describe("clone URL validation", () => { + it("accepts the supported URL forms", () => { + for (const url of [ + "https://github.com/owner/repo.git", + "https://gitlab.com/group/sub/repo", + "http://git.internal/owner/repo.git", + "ssh://git@github.com:22/owner/repo.git", + "git://git.internal/repo.git", + "git@github.com:owner/repo.git", + "gitlab.com:group/repo.git", + " https://github.com/owner/repo.git ", + ]) { + expect(isCloneUrl(url), url).toBe(true); + } + }); + + it("rejects everything else", () => { + for (const url of [ + "", + " ", + // git's transport-helper form: `ext::` RUNS the command. + "ext::sh -c 'touch /tmp/pwned'", + "file:///etc", + "/etc/passwd", + "../repo", + "github.com/owner/repo", + "--upload-pack=x:y", + ]) { + expect(isCloneUrl(url), url).toBe(false); + } + }); +}); + +describe("project name from a clone URL", () => { + it("takes the repository's own name, without .git", () => { + expect(repoNameFromCloneUrl("https://github.com/owner/my-repo.git")).toBe("my-repo"); + expect(repoNameFromCloneUrl("https://gitlab.com/group/sub/my-repo")).toBe("my-repo"); + expect(repoNameFromCloneUrl("git@github.com:owner/my-repo.git")).toBe("my-repo"); + // A trailing slash (copied from the browser's address bar) is not a segment. + expect(repoNameFromCloneUrl("https://github.com/owner/my-repo/")).toBe("my-repo"); + // scp-like with no path separator: the repo is the whole path. + expect(repoNameFromCloneUrl("git@host:repo.git")).toBe("repo"); + }); + + it("yields nothing name-like for an empty URL", () => { + expect(repoNameFromCloneUrl("")).toBe(""); + expect(repoNameFromCloneUrl(" ")).toBe(""); + }); + + it("survives sanitizing into the destination folder name", () => { + // The clone lands at `/`, so a repo name with + // characters the project-name sanitizer strips must still yield a folder. + expect(sanitizeName(repoNameFromCloneUrl("https://github.com/owner/My.Repo.git"))).toBe("my-repo"); + }); +}); diff --git a/src/__tests__/DetachFromRemote.test.ts b/src/__tests__/DetachFromRemote.test.ts new file mode 100644 index 0000000..abdf486 --- /dev/null +++ b/src/__tests__/DetachFromRemote.test.ts @@ -0,0 +1,141 @@ +/** + * Detaching a project from its SSH host must re-point its tabs. + * + * The live bug this pins (SimpleGNN): while a project is remote its `directory` is the + * state dir `~/.local/share/eldrun/remote-projects//`, and `loadFromLayout` stores + * exactly that as every tab's `cwd`. Nothing ever noticed, because `localTabCwd` rewrote + * it at render time to the real mirror — an override gated on `isRemoteProject`. + * + * Detach flips that gate to false. The override stops firing, every tab falls back to the + * stored cwd it should never have had, and agents relaunch inside the state dir — which + * detach has just emptied. Claude keys its session history by cwd, so `--resume` then finds + * no conversation (it lives under the mirror's path) and the agent starts asking for + * permissions under `.local/share/eldrun/remote-projects/…`. + * + * So: the fallback path is real, it is reachable, and `detachScopeFromRemote` is what keeps + * it from being taken. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +import { invoke } from "@tauri-apps/api/core"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(() => Promise.resolve()) })); + +import { localTabCwd, useTabsStore, type TabEntry } from "../stores/tabs"; + +const STATE_DIR = "/home/u/.local/share/eldrun/remote-projects/pid"; +const MIRROR = "/home/u/Documents/CodeProjectsGit/SimpleGNN"; + +function tab(over: Partial): TabEntry { + return { + key: "agent-1", + label: "Claude", + cmd: "claude", + args: [], + env: {}, + cwd: STATE_DIR, + kind: "agent", + scope: "pid", + ...over, + } as TabEntry; +} + +function seed(tabs: TabEntry[]) { + useTabsStore.setState({ + scope: "pid", + tabsByScope: { pid: tabs }, + layoutByScope: { pid: null }, + focusedGroupByScope: { pid: null }, + tabs, + layout: null, + focusedGroupId: null, + activeKey: null, + }); +} + +beforeEach(() => { + vi.mocked(invoke).mockClear(); +}); + +describe("localTabCwd — why detach breaks tabs", () => { + it("hides the bad cwd while remote, and exposes it the instant the project is local", () => { + const t = { kind: "agent" as const, location: "local" as const }; + + // Remote: the stored state-dir cwd is overridden by the mirror. Looks fine. + expect( + localTabCwd(t, { + isRemoteProject: true, + projectDirectory: STATE_DIR, + fallback: STATE_DIR, + mirror: MIRROR, + }), + ).toBe(MIRROR); + + // Detached: no override — the tab falls straight back to the cwd it was storing all + // along. THIS is the `.local/share/eldrun/remote-projects/…` the agent ends up in. + expect( + localTabCwd(t, { + isRemoteProject: false, + projectDirectory: MIRROR, + fallback: STATE_DIR, + mirror: null, + }), + ).toBe(STATE_DIR); + }); +}); + +describe("detachScopeFromRemote", () => { + it("moves every tab's cwd out of the state dir and into the promoted mirror", () => { + seed([ + tab({ key: "agent-1", cwd: STATE_DIR, location: "local" }), + tab({ key: "agent-2", cwd: `${STATE_DIR}/sub`, location: "local", label: "Claude 2" }), + ]); + + useTabsStore.getState().detachScopeFromRemote("pid", STATE_DIR, MIRROR); + + const tabs = useTabsStore.getState().tabsByScope.pid; + expect(tabs[0].cwd).toBe(MIRROR); + // A cwd *under* the old dir keeps its suffix rather than being flattened. + expect(tabs[1].cwd).toBe(`${MIRROR}/sub`); + // And the resolver now agrees, because the fallback is finally correct. + expect( + localTabCwd(tabs[0], { + isRemoteProject: false, + projectDirectory: MIRROR, + fallback: tabs[0].cwd, + mirror: null, + }), + ).toBe(MIRROR); + }); + + it("brings host-located tabs home — their cwd is a path on a machine we no longer have", () => { + seed([ + tab({ key: "shell-1", kind: "shell", cmd: "bash", cwd: "/home/remote/Code/simplegnn", location: "remote" }), + ]); + + useTabsStore.getState().detachScopeFromRemote("pid", STATE_DIR, MIRROR); + + const t = useTabsStore.getState().tabsByScope.pid[0]; + expect(t.cwd).toBe(MIRROR); + expect(t.location).toBeUndefined(); // back to the kind's default (local) + }); + + it("leaves a tab already sitting in the mirror alone", () => { + seed([tab({ key: "shell-1", kind: "shell", cmd: "bash", cwd: `${MIRROR}/src`, location: "local" })]); + + useTabsStore.getState().detachScopeFromRemote("pid", STATE_DIR, MIRROR); + + expect(useTabsStore.getState().tabsByScope.pid[0].cwd).toBe(`${MIRROR}/src`); + }); + + it("is a no-op when there is nothing to move", () => { + seed([tab({ key: "agent-1", cwd: MIRROR, location: "local" })]); + const before = useTabsStore.getState().tabsByScope.pid; + + useTabsStore.getState().detachScopeFromRemote("pid", STATE_DIR, MIRROR); + useTabsStore.getState().detachScopeFromRemote("pid", MIRROR, MIRROR); // same dir + useTabsStore.getState().detachScopeFromRemote("pid", "", MIRROR); // no old dir + + expect(useTabsStore.getState().tabsByScope.pid).toBe(before); + }); +}); diff --git a/src/__tests__/FileTreeDirSize.test.tsx b/src/__tests__/FileTreeDirSize.test.tsx new file mode 100644 index 0000000..a548ce3 --- /dev/null +++ b/src/__tests__/FileTreeDirSize.test.tsx @@ -0,0 +1,118 @@ +/** + * Regression test for the folder-size holes in the right-panel file tree. + * + * `load()` sets rawEntries, which fires the size-fetch effects, and only THEN + * awaits `git_file_statuses` — whose result changes `sections`' identity. When + * the effects cancelled on that (a `cancelled` flag in their cleanup), every + * size call still walking at that moment had its bytes thrown away, while the + * `requestedSizes` guard kept the folder from ever being re-requested. Small + * folders won the race and showed a size; a big one — `results/` in a training + * project, exactly what the feature is for — never did. + * + * So: resolve `dir_size_breakdown` only AFTER `git_file_statuses` has landed, + * and assert the size still renders. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; + +const { mockInvoke } = vi.hoisted(() => ({ mockInvoke: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mockInvoke })); +vi.mock("../stores/projects", () => ({ useProjectsStore: vi.fn() })); +vi.mock("../stores/windows", () => ({ + useWindowsStore: () => ({ windows: [], refresh: vi.fn(), untrack: vi.fn() }), +})); +vi.mock("../stores/settings", () => ({ useSettingsStore: () => null })); + +import { useProjectsStore } from "../stores/projects"; +import { RightPanel } from "../components/layout/RightPanel"; + +const mockUseProjectsStore = vi.mocked(useProjectsStore); + +const ACTIVE_PROJECT = { + id: "proj-1", + name: "simplegnn", + status: "active", + position: 0, + local_file: "/tmp/simplegnn/project.json", +}; + +function dirEntry(name: string) { + return { + name, + path: `/tmp/simplegnn/${name}`, + is_dir: true, + size: 0, + extension: null, + mime: null, + }; +} + +describe("file tree folder sizes", () => { + beforeEach(() => { + vi.clearAllMocks(); + const state = { + projects: [ACTIVE_PROJECT], + activeId: "proj-1", + rightPanelFolderByProject: {}, + setRightPanelFolder: vi.fn(), + } as unknown as ReturnType; + mockUseProjectsStore.mockImplementation(((selector?: (s: typeof state) => unknown) => + selector ? selector(state) : state) as typeof useProjectsStore); + }); + + it("shows a folder's size even when the walk outlives the git-status round-trip", async () => { + // Two round-trips held open by hand. They must land in this order, and each + // in its OWN render — that is the whole bug. Resolving them both eagerly lets + // React batch `setRawEntries` and `setGitStatuses` into one render, the size + // effect never sees the intermediate state, and the race can't happen at all. + const deferred = () => { + let release!: (v: T) => void; + const promise = new Promise((res) => { release = res; }); + return { promise, release }; + }; + const statuses = deferred>(); + const size = deferred<{ total: number; ignored: number }>(); + + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === "list_dir") return Promise.resolve([dirEntry("results")]); + // New object identity when it lands → `sections` recomputes → the effects' + // old cleanup fired and cancelled the size call still in flight. + if (cmd === "git_file_statuses") return statuses.promise; + if (cmd === "dir_size_breakdown") return size.promise; // the slow walk + if (cmd === "dir_size") return Promise.resolve(0); + if (cmd === "git_status") + return Promise.resolve({ staged: 0, unstaged: 0, untracked: 0, has_remote: false, is_repo: false }); + if (cmd === "git_unpushed_commits") return Promise.resolve([]); + if (cmd === "load_project") return Promise.resolve({}); + if (cmd === "list_project_endings") return Promise.resolve([]); + return Promise.resolve(null); + }); + + // 1. The listing lands and renders; the size effect dispatches the walk. + await act(async () => { + render(); + }); + expect(await screen.findByText("results")).toBeTruthy(); + expect(mockInvoke).toHaveBeenCalledWith( + "dir_size_breakdown", + expect.objectContaining({ relPath: "results" }), + ); + + // 2. Git statuses land while the walk is still running. + await act(async () => { + statuses.release({}); + await statuses.promise; + }); + + // 3. Only now does the walk finish: 2 GiB. + await act(async () => { + size.release({ total: 2 * 1024 * 1024 * 1024, ignored: 0 }); + await size.promise; + }); + + // Scoped to the row: the section-total line shows the same figure. + const row = screen.getByText("results").closest(".file-entry"); + expect(row?.querySelector(".file-size")?.textContent).toBe("2.0 GB"); + }); +}); diff --git a/src/__tests__/FileTreeNav.test.tsx b/src/__tests__/FileTreeNav.test.tsx index bb96b5a..b92697d 100644 --- a/src/__tests__/FileTreeNav.test.tsx +++ b/src/__tests__/FileTreeNav.test.tsx @@ -1,7 +1,8 @@ /** * Render tests for the right-panel file tree navigation: - * - #2 (Group D.1): a file's `.file-name` span carries a native `title` with the - * full name so long names are readable on hover (CSS ellipsis aside). + * - #2 (Group D.1): a file's full name is readable on hover via the existing + * custom `.file-tooltip` popup (not a native `title` attribute — that used + * to render as an unthemed OS tooltip instead of the app's own hover UI). * - #3 (Group D.1): entering a subfolder renders a breadcrumb (↑, ⌂ root crumb, * and a crumb per path segment); clicking the root crumb navigates back. */ @@ -88,11 +89,16 @@ describe("file tree navigation", () => { selector ? selector(state) : state) as typeof useProjectsStore); }); - it("#2 shows the full file name in a title attribute", async () => { + it("#2 shows the full file name in the hover tooltip, not a title attribute", async () => { await renderPanel(); const nameSpan = await screen.findByText(LONG_NAME); expect(nameSpan.classList.contains("file-name")).toBe(true); - expect(nameSpan.getAttribute("title")).toBe(LONG_NAME); + expect(nameSpan.getAttribute("title")).toBeNull(); + + const row = nameSpan.closest(".file-entry"); + expect(row).toBeTruthy(); + fireEvent.mouseEnter(row!); + expect(await screen.findByText(LONG_NAME, { selector: ".file-tooltip-name" })).toBeTruthy(); }); it("#3 builds a breadcrumb on entering a subfolder and the root crumb goes back", async () => { diff --git a/src/__tests__/PillRunningIndicator.test.ts b/src/__tests__/PillRunningIndicator.test.ts index 601e7f7..8a5b13e 100644 --- a/src/__tests__/PillRunningIndicator.test.ts +++ b/src/__tests__/PillRunningIndicator.test.ts @@ -342,6 +342,48 @@ describe("activity store attention state", () => { expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBeUndefined(); }); + it("keeps flagging a decision on the tab the user IS looking at", () => { + // A blocked agent stays blocked while you look at it — the flag is about the + // agent's state, not about unread output. (Contrast the "done" case above: + // that one IS about unread output, so viewing the tab retires it.) + useTabsStore.setState({ scope: "proj-a" }); // agent-1 is now the visible tab + runThenPrompt("proj-a:agent-1"); + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBe("decision"); + + // And it holds tick after tick: looking at a prompt does not answer it. + vi.advanceTimersByTime(5000); + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBe("decision"); + expect(useActivityStore.getState().attentionByScope["proj-a"]).toBe("decision"); + }); + + it("retires a watched decision when it is answered", () => { + // Input is what clears it — the keystroke that picks an option drops the tail + // the prompt was matched from (noteUserInput), so the answered menu can't be + // matched again as a live one even before the agent's reply arrives. + useTabsStore.setState({ scope: "proj-a" }); + runThenPrompt("proj-a:agent-1"); + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBe("decision"); + + noteUserInput("proj-a:agent-1"); // the human picks an option + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBeUndefined(); + }); + + it("does not clear a live decision when the tab is merely viewed", () => { + // Switching TO the tab marks its output read (clearAttention), which must not + // knock the lamp out for one tick just for `recompute` to raise it right back. + runThenPrompt("proj-a:agent-1"); + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBe("decision"); + + useActivityStore.getState().clearAttention("proj-a:agent-1"); + expect(useActivityStore.getState().attentionByTab["proj-a:agent-1"]).toBe("decision"); + expect(useActivityStore.getState().attentionByScope["proj-a"]).toBe("decision"); + }); + it("clears the flag (and scope rollup) once the tab is viewed, for good", () => { runThenFinish("proj-a:agent-1"); useActivityStore.getState().recompute(); @@ -444,6 +486,33 @@ describe("activity store per-scope status counts (pill status bars)", () => { }); }); + it("counts the tabs of the SELECTED project too, including the visible one", () => { + // The strip answers "is anything still running in there?" — a question the + // project you are currently in has just as much right to answer. Selecting a + // project used to empty its bars, which read as "nothing running". + useTabsStore.setState({ scope: "proj-a" }); // agent-1 is the visible tab + sustainAll(["proj-a:agent-1", "proj-a:agent-2"]); + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().statusCountsByScope["proj-a"]).toEqual({ + working: 2, + decision: 0, + done: 0, + }); + }); + + it("keeps a decision bar for the selected project's visible tab", () => { + useTabsStore.setState({ scope: "proj-a" }); // agent-1 is the visible tab + runThenPrompt("proj-a:agent-1"); + vi.advanceTimersByTime(200); // let the prompt's own output age out of the busy + // window (800ms), so the tab reads as blocked rather than still writing + useActivityStore.getState().recompute(); + expect(useActivityStore.getState().statusCountsByScope["proj-a"]).toEqual({ + working: 0, + decision: 1, + done: 0, + }); + }); + it("drops the scope entirely once its tabs go idle with nothing to report", () => { sustainOutput("proj-a:agent-1"); useActivityStore.getState().recompute(); diff --git a/src/__tests__/RemoteCreateCredentials.test.tsx b/src/__tests__/RemoteCreateCredentials.test.tsx new file mode 100644 index 0000000..db334bc --- /dev/null +++ b/src/__tests__/RemoteCreateCredentials.test.tsx @@ -0,0 +1,207 @@ +/** + * The create/extend dialogs save credentials the same way the Connect modal does — + * and hand the one they just used to the connection that follows. + * + * Two gaps this pins shut: + * + * 1. **No "Save password" in the create/extend flow.** The keychain is keyed by + * *host target* (and by config path for a tunnel), not by project — so there was + * never a reason the toggle couldn't live here too. Without it, a project set up + * with a typed password asked for it again on the very next activation, and + * auto-connect could not be offered at all. + * 2. **The first pooled connect went out password-less.** It only worked because the + * dialog's ControlMaster was still up — and the backend reads "no password given, + * none saved" as *key* auth, so it recorded `key_auth: true` on a password host. + * The project then claimed to be auto-connect-eligible and failed on next launch. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { invoke } from "@tauri-apps/api/core"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(() => Promise.resolve()) })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); +vi.mock("../components/terminal/TerminalView", () => ({ TerminalView: () => null })); + +import { ExtendToRemoteDialog } from "../components/projects/ExtendToRemoteDialog"; +import { useProjectsStore } from "../stores/projects"; +import { useRemoteStatusStore } from "../stores/remoteStatus"; +import { useSettingsStore } from "../stores/settings"; +import { useVpnStatusStore } from "../stores/vpnStatus"; +import type { ProjectEntry } from "../types"; + +const invokeMock = vi.mocked(invoke); + +const PROJECT = { + id: "p1", + name: "sshtest", + directory: "/home/u/eldrun/projects/sshtest", + position: 0, + status: "active", +} as unknown as ProjectEntry; + +/** Args of the last call to `cmd` (or undefined if it was never called). */ +const lastArgs = (cmd: string) => { + const calls = invokeMock.mock.calls.filter(([name]) => name === cmd); + return calls[calls.length - 1]?.[1] as Record | undefined; +}; + +/** Whether a password is already in the keychain for the host we type below. */ +let sshPasswordSaved = false; + +beforeEach(() => { + // The VPN connect renders the live handshake log, which scrolls itself into view. + // jsdom has no layout, hence no scrollIntoView. + HTMLElement.prototype.scrollIntoView = vi.fn(); + sshPasswordSaved = false; + invokeMock.mockReset(); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "remote_has_saved_password") return Promise.resolve(sshPasswordSaved); + if (cmd === "vpn_has_saved_password") return Promise.resolve(false); + if (cmd === "openvpn_list_configs") return Promise.resolve([]); + if (cmd === "ssh_list_addresses" || cmd === "remote_list_paths") return Promise.resolve([]); + if (cmd === "ssh_tooling_status") { + return Promise.resolve({ password_auth: true, openvpn: true }); + } + if (cmd === "ssh_default_dir") return Promise.resolve("/home/alice"); + if (cmd === "ssh_list_dir") return Promise.resolve([]); + return Promise.resolve(); + }); + + useSettingsStore.setState({ settings: { connections_headless: true } as never }); + useProjectsStore.setState({ + projects: [PROJECT], + extendProjectToRemote: vi.fn(() => Promise.resolve()), + } as never); + useRemoteStatusStore.setState({ byProject: {} } as never); + useVpnStatusStore.setState({ byConfig: {}, holders: {} }); +}); + +/** Fill in the SSH address + password of the connect step. */ +async function fillSsh(password: string) { + await userEvent.type(await screen.findByPlaceholderText(/user@host/i), "alice@host.example"); + if (password) { + await userEvent.type(screen.getByPlaceholderText(/leave empty for key/i), password); + } +} + +describe("the create/extend dialog can save the SSH password", () => { + it("passes the ticked checkbox to the connect, so the working password is stored", async () => { + render( {}} />); + await fillSsh("hunter2"); + + await userEvent.click(screen.getByRole("checkbox", { name: /save password/i })); + await userEvent.click(screen.getByRole("button", { name: /^connect$/i })); + + await waitFor(() => + expect(lastArgs("ssh_connect")).toMatchObject({ password: "hunter2", remember: true }), + ); + }); + + it("leaves it unticked by default — nothing is stored unless asked for", async () => { + render( {}} />); + await fillSsh("hunter2"); + + await userEvent.click(screen.getByRole("button", { name: /^connect$/i })); + + await waitFor(() => expect(lastArgs("ssh_connect")).toMatchObject({ remember: false })); + }); + + it("pre-ticks for a host that already has a saved password, and unticking deletes it", async () => { + // Pre-ticked matters for more than looks: `remember: false` is an explicit + // untick, so connecting with the box unticked would *clear* the credential + // another project saved for this same host. + sshPasswordSaved = true; + render( {}} />); + await fillSsh(""); + + const save = await screen.findByRole("checkbox", { name: /save password/i }); + await waitFor(() => expect(save.checked).toBe(true)); + // And the field says the saved one will be used — it can't be pre-filled. + expect(screen.getByPlaceholderText(/using saved password/i)).toBeTruthy(); + + await userEvent.click(save); + await waitFor(() => + expect(lastArgs("remote_forget_password")).toMatchObject({ host: "host.example" }), + ); + expect(save.checked).toBe(false); + }); +}); + +describe("the create/extend dialog can save the VPN passphrase", () => { + const VPN_CONFIG = "/store/office.ovpn"; + + it("passes the ticked checkbox to the tunnel's connect", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "openvpn_list_configs") { + return Promise.resolve([{ path: VPN_CONFIG, name: "office.ovpn" }]); + } + if (cmd === "remote_has_saved_password" || cmd === "vpn_has_saved_password") { + return Promise.resolve(false); + } + if (cmd === "ssh_list_addresses" || cmd === "remote_list_paths") return Promise.resolve([]); + if (cmd === "ssh_tooling_status") { + return Promise.resolve({ password_auth: true, openvpn: true }); + } + if (cmd === "openvpn_auth_needs") { + return Promise.resolve({ username: false, keyPassphrase: false }); + } + return Promise.resolve(); + }); + + render( {}} />); + + await userEvent.click( + await screen.findByRole("checkbox", { name: /connect via openvpn/i }), + ); + // Pick the stored config from the recents dropdown. + await userEvent.click(await screen.findByTitle(/reuse a previously-used openvpn config/i)); + await userEvent.click(await screen.findByRole("option", { name: /office\.ovpn/i })); + + await userEvent.type(await screen.findByPlaceholderText(/vpn passphrase/i), "s3cret"); + await userEvent.click(await screen.findByRole("checkbox", { name: /save passphrase/i })); + await userEvent.click(screen.getByRole("button", { name: /connect vpn/i })); + + await waitFor(() => + expect(lastArgs("openvpn_connect")).toMatchObject({ + config: VPN_CONFIG, + password: "s3cret", + remember: true, + }), + ); + }); +}); + +describe("the credential the dialog used is the one the project connects with", () => { + it("hands the typed password to the pooled connect after extending", async () => { + render( {}} />); + await fillSsh("hunter2"); + await userEvent.click(screen.getByRole("button", { name: /^connect$/i })); + + // Connected → the browse step opens on the remote home dir; take it as-is. + await userEvent.click(await screen.findByRole("button", { name: /use this folder/i })); + await userEvent.click(await screen.findByRole("button", { name: /extend to remote/i })); + + await waitFor(() => + expect(lastArgs("remote_connect")).toEqual({ projectId: "p1", password: "hunter2" }), + ); + // A password-less connect here would have been recorded as key auth (it rides the + // master the dialog left up), which is what made auto-connect lie. + expect(useProjectsStore.getState().extendProjectToRemote).toHaveBeenCalled(); + }); + + it("sends no password for a key-auth host — there is nothing to hand over", async () => { + render( {}} />); + await fillSsh(""); + await userEvent.click(screen.getByRole("button", { name: /^connect$/i })); + + await userEvent.click(await screen.findByRole("button", { name: /use this folder/i })); + await userEvent.click(await screen.findByRole("button", { name: /extend to remote/i })); + + await waitFor(() => + expect(lastArgs("remote_connect")).toEqual({ projectId: "p1", password: null }), + ); + }); +}); diff --git a/src/__tests__/RemoteVpnDisconnect.test.tsx b/src/__tests__/RemoteVpnDisconnect.test.tsx index c72b1dc..4ed8d01 100644 --- a/src/__tests__/RemoteVpnDisconnect.test.tsx +++ b/src/__tests__/RemoteVpnDisconnect.test.tsx @@ -90,7 +90,12 @@ describe("VPN-only disconnect (tunnel up, SSH down)", () => { expect(toggle.checked).toBe(true); }); - it("opens the VPN section when the matching tunnel was started globally", async () => { + it("collapses to the already-up notice when a matching tunnel was started globally, not by this project", async () => { + // A tunnel that matches this project's stored config but was brought up by the + // header (or another project) is not this project's own — it must not be + // silently adopted as a holder here (see `useRemoteReconnect`), or logging out + // of *this* project could later drop the tunnel out from under whoever actually + // started it. It gets the same collapsed notice every other dialog shows. useRemoteStatusStore.setState({ byProject: { p1: { ssh: "off", vpn: "off" } } } as never); useVpnStatusStore.setState({ byConfig: { [VPN_CONFIG]: "connected" }, @@ -99,14 +104,10 @@ describe("VPN-only disconnect (tunnel up, SSH down)", () => { render(); - const toggle = await screen.findByRole("checkbox", { - name: /connect via openvpn/i, - }); - await waitFor(() => expect(toggle.checked).toBe(true)); - expect(useRemoteStatusStore.getState().byProject.p1?.vpn).toBe("connected"); - expect(useVpnStatusStore.getState().holders[VPN_CONFIG]).toEqual(["p1"]); - expect(screen.queryByRole("button", { name: /^connect vpn$/i })).toBeNull(); - expect(screen.getByRole("button", { name: /disconnect vpn/i })).toBeTruthy(); + expect(await screen.findByText(/openvpn tunnel already up/i)).toBeTruthy(); + expect(screen.queryByRole("checkbox", { name: /connect via openvpn/i })).toBeNull(); + expect(useRemoteStatusStore.getState().byProject.p1?.vpn).toBe("off"); + expect(useVpnStatusStore.getState().holders[VPN_CONFIG] ?? []).toEqual([]); }); it("offers a VPN disconnect while connected, and drops only the tunnel", async () => { diff --git a/src/__tests__/VpnAlreadyUpSections.test.tsx b/src/__tests__/VpnAlreadyUpSections.test.tsx new file mode 100644 index 0000000..81204aa --- /dev/null +++ b/src/__tests__/VpnAlreadyUpSections.test.tsx @@ -0,0 +1,140 @@ +/** + * One rule for every remote menu that can start a tunnel: **if a tunnel is already + * up machine-wide, don't offer another one.** + * + * A tunnel reroutes the whole computer, so it is not the project's to own twice. The + * Connect modal already collapsed its OpenVPN block to a one-line notice in that + * state; the section the new-project and extend-to-remote dialogs share still asked + * "Connect via OpenVPN" — for routing that was already in place. Both now go through + * `useVpnSectionVisible` / `VpnTunnelUpNotice`, and this pins that they agree. + * + * The exception the gate must preserve: a tunnel *this* dialog brought up keeps its + * controls (handshake log, Stop/Disconnect) where the user started it. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { invoke } from "@tauri-apps/api/core"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(() => Promise.resolve()) })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); +// Neither dialog mounts a terminal on the headless path, but the import drags xterm +// into jsdom — stub it. +vi.mock("../components/terminal/TerminalView", () => ({ TerminalView: () => null })); + +import { ExtendToRemoteDialog } from "../components/projects/ExtendToRemoteDialog"; +import { RemoteConnectDialog } from "../components/projects/RemoteConnectDialog"; +import { useProjectsStore } from "../stores/projects"; +import { useConnectDialogStore } from "../stores/connectDialog"; +import { useRemoteStatusStore } from "../stores/remoteStatus"; +import { useSettingsStore } from "../stores/settings"; +import { useVpnStatusStore } from "../stores/vpnStatus"; +import type { ProjectEntry } from "../types"; + +const invokeMock = vi.mocked(invoke); + +/** The tunnel someone else (the header, another project, connect-on-launch) put up. */ +const OTHER_CONFIG = "/store/office.ovpn"; + +const LOCAL_PROJECT = { + id: "p1", + name: "sshtest", + directory: "/home/u/eldrun/projects/sshtest", + position: 0, + status: "active", +} as unknown as ProjectEntry; + +const REMOTE_PROJECT = { + ...LOCAL_PROJECT, + remote: { user: "alice", host: "host.example", remote_path: "/srv/work" }, +} as unknown as ProjectEntry; + +const vpnToggle = () => screen.queryByRole("checkbox", { name: /connect via openvpn/i }); +const upNotice = () => screen.queryByText(/openvpn tunnel already up/i); + +beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "remote_has_saved_password" || cmd === "vpn_has_saved_password") { + return Promise.resolve(false); + } + if (cmd === "openvpn_list_configs") return Promise.resolve([]); + if (cmd === "ssh_list_addresses" || cmd === "remote_list_paths") return Promise.resolve([]); + if (cmd === "ssh_tooling_status") { + return Promise.resolve({ password_auth: true, openvpn: true }); + } + if (cmd === "openvpn_auth_needs") { + return Promise.resolve({ username: false, keyPassphrase: false }); + } + return Promise.resolve(); + }); + + useSettingsStore.setState({ settings: { connections_headless: true } as never }); + useProjectsStore.setState({ projects: [REMOTE_PROJECT] } as never); + useConnectDialogStore.setState({ projectId: REMOTE_PROJECT.id } as never); + useRemoteStatusStore.setState({ byProject: {} } as never); + useVpnStatusStore.setState({ byConfig: {}, holders: {} }); +}); + +describe("extend-to-remote: the OpenVPN block yields to a live tunnel", () => { + it("offers the tunnel when none is up", async () => { + render( {}} />); + + expect(await screen.findByRole("checkbox", { name: /connect via openvpn/i })).toBeTruthy(); + expect(upNotice()).toBeNull(); + }); + + it("collapses to the already-up notice when a tunnel is live machine-wide", async () => { + useVpnStatusStore.setState({ byConfig: { [OTHER_CONFIG]: "connected" }, holders: {} }); + + render( {}} />); + + // The SSH step still renders — it is only the redundant VPN offer that goes. + expect(await screen.findByPlaceholderText(/user@host/i)).toBeTruthy(); + expect(upNotice()).toBeTruthy(); + expect(vpnToggle()).toBeNull(); + }); + + it("still collapses while a tunnel is only coming up", async () => { + useVpnStatusStore.setState({ byConfig: { [OTHER_CONFIG]: "connecting" }, holders: {} }); + + render( {}} />); + + expect(await screen.findByText(/openvpn tunnel already up/i)).toBeTruthy(); + expect(vpnToggle()).toBeNull(); + }); +}); + +describe("the Connect modal answers the same question the same way", () => { + it("collapses when another config's tunnel is up", async () => { + useVpnStatusStore.setState({ byConfig: { [OTHER_CONFIG]: "connected" }, holders: {} }); + + render(); + + expect(await screen.findByText(/openvpn tunnel already up/i)).toBeTruthy(); + expect(vpnToggle()).toBeNull(); + }); + + it("keeps the section when the live tunnel is this project's own", async () => { + // Its log and its Disconnect live here — hiding them would strand the tunnel the + // user started from this very dialog. + useProjectsStore.setState({ + projects: [ + { + ...REMOTE_PROJECT, + remote: { ...REMOTE_PROJECT.remote, openvpn: { config: OTHER_CONFIG } }, + }, + ], + } as never); + useRemoteStatusStore.setState({ + byProject: { p1: { ssh: "off", vpn: "connected" } }, + } as never); + useVpnStatusStore.setState({ byConfig: { [OTHER_CONFIG]: "connected" }, holders: { [OTHER_CONFIG]: ["p1"] } }); + + render(); + + expect(await screen.findByRole("button", { name: /disconnect vpn/i })).toBeTruthy(); + expect(upNotice()).toBeNull(); + }); +}); diff --git a/src/components/common/VpnTunnelUpNotice.tsx b/src/components/common/VpnTunnelUpNotice.tsx new file mode 100644 index 0000000..4cdd3db --- /dev/null +++ b/src/components/common/VpnTunnelUpNotice.tsx @@ -0,0 +1,23 @@ +import { ConnLamp } from "./ConnLamp"; + +/** + * What a remote dialog shows in place of its "Connect via OpenVPN" section once a + * tunnel is already up machine-wide (the gate is `useVpnSectionVisible`): a + * project-scoped VPN control has nothing left to do — the computer is already + * routing through a tunnel — and the header's `VpnIndicator` is where a + * machine-wide tunnel is managed. + */ +export function VpnTunnelUpNotice() { + return ( +
+ + + OpenVPN tunnel already up + + + This computer already routes through a tunnel, so this project needs no + second one. Manage tunnels from the VPN control in the header. + +
+ ); +} diff --git a/src/components/files/FileTree.tsx b/src/components/files/FileTree.tsx index 91ccfaf..cc23a27 100644 --- a/src/components/files/FileTree.tsx +++ b/src/components/files/FileTree.tsx @@ -10,8 +10,15 @@ import { useDragStore, type EmbedCap, type FileDragItem } from "../../stores/dra import { commitFileDrop, fileDropGoesToNewWindow } from "../tabs/commitFileDrop"; import { startDetachedDropSession } from "../tabs/detachedDropTargets"; import { closeTabsForDeletedPath, retargetTabsForRenamedPath } from "./fileTabSync"; -import { startCursorPoll, desktopCursor, type PhysPoint } from "../../lib/coords"; -import { bindDragRelease, dragPlatform } from "../../lib/dragPlatform"; +import { + startCursorPoll, + desktopCursor, + snapshotFrame, + physToClient, + type PhysPoint, + type WindowFrame, +} from "../../lib/coords"; +import { bindDragRelease, dragPlatform, PLATFORM } from "../../lib/dragPlatform"; import { useSettingsStore } from "../../stores/settings"; import { useProjectsStore } from "../../stores/projects"; import { useRemoteStatusStore } from "../../stores/remoteStatus"; @@ -36,6 +43,12 @@ function sizeCategory(bytes: number): string { return "size-huge"; } +/** Hover title for a non-ignored size figure: the total + ignored split when + * there's ignored content to split out, else the plain fallback wording. */ +function sizeTitle(shown: number, ignored: number, fallback: string): string { + return ignored > 0 ? `${fmtSize(shown + ignored)} total — ${fmtSize(ignored)} git-ignored` : fallback; +} + interface Props { projectDir: string; projectId: string | null; @@ -161,14 +174,37 @@ export function FileTree({ }: Props) { const [rawEntries, setRawEntries] = useState([]); // Recursive folder sizes (bytes), keyed by absolute folder path. Filled in - // lazily by a per-folder backend `dir_size` call so a big subtree never blocks - // the listing — the tree renders immediately and each folder's size appears + // lazily by a per-folder backend call so a big subtree never blocks the + // listing — the tree renders immediately and each folder's size appears // once it resolves. `requestedSizes` guards against re-dispatching the same // folder while it's in flight (or after it failed), so fs-watch churn doesn't // trigger a request storm; both reset in `load()` (navigation / refresh) so a - // re-listed folder recomputes. + // re-listed folder recomputes. Shared between the two size-fetch effects + // below (plain `dir_size` for the gitignored section, `dir_size_breakdown` + // for everything else) so the same folder is never fetched by both — besides + // being wasted work, two independent walks of the same folder can disagree + // if it's being actively written to, which would make the ignored split + // exceed the total from the other call. const [dirSizes, setDirSizes] = useState>({}); const requestedSizes = useRef>(new Set()); + // Which listing the in-flight size calls belong to. Bumped by `load()`, the + // only thing that invalidates the cache — so a result is stale ONLY if the + // folder was re-listed under it, never merely because the tree re-rendered. + // This is load-bearing: the effects below key on `sections`, whose identity + // changes on any re-render (`load()` itself sets rawEntries and THEN, a + // round-trip later, gitStatuses), and cancelling on that would drop every + // size call still in flight — permanently, since `requestedSizes` keeps it + // from being re-dispatched. Small folders resolved inside the gap and showed + // a size; a big one (the whole point of the feature) never did. + const sizeGeneration = useRef(0); + // Bytes of a folder's recursive size that are git-ignored, for folders that + // are NOT themselves ignored (they sit in the regular/standard section) but + // contain ignored content — e.g. a source folder with a build output dir + // inside. Filled from the same `dir_size_breakdown` call that fills + // `dirSizes` for these folders (one walk, both numbers), so the split can + // never exceed the total it was measured against. A folder with no ignored + // content is simply absent from this map (nothing to annotate). + const [dirIgnoredBytes, setDirIgnoredBytes] = useState>({}); // Which files can be embedded as a frameless in-tab app (Group K #40). Keyed // by extension (default-app resolution is per-mime/extension), so we only // query the backend once per distinct extension. Only embeddable files get the @@ -406,40 +442,93 @@ export function FileTree({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [entries]); - // Lazily compute the recursive size of each visible folder. Fires one backend - // call per not-yet-requested folder (concurrently — they're independent) and - // fills `dirSizes` as each resolves. A failed call is left unresolved; the - // `requestedSizes` guard keeps it (and steady-state re-renders / fs-watch - // reloads) from re-dispatching. + // Lazily compute the recursive size of each visible gitignored-section + // folder. Fires one backend call per not-yet-requested folder (concurrently + // — they're independent) and fills `dirSizes` as each resolves. A failed + // call is left unresolved; the `requestedSizes` guard keeps it (and + // steady-state re-renders / fs-watch reloads) from re-dispatching. Plain + // `dir_size` is enough here — the whole folder is ignored by definition, so + // there's no split to compute, unlike the regular/standard effect below. useEffect(() => { - const pending = entries.filter((e) => e.is_dir && !requestedSizes.current.has(e.path)); + const pending = sections.gitignored.filter((e) => e.is_dir && !requestedSizes.current.has(e.path)); if (pending.length === 0) return; pending.forEach((e) => requestedSizes.current.add(e.path)); - let cancelled = false; + const gen = sizeGeneration.current; for (const e of pending) { invoke("dir_size", { projectDir, relPath: relForEntry(e) }) .then((bytes) => { - if (!cancelled) setDirSizes((m) => ({ ...m, [e.path]: bytes })); + if (sizeGeneration.current === gen) setDirSizes((m) => ({ ...m, [e.path]: bytes })); }) .catch(() => { /* best-effort display aid — leave unresolved on failure */ }); } - return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries]); + }, [sections.gitignored]); + + // Lazily compute the recursive size of each visible regular/standard-section + // folder, split into ignored vs. non-ignored bytes in the SAME backend walk + // that produces the total — so a folder with e.g. a build output dir mixed + // in with source can show that split, and the ignored figure can never + // exceed the total it was measured against (two independent walks of the + // same folder can disagree if it's being actively written to, which is + // exactly the kind of folder — build/output dirs — this feature targets). + // Fills both `dirSizes` and `dirIgnoredBytes` from one response, sharing + // `requestedSizes` with the effect above so no folder is fetched twice. + useEffect(() => { + const candidates = [...sections.regular, ...sections.standard]; + const pending = candidates.filter((e) => e.is_dir && !requestedSizes.current.has(e.path)); + if (pending.length === 0) return; + pending.forEach((e) => requestedSizes.current.add(e.path)); + const gen = sizeGeneration.current; + for (const e of pending) { + invoke<{ total: number; ignored: number }>("dir_size_breakdown", { projectDir, relPath: relForEntry(e) }) + .then(({ total, ignored }) => { + if (sizeGeneration.current !== gen) return; + setDirSizes((m) => ({ ...m, [e.path]: total })); + if (ignored > 0) setDirIgnoredBytes((m) => ({ ...m, [e.path]: ignored })); + }) + .catch(() => { + // Fall back to the plain total rather than showing no size at all — + // `dir_size_breakdown` can fail for reasons `dir_size` wouldn't (a + // backend that hasn't picked up this new command yet, no `git` on + // PATH), and a folder's size is more important than its ignored split. + invoke("dir_size", { projectDir, relPath: relForEntry(e) }) + .then((bytes) => { + if (sizeGeneration.current === gen) setDirSizes((m) => ({ ...m, [e.path]: bytes })); + }) + .catch(() => { + /* best-effort display aid — leave unresolved on failure */ + }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections.regular, sections.standard]); // Total bytes contained in each section, kept separate rather than merged // into one figure — the point of splitting scaffold/gitignored out visually // is that their weight (e.g. a huge gitignored build dir) shouldn't hide // inside the "real" content total. Sums whatever `dirSizes`/`e.size` already // know; unresolved subfolder sizes count as 0 until their `dir_size` call - // lands, same best-effort as the per-row display above. + // lands, same best-effort as the per-row display above. `regular`/`standard` + // are non-ignored-only (mirroring the per-row headline number), with the + // ignored portion pulled out separately so callers can put it on hover — + // `gitignored` needs no such split, it's ignored content in its entirety. const groupSizes = useMemo(() => { const total = (list: FileEntry[]) => list.reduce((sum, e) => sum + (e.is_dir ? (dirSizes[e.path] ?? 0) : e.size), 0); - return { regular: total(sections.regular), standard: total(sections.standard), gitignored: total(sections.gitignored) }; - }, [sections, dirSizes]); + const ignored = (list: FileEntry[]) => + list.reduce((sum, e) => sum + (e.is_dir ? (dirIgnoredBytes[e.path] ?? 0) : 0), 0); + const regularIgnored = ignored(sections.regular); + const standardIgnored = ignored(sections.standard); + return { + regular: total(sections.regular) - regularIgnored, + regularIgnored, + standard: total(sections.standard) - standardIgnored, + standardIgnored, + gitignored: total(sections.gitignored), + }; + }, [sections, dirSizes, dirIgnoredBytes]); const isEmbeddable = (e: FileEntry): boolean => !e.is_dir && embedByExt[e.extension ?? ""] === true; @@ -467,8 +556,9 @@ export function FileTree({ // in native-drag mode. useEffect(() => { // Warm the native drag-out preview icon so its path is ready (sync-readable) - // by the time the user starts a Ctrl-drag. - void warmDragIcon(); + // by the time the user starts a Ctrl-drag. Only the plugin path (Win/mac) + // needs it — the Linux `start_file_drag` embeds the icon backend-side. + if (PLATFORM !== "linux") void warmDragIcon(); const sync = (e: KeyboardEvent) => setCtrlHeld(e.ctrlKey); const clear = () => setCtrlHeld(false); window.addEventListener("keydown", sync); @@ -573,11 +663,14 @@ export function FileTree({ // does NOT clear — a background re-list must not wipe an in-progress selection. clearSelection(); // Re-listing (navigation or an explicit refresh) recomputes folder sizes: - // drop the cache + in-flight guard so the effect re-requests them fresh. The - // quiet fs-watch `refresh()` deliberately does NOT do this — folder sizes - // stay put through watch churn. + // drop the cache + in-flight guard so the effect re-requests them fresh, and + // bump the generation so a call still walking the OLD folder can't land its + // bytes on the new listing. The quiet fs-watch `refresh()` deliberately does + // NOT do this — folder sizes stay put through watch churn. + sizeGeneration.current += 1; requestedSizes.current.clear(); setDirSizes({}); + setDirIgnoredBytes({}); try { const result = await invoke("list_dir", { projectDir, @@ -704,24 +797,34 @@ export function FileTree({ } function handleEntryMouseEnter(e: React.MouseEvent, entry: FileEntry) { - if (entry.modified_secs || entry.created_secs) { - setTooltip({ rect: e.currentTarget.getBoundingClientRect(), entry }); - } + // Always shows the name (the row can truncate it); Created/Modified/Ignored + // lines below are conditional on the entry actually having that data. + setTooltip({ rect: e.currentTarget.getBoundingClientRect(), entry }); } - function handleEntryDragStart(e: React.DragEvent, entry: FileEntry) { - // WebKitGTK's HTML5 drag-out renders no drag image outside the window and - // doesn't reliably export the file to other apps, so suppress it and hand - // off to the native OS drag (tauri-plugin-drag): real OLE/NSDragging/GTK - // drag, with an OS-rendered icon that crosses into Signal / a browser / a - // file manager / the desktop. `mode: "copy"` so the file is never MOVED out - // of the project. The preview icon path was warmed at mount (warmDragIcon). - e.preventDefault(); - const begin = (icon: string) => - startDrag({ item: [entry.path], icon, mode: "copy" }).catch((err) => + // Start the native OS drag-out (EXPORT/COPY to another app: a browser, Signal, + // a file manager, the desktop). Real OLE/NSDragging/GTK drag with an + // OS-rendered icon; copy semantics, so files are never MOVED out of the + // project. Reached two ways: a dir's HTML5 dragstart (handleEntryDragStart), + // and the mid-drag Ctrl handoff inside onEntryPointerDown. + function beginNativeFileDrag(paths: string[]) { + // Linux uses our own GTK drag (`start_file_drag`): tauri-plugin-drag's GTK + // backend hands external targets an EMPTY payload — it tears down its + // drag-data-get handler at drop-performed, before the target requests the + // text/uri-list data — so a drop into a browser silently did nothing (it + // also ships unencoded file:// URIs, which strict consumers discard). + if (PLATFORM === "linux") { + void invoke("start_file_drag", { paths }).catch((err) => // Surfaces the most common failure: the backend wasn't rebuilt, so the - // `plugin:drag|start_drag` command (and `drag_preview_icon`) don't exist - // yet — the drag silently no-ops. Log it so the cause is visible. + // command doesn't exist yet — the drag silently no-ops otherwise. + console.error("[eldrun] native file drag-out failed:", err), + ); + return; + } + const begin = (icon: string) => + startDrag({ item: paths, icon, mode: "copy" }).catch((err) => + // Same visibility for the plugin path (`plugin:drag|start_drag` and + // `drag_preview_icon` only exist after a backend rebuild). console.error("[eldrun] native file drag-out failed:", err), ); // The icon data URL is normally warm by drag time; if not, resolve first. @@ -729,6 +832,20 @@ export function FileTree({ else void warmDragIcon().then(begin); } + function handleEntryDragStart(e: React.DragEvent, entry: FileEntry) { + // WebKitGTK's HTML5 drag-out renders no drag image outside the window and + // doesn't reliably export the file to other apps, so suppress it and hand + // off to the native OS drag. + e.preventDefault(); + // Dragging a row that belongs to a >1 selection exports the whole + // selection, mirroring the pointer drag-to-tab's multi-drag. + const paths = + selected.has(entry.path) && selected.size > 1 + ? entries.filter((en) => selected.has(en.path)).map((en) => en.path) + : [entry.path]; + beginNativeFileDrag(paths); + } + // Start a pointer-based drag from a file row, mirroring TabBar.onTabPointerDown. // HTML5 native DnD is unreliable on WebKitGTK (it hijacks the pointer stream // and fires pointercancel), so once the pointer crosses a 5px threshold we @@ -797,6 +914,67 @@ export function FileTree({ let lastPhys: PhysPoint | null = null; let stopPoll: (() => void) | null = null; + // Mid-drag Ctrl → arms the native OS EXPORT drag (copy the file(s) out to + // whatever app the cursor ends up over). Ctrl *before* the press is the + // multi-select toggle, so export is armed by pressing Ctrl AFTER the drag + // is underway — symmetric with Shift, which modifies the drop while + // already dragging (see DragGhost's modifier highlight). Mirrors that: + // holding Ctrl only MARKS the ghost's "copy out" option; the in-app hover + // (ghost, folder highlight, popout hover) keeps running unchanged. + // + // The gesture then switches between two modes at the WINDOW BOUNDARY, and + // can switch back and forth as often as the user likes: + // - leaving the window with Ctrl held → the OS drag takes over the still- + // held button (GTK can only BEGIN a drag while the button is physically + // down, so the crossing is the last moment this is possible), and a + // release out there drops into the external app. + // - coming back INTO the window → the OS drag is cancelled and the in-app + // ghost/hover resumes, so re-entering never leaves the user staring at + // an OS drag icon over Eldrun's own window. + // While the OS owns the drag the webview sees no pointer events at all, so + // the boundary test runs off the OS-cursor poll (physical px → this + // window's client px via the frame snapshot), which keeps ticking + // regardless of who holds the pointer grab. + let nativeActive = false; + let frame: WindowFrame | null = null; + // The `eldrun:file-drag-ended` subscription (registered below, once the + // gesture is real) and whether the gesture has already ended — `listen` is + // async, so it can resolve after cleanup and must then unsubscribe at once. + let unlistenEnded: (() => void) | null = null; + let gestureOver = false; + const inWindow = (c: { x: number; y: number }) => + c.x >= 0 && c.y >= 0 && c.x < window.innerWidth && c.y < window.innerHeight; + const toNativeDrag = () => { + if (!dragging || nativeActive) return; + nativeActive = true; + // The in-app drop targets are meaningless while the OS owns the drag. + setMoveTarget(null); + detached.hover(null, { x: 0, y: 0 }, entry.name); + beginNativeFileDrag(dragEntries.map((en) => en.path)); + }; + const backToInAppDrag = () => { + if (!nativeActive) return; + nativeActive = false; + void invoke("cancel_file_drag").catch(() => {}); + }; + // Ctrl, tracked as gesture state rather than read off each pointer event. + // Cancelling the GTK drag hands the pointer back, but NOT the implicit grab + // the original pointerdown had: the webview then sees plain mousemove while + // the cursor is over the window and NOTHING once it leaves. So after one + // handoff, `PointerEvent.ctrlKey` stops arriving exactly where the next + // handoff would need it — hence a flag the key events maintain, plus the + // cursor poll below as the position source. (The keydown arm also covers + // Ctrl pressed while the cursor already sits outside the window.) + let ctrlDown = false; + const onKeyDown = (ev: KeyboardEvent) => { + if (ev.key !== "Control") return; + ctrlDown = true; + if (dragging && !inWindow(lastClient)) toNativeDrag(); + }; + const onKeyUp = (ev: KeyboardEvent) => { + if (ev.key === "Control") ctrlDown = false; + }; + const onMove = (ev: PointerEvent) => { if (!dragging) { if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 5) return; @@ -830,13 +1008,44 @@ export function FileTree({ /* capture is best-effort; the OS-cursor poll does not depend on it */ } } + // This window's geometry, for mapping the polled OS cursor back into + // client px while the OS drag owns the pointer (see `nativeActive`). + void snapshotFrame() + .then((f) => { + frame = f; + }) + .catch(() => {}); // Poll the OS cursor (physical desktop px) to drive the popout hover past // the main viewport — DOM pointermove may not cross the OS window boundary. stopPoll = startCursorPoll((p) => { lastPhys = p; + // The poll is the SOLE arbiter of the window boundary, in BOTH + // directions. DOM pointer events can't be: once the OS drag has run + // even once, the implicit grab is gone, so they stop at the window + // edge — exactly where the crossing has to be detected. + const c = frame ? physToClient(frame, p) : null; + if (nativeActive) { + if (!c) return; + // The OS owns the pointer: this poll is also the only position + // source, so keep the ghost tracking the cursor for the moment it + // comes back in — whereupon the OS drag is cancelled and the + // in-app hover takes over again. + lastClient = c; + useDragStore.getState().move(c.x, c.y); + if (inWindow(c)) backToInAppDrag(); + return; + } + if (c && ctrlDown && !inWindow(c)) { + toNativeDrag(); + return; + } detached.hover(detached.at(p), p, entry.name); }); } + // While the OS drag owns the pointer, a stray DOM move must not fight the + // poll for the ghost position or revive the in-app drop targets. + if (nativeActive) return; + ctrlDown = ev.ctrlKey; lastClient = { x: ev.clientX, y: ev.clientY }; useDragStore.getState().move(ev.clientX, ev.clientY); // Drag-to-move: highlight the folder row / breadcrumb / up button under the @@ -852,9 +1061,14 @@ export function FileTree({ // Tear down the move listener, poll, popout highlight, and pointer capture — // however the gesture resolves. const cleanup = () => { + gestureOver = true; window.removeEventListener("pointermove", onMove); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); setMoveTarget(null); stopPoll?.(); + unlistenEnded?.(); + unlistenEnded = null; // Clear the popout highlight + tear down the panes listener. `targetAt` // still hit-tests the cached pane geometry for the commit below. detached.dispose(); @@ -868,6 +1082,11 @@ export function FileTree({ }; const commitRelease = async (shiftKey: boolean) => { + // The OS owns the drag: it is dropping into an external app, and the + // in-app drop targets don't apply. `eldrun:file-drag-ended` ends the + // gesture instead (a stray pointerup here must not ALSO spawn a tab or a + // window on top of the export). + if (nativeActive) return; // Read the drop-target folder BEFORE cleanup() runs — cleanup calls // setMoveTarget(null), which nulls moveTargetRef.current, so reading it // afterwards always saw null and silently dropped every drag-to-move (the @@ -972,7 +1191,24 @@ export function FileTree({ }; window.addEventListener("pointermove", onMove); - bindDragRelease({ onCommit: (shiftKey) => void commitRelease(shiftKey), onAbort }); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + const unbindRelease = bindDragRelease({ + onCommit: (shiftKey) => void commitRelease(shiftKey), + onAbort, + }); + // Once the OS drag owns the pointer, the webview never sees the release, so + // the backend reports the drop (or the user's abort) that ends the whole + // gesture. NOT fired by the cancel we ourselves issue on re-entry — that + // hands control back to the in-app drag, which is still very much alive. + void listen("eldrun:file-drag-ended", () => { + unbindRelease(); + onAbort(); + }).then((un) => { + // `listen` is async: the gesture can already be over when it resolves. + if (gestureOver) un(); + else unlistenEnded = un; + }); } function relForEntry(entry: FileEntry): string { @@ -1673,7 +1909,10 @@ export function FileTree({ ); })} - + {fmtSize(groupSizes.regular)} @@ -1685,7 +1924,10 @@ export function FileTree({ @@ -1699,6 +1941,13 @@ export function FileTree({ function renderEntry(e: FileEntry, isScaffold = false, isGitignored = false) { const status = isGitignored ? undefined : gitStatuses[e.name]; const sizeClass = !e.is_dir ? sizeCategory(e.size) : ""; + // A folder's headline number is its non-ignored weight — the part + // that's actually "yours" — with the full total + ignored split + // available on hover; the gitignored section shows a folder's whole + // size plainly since there nothing but ignored content is left. + const dirTotal = e.is_dir ? dirSizes[e.path] : undefined; + const dirIgnored = e.is_dir ? dirIgnoredBytes[e.path] : undefined; + const dirShown = dirTotal !== undefined ? dirTotal - (dirIgnored ?? 0) : undefined; const canRun = !e.is_dir && e.extension === ".sh"; const isRunning = runningScripts.has(e.path); const isCompiling = compiling.has(e.path); @@ -1827,11 +2076,11 @@ export function FileTree({ )} {e.is_dir ? folderIcon() : fileIcon(e.extension)} - {e.name} + {e.name} {e.is_dir - ? dirSizes[e.path] !== undefined && ( - - {fmtSize(dirSizes[e.path])} + ? dirShown !== undefined && ( + + {fmtSize(dirShown)} ) : {fmtSize(e.size)}} @@ -1853,7 +2102,10 @@ export function FileTree({ > {scaffoldExpanded ? "▾" : "▸"} scaffold ({standard.length}) - + {fmtSize(groupSizes.standard)} @@ -2338,12 +2590,20 @@ export function FileTree({ className="file-tooltip" style={{ right: window.innerWidth - tooltip.rect.left + 8, top: tooltip.rect.top }} > +
{tooltip.entry.name}
{tooltip.entry.created_secs && (
Created {fmtModified(tooltip.entry.created_secs)}
)} {tooltip.entry.modified_secs && (
Modified{fmtModified(tooltip.entry.modified_secs)}
)} + {tooltip.entry.is_dir && dirIgnoredBytes[tooltip.entry.path] !== undefined && ( +
+ Ignored + {fmtSize(dirIgnoredBytes[tooltip.entry.path])} + {dirSizes[tooltip.entry.path] !== undefined && ` of ${fmtSize(dirSizes[tooltip.entry.path])} total`} +
+ )} , document.body )} diff --git a/src/components/layout/CenterPanel.tsx b/src/components/layout/CenterPanel.tsx index 66e5a66..b08f279 100644 --- a/src/components/layout/CenterPanel.tsx +++ b/src/components/layout/CenterPanel.tsx @@ -88,7 +88,12 @@ export function CenterPanel() { // this array's identity) — otherwise bounds drags wouldn't reach project.json // until the next tab/layout change or app quit. const detachedGroups = useTabsStore((s) => s.detachedGroupsByScope[s.scope]); - const saveLayout = useTabsStore((s) => s.saveLayout); + // `persistScope`, not `saveLayout`: the latter persists the tab store's CURRENT + // scope into whatever `localFile` it is handed, and those are two independently + // tracked values. `activeId` and `localFile` both come from `activeProject` here, + // so passing the scope explicitly makes it impossible to write one project's tabs + // (or, worse, an empty layout standing in for them) into another project's file. + const persistScope = useTabsStore((s) => s.persistScope); const updateTabEnv = useTabsStore((s) => s.updateTabEnv); // #42: popouts to re-open for the current scope (restored docked, then detached). const pendingRespawn = useTabsStore((s) => s.pendingRespawnByScope[s.scope]); @@ -246,10 +251,10 @@ export function CenterPanel() { useEffect(() => { if (!activeId || !localFile) return; const timer = window.setTimeout(() => { - saveLayout(localFile).catch(() => {}); + persistScope(activeId, localFile).catch(() => {}); }, 300); return () => window.clearTimeout(timer); - }, [activeId, localFile, tabs, layout, detachedGroups, saveLayout]); + }, [activeId, localFile, tabs, layout, detachedGroups, persistScope]); // #42: re-open popouts that were detached when this scope was last saved. The // groups were restored DOCKED (above) so their panes mount and spawn their @@ -1223,19 +1228,26 @@ export function DragGhost() { const srcH = useDragStore((s) => s.drag?.previewH ?? 0); const thumbRef = useRef(null); - // Track Shift live so the options legend can highlight "force new window" - // while the file drag is in flight (Shift can toggle without a pointer move, - // which wouldn't otherwise re-render the ghost). + // Track Shift/Ctrl live so the options legend can highlight "force new + // window" / "copy out to another app" while the file drag is in flight (a + // modifier can toggle without a pointer move, which wouldn't otherwise + // re-render the ghost). Ctrl only marks the option here — FileTree's own + // drag gesture decides when to actually hand off to the native OS drag. const [shiftHeld, setShiftHeld] = useState(false); + const [ctrlHeld, setCtrlHeld] = useState(false); useEffect(() => { if (!isFileDrag) return; - const sync = (e: KeyboardEvent) => setShiftHeld(e.shiftKey); + const sync = (e: KeyboardEvent) => { + setShiftHeld(e.shiftKey); + setCtrlHeld(e.ctrlKey); + }; window.addEventListener("keydown", sync); window.addEventListener("keyup", sync); return () => { window.removeEventListener("keydown", sync); window.removeEventListener("keyup", sync); setShiftHeld(false); + setCtrlHeld(false); }; }, [isFileDrag]); @@ -1261,14 +1273,21 @@ export function DragGhost() {
{label}
{isFileDrag ? (
+ {/* Exactly ONE option is marked at a time — it is what THIS release + would do. Ctrl (copy out) wins over Shift (force a new window), + matching the drag itself: once the cursor leaves the window with + Ctrl held, the OS drag owns the drop and the in-app targets are + out of the picture. */}
Drop on a folder → move file there
-
+
Drop → open in new window / dock into popout
-
+
⇧ Shift → force a new window
-
⌃ Ctrl-drag → copy to Signal / browser
+
+ ⌃ Ctrl → copy out to another app +
) : null} {node ? ( diff --git a/src/components/layout/ProjectSwitcher.tsx b/src/components/layout/ProjectSwitcher.tsx index e695366..1873e90 100644 --- a/src/components/layout/ProjectSwitcher.tsx +++ b/src/components/layout/ProjectSwitcher.tsx @@ -40,7 +40,9 @@ export function ProjectSwitcher({ open = true }: { open?: boolean }) { const [showSettingsMenu, setShowSettingsMenu] = useState(false); const [settingsPanel, setSettingsPanel] = useState("main"); const [showAddMenu, setShowAddMenu] = useState(false); - const [dialog, setDialog] = useState<"new" | "import" | null>(null); + // "clone" is the import dialog opened straight onto its GitHub/GitLab source — + // the same dialog, so the source can still be switched back inside it. + const [dialog, setDialog] = useState<"new" | "import" | "clone" | null>(null); const settingsMenuRef = useRef(null); const addMenuRef = useRef(null); // Hover-opened, like the header's sibling menus (GlobalAppMenu, LocalModelMenu, @@ -343,9 +345,10 @@ export function ProjectSwitcher({ open = true }: { open?: boolean }) { />, document.body, )} - {dialog === "import" && createPortal( + {(dialog === "import" || dialog === "clone") && createPortal( setDialog(null)} onProject={(project) => addProject(project)} />, @@ -524,6 +527,9 @@ export function ProjectSwitcher({ open = true }: { open?: boolean }) { + {!hiddenCollapsed && (
- {hiddenGroups.map((h) => { + {hiddenGroups.map((h, hi) => { const keys = orderedTabKeys(h.subtree); + const { status: rowStatus, tabStatuses } = hiddenStatus.rows[hi]; return (
- +
- {keys.map((k) => { + {keys.map((k, ki) => { const label = scopeTabs?.find((t) => t.key === k)?.label ?? k; + const status = tabStatuses[ki]; return ( +
+

+ Arm one stored OpenVPN tunnel to come up automatically when Eldrun starts. + It reroutes this computer's traffic — not just Eldrun's — for as long as it + is up, so only one config can be armed at a time; arming another disarms the + first. Stored configs are added from a remote project's Connect dialog or the + VPN menu in the header. +

+ {configs === null ? ( +

Loading…

+ ) : configs.length === 0 ? ( +
+ No OpenVPN config stored yet. Add one from a remote project's Connect dialog. +
+ ) : ( +
+ {configs.map((c) => { + const on = armed === c.path; + const eligible = !headless || silent[c.path] === true; + return ( +
+ + {!eligible && !on && ( +

+ Connect once with Save passphrase ticked to enable this — + auto-connect never prompts. +

+ )} + {on && ( +

+ Starts with Eldrun{headless ? "" : " (waits in the root terminal)"}. +

+ )} +
+ ); + })} +
+ )} + + ); +} + function ArchivedProjectsPanel({ onBack }: { onBack: () => void }) { const [items, setItems] = useState(null); const [busyId, setBusyId] = useState(null); @@ -574,7 +669,7 @@ function HelpPanel({ onBack }: { onBack: () => void }) { ); } -export type SettingsPanelKind = "main" | "global" | "filetypes" | "ollama" | "agents" | "shortcuts" | "git" | "archive" | "scaffoldRepair" | "help"; +export type SettingsPanelKind = "main" | "global" | "filetypes" | "ollama" | "agents" | "shortcuts" | "git" | "vpn" | "remoteHosts" | "archive" | "scaffoldRepair" | "help"; /** Sub-panel navigation shown as a card menu at the foot of the main settings * panel (styled like the Lessons / How-to-start menus). */ @@ -584,6 +679,8 @@ const SETTINGS_NAV: { blurb: string; }[] = [ { panel: "git", title: "Git Hosting", blurb: "Hosting profile, access token, and publishing." }, + { panel: "vpn", title: "VPN Auto-Connect", blurb: "Arm a stored tunnel to connect on launch." }, + { panel: "remoteHosts", title: "Remote Connections", blurb: "Set a standard path per SSH host." }, { panel: "global", title: "Global Apps", blurb: "Toolbar launchers shown across every project." }, { panel: "filetypes", title: "File Type Apps", blurb: "Choose which app opens each file type." }, { panel: "agents", title: "Manage Agents", blurb: "Install or update the agent CLIs." }, @@ -1030,6 +1127,8 @@ export function SettingsDialog({ {panel === "agents" && setPanel("main")} />} {panel === "shortcuts" && setPanel("main")} />} {panel === "git" && setPanel("main")} />} + {panel === "vpn" && setPanel("main")} />} + {panel === "remoteHosts" && setPanel("main")} />} {panel === "archive" && setPanel("main")} />} {panel === "scaffoldRepair" && setPanel("main")} />} {panel === "help" && setPanel("main")} />} diff --git a/src/components/layout/SettingsSubPanels.tsx b/src/components/layout/SettingsSubPanels.tsx index 6a2a78f..0cf5a8e 100644 --- a/src/components/layout/SettingsSubPanels.tsx +++ b/src/components/layout/SettingsSubPanels.tsx @@ -14,6 +14,8 @@ import { type CodexHookState, } from "../../lib/codexHooks"; import type { GlobalAppEntry } from "../../types"; +import { parseSshAddress } from "../projects/scaffold"; +import { useProjectsStore } from "../../stores/projects"; interface OllamaModelInfo { name: string; @@ -246,6 +248,139 @@ export function FileTypeSettings({ onBack }: { onBack: () => void }) { ); } +/** + * "Remote Connections" settings panel: a standard/default remote path per SSH + * host, set once here instead of re-browsing it every time. Consulted by + * `useRemoteSession`'s `resolveStartDir` whenever a connect/browse flow would + * otherwise fall back to the SSH-reported home directory — so a host with a + * preferred working directory (e.g. a projects folder that isn't `$HOME`) + * opens there every time, for both new remote projects and reconnects. + * + * Distinct from the auto-remembered "recently used paths" dropdown in the New + * Project dialog (`remote_paths.json`): that list grows on its own from every + * folder you browse to, while this is one explicit choice per host, edited + * only here. + */ +export function RemoteHostsSettings({ onBack }: { onBack: () => void }) { + const [defaults, setDefaults] = useState | null>(null); + const [draftHost, setDraftHost] = useState(""); + const [draftPath, setDraftPath] = useState(""); + const [error, setError] = useState(""); + + const refresh = () => { + invoke>("remote_list_default_paths") + .then(setDefaults) + .catch((e) => setError(String(e))); + }; + useEffect(refresh, []); + + // Hosts seen before (recent SSH addresses + any active remote project), + // offered as suggestions when adding a new entry — typing the exact host is + // still required (paths are host-specific and a typo would silently do + // nothing), this just saves re-typing one you've already connected to. + const knownHosts = useProjectsStore((s) => s.projects); + const [hostSuggestions, setHostSuggestions] = useState([]); + useEffect(() => { + invoke("ssh_list_addresses") + .then((addrs) => { + const fromAddrs = addrs.map((a) => parseSshAddress(a)?.host).filter((h): h is string => !!h); + const fromProjects = knownHosts.map((p) => p.remote?.host).filter((h): h is string => !!h); + const seen = new Set(); + const out: string[] = []; + for (const h of [...fromAddrs, ...fromProjects]) { + const key = h.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(h); + } + setHostSuggestions(out); + }) + .catch(() => setHostSuggestions([])); + }, [knownHosts]); + + const savePath = (host: string, path: string) => { + setError(""); + invoke("remote_set_default_path", { host, path }) + .then(refresh) + .catch((e) => setError(String(e))); + }; + + const removeHost = (host: string) => savePath(host, ""); + + const addEntry = () => { + const host = draftHost.trim(); + const path = draftPath.trim(); + if (!host || !path) return; + savePath(host, path); + setDraftHost(""); + setDraftPath(""); + }; + + const entries = defaults ? Object.entries(defaults).sort(([a], [b]) => a.localeCompare(b)) : []; + + return ( + <> +
+

Remote Connections

+ +
+

+ The folder a connect or browse opens in for a given SSH host, instead of + the host's home directory. Set once here and it applies the next time you + connect to that host — for a new remote project's folder browser and for + reconnecting an existing one. +

+ {error &&
{error}
} + {defaults === null ? ( +

Loading…

+ ) : entries.length === 0 ? ( +
No standard paths set — hosts default to their home directory.
+ ) : ( +
+ {entries.map(([host, path]) => ( +
+ {host} + setDefaults((d) => (d ? { ...d, [host]: e.target.value } : d))} + onBlur={(e) => savePath(host, e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") savePath(host, e.currentTarget.value); + }} + /> + +
+ ))} +
+ )} +
+ setDraftHost(e.target.value)} + /> + + {hostSuggestions.map((h) => ( + + setDraftPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") addEntry(); + }} + /> + +
+ + ); +} + function fmtBytes(n: number): string { if (n === 0) return "0 B"; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(0) + " MB"; diff --git a/src/components/projects/ExtendToRemoteDialog.tsx b/src/components/projects/ExtendToRemoteDialog.tsx index 6c68005..f78416c 100644 --- a/src/components/projects/ExtendToRemoteDialog.tsx +++ b/src/components/projects/ExtendToRemoteDialog.tsx @@ -43,6 +43,7 @@ export function ExtendToRemoteDialog({ setRemoteChosenPath, remoteChosenPath, remoteReady, + remotePassword, buildRemoteSpec, } = remote; @@ -93,14 +94,19 @@ export function ExtendToRemoteDialog({ try { await extendProjectToRemote(project.id, spec); // Reaching this step required a live, authenticated SSH session (the flow - // browsed/created the remote folder over it), so the host's ControlMaster - // is already up. Carry that straight over to the now-remote project rather - // than dropping it to a "Connect" prompt: open its pooled SSH/SFTP channel - // (rides the existing master, password-less) and light the SSH lamp green. - // Fire-and-forget — the lamp reflects connecting → connected as it resolves. + // browsed/created the remote folder over it). Carry that straight over to the + // now-remote project rather than dropping it to a "Connect" prompt: open its + // pooled SSH/SFTP channel and light the SSH lamp green. Fire-and-forget — the + // lamp reflects connecting → connected as it resolves. + // + // Hand it the credential the dialog authenticated with (empty for a key-auth + // host). Riding the still-live ControlMaster password-less would connect too, + // but the backend reads a password-less connect with nothing in the keychain as + // *key* auth and records `key_auth: true` — on a password host that is a lie the + // project keeps, and auto-connect later believes. const status = useRemoteStatusStore.getState(); status.setSsh(project.id, "connecting"); - void invoke("remote_connect", { projectId: project.id, password: null }) + void invoke("remote_connect", { projectId: project.id, password: remotePassword || null }) .then(() => useRemoteStatusStore.getState().setSsh(project.id, "connected")) .catch((err) => { console.warn("remote_connect after extend failed", err); diff --git a/src/components/projects/ProjectDialog.tsx b/src/components/projects/ProjectDialog.tsx index e9c5a70..707bca4 100644 --- a/src/components/projects/ProjectDialog.tsx +++ b/src/components/projects/ProjectDialog.tsx @@ -15,20 +15,29 @@ import { buildDescriptionFillPrompt, buildScaffoldFillPrompt, collectScaffoldAgentFills, + isCloneUrl, joinRemotePath, + repoNameFromCloneUrl, sanitizeName, type ScaffoldPreviewItem, } from "./scaffold"; import { useRemoteSession, type RemoteStep } from "./useRemoteSession"; import { RemoteProjectSection } from "./RemoteProjectSection"; +import { stashRemotePassword } from "../../stores/projects"; import { Dropdown } from "../common/Dropdown"; +/** Where an import's files come from: a folder already on this machine, or a + * repository cloned from GitHub/GitLab (any git URL). */ +export type ImportSource = "folder" | "git"; + export function ProjectDialog({ kind, + initialImportSource = "folder", onClose, onProject, }: { kind: "new" | "import"; + initialImportSource?: ImportSource; onClose: () => void; onProject: (project: ProjectEntry) => void | Promise; }) { @@ -51,6 +60,10 @@ export function ProjectDialog({ const [mode, setMode] = useState("keep"); const [skipScaffold, setSkipScaffold] = useState(false); const [sourceDir, setSourceDir] = useState(""); + // Import source: an existing local folder, or a clone from GitHub/GitLab. + const [importSource, setImportSource] = useState(initialImportSource); + const [repoUrl, setRepoUrl] = useState(""); + const [cloning, setCloning] = useState(false); const [scaffoldPreview, setScaffoldPreview] = useState([]); const [scaffoldFillModes, setScaffoldFillModes] = useState>({}); const [scaffoldError, setScaffoldError] = useState(""); @@ -70,6 +83,7 @@ export function ProjectDialog({ remoteChosenPath, setRemoteChosenPath, rememberChosenPath, + remotePassword, toggleRemoteProject, buildRemoteSpec, } = remote; @@ -95,12 +109,36 @@ export function ProjectDialog({ : false; const safeName = sanitizeName(name); const targetDir = safeName && projectsRoot ? `${projectsRoot}/${safeName}` : ""; + // Cloning from a hosting service. A remote (SSH) project's tree lives on the + // host and is never cloned locally, so the two are mutually exclusive. + const isCloneImport = kind === "import" && !isRemoteProject && importSource === "git"; // "Push to GitHub/GitLab" was chosen, but no Eldrun connection is set up yet. // Here "remote" is the git push target (a hosting service), distinct from the // SSH host the files may live on — see the git-hosting hint below. const wantsRemoteGit = gitType === "remote-private" || gitType === "remote-public"; const gitConnected = gitToken.trim() !== ""; - const needsGitConnection = wantsRemoteGit && !gitConnected; + // A clone is exempt: the gate exists because publishing a *new* repo needs a + // token, and a cloned repo is already hosted — it has an origin to push back + // to. (The clone itself only needs the token when the repo is private, which + // the URL field's own hint covers.) + const needsGitConnection = wantsRemoteGit && !gitConnected && !isCloneImport; + + // Switching the import source resets the git-hosting default to the one that + // fits it: a clone comes from a host, a plain folder does not. Both remain + // freely overridable in the dropdown below. + const changeImportSource = (next: ImportSource) => { + setImportSource(next); + setGitType(next === "git" ? "remote-private" : "local"); + }; + + const setRepoUrlAndName = (url: string) => { + setRepoUrl(url); + // Pre-fill the project name from the repo's own name, but only while the + // user hasn't typed one of their own (and only while it still matches what + // the previous URL suggested, so backspacing the URL keeps updating it). + const suggested = repoNameFromCloneUrl(url); + setName((cur) => (cur === "" || cur === repoNameFromCloneUrl(repoUrl) ? suggested : cur)); + }; // Send the user to Settings → Git Hosting to establish the GitHub/GitLab // connection. The project dialog stays open (so the half-filled form isn't @@ -276,6 +314,22 @@ export function ProjectDialog({ : new Map(); const descriptionAgent = selectedDescriptionAgent(); const remoteSpec = buildRemoteSpec(safeName); + // Clone first, then import the resulting directory in place: the clone owns + // getting the files onto the disk, `import_project` owns registering them. + // A failed clone throws here, so nothing is registered for a tree that + // isn't there. + let clonedDir = ""; + if (isCloneImport) { + setCloning(true); + try { + clonedDir = await invoke("git_clone", { + url: repoUrl.trim(), + dest: targetDir, + }); + } finally { + setCloning(false); + } + } const project = kind === "new" ? await invoke("create_project", { @@ -293,12 +347,17 @@ export function ProjectDialog({ : await invoke("import_project", { req: { // Backend ignores sourceDir for remote but the field is required; - // pass the (browsed or typed) remote path as a stand-in. - sourceDir: isRemoteProject ? remoteChosenPath : sourceDir, + // pass the (browsed or typed) remote path as a stand-in. A clone + // registers the directory it just landed in. + sourceDir: isRemoteProject + ? remoteChosenPath + : isCloneImport + ? clonedDir + : sourceDir, name, description, gitType, - mode: isRemoteProject ? "keep" : mode, + mode: isRemoteProject || isCloneImport ? "keep" : mode, scaffoldFillModes, manualValidationConfirmed, skipScaffold, @@ -307,7 +366,15 @@ export function ProjectDialog({ mirrorParent: isRemoteProject ? mirrorParent : undefined, }, }); - if (isRemoteProject) rememberChosenPath(); + if (isRemoteProject) { + rememberChosenPath(); + // The new project's pooled connect happens on activation, inside `onProject` + // below — hand it the credential this dialog authenticated with, so that leg + // doesn't have to ride the master we happen to have left up (and so a + // password host isn't recorded as key-auth). Single-use; not persisted — + // persisting is what the "Save password" toggle is for. + stashRemotePassword(project.id, remotePassword); + } await onProject(project); await openScaffoldAgentTabs(project, scaffoldAgentFills); await openDescriptionAgentTab(project, descriptionAgent); @@ -333,12 +400,16 @@ export function ProjectDialog({ : Boolean(name.trim() && remoteChosenPath && mirrorParent.trim()) : kind === "new" ? Boolean(name.trim() && targetDir && safeName) - : Boolean( - name.trim() && - sourceDir && - (mode === "keep" || safeName) && - (mode === "keep" || manualValidationConfirmed), - )); + : isCloneImport + ? // A clone needs a plausible URL and somewhere to land; it has no + // source folder and no copy/move decision to validate. + Boolean(name.trim() && safeName && targetDir && isCloneUrl(repoUrl)) + : Boolean( + name.trim() && + sourceDir && + (mode === "keep" || safeName) && + (mode === "keep" || manualValidationConfirmed), + )); const missingFillableScaffoldCount = scaffoldPreview.filter((item) => !item.exists && item.kind === "file").length; @@ -365,7 +436,9 @@ export function ProjectDialog({