The guard moves off the worktree and onto the kill. DELETE /api/sessions/:id now runs the shared unsaved_work probe before tearing anything down and answers 409 kill_guarded with the breakdown on the wire, so a client can say "3 uncommitted files, 2 unpushed commits". The session keeps running and the tree is untouched. WorktreeManager::remove is unconditional now — git itself still refuses a dirty tree as a last line of defense.
201 lines
7.4 KiB
Rust
201 lines
7.4 KiB
Rust
//! Directory listing for the spawn-form working-directory autocomplete (NAP-329).
|
|
//!
|
|
//! A daemon answers `ListDir { path }` by listing the **immediate
|
|
//! subdirectories** of one directory on its own host. The web client lists one
|
|
//! directory at a time and filters the trailing path segment locally, so this
|
|
//! never walks recursively and never returns files.
|
|
//!
|
|
//! Path expansion is done against the daemon's own `$HOME` so a client can send
|
|
//! an abbreviated path (empty, `~`, or relative) and get a sensible result:
|
|
//!
|
|
//! - empty → home dir
|
|
//! - `~` / `~/sub` → home / home + `sub`
|
|
//! - relative `a/b` → home + `a/b`
|
|
//! - absolute `/a/b` → as-is
|
|
//!
|
|
//! The pure [`list_dir`] is unit-tested in isolation; the HTTP handler
|
|
//! just wraps its `Result` in an `RpcResult` for the wire.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use maestro_protocol::{DirListing, ErrorInfo};
|
|
|
|
/// Maximum number of entries returned in a single listing. A directory with
|
|
/// more subdirectories than this is reported `truncated`; the client narrows
|
|
/// its query by typing more of the name. Bounds the wire payload regardless of
|
|
/// how large a directory is.
|
|
pub const MAX_ENTRIES: usize = 1000;
|
|
|
|
/// List the immediate subdirectories of `requested`, expanding it against
|
|
/// `home_dir` first. Returns directory **names** only (not full paths), sorted
|
|
/// case-insensitively. Hidden directories (dotfiles) are excluded.
|
|
pub fn list_dir(home_dir: &str, requested: &str) -> Result<DirListing, ErrorInfo> {
|
|
let dir = resolve(home_dir, requested);
|
|
|
|
let read = std::fs::read_dir(&dir).map_err(|e| io_error(&dir, &e))?;
|
|
|
|
let mut entries: Vec<String> = Vec::new();
|
|
for entry in read.flatten() {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
// Skip hidden directories — the spawn form rarely targets dotfile dirs
|
|
// and they only clutter the completion list.
|
|
if name.starts_with('.') {
|
|
continue;
|
|
}
|
|
if is_dir(&entry) {
|
|
entries.push(name);
|
|
}
|
|
}
|
|
// Case-insensitive sort so `Projects` and `projects` interleave the way a
|
|
// user scanning the list expects.
|
|
entries.sort_by_key(|s| s.to_lowercase());
|
|
|
|
let truncated = entries.len() > MAX_ENTRIES;
|
|
entries.truncate(MAX_ENTRIES);
|
|
|
|
Ok(DirListing {
|
|
path: dir,
|
|
entries,
|
|
truncated,
|
|
})
|
|
}
|
|
|
|
/// Expand an abbreviated client path against the daemon's home dir.
|
|
///
|
|
/// Shared with the gitroot probe (#26) so a `~`/relative/empty path the client
|
|
/// sends is resolved against the daemon's `$HOME` the same way before walking
|
|
/// up for the enclosing repo.
|
|
pub fn resolve(home_dir: &str, requested: &str) -> PathBuf {
|
|
let home = PathBuf::from(home_dir);
|
|
let trimmed = requested.trim();
|
|
if trimmed.is_empty() {
|
|
return home;
|
|
}
|
|
if trimmed == "~" {
|
|
return home;
|
|
}
|
|
if let Some(rest) = trimmed.strip_prefix("~/") {
|
|
return home.join(rest);
|
|
}
|
|
let p = PathBuf::from(trimmed);
|
|
if p.is_absolute() {
|
|
p
|
|
} else {
|
|
home.join(p)
|
|
}
|
|
}
|
|
|
|
/// Whether a `DirEntry` denotes a directory, following symlinks so that a
|
|
/// symlinked project dir still completes (shells do the same).
|
|
fn is_dir(entry: &std::fs::DirEntry) -> bool {
|
|
match entry.file_type() {
|
|
Ok(ft) if ft.is_dir() => true,
|
|
Ok(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
|
|
.map(|m| m.is_dir())
|
|
.unwrap_or(false),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Map a `read_dir` failure to a stable [`ErrorInfo`] code the client can react
|
|
/// to. `NotADirectory` isn't a stable `ErrorKind` across platforms, so we probe
|
|
/// with a `metadata` call to distinguish "file, not dir" from "doesn't exist".
|
|
fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
|
use std::io::ErrorKind;
|
|
let (code, message) = match e.kind() {
|
|
ErrorKind::NotFound => ("not_found", "no such directory"),
|
|
ErrorKind::PermissionDenied => ("permission_denied", "permission denied"),
|
|
_ if dir.is_file() => ("not_a_directory", "path is a file, not a directory"),
|
|
_ => ("io_error", "could not read directory"),
|
|
};
|
|
ErrorInfo {
|
|
code: code.into(),
|
|
message: format!("{message}: {}", dir.display()),
|
|
unsaved_work: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
/// Build a temp home with a few subdirs (one hidden), a file, and a symlink
|
|
/// to a dir. Returns the temp root path (its `Drop` cleans up).
|
|
fn fixture() -> tempfile::TempDir {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let home = tmp.path();
|
|
fs::create_dir(home.join("projects")).unwrap();
|
|
fs::create_dir(home.join("Downloads")).unwrap();
|
|
fs::create_dir(home.join(".config")).unwrap();
|
|
fs::write(home.join("notes.txt"), b"x").unwrap();
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::symlink(home.join("projects"), home.join("link-to-projects")).unwrap();
|
|
tmp
|
|
}
|
|
|
|
#[test]
|
|
fn lists_visible_dirs_only_sorted_case_insensitively() {
|
|
let tmp = fixture();
|
|
let home = tmp.path().to_string_lossy();
|
|
let listing = list_dir(&home, "").unwrap();
|
|
// Files and hidden dirs are excluded; visible dirs (incl. a symlinked
|
|
// dir) are present.
|
|
assert!(!listing.entries.contains(&".config".to_string()));
|
|
assert!(listing.entries.contains(&"projects".to_string()));
|
|
assert!(listing.entries.contains(&"Downloads".to_string()));
|
|
assert!(!listing.entries.contains(&"notes.txt".to_string()));
|
|
#[cfg(unix)]
|
|
assert!(listing.entries.contains(&"link-to-projects".to_string()));
|
|
// Case-insensitive order: .config, Downloads, link-to-projects, projects
|
|
let lower: Vec<String> = listing.entries.iter().map(|s| s.to_lowercase()).collect();
|
|
let mut sorted = lower.clone();
|
|
sorted.sort();
|
|
assert_eq!(lower, sorted);
|
|
assert!(!listing.truncated);
|
|
assert_eq!(listing.path, tmp.path());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_tilde_resolve_to_home() {
|
|
let tmp = fixture();
|
|
let home = tmp.path().to_string_lossy();
|
|
assert_eq!(list_dir(&home, "").unwrap().path, tmp.path());
|
|
assert_eq!(list_dir(&home, "~").unwrap().path, tmp.path());
|
|
}
|
|
|
|
#[test]
|
|
fn relative_and_tilde_slash_join_onto_home() {
|
|
let tmp = fixture();
|
|
let home = tmp.path().to_string_lossy();
|
|
let rel = list_dir(&home, "projects").unwrap();
|
|
assert_eq!(rel.path, tmp.path().join("projects"));
|
|
let tilde = list_dir(&home, "~/projects").unwrap();
|
|
assert_eq!(tilde.path, tmp.path().join("projects"));
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_path_is_listed_as_is() {
|
|
let tmp = fixture();
|
|
let abs = tmp.path().join("projects");
|
|
// home is irrelevant for an absolute path.
|
|
let listing = list_dir("/nonexistent-home", &abs.to_string_lossy()).unwrap();
|
|
assert_eq!(listing.path, abs);
|
|
assert!(listing.entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_dir_is_not_found() {
|
|
let tmp = fixture();
|
|
let err = list_dir(&tmp.path().to_string_lossy(), "does-not-exist").unwrap_err();
|
|
assert_eq!(err.code, "not_found");
|
|
}
|
|
|
|
#[test]
|
|
fn file_target_is_not_a_directory() {
|
|
let tmp = fixture();
|
|
let err = list_dir(&tmp.path().to_string_lossy(), "notes.txt").unwrap_err();
|
|
assert_eq!(err.code, "not_a_directory");
|
|
}
|
|
}
|