diff --git a/README.md b/README.md index a2cfcf2..1dcd4ad 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ Press `?` in the app (outside a text input) for the full, always-current list. | Attach / recreate a session | click its row | | Full session menu (shell, review, rename, editor, PR, lifecycle) | right-click row | | New session in a project | `+` on the project | +| Add-project path: complete to common prefix | `Tab` | +| Add-project path: pick / drill into a directory | `↑` / `↓`, then `Enter` | +| Add-project path: native folder picker | `Browse…` | | Project shell | `$` on the project | | Cycle sidebar view (project / sections / stacks) | `⇄` | | Move a session to a section | drag its row onto a section header (drop on *In Progress* to unpin) | diff --git a/package-lock.json b/package-lock.json index 3de8d14..435d0c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@shikijs/themes": "^4.2.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", @@ -1411,6 +1412,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/package.json b/package.json index 7d0002e..3a09e30 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@shikijs/themes": "^4.2.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 28aa281..cd9fd82 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -702,6 +702,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", + "tempfile", "tokio", "tracing", "uuid", @@ -4178,7 +4180,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4317,6 +4319,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -5531,6 +5534,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rgb" version = "0.8.53" @@ -6542,6 +6569,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -8182,6 +8251,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -8230,13 +8308,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -8273,6 +8368,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -8291,6 +8392,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -8309,12 +8416,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -8333,6 +8452,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -8351,6 +8476,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -8369,6 +8500,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -8387,6 +8524,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 69f3b19..ed5a609 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-clipboard-manager = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" @@ -24,3 +25,6 @@ claude-commander = { git = "https://github.com/sizeak/claude-commander", tag = " futures = "0.3.32" tracing = "0.1.44" chrono = "0.4.45" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 428ff22..c81835c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:window:allow-close", "clipboard-manager:allow-write-text", - "clipboard-manager:allow-read-text" + "clipboard-manager:allow-read-text", + "dialog:allow-open" ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 82c4862..49a3069 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -72,6 +72,7 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_dialog::init()) .manage(pty::PtyState::default()) .setup(|app| { groups::spawn_sessions_loop(app.handle().clone()); @@ -105,6 +106,7 @@ fn main() { cascade::cascade_abandon, cascade::push_stack, projects::add_project, + projects::complete_path, projects::scan_directory, projects::remove_project, projects::prepare_project_shell, diff --git a/src-tauri/src/projects.rs b/src-tauri/src/projects.rs index b78e0b6..bdbe1a0 100644 --- a/src-tauri/src/projects.rs +++ b/src-tauri/src/projects.rs @@ -1,6 +1,6 @@ //! Project management, editor/browser opening, and project shells. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::Serialize; @@ -8,6 +8,7 @@ use crate::service::{parse_project_id, parse_session_id, service, with_service}; #[tauri::command] pub async fn add_project(path: String) -> Result { + let path = expand_tilde(&path); with_service(move |svc| async move { svc.add_project(PathBuf::from(path)) .await @@ -17,6 +18,19 @@ pub async fn add_project(path: String) -> Result { .await } +/// List directories matching `partial` for the add-project / scan path input's +/// live autocomplete. `partial` is the (possibly tilde-prefixed) path typed so +/// far; results are returned in the same tilde form the input used, so the +/// frontend can drop them straight back into the field. +#[tauri::command] +pub fn complete_path(partial: String) -> Vec { + let expanded = expand_tilde(&partial); + list_matching_dirs(&expanded) + .into_iter() + .map(|p| unexpand_tilde(&partial, &p)) + .collect() +} + #[derive(Serialize)] pub struct ScanOutcome { added: usize, @@ -26,7 +40,7 @@ pub struct ScanOutcome { /// Scan a directory tree for git repos and add them all as projects. #[tauri::command] pub async fn scan_directory(path: String) -> Result { - let dir = PathBuf::from(shellexpand_tilde(&path)); + let dir = PathBuf::from(expand_tilde(&path)); if !dir.is_dir() { return Err(format!("not a directory: {}", dir.display())); } @@ -127,11 +141,143 @@ fn open_with_platform_opener(target: &str) -> Result<(), String> { .map_err(|e| format!("failed to open {target}: {e}")) } -fn shellexpand_tilde(path: &str) -> String { - if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = std::env::var_os("HOME") { - return format!("{}/{}", home.to_string_lossy(), rest); +fn home() -> Option { + std::env::var_os("HOME").map(|h| h.to_string_lossy().into_owned()) +} + +/// Expand a leading `~` or `~/` to the user's home directory. +fn expand_tilde(path: &str) -> String { + if path == "~" { + if let Some(home) = home() { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home() { + return format!("{home}/{rest}"); } } path.to_string() } + +/// If `original` used a `~` prefix, re-collapse the home prefix in `expanded` +/// so completions are shown in the same form the user typed. +fn unexpand_tilde(original: &str, expanded: &str) -> String { + if original.starts_with('~') { + if let Some(home) = home() { + if let Some(rest) = expanded.strip_prefix(&home) { + return format!("~{rest}"); + } + } + } + expanded.to_string() +} + +/// Split a path into `(parent_dir, partial_name)` at the last `/`. +fn split_path(value: &str) -> (&str, &str) { + match value.rfind('/') { + Some(pos) => (&value[..=pos], &value[pos + 1..]), + None => ("", value), + } +} + +/// List directories inside the parent of `value` whose names start with the +/// trailing partial name. Symlinks are followed only when they resolve to a +/// directory; unreadable parents yield an empty list. +fn list_matching_dirs(value: &str) -> Vec { + let (parent, partial) = split_path(value); + + let parent_path = if parent.is_empty() { + Path::new(".") + } else { + Path::new(parent) + }; + + let Ok(entries) = std::fs::read_dir(parent_path) else { + return Vec::new(); + }; + + let mut matches: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type() + .map(|ft| ft.is_dir() || ft.is_symlink()) + .unwrap_or(false) + }) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with(partial) { + // For symlinks, verify the target is a directory. + if e.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) && !e.path().is_dir() { + return None; + } + let full = if parent.is_empty() { + name + } else if parent.ends_with('/') { + format!("{parent}{name}") + } else { + format!("{parent}/{name}") + }; + Some(full) + } else { + None + } + }) + .collect(); + + matches.sort(); + matches +} + +#[cfg(test)] +mod tests { + use super::{list_matching_dirs, split_path}; + use std::fs; + + fn setup_dirs(names: &[&str]) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + for name in names { + fs::create_dir_all(tmp.path().join(name)).unwrap(); + } + tmp + } + + #[test] + fn lists_matching_subdirectories_sorted() { + let tmp = setup_dirs(&["project-a", "project-b", "other"]); + let got = list_matching_dirs(&format!("{}/project", tmp.path().display())); + assert_eq!( + got, + vec![ + format!("{}/project-a", tmp.path().display()), + format!("{}/project-b", tmp.path().display()), + ] + ); + } + + #[test] + fn files_are_excluded() { + let tmp = setup_dirs(&["dir_a"]); + fs::write(tmp.path().join("file_a"), "x").unwrap(); + let got = list_matching_dirs(&format!("{}/", tmp.path().display())); + assert_eq!(got, vec![format!("{}/dir_a", tmp.path().display())]); + } + + #[test] + fn hidden_dirs_are_included() { + let tmp = setup_dirs(&[".hidden", "visible"]); + let got = list_matching_dirs(&format!("{}/.h", tmp.path().display())); + assert_eq!(got, vec![format!("{}/.hidden", tmp.path().display())]); + } + + #[test] + fn unreadable_parent_yields_empty() { + assert!(list_matching_dirs("/nonexistent_surely_xyz_123/foo").is_empty()); + } + + #[test] + fn split_path_splits_at_last_slash() { + assert_eq!(split_path("/home/user/pro"), ("/home/user/", "pro")); + assert_eq!(split_path("/home/user/"), ("/home/user/", "")); + assert_eq!(split_path("pro"), ("", "pro")); + } +} diff --git a/src/help.ts b/src/help.ts index 3d27357..334a7bc 100644 --- a/src/help.ts +++ b/src/help.ts @@ -17,6 +17,7 @@ const HELP_SECTIONS: [string, [string, string][]][] = [ "Sidebar", [ ["⋯ menu", "Add project, scan directory, delete merged-PR sessions"], + ["Path input", "Type to autocomplete dirs (Tab completes, ↑/↓ pick, Enter drills in/commits, Browse… opens picker)"], ["⇄", "Cycle view: project / sections / section stacks"], ["Drag row → section", "Move a session to a section (drop on In Progress to unpin)"], ["● (yellow)", "Unread: agent finished while you were away"], diff --git a/src/main.ts b/src/main.ts index 00b7aec..d7aa6c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { invoke, Channel } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { writeText, readText } from "@tauri-apps/plugin-clipboard-manager"; +import { open as openFolderDialog } from "@tauri-apps/plugin-dialog"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -1738,35 +1739,152 @@ function projectMenuItems(group: ProjectGroup, createKey: string = group.id): Me ]; } -/** Path input at the top of the sidebar for add-project / scan-directory. */ +/** Longest common prefix of a list of strings (drives Tab completion). */ +function longestCommonPrefix(strings: string[]): string { + if (!strings.length) return ""; + let prefix = strings[0]; + for (const s of strings.slice(1)) { + while (prefix && !s.startsWith(prefix)) prefix = prefix.slice(0, -1); + if (!prefix) break; + } + return prefix; +} + +/** Path input at the top of the sidebar for add-project / scan-directory, with + * a live directory-completion dropdown (Tab → common prefix, ↑/↓ to pick, + * Enter on a match drills in, Enter on free text commits) and a native folder + * picker via "Browse…". Seeded with `~/` so the first listing shows $HOME. */ function renderTopInput(mode: "add" | "scan"): HTMLDivElement { const wrap = document.createElement("div"); - wrap.className = "create-input"; + wrap.className = "create-input path-input"; + const row = document.createElement("div"); + row.className = "path-input-row"; const input = noTextAssist(document.createElement("input")); input.placeholder = mode === "add" ? "path to git repo…" : "directory to scan for repos…"; + input.value = "~/"; + const browse = document.createElement("button"); + browse.className = "path-browse"; + browse.textContent = "Browse…"; + const listEl = document.createElement("div"); + listEl.className = "path-completions"; + row.append(input, browse); + wrap.append(row, listEl); + + let completions: string[] = []; + let selected = -1; // -1 = nothing highlighted (Enter commits the typed value) + let debounce: number | undefined; + + function renderCompletions(): void { + listEl.innerHTML = ""; + completions.forEach((c, i) => { + const r = document.createElement("div"); + r.className = "path-completion"; + r.classList.toggle("selected", i === selected); + r.textContent = c; + // mousedown (not click) so the pick lands before the input's blur. + r.addEventListener("mousedown", (e) => { + e.preventDefault(); + input.value = `${c}/`; + selected = -1; + void refresh(); + input.focus(); + }); + listEl.appendChild(r); + }); + } + + async function refresh(): Promise { + let next: string[]; + try { + next = await invoke("complete_path", { partial: input.value }); + } catch { + next = []; + } + completions = next; + selected = completions.length ? Math.min(selected, completions.length - 1) : -1; + renderCompletions(); + } + + function commit(path: string): void { + topInput = null; + input.disabled = true; + const call = + mode === "add" + ? invoke("add_project", { path }) + : invoke<{ added: number; skipped: number }>("scan_directory", { path }).then((r) => + toast(`Scan complete: ${r.added} added, ${r.skipped} already present`), + ); + call + .catch((err) => toast(`${mode === "add" ? "add project" : "scan"} failed: ${err}`, "error")) + .finally(() => void refreshNow()); + renderSidebar(); + } + + input.addEventListener("input", () => { + selected = -1; + clearTimeout(debounce); + debounce = window.setTimeout(() => void refresh(), 100); + }); + input.addEventListener("keydown", (e) => { + e.stopPropagation(); if (e.key === "Escape") { topInput = null; renderSidebar(); + return; } - if (e.key === "Enter" && input.value.trim()) { - const path = input.value.trim(); - topInput = null; - input.disabled = true; - const call = - mode === "add" - ? invoke("add_project", { path }) - : invoke<{ added: number; skipped: number }>("scan_directory", { path }).then((r) => - toast(`Scan complete: ${r.added} added, ${r.skipped} already present`), - ); - call - .catch((err) => toast(`${mode === "add" ? "add project" : "scan"} failed: ${err}`, "error")) - .finally(() => void refreshNow()); - renderSidebar(); + if (e.key === "ArrowDown") { + e.preventDefault(); + if (completions.length) { + selected = (selected + 1) % completions.length; + renderCompletions(); + } + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (completions.length) { + selected = selected <= 0 ? completions.length - 1 : selected - 1; + renderCompletions(); + } + return; + } + if (e.key === "Tab") { + e.preventDefault(); + const lcp = longestCommonPrefix(completions); + if (lcp && lcp.length > input.value.length) { + input.value = lcp; + void refresh(); + } + return; + } + if (e.key === "Enter") { + // A highlighted row drills into that directory; otherwise commit the + // typed path (the "I typed the full path, just add it" case). + if (selected >= 0 && completions[selected]) { + input.value = `${completions[selected]}/`; + selected = -1; + void refresh(); + } else if (input.value.trim()) { + commit(input.value.trim()); + } } }); - wrap.appendChild(input); - setTimeout(() => input.focus(), 0); + + browse.addEventListener("click", () => { + void openFolderDialog({ directory: true }).then((picked) => { + if (typeof picked === "string") { + input.value = picked; + input.focus(); + void refresh(); + } + }); + }); + + setTimeout(() => { + input.focus(); + void refresh(); + }, 0); return wrap; } diff --git a/src/playwright/iwft/network/TauriSimulator.testHelper.ts b/src/playwright/iwft/network/TauriSimulator.testHelper.ts index 2519e98..b5ce45e 100644 --- a/src/playwright/iwft/network/TauriSimulator.testHelper.ts +++ b/src/playwright/iwft/network/TauriSimulator.testHelper.ts @@ -32,6 +32,9 @@ class TauriSimulator { private ptyChannels: Record = {}; private nextComment = 1; private nextSession = 1; + private nextProject = 1; + private dirs: string[]; + private browsePath: string | null; private openedUrls: string[] = []; // Section moves the frontend actually dispatched (a no-op drop short-circuits // before invoke, so this stays empty — how a negative test tells them apart). @@ -43,6 +46,8 @@ class TauriSimulator { this.config = seed.config ?? {}; this.keybindings = seed.keybindings ?? {}; this.customThemes = seed.customThemes ?? []; + this.dirs = seed.dirs ?? []; + this.browsePath = seed.browsePath ?? null; this.comments = {}; this.reviewed = {}; for (const [id, review] of Object.entries(seed.reviews)) { @@ -73,6 +78,12 @@ class TauriSimulator { return this.snapshot.groups.flatMap((g) => g.sessions); } + /** Projects the fake now holds (name + repo_path) — what an add-project test + * asserts against. */ + getProjects(): { id: string; name: string; repo_path: string }[] { + return this.snapshot.groups.map((g) => ({ id: g.id, name: g.name, repo_path: g.repo_path })); + } + /** Current section buckets (null in project view) — what a move-to-section * test asserts placement against. */ getSectionBuckets(): { name: string; session_ids: string[] }[] | null { @@ -136,6 +147,16 @@ class TauriSimulator { return this.moveToSection(args.id as string, (args.section as string | null) ?? null); case "merged_pr_sessions": return this.mergedPrSessions(); + // ----- add project / scan / path autocomplete ----- + case "complete_path": + return this.completePath(args.partial as string); + case "add_project": + return this.addProject(args.path as string); + case "scan_directory": + return this.scanDirectory(args.path as string); + // ----- dialog plugin (native folder picker behind "Browse…") ----- + case "plugin:dialog|open": + return this.browsePath; // ----- PTY lifecycle (terminal tabs) ----- case "prepare_attach": return null; @@ -278,6 +299,39 @@ class TauriSimulator { this.snapshot.sections = buckets; } + /** Directories that are direct children of `partial`'s parent and whose + * basename starts with its trailing segment — mirrors the backend's + * list_matching_dirs over the seeded `dirs` (no real filesystem). */ + private completePath(partial: string): string[] { + const slash = partial.lastIndexOf("/"); + const parent = slash >= 0 ? partial.slice(0, slash + 1) : ""; + const name = slash >= 0 ? partial.slice(slash + 1) : partial; + const results = new Set(); + for (const d of this.dirs) { + if (parent && !d.startsWith(parent)) continue; + const rest = parent ? d.slice(parent.length) : d; + const seg = rest.split("/")[0]; + if (seg && seg.startsWith(name)) results.add(`${parent}${seg}`); + } + return [...results].sort(); + } + + /** Add a project group (no sessions) keyed off the path's basename, mirroring + * the backend add_project; returns the new id like the real command. */ + private addProject(path: string): string { + const id = `proj-add-${this.nextProject++}`; + const name = path.replace(/\/+$/, "").split("/").pop() || path; + this.snapshot.groups.push({ id, name, repo_path: path, pull_blocked: null, sessions: [] }); + return id; + } + + /** Count seeded dirs nested under `path` as the "added" repos. */ + private scanDirectory(path: string): { added: number; skipped: number } { + const prefix = path.endsWith("/") ? path : `${path}/`; + const added = this.dirs.filter((d) => d.startsWith(prefix)).length; + return { added, skipped: 0 }; + } + /** [id, label] pairs for sessions whose PR has merged — the merged-PR sweep source. */ private mergedPrSessions(): [string, string][] { return this.getSessions() diff --git a/src/playwright/iwft/network/types.testHelper.ts b/src/playwright/iwft/network/types.testHelper.ts index 8950381..b162465 100644 --- a/src/playwright/iwft/network/types.testHelper.ts +++ b/src/playwright/iwft/network/types.testHelper.ts @@ -52,4 +52,9 @@ export type Seed = { config?: Record; /** Raw custom themes answered to list_custom_themes (validated by theme.ts). */ customThemes?: unknown[]; + /** Absolute directory paths the fake filesystem "contains" — the source for + * complete_path autocomplete. */ + dirs?: string[]; + /** Path the native folder picker (Browse…) returns; null models a cancel. */ + browsePath?: string | null; }; diff --git a/src/playwright/iwft/scenarios/sidebar/addProject.iwft.ts b/src/playwright/iwft/scenarios/sidebar/addProject.iwft.ts new file mode 100644 index 0000000..bd0597f --- /dev/null +++ b/src/playwright/iwft/scenarios/sidebar/addProject.iwft.ts @@ -0,0 +1,62 @@ +import { test, expect } from "../../support/fixture.testHelper"; +import { makeSnapshot } from "../../network/seed.testHelper"; + +// The add-project path input lives behind the sidebar ⋯ menu. These exercise the +// autocomplete dropdown (type → filter, Tab → common prefix, ↑/↓ → drill in), +// the free-text commit, and the native Browse… picker. The fake answers +// complete_path from a seeded directory list (no real filesystem). +test.use({ + seed: { + snapshot: makeSnapshot(), + reviews: {}, + dirs: ["/repos/acme", "/repos/beta", "/repos/beta-two", "/work/proj"], + browsePath: "/picked/repo", + }, +}); + +test("autocomplete lists directories matching what you type", async ({ sidebar }) => { + await sidebar.openAddProject(); + await sidebar.typePath("/repos/"); + await expect(sidebar.pathCompletions()).toHaveText(["/repos/acme", "/repos/beta", "/repos/beta-two"]); + + await sidebar.typePath("/repos/be"); + await expect(sidebar.pathCompletions()).toHaveText(["/repos/beta", "/repos/beta-two"]); +}); + +test("Tab completes to the longest common prefix", async ({ sidebar }) => { + await sidebar.openAddProject(); + await sidebar.typePath("/repos/be"); + await sidebar.pressInPath("Tab"); + expect(await sidebar.pathValue()).toBe("/repos/beta"); +}); + +test("arrow-select then Enter drills into the directory", async ({ sidebar }) => { + await sidebar.openAddProject(); + await sidebar.typePath("/repos/"); + await sidebar.pressInPath("ArrowDown"); // highlight /repos/acme + await sidebar.pressInPath("Enter"); // drill in, not commit + expect(await sidebar.pathValue()).toBe("/repos/acme/"); + // Still in the input — no project added yet. + expect((await sidebar.storedProjects()).map((p) => p.repo_path)).not.toContain("/repos/acme/"); +}); + +test("Enter on free text commits the project", async ({ sidebar }) => { + await sidebar.openAddProject(); + await sidebar.typePath("/repos/beta-two"); + await sidebar.pressInPath("Enter"); // nothing highlighted → commit + + await expect(async () => { + const repos = (await sidebar.storedProjects()).map((p) => p.repo_path); + expect(repos).toContain("/repos/beta-two"); + }).toPass(); + // The new project header renders in the sidebar. + await expect(sidebar.sectionHeader("beta-two")).toBeVisible(); +}); + +test("Browse… fills the input from the native picker", async ({ sidebar }) => { + await sidebar.openAddProject(); + await sidebar.clickBrowse(); + await expect(async () => { + expect(await sidebar.pathValue()).toBe("/picked/repo"); + }).toPass(); +}); diff --git a/src/playwright/pageObjects/SidebarPageObject.testHelper.ts b/src/playwright/pageObjects/SidebarPageObject.testHelper.ts index a7fb780..5411bcf 100644 --- a/src/playwright/pageObjects/SidebarPageObject.testHelper.ts +++ b/src/playwright/pageObjects/SidebarPageObject.testHelper.ts @@ -138,6 +138,60 @@ export class SidebarPageObject extends AppPageObject { }); } + // ----- add project (sidebar menu → path input with autocomplete) ----- + private readonly pathInput = this.sessions.locator(".path-input input"); + + /** Open the sidebar menu and choose "Add project…" — leaves the path input + * focused and pre-seeded with `~/`. */ + openAddProject(): Promise { + return this.step("openAddProject", async () => { + await this.page.locator("#sidebar-menu").click(); + await this.menuItem("Add project…").click(); + await expect(this.pathInput).toBeVisible(); + }); + } + + /** Replace the path input's value and let the debounced completion settle. */ + typePath(value: string): Promise { + return this.step(`typePath: ${value}`, async () => { + await this.pathInput.fill(value); + // Debounce is 100ms; press a no-op key so `fill` still triggers `input`. + await this.page.waitForTimeout(150); + }); + } + + /** The visible autocomplete completion rows, top to bottom. */ + pathCompletions(): Locator { + return this.sessions.locator(".path-completion"); + } + + /** Press a key in the focused path input (e.g. "Tab", "ArrowDown", "Enter"). */ + pressInPath(key: string): Promise { + return this.step(`pressInPath: ${key}`, () => this.pathInput.press(key)); + } + + /** The current text in the path input. */ + pathValue(): Promise { + return this.pathInput.inputValue(); + } + + /** Click the native folder-picker button. */ + clickBrowse(): Promise { + return this.step("clickBrowse", () => this.sessions.locator(".path-browse").click()); + } + + /** Projects the fake holds (name + repo_path) — assert an add landed. */ + storedProjects(): Promise<{ id: string; name: string; repo_path: string }[]> { + return this.page.evaluate( + () => + ( + window as unknown as { + __CC_SIM__: { getProjects(): { id: string; name: string; repo_path: string }[] }; + } + ).__CC_SIM__.getProjects(), + ); + } + // ----- rename (via row context menu → inline input) ----- rename(title: string, newTitle: string): Promise { return this.step(`rename: ${title} → ${newTitle}`, async () => { diff --git a/src/style.css b/src/style.css index 6cdd6c7..136b6b4 100644 --- a/src/style.css +++ b/src/style.css @@ -1123,6 +1123,48 @@ body { outline: none; } +.path-input-row { + display: flex; + gap: 4px; +} + +.path-browse { + flex: none; + background: var(--bg-inset); + border: 1px solid var(--border-strong); + border-radius: 4px; + color: var(--text); + font-size: 11px; + padding: 0 8px; + cursor: pointer; +} + +.path-browse:hover { + border-color: var(--accent); +} + +.path-completions { + margin-top: 4px; + max-height: 180px; + overflow-y: auto; +} + +.path-completion { + padding: 4px 8px; + font-size: 12px; + color: var(--text); + border-radius: 4px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-completion.selected, +.path-completion:hover { + background: var(--border); +} + .error { padding: 12px; color: var(--danger);