Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca585447ff | ||
|
|
71301861c0 | ||
|
|
2ce1dc43fe | ||
|
|
e99e21260b | ||
|
|
cea06f9204 | ||
|
|
1199596528 | ||
|
|
95f0e42df5 |
@@ -162,15 +162,6 @@ pub struct SpawnSection {
|
|||||||
/// it resolves to just the `custom` tile.
|
/// it resolves to just the `custom` tile.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub presets: Option<Vec<SpawnPreset>>,
|
pub presets: Option<Vec<SpawnPreset>>,
|
||||||
/// Root directories the daemon scans for git repositories (#54), `~`
|
|
||||||
/// expanded by the daemon against its own home. When absent the daemon
|
|
||||||
/// scans `["~"]`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub project_roots: Option<Vec<String>>,
|
|
||||||
/// How many directory levels below each [`Self::project_roots`] entry the
|
|
||||||
/// scanner descends (#54). When absent the daemon uses `3`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub project_scan_depth: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnSection {
|
impl SpawnSection {
|
||||||
@@ -396,7 +387,6 @@ mod tests {
|
|||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
let ids: Vec<String> = section
|
let ids: Vec<String> = section
|
||||||
.resolve()
|
.resolve()
|
||||||
@@ -413,7 +403,6 @@ mod tests {
|
|||||||
// only the implicit custom tile.
|
// only the implicit custom tile.
|
||||||
let section = SpawnSection {
|
let section = SpawnSection {
|
||||||
presets: Some(vec![]),
|
presets: Some(vec![]),
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
let ids: Vec<String> = section
|
let ids: Vec<String> = section
|
||||||
.resolve()
|
.resolve()
|
||||||
@@ -432,7 +421,6 @@ mod tests {
|
|||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
||||||
|
|
||||||
@@ -442,7 +430,6 @@ mod tests {
|
|||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
empty_cmd.resolve().unwrap_err(),
|
empty_cmd.resolve().unwrap_err(),
|
||||||
@@ -465,7 +452,6 @@ mod tests {
|
|||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dup.resolve().unwrap_err(),
|
dup.resolve().unwrap_err(),
|
||||||
@@ -478,7 +464,6 @@ mod tests {
|
|||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reserved.resolve().unwrap_err(),
|
reserved.resolve().unwrap_err(),
|
||||||
@@ -512,30 +497,6 @@ presets = [
|
|||||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_project_scan_fields_default_to_none() {
|
|
||||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
|
||||||
assert_eq!(SpawnSection::default().project_roots, None);
|
|
||||||
assert_eq!(SpawnSection::default().project_scan_depth, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_project_scan_fields_parse_from_toml() {
|
|
||||||
let cfg = toml::from_str::<MaestroConfigFile>(
|
|
||||||
r#"
|
|
||||||
[spawn]
|
|
||||||
project_roots = ["~/code", "/srv/work"]
|
|
||||||
project_scan_depth = 5
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.expect("parse");
|
|
||||||
assert_eq!(
|
|
||||||
cfg.spawn.project_roots,
|
|
||||||
Some(vec!["~/code".to_string(), "/srv/work".to_string()])
|
|
||||||
);
|
|
||||||
assert_eq!(cfg.spawn.project_scan_depth, Some(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_file_loads_with_section_defaults() {
|
fn partial_file_loads_with_section_defaults() {
|
||||||
// Only [daemon] present (typical single-host install).
|
// Only [daemon] present (typical single-host install).
|
||||||
|
|||||||
@@ -72,19 +72,11 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
||||||
|
|
||||||
let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new(
|
|
||||||
config.project_roots.clone(),
|
|
||||||
config.project_scan_depth,
|
|
||||||
PathBuf::from(&config.home_dir),
|
|
||||||
));
|
|
||||||
projects.start_background_refresh();
|
|
||||||
|
|
||||||
let router = local_api::router(local_api::state_for(
|
let router = local_api::router(local_api::state_for(
|
||||||
manager,
|
manager,
|
||||||
config.hostname.clone(),
|
config.hostname.clone(),
|
||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
projects,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
|
|||||||
@@ -23,14 +23,6 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
|||||||
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
||||||
|
|
||||||
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
|
|
||||||
/// is unset: the whole home directory, with the scanner's own ignore list
|
|
||||||
/// keeping it cheap.
|
|
||||||
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
|
|
||||||
/// Default directory depth for project discovery (#54) when
|
|
||||||
/// `[spawn].project_scan_depth` is unset.
|
|
||||||
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
|
|
||||||
|
|
||||||
/// Env var that overrides `[daemon].bind_addr`.
|
/// Env var that overrides `[daemon].bind_addr`.
|
||||||
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
||||||
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
||||||
@@ -82,12 +74,6 @@ pub struct DaemonConfig {
|
|||||||
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
||||||
/// clients in `GET /api/info`.
|
/// clients in `GET /api/info`.
|
||||||
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
||||||
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
|
|
||||||
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
|
|
||||||
pub project_roots: Vec<PathBuf>,
|
|
||||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
|
||||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
|
||||||
pub project_scan_depth: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonConfig {
|
impl DaemonConfig {
|
||||||
@@ -161,25 +147,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
.spawn_history_days
|
.spawn_history_days
|
||||||
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
||||||
|
|
||||||
// `~` in scan roots means the daemon's own home; an unresolvable home
|
|
||||||
// leaves the tilde as-is and the scanner treats the root as missing.
|
|
||||||
let project_roots: Vec<PathBuf> = file
|
|
||||||
.spawn
|
|
||||||
.project_roots
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
DEFAULT_PROJECT_ROOTS
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.iter()
|
|
||||||
.map(|root| expand_tilde(root, &home_dir))
|
|
||||||
.collect();
|
|
||||||
let project_scan_depth = file
|
|
||||||
.spawn
|
|
||||||
.project_scan_depth
|
|
||||||
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
|
|
||||||
|
|
||||||
Ok(DaemonConfig {
|
Ok(DaemonConfig {
|
||||||
hostname,
|
hostname,
|
||||||
home_dir,
|
home_dir,
|
||||||
@@ -190,24 +157,9 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
tmux_tmpdir,
|
tmux_tmpdir,
|
||||||
spawn_history_days,
|
spawn_history_days,
|
||||||
spawn_presets,
|
spawn_presets,
|
||||||
project_roots,
|
|
||||||
project_scan_depth,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
|
|
||||||
fn expand_tilde(path: &str, home: &str) -> PathBuf {
|
|
||||||
match path.strip_prefix("~") {
|
|
||||||
Some(_) if home.is_empty() => PathBuf::from(path),
|
|
||||||
Some(rest) => {
|
|
||||||
let mut p = PathBuf::from(home);
|
|
||||||
p.push(rest.trim_start_matches('/'));
|
|
||||||
p
|
|
||||||
}
|
|
||||||
None => PathBuf::from(path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
||||||
Ok(maestro_config::default_state_dir("maestro")?)
|
Ok(maestro_config::default_state_dir("maestro")?)
|
||||||
}
|
}
|
||||||
@@ -328,56 +280,6 @@ spawn_history_days = 0
|
|||||||
assert_eq!(cfg.spawn_history_days, 0);
|
assert_eq!(cfg.spawn_history_days, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_scan_defaults_to_home_root_at_depth_3() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
std::fs::write(&path, "").unwrap();
|
|
||||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
||||||
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
|
|
||||||
assert_eq!(
|
|
||||||
cfg.project_roots,
|
|
||||||
vec![PathBuf::from(cfg.home_dir.clone())],
|
|
||||||
"bare ~ must expand to the daemon's home"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
r#"
|
|
||||||
[spawn]
|
|
||||||
project_roots = ["~/code", "/srv/work"]
|
|
||||||
project_scan_depth = 5
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
||||||
assert_eq!(cfg.project_scan_depth, 5);
|
|
||||||
assert_eq!(
|
|
||||||
cfg.project_roots,
|
|
||||||
vec![
|
|
||||||
PathBuf::from(cfg.home_dir.clone()).join("code"),
|
|
||||||
PathBuf::from("/srv/work"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tilde_expansion_handles_bare_and_nested_forms() {
|
|
||||||
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
|
|
||||||
assert_eq!(
|
|
||||||
expand_tilde("~/a/b", "/home/u"),
|
|
||||||
PathBuf::from("/home/u/a/b")
|
|
||||||
);
|
|
||||||
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
|
|
||||||
// Unresolvable home leaves the tilde as-is rather than mangling it.
|
|
||||||
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn system_hostname_is_nonempty() {
|
fn system_hostname_is_nonempty() {
|
||||||
let h = system_hostname();
|
let h = system_hostname();
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ pub mod gitroot;
|
|||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
pub mod projects;
|
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ pub struct LocalApiState {
|
|||||||
pub home_dir: Arc<String>,
|
pub home_dir: Arc<String>,
|
||||||
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
||||||
pub presets: Arc<Vec<SpawnPreset>>,
|
pub presets: Arc<Vec<SpawnPreset>>,
|
||||||
/// Project discovery (#54) backing `GET /api/projects`.
|
|
||||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: LocalApiState) -> Router {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
@@ -46,7 +44,6 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
.route(routes::PROJECTS, get(projects))
|
|
||||||
.route(routes::GITROOT, get(git_root))
|
.route(routes::GITROOT, get(git_root))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
@@ -101,15 +98,6 @@ async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
|
|||||||
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discovered git repositories and worktrees, newest activity first (#54).
|
|
||||||
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
|
|
||||||
/// spawn-history use of any directory inside it.
|
|
||||||
async fn projects(State(s): State<LocalApiState>) -> Response {
|
|
||||||
let repos = s.projects.repos().await;
|
|
||||||
let history = s.manager.all_recent_dirs();
|
|
||||||
Json(crate::projects::to_projects(&repos, &history)).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
||||||
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
||||||
/// (empty/`~`/relative), like [`list_dir`].
|
/// (empty/`~`/relative), like [`list_dir`].
|
||||||
@@ -169,23 +157,18 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand.
|
||||||
/// scanner starts with its cache cold; wire
|
|
||||||
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
|
|
||||||
/// on the returned handle's scanner to keep it eventually fresh.
|
|
||||||
pub fn state_for(
|
pub fn state_for(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
hostname: impl Into<String>,
|
hostname: impl Into<String>,
|
||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
projects: Arc<crate::projects::ProjectScanner>,
|
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
hostname: Arc::new(hostname.into()),
|
hostname: Arc::new(hostname.into()),
|
||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
presets: Arc::new(presets),
|
||||||
projects,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
//! Project discovery for `GET /api/projects` (#54).
|
|
||||||
//!
|
|
||||||
//! Scans `[spawn].project_roots` (default the whole home) for `.git` entries —
|
|
||||||
//! directory or file, so linked worktrees count too — up to
|
|
||||||
//! `[spawn].project_scan_depth` levels. Descent stops at the first repo found:
|
|
||||||
//! nested independent repos are not discovered, but worktrees of every hit
|
|
||||||
//! still are, via `git worktree list`, which also catches linked worktrees
|
|
||||||
//! living outside the roots.
|
|
||||||
//!
|
|
||||||
//! Hidden directories are skipped everywhere; heavy well-known directories
|
|
||||||
//! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the
|
|
||||||
//! home directory, so scanning all of home stays cheap.
|
|
||||||
//!
|
|
||||||
//! Results are cached behind a short TTL and refreshed on a slow background
|
|
||||||
//! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear
|
|
||||||
//! rarely, and `/api/projects` may be polled.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
use crate::spawn_history::RecentDir;
|
|
||||||
|
|
||||||
/// Directory names never descended when they sit directly in the home dir, so
|
|
||||||
/// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere
|
|
||||||
/// regardless.
|
|
||||||
pub const DEFAULT_HOME_IGNORE: &[&str] =
|
|
||||||
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
|
|
||||||
|
|
||||||
/// How long a discovery snapshot is served as-is before the next request
|
|
||||||
/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one
|
|
||||||
/// `git worktree list` per hit.
|
|
||||||
pub const CACHE_TTL: Duration = Duration::from_secs(5);
|
|
||||||
/// Background refresh cadence. Repositories appear rarely; this keeps the
|
|
||||||
/// served list eventually fresh without anyone asking.
|
|
||||||
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
|
||||||
|
|
||||||
/// One repository or linked worktree found by [`discover`].
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct DiscoveredRepo {
|
|
||||||
pub path: PathBuf,
|
|
||||||
/// Basename of `path`.
|
|
||||||
pub name: String,
|
|
||||||
pub is_worktree: bool,
|
|
||||||
/// The main checkout this worktree belongs to; set only when
|
|
||||||
/// [`Self::is_worktree`].
|
|
||||||
pub base_repo: Option<PathBuf>,
|
|
||||||
/// Mtime of the checkout's git HEAD — "someone committed / checked out
|
|
||||||
/// here". The later-of merge with spawn history happens in
|
|
||||||
/// [`to_projects`], which also sorts.
|
|
||||||
pub head_mtime: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep.
|
|
||||||
/// A dir holding `.git` (dir *or* file) is a hit and is not descended into;
|
|
||||||
/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are
|
|
||||||
/// skipped directly under `home`.
|
|
||||||
pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<PathBuf> {
|
|
||||||
let mut found = Vec::new();
|
|
||||||
for root in roots {
|
|
||||||
walk(root, 0, max_depth, home, &mut found);
|
|
||||||
}
|
|
||||||
found.sort();
|
|
||||||
found.dedup();
|
|
||||||
found
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec<PathBuf>) {
|
|
||||||
let entries = match std::fs::read_dir(dir) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
let mut subdirs = Vec::new();
|
|
||||||
let mut has_git = false;
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
match entry.file_name().to_string_lossy().into_owned() {
|
|
||||||
name if name == ".git" => has_git = true,
|
|
||||||
name if name.starts_with('.') => {}
|
|
||||||
name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {}
|
|
||||||
_ => {
|
|
||||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
|
||||||
subdirs.push(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if has_git {
|
|
||||||
// Match `git worktree list` output, which prints real paths.
|
|
||||||
found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if depth >= max_depth {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for sub in subdirs {
|
|
||||||
walk(&sub, depth + 1, max_depth, home, found);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every discovered project: the scanned repos plus all worktrees their
|
|
||||||
/// `git worktree list` reports (linked worktrees can live outside the roots).
|
|
||||||
/// A hit whose git invocation fails still lists, un-worktreed.
|
|
||||||
pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<DiscoveredRepo> {
|
|
||||||
let scanned = scan_roots(roots, max_depth, home);
|
|
||||||
let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main)
|
|
||||||
for repo in scanned {
|
|
||||||
if map.iter().any(|(c, _)| c == &repo) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]);
|
|
||||||
let main = worktrees[0].clone();
|
|
||||||
for wt in worktrees {
|
|
||||||
if !map.iter().any(|(c, _)| c == &wt) {
|
|
||||||
map.push((wt, main.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map.into_iter()
|
|
||||||
.map(|(path, main)| {
|
|
||||||
let is_worktree = path != main;
|
|
||||||
DiscoveredRepo {
|
|
||||||
name: path
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
is_worktree,
|
|
||||||
base_repo: is_worktree.then_some(main),
|
|
||||||
head_mtime: head_mtime(&path),
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `git worktree list --porcelain` from `repo`: absolute paths, main first.
|
|
||||||
/// `None` when git fails (broken/bare checkout).
|
|
||||||
fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(repo)
|
|
||||||
.args(["worktree", "list", "--porcelain"])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
|
||||||
if let Some(p) = line.strip_prefix("worktree ") {
|
|
||||||
paths.push(PathBuf::from(p));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(!paths.is_empty()).then_some(paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file
|
|
||||||
/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD
|
|
||||||
/// rather than nothing.
|
|
||||||
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
|
||||||
let dot_git = repo.join(".git");
|
|
||||||
let head = if dot_git.is_dir() {
|
|
||||||
dot_git.join("HEAD")
|
|
||||||
} else {
|
|
||||||
let ptr = std::fs::read_to_string(&dot_git).ok()?;
|
|
||||||
let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim();
|
|
||||||
// Relative pointers are relative to the worktree.
|
|
||||||
let p = Path::new(gitdir);
|
|
||||||
let p = if p.is_absolute() {
|
|
||||||
p.to_path_buf()
|
|
||||||
} else {
|
|
||||||
repo.join(p)
|
|
||||||
};
|
|
||||||
p.join("HEAD")
|
|
||||||
};
|
|
||||||
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
|
|
||||||
Some(DateTime::<Utc>::from(mtime))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fold spawn history into the discovery snapshot and produce the wire list,
|
|
||||||
/// sorted newest-activity-first. `history` must be newest-first (what
|
|
||||||
/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory)
|
|
||||||
/// returns): the first entry whose cwd sits inside a project is its most
|
|
||||||
/// recent use.
|
|
||||||
pub fn to_projects(
|
|
||||||
repos: &[DiscoveredRepo],
|
|
||||||
history: &[RecentDir],
|
|
||||||
) -> Vec<maestro_protocol::Project> {
|
|
||||||
let mut projects: Vec<maestro_protocol::Project> = repos
|
|
||||||
.iter()
|
|
||||||
.map(|repo| {
|
|
||||||
let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd));
|
|
||||||
maestro_protocol::Project {
|
|
||||||
path: repo.path.clone(),
|
|
||||||
name: repo.name.clone(),
|
|
||||||
is_worktree: repo.is_worktree,
|
|
||||||
base_repo: repo.base_repo.clone(),
|
|
||||||
last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)),
|
|
||||||
last_command: last_use.map(|u| u.last_command.clone()),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity));
|
|
||||||
projects
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `dir` is `project` itself or anywhere inside it.
|
|
||||||
fn contains(project: &Path, dir: &Path) -> bool {
|
|
||||||
dir == project || dir.starts_with(project)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_opt(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
|
|
||||||
match (a, b) {
|
|
||||||
(Some(a), Some(b)) => Some(a.max(b)),
|
|
||||||
(a, b) => a.or(b),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached discovery behind a short TTL plus an optional slow background
|
|
||||||
/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`].
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ProjectScanner {
|
|
||||||
roots: Vec<PathBuf>,
|
|
||||||
max_depth: u32,
|
|
||||||
home: PathBuf,
|
|
||||||
ttl: Duration,
|
|
||||||
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProjectScanner {
|
|
||||||
/// Scanner over the resolved config roots. An empty `roots` list scans
|
|
||||||
/// nothing and always serves an empty list.
|
|
||||||
pub fn new(roots: Vec<PathBuf>, max_depth: u32, home: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
roots,
|
|
||||||
max_depth,
|
|
||||||
home,
|
|
||||||
ttl: CACHE_TTL,
|
|
||||||
cache: tokio::sync::Mutex::new(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scanner that never discovers anything — tests and embedders that don't
|
|
||||||
/// want the endpoint populated.
|
|
||||||
pub fn disabled() -> Self {
|
|
||||||
Self::new(Vec::new(), 0, PathBuf::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
|
||||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
|
||||||
let mut cache = self.cache.lock().await;
|
|
||||||
if let Some((at, repos)) = cache.as_ref() {
|
|
||||||
if at.elapsed() < self.ttl {
|
|
||||||
return repos.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let roots = self.roots.clone();
|
|
||||||
let max_depth = self.max_depth;
|
|
||||||
let home = self.home.clone();
|
|
||||||
// Blocking fs walk + git subprocesses must not stall the reactor.
|
|
||||||
let repos = std::sync::Arc::new(
|
|
||||||
tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home))
|
|
||||||
.await
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
*cache = Some((Instant::now(), repos.clone()));
|
|
||||||
repos
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rescan now, ignoring the TTL. Called by the background timer.
|
|
||||||
async fn refresh(&self) {
|
|
||||||
self.repos().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
|
||||||
/// detach it; it stops when the runtime shuts down.
|
|
||||||
pub fn start_background_refresh(self: &std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
|
|
||||||
let scanner = std::sync::Arc::clone(self);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
|
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
ticker.tick().await; // the immediate first tick — wait a full interval
|
|
||||||
loop {
|
|
||||||
scanner.refresh().await;
|
|
||||||
ticker.tick().await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::spawn_history::RecentDir;
|
|
||||||
|
|
||||||
/// Real git repo at `<root>/<name>`, one initial commit so HEAD exists.
|
|
||||||
fn make_repo(root: &Path, name: &str) -> PathBuf {
|
|
||||||
let path = root.join(name);
|
|
||||||
std::fs::create_dir_all(&path).unwrap();
|
|
||||||
run_git(&path, &["init", "-q"]);
|
|
||||||
std::fs::write(path.join("f.txt"), "x\n").unwrap();
|
|
||||||
run_git(&path, &["add", "-A"]);
|
|
||||||
run_git(
|
|
||||||
&path,
|
|
||||||
&[
|
|
||||||
"-c",
|
|
||||||
"user.email=t@t",
|
|
||||||
"-c",
|
|
||||||
"user.name=t",
|
|
||||||
"commit",
|
|
||||||
"-qm",
|
|
||||||
"init",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_git(dir: &Path, args: &[&str]) {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(dir)
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"git {args:?} failed: {} | {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr),
|
|
||||||
String::from_utf8_lossy(&out.stdout)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn names(repos: &[DiscoveredRepo]) -> Vec<String> {
|
|
||||||
let mut v: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
|
|
||||||
v.sort();
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scan_finds_repos_and_stops_at_the_first_hit() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let outer = make_repo(tmp.path(), "outer");
|
|
||||||
// A nested independent repo inside `outer` is NOT discovered
|
|
||||||
// separately: descent stops once a repo is found.
|
|
||||||
make_repo(&outer, "vendored");
|
|
||||||
std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap();
|
|
||||||
|
|
||||||
let found = scan_roots(
|
|
||||||
&[tmp.path().to_path_buf()],
|
|
||||||
3,
|
|
||||||
Path::new("/nonexistent-home"),
|
|
||||||
);
|
|
||||||
assert_eq!(found, vec![outer]);
|
|
||||||
|
|
||||||
let discovered = discover(
|
|
||||||
&[tmp.path().to_path_buf()],
|
|
||||||
3,
|
|
||||||
Path::new("/nonexistent-home"),
|
|
||||||
);
|
|
||||||
assert_eq!(names(&discovered), ["outer"]);
|
|
||||||
assert!(!discovered[0].is_worktree);
|
|
||||||
assert_eq!(discovered[0].base_repo, None);
|
|
||||||
assert!(discovered[0].head_mtime.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scan_respects_depth_hidden_dirs_and_home_ignore() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let home = tmp.path().join("home");
|
|
||||||
|
|
||||||
// Hidden dirs are never entered, at any depth.
|
|
||||||
std::fs::create_dir_all(home.join(".secret/.git")).unwrap();
|
|
||||||
// node_modules directly under home is not descended…
|
|
||||||
let _ignored = make_repo(&home.join("node_modules"), "dep");
|
|
||||||
// …but the same name deeper down is fair game.
|
|
||||||
let kept = make_repo(&home.join("code/node_modules"), "dep2");
|
|
||||||
|
|
||||||
// Depth cap: a repo three levels below the root is the last tier —
|
|
||||||
// found at depth 3, but a fourth-level repo isn't.
|
|
||||||
let at_three = home.join("a/b/c");
|
|
||||||
std::fs::create_dir_all(at_three.join(".git")).unwrap();
|
|
||||||
std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap();
|
|
||||||
|
|
||||||
let found = scan_roots(std::slice::from_ref(&home), 3, &home);
|
|
||||||
assert!(found.iter().all(|p| !p.starts_with(home.join(".secret"))));
|
|
||||||
assert!(found
|
|
||||||
.iter()
|
|
||||||
.all(|p| !p.starts_with(home.join("node_modules"))));
|
|
||||||
assert!(found.contains(&kept.canonicalize().unwrap()));
|
|
||||||
assert!(found.contains(&at_three.canonicalize().unwrap()));
|
|
||||||
assert!(!found.iter().any(|p| p.ends_with("d")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let root = tmp.path().join("scan-root");
|
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
|
||||||
let main = make_repo(&root, "main-repo");
|
|
||||||
// Linked worktree deliberately outside the scan root.
|
|
||||||
let wt = tmp.path().join("elsewhere/wt-of-main");
|
|
||||||
std::fs::create_dir_all(wt.parent().unwrap()).unwrap();
|
|
||||||
run_git(
|
|
||||||
&main,
|
|
||||||
&[
|
|
||||||
"worktree",
|
|
||||||
"add",
|
|
||||||
"-q",
|
|
||||||
"-b",
|
|
||||||
"feature",
|
|
||||||
&wt.to_string_lossy(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let discovered = discover(&[root], 3, Path::new("/nonexistent-home"));
|
|
||||||
assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]);
|
|
||||||
let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap();
|
|
||||||
assert!(wt_entry.is_worktree);
|
|
||||||
assert_eq!(
|
|
||||||
wt_entry.base_repo.as_deref(),
|
|
||||||
Some(main.canonicalize().unwrap().as_path())
|
|
||||||
);
|
|
||||||
let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap();
|
|
||||||
assert!(!main_entry.is_worktree);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn head_mtime_resolves_a_worktrees_git_file_pointer() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let main = make_repo(tmp.path(), "m");
|
|
||||||
let wt = tmp.path().join("wt");
|
|
||||||
run_git(
|
|
||||||
&main,
|
|
||||||
&["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()],
|
|
||||||
);
|
|
||||||
assert!(head_mtime(&wt).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir {
|
|
||||||
RecentDir {
|
|
||||||
cwd: PathBuf::from(cwd),
|
|
||||||
last_command: cmd.into(),
|
|
||||||
last_used: DateTime::parse_from_rfc3339(at)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn to_projects_merges_history_and_sorts_newest_first() {
|
|
||||||
let repo_old = DiscoveredRepo {
|
|
||||||
path: PathBuf::from("/w/old"),
|
|
||||||
name: "old".into(),
|
|
||||||
is_worktree: false,
|
|
||||||
base_repo: None,
|
|
||||||
// Committed recently but never spawned in.
|
|
||||||
head_mtime: Some(t("2026-08-23T10:00:00Z")),
|
|
||||||
};
|
|
||||||
let repo_hist = DiscoveredRepo {
|
|
||||||
path: PathBuf::from("/w/hist"),
|
|
||||||
name: "hist".into(),
|
|
||||||
is_worktree: false,
|
|
||||||
base_repo: None,
|
|
||||||
// Old commit…
|
|
||||||
head_mtime: Some(t("2026-08-01T10:00:00Z")),
|
|
||||||
};
|
|
||||||
let repo_stale = DiscoveredRepo {
|
|
||||||
path: PathBuf::from("/w/stale"),
|
|
||||||
name: "stale".into(),
|
|
||||||
is_worktree: false,
|
|
||||||
base_repo: None,
|
|
||||||
head_mtime: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let history = vec![
|
|
||||||
// Newest overall: a subdir of /w/hist (first match wins for it).
|
|
||||||
recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"),
|
|
||||||
recent("/w/hist", "claude", "2026-08-20T09:00:00Z"),
|
|
||||||
];
|
|
||||||
let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history);
|
|
||||||
|
|
||||||
let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect();
|
|
||||||
assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]);
|
|
||||||
|
|
||||||
// old: git activity wins (never spawned).
|
|
||||||
assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z")));
|
|
||||||
assert_eq!(projects[0].last_command, None);
|
|
||||||
// hist: spawn history beats the old commit; command from the newest
|
|
||||||
// matching entry (the crates/ one, not the repo root one).
|
|
||||||
assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z")));
|
|
||||||
assert_eq!(projects[1].last_command.as_deref(), Some("opencode"));
|
|
||||||
// stale: neither source knows anything.
|
|
||||||
assert_eq!(projects[2].last_activity, None);
|
|
||||||
assert_eq!(projects[2].last_command, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn t(s: &str) -> DateTime<Utc> {
|
|
||||||
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn scanner_caches_within_ttl_and_rescans_after() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let mut scanner = ProjectScanner::new(
|
|
||||||
vec![tmp.path().to_path_buf()],
|
|
||||||
3,
|
|
||||||
PathBuf::from("/nonexistent"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let empty = scanner.repos().await;
|
|
||||||
assert!(empty.is_empty());
|
|
||||||
|
|
||||||
make_repo(tmp.path(), "late");
|
|
||||||
// Within the TTL the cached (empty) snapshot is still served.
|
|
||||||
let still_empty = scanner.repos().await;
|
|
||||||
assert!(still_empty.is_empty());
|
|
||||||
|
|
||||||
// After the TTL lapses the rescan sees the new repo.
|
|
||||||
scanner.ttl = Duration::ZERO;
|
|
||||||
let fresh = scanner.repos().await;
|
|
||||||
assert_eq!(fresh.len(), 1);
|
|
||||||
assert_eq!(fresh[0].name, "late");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disabled_scanner_always_serves_empty() {
|
|
||||||
let scanner = ProjectScanner::disabled();
|
|
||||||
assert_eq!(scanner.roots, Vec::<PathBuf>::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -169,16 +169,6 @@ impl SessionManager {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every deduped recent dir, newest-first, without the picker's result
|
|
||||||
/// cap (#54): project enrichment folds the whole history.
|
|
||||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
|
||||||
self.inner
|
|
||||||
.spawn_history
|
|
||||||
.as_ref()
|
|
||||||
.map(|h| h.all_recent_dirs())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
|
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
|
||||||
/// a slow client makes only itself resync.
|
/// a slow client makes only itself resync.
|
||||||
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
||||||
|
|||||||
@@ -143,23 +143,11 @@ impl SpawnHistory {
|
|||||||
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
||||||
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
||||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||||
self.recent_dirs_capped(Utc::now(), self.max_results)
|
self.recent_dirs_as_of(Utc::now())
|
||||||
}
|
|
||||||
|
|
||||||
/// Every deduped recent dir, newest-first, without the picker's result
|
|
||||||
/// cap (#54): project enrichment folds the whole history, not its first
|
|
||||||
/// page, so a project spawned in long ago still reads as fresh.
|
|
||||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
|
||||||
self.recent_dirs_capped(Utc::now(), usize::MAX)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
||||||
#[cfg(test)]
|
|
||||||
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
||||||
self.recent_dirs_capped(now, self.max_results)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
|
|
||||||
if !self.is_enabled() {
|
if !self.is_enabled() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -180,7 +168,7 @@ impl SpawnHistory {
|
|||||||
last_command: rec.command,
|
last_command: rec.command,
|
||||||
last_used: rec.at,
|
last_used: rec.at,
|
||||||
});
|
});
|
||||||
if out.len() >= max_results {
|
if out.len() >= self.max_results {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,28 +362,4 @@ mod tests {
|
|||||||
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
||||||
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Project enrichment (#54) folds the whole log, not just the picker's
|
|
||||||
/// first page.
|
|
||||||
#[test]
|
|
||||||
fn all_recent_dirs_skips_the_result_cap() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
|
||||||
h.max_results = 2;
|
|
||||||
// Recent enough to survive the 30-day cap against the real clock.
|
|
||||||
let base = Utc::now() - Duration::hours(1);
|
|
||||||
for i in 0..5 {
|
|
||||||
h.append(&rec(
|
|
||||||
&format!("/d{i}"),
|
|
||||||
"claude",
|
|
||||||
base + Duration::minutes(i),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
assert_eq!(h.recent_dirs().len(), 2);
|
|
||||||
let all = h.all_recent_dirs();
|
|
||||||
assert_eq!(all.len(), 5);
|
|
||||||
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
|
|
||||||
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ async fn spawn_daemon_ws() -> Harness {
|
|||||||
"test-host",
|
"test-host",
|
||||||
"/home/test",
|
"/home/test",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -26,17 +26,6 @@ struct Harness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn(hostname: &str) -> Harness {
|
async fn spawn(hostname: &str) -> Harness {
|
||||||
spawn_with(
|
|
||||||
hostname,
|
|
||||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_with(
|
|
||||||
hostname: &str,
|
|
||||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
|
||||||
) -> Harness {
|
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
||||||
@@ -47,7 +36,6 @@ async fn spawn_with(
|
|||||||
hostname,
|
hostname,
|
||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
presets,
|
||||||
projects,
|
|
||||||
));
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
@@ -165,73 +153,6 @@ async fn recent_dirs_returns_empty_without_history() {
|
|||||||
assert_eq!(resp["truncated"], false);
|
assert_eq!(resp["truncated"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
|
|
||||||
/// real git checkouts — not mocks.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn projects_lists_repos_under_the_scan_roots() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
for name in ["alpha", "beta"] {
|
|
||||||
let repo = tmp.path().join(name);
|
|
||||||
std::fs::create_dir_all(&repo).unwrap();
|
|
||||||
for args in [
|
|
||||||
vec!["init", "-q"],
|
|
||||||
vec![
|
|
||||||
"-c",
|
|
||||||
"user.email=t@t",
|
|
||||||
"-c",
|
|
||||||
"user.name=t",
|
|
||||||
"commit",
|
|
||||||
"-qm",
|
|
||||||
"init",
|
|
||||||
"--allow-empty",
|
|
||||||
],
|
|
||||||
] {
|
|
||||||
let out = std::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&repo)
|
|
||||||
.args(&args)
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.status.success());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
|
|
||||||
vec![tmp.path().to_path_buf()],
|
|
||||||
3,
|
|
||||||
tmp.path().to_path_buf(),
|
|
||||||
));
|
|
||||||
let h = spawn_with("naps-laptop", scanner).await;
|
|
||||||
|
|
||||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
|
||||||
names.sort();
|
|
||||||
assert_eq!(names, ["alpha", "beta"]);
|
|
||||||
for p in &projects {
|
|
||||||
assert!(!p.is_worktree);
|
|
||||||
assert_eq!(p.base_repo, None);
|
|
||||||
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn projects_returns_empty_for_a_disabled_scanner() {
|
|
||||||
let h = spawn("naps-laptop").await;
|
|
||||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(projects.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gitroot_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: resolves the query against the daemon home dir and walks up for a
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
|
||||||
RecentDirs, Session,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -139,10 +138,6 @@ impl DaemonClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
|
||||||
self.get(routes::PROJECTS, &[]).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- plumbing -----------------------------------------------------------
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
async fn get<T: DeserializeOwned>(
|
async fn get<T: DeserializeOwned>(
|
||||||
|
|||||||
@@ -188,30 +188,6 @@ pub struct GitRoot {
|
|||||||
pub root: Option<PathBuf>,
|
pub root: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One discovered git repository (or linked worktree) for `GET /api/projects`
|
|
||||||
/// (#54). The daemon reports the timestamp; clients decide what "stale" means
|
|
||||||
/// (the web UI dims entries older than 7 days).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct Project {
|
|
||||||
/// Absolute path of the repository/worktree toplevel.
|
|
||||||
pub path: PathBuf,
|
|
||||||
/// Basename of `path`.
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_worktree: bool,
|
|
||||||
/// The main checkout this worktree belongs to. Set only when
|
|
||||||
/// [`Self::is_worktree`].
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub base_repo: Option<PathBuf>,
|
|
||||||
/// Later of the repo's git HEAD mtime and the newest spawn-history use of
|
|
||||||
/// any directory inside it. `None` when neither is known.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_activity: Option<DateTime<Utc>>,
|
|
||||||
/// Command last spawned in this project, from spawn history.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_command: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structured failure used everywhere we don't want to leak a string-only error.
|
/// Structured failure used everywhere we don't want to leak a string-only error.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ErrorInfo {
|
pub struct ErrorInfo {
|
||||||
@@ -353,34 +329,6 @@ mod tests {
|
|||||||
serde_json::from_str(&json).expect("deserialize")
|
serde_json::from_str(&json).expect("deserialize")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn project_round_trips_and_defaults_on_legacy_payload() {
|
|
||||||
let full = Project {
|
|
||||||
path: PathBuf::from("/home/u/tea/maestro"),
|
|
||||||
name: "maestro".into(),
|
|
||||||
is_worktree: true,
|
|
||||||
base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")),
|
|
||||||
last_activity: Some(
|
|
||||||
DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z")
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc),
|
|
||||||
),
|
|
||||||
last_command: Some("claude".into()),
|
|
||||||
};
|
|
||||||
assert_eq!(full, round_trip(&full));
|
|
||||||
|
|
||||||
// A payload from a daemon predating the optional fields still decodes.
|
|
||||||
let legacy = serde_json::json!({
|
|
||||||
"path": "/home/u/proj",
|
|
||||||
"name": "proj",
|
|
||||||
});
|
|
||||||
let p: Project = serde_json::from_value(legacy).unwrap();
|
|
||||||
assert!(!p.is_worktree);
|
|
||||||
assert_eq!(p.base_repo, None);
|
|
||||||
assert_eq!(p.last_activity, None);
|
|
||||||
assert_eq!(p.last_command, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_session() -> Session {
|
fn sample_session() -> Session {
|
||||||
Session {
|
Session {
|
||||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ pub const INFO: &str = "/api/info";
|
|||||||
pub const DIRS: &str = "/api/dirs";
|
pub const DIRS: &str = "/api/dirs";
|
||||||
/// `GET` spawn history.
|
/// `GET` spawn history.
|
||||||
pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
||||||
/// `GET` discovered git repositories and worktrees, newest activity first.
|
|
||||||
pub const PROJECTS: &str = "/api/projects";
|
|
||||||
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||||
pub const GITROOT: &str = "/api/gitroot";
|
pub const GITROOT: &str = "/api/gitroot";
|
||||||
/// `WS` catalog snapshot + session deltas.
|
/// `WS` catalog snapshot + session deltas.
|
||||||
@@ -174,7 +172,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_route_sits_under_the_api_prefix() {
|
fn every_route_sits_under_the_api_prefix() {
|
||||||
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] {
|
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
|
||||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
}
|
}
|
||||||
assert!(attach(id()).starts_with(API_PREFIX));
|
assert!(attach(id()).starts_with(API_PREFIX));
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub enum Action {
|
|||||||
// Catalog actions on the highlighted sidebar row
|
// Catalog actions on the highlighted sidebar row
|
||||||
AttachHighlighted,
|
AttachHighlighted,
|
||||||
ToggleAttachHighlighted,
|
ToggleAttachHighlighted,
|
||||||
OpenSpawnModal,
|
NewDraftPane,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
@@ -78,7 +78,7 @@ impl Action {
|
|||||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||||
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
"NewTab" => return Ok(Action::NewTab),
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
"CloseTab" => return Ok(Action::CloseTab),
|
||||||
@@ -202,6 +202,7 @@ mod tests {
|
|||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
|
Action::NewDraftPane,
|
||||||
Action::FocusPane(Direction::Right),
|
Action::FocusPane(Direction::Right),
|
||||||
Action::GotoTab(7),
|
Action::GotoTab(7),
|
||||||
] {
|
] {
|
||||||
@@ -209,4 +210,14 @@ mod tests {
|
|||||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The spawn modal is gone (issue #63); its old action string must not
|
||||||
|
/// resolve to anything.
|
||||||
|
#[test]
|
||||||
|
fn the_removed_spawn_modal_action_no_longer_parses() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("OpenSpawnModal").unwrap_err(),
|
||||||
|
ActionParseError::Unknown("OpenSpawnModal".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-84
@@ -6,9 +6,9 @@
|
|||||||
//! changes, and a 30Hz heartbeat.
|
//! changes, and a 30Hz heartbeat.
|
||||||
//!
|
//!
|
||||||
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
||||||
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
|
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
|
||||||
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
||||||
//! `dispatch` / `handle_overlay_input`.
|
//! `dispatch` / the Insert-mode pane routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Stdout;
|
use std::io::Stdout;
|
||||||
@@ -24,15 +24,16 @@ use uuid::Uuid;
|
|||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
|
use crate::draft::DraftInputResult;
|
||||||
use crate::emulators::SessionEmulators;
|
use crate::emulators::SessionEmulators;
|
||||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, SpawnModal};
|
use crate::overlays::HelpOverlay;
|
||||||
use crate::pane::{Pane, PaneStatus};
|
use crate::pane::{Pane, PaneStatus};
|
||||||
use crate::render::{draw, RegionMap, RenderInputs};
|
use crate::render::{draw, RegionMap, RenderInputs};
|
||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -49,7 +50,6 @@ pub struct App {
|
|||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
spawn: SpawnModal,
|
|
||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
daemon: DaemonClient,
|
||||||
@@ -90,7 +90,6 @@ impl App {
|
|||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
spawn: SpawnModal::default(),
|
|
||||||
help,
|
help,
|
||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
@@ -152,7 +151,15 @@ impl App {
|
|||||||
self.perform(effects).await;
|
self.perform(effects).await;
|
||||||
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
||||||
{
|
{
|
||||||
if let Some(effect) = self.forward_to_session(&key) {
|
// Insert mode over a focused pane: a draft eats the key
|
||||||
|
// into its form, a session gets the raw bytes.
|
||||||
|
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
||||||
|
{
|
||||||
|
self.handle_draft_input(&key)
|
||||||
|
} else {
|
||||||
|
self.forward_to_session(&key)
|
||||||
|
};
|
||||||
|
if let Some(effect) = effect {
|
||||||
self.perform(vec![effect]).await;
|
self.perform(vec![effect]).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,27 +186,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
||||||
use crate::overlays::SpawnInputResult;
|
|
||||||
match self.state.overlay().cloned() {
|
match self.state.overlay().cloned() {
|
||||||
Some(OverlayKind::Spawn) => {
|
|
||||||
let mut effects = Vec::new();
|
|
||||||
match self.spawn.on_input(key) {
|
|
||||||
SpawnInputResult::Consumed => {}
|
|
||||||
SpawnInputResult::Cancel => {
|
|
||||||
self.state.dismiss_overlay();
|
|
||||||
}
|
|
||||||
SpawnInputResult::Submit => {
|
|
||||||
effects.push(Effect::Spawn(CreateSessionRequest {
|
|
||||||
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
|
|
||||||
command: self.spawn.command.value().to_string(),
|
|
||||||
env: Default::default(),
|
|
||||||
metadata: Default::default(),
|
|
||||||
worktree: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(OverlayDecision::Consumed, effects)
|
|
||||||
}
|
|
||||||
Some(OverlayKind::KillConfirm(id)) => match key {
|
Some(OverlayKind::KillConfirm(id)) => match key {
|
||||||
InputKey::Char { c: 'y', .. } => {
|
InputKey::Char { c: 'y', .. } => {
|
||||||
self.state.dismiss_overlay();
|
self.state.dismiss_overlay();
|
||||||
@@ -265,14 +252,7 @@ impl App {
|
|||||||
};
|
};
|
||||||
self.state.show_overlay(OverlayKind::Help);
|
self.state.show_overlay(OverlayKind::Help);
|
||||||
}
|
}
|
||||||
Action::OpenSpawnModal => {
|
Action::NewDraftPane => self.state.open_draft_pane(),
|
||||||
self.spawn = SpawnModal {
|
|
||||||
cwd: Default::default(),
|
|
||||||
command: Default::default(),
|
|
||||||
focused_field: Default::default(),
|
|
||||||
};
|
|
||||||
self.state.show_overlay(OverlayKind::Spawn);
|
|
||||||
}
|
|
||||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
effects.push(Effect::Attach(sid));
|
effects.push(Effect::Attach(sid));
|
||||||
@@ -322,44 +302,38 @@ impl App {
|
|||||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
let sid = self.state.active_tab().pane?;
|
let sid = self.state.active_session()?;
|
||||||
self.emulators.get(sid)?;
|
self.emulators.get(sid)?;
|
||||||
let bytes = match key {
|
let bytes = encode_key(key)?;
|
||||||
InputKey::Char { c, ctrl, alt, .. } => {
|
|
||||||
let mut out = Vec::with_capacity(8);
|
|
||||||
if *alt {
|
|
||||||
out.push(0x1b);
|
|
||||||
}
|
|
||||||
if *ctrl && c.is_ascii_alphabetic() {
|
|
||||||
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
|
||||||
} else {
|
|
||||||
let mut b = [0u8; 4];
|
|
||||||
let s = c.encode_utf8(&mut b);
|
|
||||||
out.extend_from_slice(s.as_bytes());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
InputKey::Enter => b"\r".to_vec(),
|
|
||||||
InputKey::Tab => b"\t".to_vec(),
|
|
||||||
InputKey::BackTab => b"\x1b[Z".to_vec(),
|
|
||||||
InputKey::Backspace => vec![0x7f],
|
|
||||||
InputKey::Esc => vec![0x1b],
|
|
||||||
InputKey::Up => b"\x1b[A".to_vec(),
|
|
||||||
InputKey::Down => b"\x1b[B".to_vec(),
|
|
||||||
InputKey::Right => b"\x1b[C".to_vec(),
|
|
||||||
InputKey::Left => b"\x1b[D".to_vec(),
|
|
||||||
InputKey::Home => b"\x1b[H".to_vec(),
|
|
||||||
InputKey::End => b"\x1b[F".to_vec(),
|
|
||||||
InputKey::Delete => b"\x1b[3~".to_vec(),
|
|
||||||
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
|
|
||||||
};
|
|
||||||
Some(Effect::Forward { id: sid, bytes })
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||||
|
/// effect when the user accepted the form.
|
||||||
|
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
|
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if draft.on_input(key) != DraftInputResult::Submit {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Effect::Spawn(CreateSessionRequest {
|
||||||
|
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
|
||||||
|
command: draft.command.value().to_string(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||||
match self.daemon.create_session(&req).await {
|
match self.daemon.create_session(&req).await {
|
||||||
Ok(_) => {
|
Ok(session) => {
|
||||||
self.state.dismiss_overlay();
|
// Fill the pane in place with the new session (web parity,
|
||||||
|
// #57) and attach to it right away.
|
||||||
|
let sid = session.id;
|
||||||
|
self.state.complete_spawn(sid);
|
||||||
|
self.attach(sid);
|
||||||
}
|
}
|
||||||
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
||||||
}
|
}
|
||||||
@@ -377,7 +351,12 @@ impl App {
|
|||||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
) -> Result<(), TuiError> {
|
) -> Result<(), TuiError> {
|
||||||
let snap = self.catalog.snapshot();
|
let snap = self.catalog.snapshot();
|
||||||
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
|
let attached: HashSet<Uuid> = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.filter_map(crate::state::Tab::session)
|
||||||
|
.collect();
|
||||||
let sidebar = Sidebar {
|
let sidebar = Sidebar {
|
||||||
sessions: snap.sessions.clone(),
|
sessions: snap.sessions.clone(),
|
||||||
highlight: self.state.sidebar_highlight,
|
highlight: self.state.sidebar_highlight,
|
||||||
@@ -387,7 +366,7 @@ impl App {
|
|||||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
||||||
active_idx: self.state.active_tab_idx,
|
active_idx: self.state.active_tab_idx,
|
||||||
};
|
};
|
||||||
let active_sid = self.state.active_tab().pane;
|
let active_sid = self.state.active_session();
|
||||||
let pane = active_sid.and_then(|sid| {
|
let pane = active_sid.and_then(|sid| {
|
||||||
self.emulators.get(sid).map(|h| Pane {
|
self.emulators.get(sid).map(|h| Pane {
|
||||||
screen: h.screen.clone(),
|
screen: h.screen.clone(),
|
||||||
@@ -395,13 +374,17 @@ impl App {
|
|||||||
focused: matches!(self.state.focus, Focus::Pane),
|
focused: matches!(self.state.focus, Focus::Pane),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let draft = match self.state.active_tab().pane.as_ref() {
|
||||||
|
Some(PaneRef::Draft(d)) => Some(d),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
tab_bar: &tab_bar,
|
tab_bar: &tab_bar,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
spawn: &self.spawn,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -449,6 +432,43 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
|||||||
snap.sessions.iter().map(|s| s.id).collect()
|
snap.sessions.iter().map(|s| s.id).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||||
|
/// with no byte representation (mouse events). A bracketed paste is forwarded
|
||||||
|
/// verbatim — the remote readline reassembles it, since the pane's PTY also
|
||||||
|
/// has bracketed paste enabled.
|
||||||
|
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||||
|
match key {
|
||||||
|
InputKey::Char { c, ctrl, alt, .. } => {
|
||||||
|
let mut out = Vec::with_capacity(8);
|
||||||
|
if *alt {
|
||||||
|
out.push(0x1b);
|
||||||
|
}
|
||||||
|
if *ctrl && c.is_ascii_alphabetic() {
|
||||||
|
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
||||||
|
} else {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
let s = c.encode_utf8(&mut b);
|
||||||
|
out.extend_from_slice(s.as_bytes());
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
InputKey::Enter => Some(b"\r".to_vec()),
|
||||||
|
InputKey::Tab => Some(b"\t".to_vec()),
|
||||||
|
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
|
||||||
|
InputKey::Backspace => Some(vec![0x7f]),
|
||||||
|
InputKey::Esc => Some(vec![0x1b]),
|
||||||
|
InputKey::Up => Some(b"\x1b[A".to_vec()),
|
||||||
|
InputKey::Down => Some(b"\x1b[B".to_vec()),
|
||||||
|
InputKey::Right => Some(b"\x1b[C".to_vec()),
|
||||||
|
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||||
|
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||||
|
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||||
|
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||||
|
InputKey::Paste(text) => Some(text.as_bytes().to_vec()),
|
||||||
|
InputKey::Mouse { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||||
/// user always sees the bindings that are actually active (defaults + their
|
/// user always sees the bindings that are actually active (defaults + their
|
||||||
/// own `[tui.keymap.*]` overrides).
|
/// own `[tui.keymap.*]` overrides).
|
||||||
@@ -516,10 +536,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_spawn_modal_sets_overlay() {
|
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::OpenSpawnModal);
|
app.dispatch(Action::NewDraftPane);
|
||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
|
assert!(matches!(
|
||||||
|
app.state.active_tab().pane,
|
||||||
|
Some(PaneRef::Draft(_))
|
||||||
|
));
|
||||||
|
assert_eq!(app.state.focus, Focus::Pane);
|
||||||
|
assert_eq!(app.state.mode, Mode::Insert);
|
||||||
|
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab already showing a session gets a new tab for the draft.
|
||||||
|
#[test]
|
||||||
|
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
||||||
|
let sid = Uuid::from_u128(10);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.dispatch(Action::AttachHighlighted);
|
||||||
|
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
assert_eq!(app.state.tabs.len(), 2);
|
||||||
|
assert_eq!(app.state.active_tab_idx, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
app.state.active_tab().pane,
|
||||||
|
Some(PaneRef::Draft(_))
|
||||||
|
));
|
||||||
|
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seed the catalog with one session and highlight it.
|
/// Seed the catalog with one session and highlight it.
|
||||||
@@ -554,7 +597,7 @@ mod tests {
|
|||||||
app.dispatch(Action::AttachHighlighted),
|
app.dispatch(Action::AttachHighlighted),
|
||||||
vec![Effect::Attach(sid)]
|
vec![Effect::Attach(sid)]
|
||||||
);
|
);
|
||||||
assert_eq!(app.state.active_tab().pane, Some(sid));
|
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -587,23 +630,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn submitting_the_spawn_modal_carries_the_typed_fields() {
|
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::OpenSpawnModal);
|
app.dispatch(Action::NewDraftPane);
|
||||||
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
|
|
||||||
app.spawn.command = crate::text_input::TextInput::with_value("claude");
|
|
||||||
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
|
|
||||||
|
|
||||||
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
for c in "/srv/work".chars() {
|
||||||
match effects.as_slice() {
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
[Effect::Spawn(req)] => {
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // cwd → command
|
||||||
|
for c in "claude".chars() {
|
||||||
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // command → submit
|
||||||
|
|
||||||
|
match app.handle_draft_input(&InputKey::Enter) {
|
||||||
|
Some(Effect::Spawn(req)) => {
|
||||||
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
||||||
assert_eq!(req.command, "claude");
|
assert_eq!(req.command, "claude");
|
||||||
}
|
}
|
||||||
other => panic!("expected one Spawn effect, got {other:?}"),
|
other => panic!("expected a Spawn effect, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keys fed while no draft exists must be ignored, not panic.
|
||||||
|
#[test]
|
||||||
|
fn draft_input_is_a_no_op_without_a_draft() {
|
||||||
|
let mut app = make_app();
|
||||||
|
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
||||||
/// failure message stayed on screen until another overlay replaced it.
|
/// failure message stayed on screen until another overlay replaced it.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -632,6 +687,37 @@ mod tests {
|
|||||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A draft pane is not a session: forwarding must not try to reach it.
|
||||||
|
#[test]
|
||||||
|
fn forwarding_is_a_no_op_with_a_draft_pane() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||||
|
/// paste into an attached session vanished once bracketed paste was
|
||||||
|
/// enabled — the payload must be forwarded verbatim.
|
||||||
|
#[test]
|
||||||
|
fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() {
|
||||||
|
assert_eq!(
|
||||||
|
encode_key(&InputKey::Paste("cd /tmp/foo".into())),
|
||||||
|
Some(b"cd /tmp/foo".to_vec())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_events_have_no_wire_encoding() {
|
||||||
|
use crate::input::{MouseKind, Region};
|
||||||
|
assert!(encode_key(&InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::Pane,
|
||||||
|
col: 3,
|
||||||
|
row: 4,
|
||||||
|
})
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kill_highlighted_opens_confirm() {
|
fn kill_highlighted_opens_confirm() {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("up", "SidebarPrev"),
|
("up", "SidebarPrev"),
|
||||||
// Catalog actions
|
// Catalog actions
|
||||||
("enter", "AttachHighlighted"),
|
("enter", "AttachHighlighted"),
|
||||||
("s", "OpenSpawnModal"),
|
("s", "NewDraftPane"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
// Tabs
|
// Tabs
|
||||||
("ctrl-t", "NewTab"),
|
("ctrl-t", "NewTab"),
|
||||||
@@ -155,6 +155,10 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
cfg.keymap.resolve(Mode::Normal, "g t"),
|
||||||
Some(Action::NextTab),
|
Some(Action::NextTab),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||||
|
Some(Action::NewDraftPane),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! Draft pane — the spawn form living in an ordinary pane (issue #63).
|
||||||
|
//!
|
||||||
|
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
|
||||||
|
//! the caller a built request and the pane is replaced with the new session,
|
||||||
|
//! matching the web client's inline draft panes (#57). Field editing goes
|
||||||
|
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
|
||||||
|
//!
|
||||||
|
//! Scope note: presets, the project list, generated names, and the worktree
|
||||||
|
//! section land with issues #64/#65 — this is cwd + command only.
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::input::InputKey;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DraftField {
|
||||||
|
#[default]
|
||||||
|
Cwd,
|
||||||
|
Command,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DraftPane {
|
||||||
|
pub cwd: crate::text_input::TextInput,
|
||||||
|
pub command: crate::text_input::TextInput,
|
||||||
|
pub focused_field: DraftField,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DraftInputResult {
|
||||||
|
Consumed,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DraftPane {
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
self.focused_field = match self.focused_field {
|
||||||
|
DraftField::Cwd => DraftField::Command,
|
||||||
|
DraftField::Command => DraftField::Submit,
|
||||||
|
DraftField::Submit => DraftField::Cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
self.focused_field = match self.focused_field {
|
||||||
|
DraftField::Cwd => DraftField::Submit,
|
||||||
|
DraftField::Command => DraftField::Cwd,
|
||||||
|
DraftField::Submit => DraftField::Command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle one keystroke while this draft owns input (Insert mode, pane
|
||||||
|
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
|
||||||
|
/// the caller fires the POST and replaces the draft with the session.
|
||||||
|
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
|
||||||
|
match key {
|
||||||
|
InputKey::Tab => {
|
||||||
|
self.next_field();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
InputKey::BackTab => {
|
||||||
|
self.prev_field();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
InputKey::Enter => match self.focused_field {
|
||||||
|
DraftField::Submit => DraftInputResult::Submit,
|
||||||
|
_ => {
|
||||||
|
self.next_field();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Everything else — characters, motion, editing, paste — goes to
|
||||||
|
// the focused field's text input.
|
||||||
|
other => {
|
||||||
|
match self.focused_field {
|
||||||
|
DraftField::Cwd => self.cwd.handle_key(other),
|
||||||
|
DraftField::Command => self.command.handle_key(other),
|
||||||
|
DraftField::Submit => {}
|
||||||
|
}
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw into the pane area. `pane_focused` gates the field highlight and
|
||||||
|
/// cursor placement the same way a session pane hides its cursor when
|
||||||
|
/// unfocused.
|
||||||
|
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
||||||
|
let block = Block::default().title(" draft ").borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let style_for = |field: DraftField| {
|
||||||
|
if pane_focused && self.focused_field == field {
|
||||||
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each text field renders through the shared `TextInput`, which
|
||||||
|
// handles horizontal scrolling and reports where the terminal cursor
|
||||||
|
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
|
||||||
|
let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} "))));
|
||||||
|
f.render_widget(label("cwd:"), rows[0]);
|
||||||
|
f.render_widget(label("command:"), rows[1]);
|
||||||
|
let draw_field =
|
||||||
|
|f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 {
|
||||||
|
let area = Rect {
|
||||||
|
x: row.x.saturating_add(9),
|
||||||
|
y: row.y,
|
||||||
|
width: row.width.saturating_sub(9),
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
input.render(area, f.buffer_mut(), style)
|
||||||
|
};
|
||||||
|
let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd));
|
||||||
|
let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![Span::styled(
|
||||||
|
"[ Spawn ]",
|
||||||
|
style_for(DraftField::Submit),
|
||||||
|
)])),
|
||||||
|
rows[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
if pane_focused {
|
||||||
|
match self.focused_field {
|
||||||
|
DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
|
||||||
|
DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
|
||||||
|
DraftField::Submit => {
|
||||||
|
// The submit button is indicated by the REVERSED style; no
|
||||||
|
// cursor placement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
|
||||||
|
let backend = TestBackend::new(w, h);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(draw).unwrap();
|
||||||
|
let buf = terminal.backend().buffer().clone();
|
||||||
|
let mut out = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
out.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_pane_renders_field_labels_and_values() {
|
||||||
|
let draft = DraftPane {
|
||||||
|
cwd: crate::text_input::TextInput::with_value("/x"),
|
||||||
|
command: crate::text_input::TextInput::with_value("bash"),
|
||||||
|
focused_field: DraftField::Cwd,
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true));
|
||||||
|
for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] {
|
||||||
|
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_rotation_wraps_in_both_directions() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
draft.next_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
draft.next_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||||
|
draft.next_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
draft.prev_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||||
|
|
||||||
|
draft.on_input(&InputKey::BackTab);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
draft.on_input(&InputKey::Tab);
|
||||||
|
draft.on_input(&InputKey::Tab);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editing keys must reach the focused field's text input, not a bare
|
||||||
|
/// String — this is what gives the operator cursor motion and word ops.
|
||||||
|
#[test]
|
||||||
|
fn editing_keys_reach_the_focused_text_input() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
for k in [
|
||||||
|
InputKey::char('/'),
|
||||||
|
InputKey::char('t'),
|
||||||
|
InputKey::char('m'),
|
||||||
|
InputKey::char('p'),
|
||||||
|
] {
|
||||||
|
draft.on_input(&k);
|
||||||
|
}
|
||||||
|
draft.on_input(&InputKey::Left);
|
||||||
|
draft.on_input(&InputKey::Backspace);
|
||||||
|
assert_eq!(draft.cwd.value(), "/tp");
|
||||||
|
|
||||||
|
// Tab to command, then a paste and ctrl-w land there, not in cwd.
|
||||||
|
draft.on_input(&InputKey::Tab);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
draft.on_input(&InputKey::Paste("/tmp/foo".into()));
|
||||||
|
assert_eq!(draft.command.value(), "/tmp/foo");
|
||||||
|
draft.on_input(&InputKey::ctrl_char('w'));
|
||||||
|
assert_eq!(draft.command.value(), "");
|
||||||
|
assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter walks forward until the submit field, where it submits.
|
||||||
|
#[test]
|
||||||
|
fn enter_walks_fields_then_submits_from_submit() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||||
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal cursor must sit right after the typed text in the focused
|
||||||
|
/// field (label is 8 chars + 1 space, block border takes column 0).
|
||||||
|
#[test]
|
||||||
|
fn render_places_the_cursor_after_the_typed_text_when_focused() {
|
||||||
|
let draft = DraftPane {
|
||||||
|
cwd: crate::text_input::TextInput::with_value("abc"),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
let backend = TestBackend::new(80, 20);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||||
|
let pos = terminal.get_cursor_position().unwrap();
|
||||||
|
assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1));
|
||||||
|
|
||||||
|
// Unfocused pane: no field highlight either.
|
||||||
|
let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false));
|
||||||
|
let backend = TestBackend::new(80, 20);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(|f| draft.render(f, f.area(), false)).unwrap();
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
assert!(
|
||||||
|
!buf.content()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)),
|
||||||
|
"unfocused draft must not highlight fields:\n{rendered_unfocused}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod app;
|
|||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod draft;
|
||||||
pub mod emulators;
|
pub mod emulators;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
|
|||||||
@@ -1,161 +1,16 @@
|
|||||||
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
|
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
||||||
//! transient toast.
|
//!
|
||||||
|
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||||
|
//! panes ([`crate::draft::DraftPane`]).
|
||||||
//!
|
//!
|
||||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Modifier, Style};
|
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SpawnField {
|
|
||||||
#[default]
|
|
||||||
Cwd,
|
|
||||||
Command,
|
|
||||||
Submit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
|
||||||
pub struct SpawnModal {
|
|
||||||
pub cwd: crate::text_input::TextInput,
|
|
||||||
pub command: crate::text_input::TextInput,
|
|
||||||
pub focused_field: SpawnField,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SpawnModal {
|
|
||||||
pub fn next_field(&mut self) {
|
|
||||||
self.focused_field = match self.focused_field {
|
|
||||||
SpawnField::Cwd => SpawnField::Command,
|
|
||||||
SpawnField::Command => SpawnField::Submit,
|
|
||||||
SpawnField::Submit => SpawnField::Cwd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_field(&mut self) {
|
|
||||||
self.focused_field = match self.focused_field {
|
|
||||||
SpawnField::Cwd => SpawnField::Submit,
|
|
||||||
SpawnField::Command => SpawnField::Cwd,
|
|
||||||
SpawnField::Submit => SpawnField::Command,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle one keystroke while this modal owns input. Returns
|
|
||||||
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
|
|
||||||
/// the POST and dismisses the modal.
|
|
||||||
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
|
|
||||||
use crate::input::InputKey;
|
|
||||||
match key {
|
|
||||||
InputKey::Tab => {
|
|
||||||
self.next_field();
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
InputKey::BackTab => {
|
|
||||||
self.prev_field();
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
InputKey::Esc => SpawnInputResult::Cancel,
|
|
||||||
InputKey::Enter => match self.focused_field {
|
|
||||||
SpawnField::Submit => SpawnInputResult::Submit,
|
|
||||||
_ => {
|
|
||||||
self.next_field();
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Everything else — characters, motion, editing, paste — goes to
|
|
||||||
// the focused field's text input.
|
|
||||||
other => {
|
|
||||||
match self.focused_field {
|
|
||||||
SpawnField::Cwd => self.cwd.handle_key(other),
|
|
||||||
SpawnField::Command => self.command.handle_key(other),
|
|
||||||
SpawnField::Submit => {}
|
|
||||||
}
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
|
||||||
let area = centered(parent, 60, 10);
|
|
||||||
f.render_widget(Clear, area);
|
|
||||||
let block = Block::default().title(" spawn ").borders(Borders::ALL);
|
|
||||||
let inner = block.inner(area);
|
|
||||||
f.render_widget(block, area);
|
|
||||||
let rows = Layout::vertical([
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
|
||||||
.split(inner);
|
|
||||||
let highlight = |field: SpawnField| {
|
|
||||||
if self.focused_field == field {
|
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Each text field renders through the shared `TextInput`, which
|
|
||||||
// handles horizontal scrolling and reports where the terminal cursor
|
|
||||||
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
|
|
||||||
let label =
|
|
||||||
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
|
|
||||||
f.render_widget(label("cwd:"), rows[0]);
|
|
||||||
f.render_widget(label("command:"), rows[1]);
|
|
||||||
let draw_field = |f: &mut Frame,
|
|
||||||
input: &crate::text_input::TextInput,
|
|
||||||
row: Rect,
|
|
||||||
focused: bool|
|
|
||||||
-> u16 {
|
|
||||||
let style = if focused {
|
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
let area = Rect {
|
|
||||||
x: row.x.saturating_add(9),
|
|
||||||
y: row.y,
|
|
||||||
width: row.width.saturating_sub(9),
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
input.render(area, f.buffer_mut(), style)
|
|
||||||
};
|
|
||||||
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
|
|
||||||
let cmd_col = draw_field(
|
|
||||||
f,
|
|
||||||
&self.command,
|
|
||||||
rows[1],
|
|
||||||
self.focused_field == SpawnField::Command,
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
|
|
||||||
"[ Spawn ]",
|
|
||||||
highlight(SpawnField::Submit),
|
|
||||||
)])),
|
|
||||||
rows[2],
|
|
||||||
);
|
|
||||||
|
|
||||||
match self.focused_field {
|
|
||||||
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
|
|
||||||
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
|
|
||||||
SpawnField::Submit => {
|
|
||||||
// The submit button is indicated by the REVERSED style; no
|
|
||||||
// cursor placement.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SpawnInputResult {
|
|
||||||
Consumed,
|
|
||||||
Cancel,
|
|
||||||
Submit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -262,33 +117,6 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_modal_renders_field_labels() {
|
|
||||||
let modal = SpawnModal {
|
|
||||||
cwd: crate::text_input::TextInput::with_value("/x"),
|
|
||||||
command: crate::text_input::TextInput::with_value("bash"),
|
|
||||||
focused_field: SpawnField::Cwd,
|
|
||||||
};
|
|
||||||
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
|
|
||||||
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
|
|
||||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawn_modal_field_rotation() {
|
|
||||||
let mut modal = SpawnModal::default();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
|
||||||
modal.next_field();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
|
||||||
modal.next_field();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
|
||||||
modal.next_field();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
|
||||||
modal.prev_field();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kill_confirm_renders_prompt() {
|
fn kill_confirm_renders_prompt() {
|
||||||
let modal = KillConfirm {
|
let modal = KillConfirm {
|
||||||
@@ -306,81 +134,11 @@ mod tests {
|
|||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
lines: vec![
|
lines: vec![
|
||||||
"i EnterInsert".into(),
|
"i EnterInsert".into(),
|
||||||
"s OpenSpawnModal".into(),
|
"s NewDraftPane".into(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||||
assert!(rendered.contains("EnterInsert"));
|
assert!(rendered.contains("EnterInsert"));
|
||||||
assert!(rendered.contains("OpenSpawnModal"));
|
assert!(rendered.contains("NewDraftPane"));
|
||||||
}
|
|
||||||
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
|
|
||||||
/// spawn modal could only be cycled forwards.
|
|
||||||
#[test]
|
|
||||||
fn shift_tab_walks_the_spawn_modal_backwards() {
|
|
||||||
let mut modal = SpawnModal::default();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
|
||||||
|
|
||||||
modal.on_input(&crate::input::InputKey::BackTab);
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
|
||||||
|
|
||||||
modal.on_input(&crate::input::InputKey::BackTab);
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_and_shift_tab_are_inverses() {
|
|
||||||
let mut modal = SpawnModal::default();
|
|
||||||
for _ in 0..3 {
|
|
||||||
modal.on_input(&crate::input::InputKey::Tab);
|
|
||||||
modal.on_input(&crate::input::InputKey::BackTab);
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Editing keys must reach the focused field's text input, not a bare
|
|
||||||
/// String — this is what gives the operator cursor motion and word ops.
|
|
||||||
#[test]
|
|
||||||
fn editing_keys_reach_the_focused_text_input() {
|
|
||||||
let mut modal = SpawnModal::default();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
|
||||||
for k in [
|
|
||||||
crate::input::InputKey::char('/'),
|
|
||||||
crate::input::InputKey::char('t'),
|
|
||||||
crate::input::InputKey::char('m'),
|
|
||||||
crate::input::InputKey::char('p'),
|
|
||||||
] {
|
|
||||||
modal.on_input(&k);
|
|
||||||
}
|
|
||||||
modal.on_input(&crate::input::InputKey::Left);
|
|
||||||
modal.on_input(&crate::input::InputKey::Backspace);
|
|
||||||
assert_eq!(modal.cwd.value(), "/tp");
|
|
||||||
|
|
||||||
// Tab to command, then a paste and ctrl-w land there, not in cwd.
|
|
||||||
modal.on_input(&crate::input::InputKey::Tab);
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Command);
|
|
||||||
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
|
|
||||||
assert_eq!(modal.command.value(), "/tmp/foo");
|
|
||||||
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
|
|
||||||
assert_eq!(modal.command.value(), "");
|
|
||||||
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The terminal cursor must sit right after the typed text in the focused
|
|
||||||
/// field (label is 8 chars + 1 space, modal is centered on screen).
|
|
||||||
#[test]
|
|
||||||
fn render_places_the_cursor_after_the_typed_text() {
|
|
||||||
use ratatui::backend::TestBackend;
|
|
||||||
use ratatui::Terminal;
|
|
||||||
|
|
||||||
let modal = SpawnModal {
|
|
||||||
cwd: crate::text_input::TextInput::with_value("abc"),
|
|
||||||
..SpawnModal::default()
|
|
||||||
};
|
|
||||||
let backend = TestBackend::new(80, 20);
|
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
|
||||||
terminal.draw(|f| modal.render(f, f.area())).unwrap();
|
|
||||||
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
|
|
||||||
let pos = terminal.get_cursor_position().unwrap();
|
|
||||||
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
|||||||
|
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind};
|
||||||
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
|
|||||||
pub sidebar: &'a Sidebar,
|
pub sidebar: &'a Sidebar,
|
||||||
pub tab_bar: &'a TabBar,
|
pub tab_bar: &'a TabBar,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
|
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||||
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
pub spawn: &'a SpawnModal,
|
|
||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_area);
|
||||||
}
|
}
|
||||||
|
if let Some(d) = inputs.draft {
|
||||||
|
d.render(f, pane_area, pane_focused);
|
||||||
|
}
|
||||||
map.pane = Some(pane_area);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
@@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
render_status_bar(f, status_area, inputs.state);
|
render_status_bar(f, status_area, inputs.state);
|
||||||
|
|
||||||
match inputs.state.overlay() {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::Spawn) => {
|
|
||||||
inputs.spawn.render(f, area);
|
|
||||||
map.modal = Some(area);
|
|
||||||
}
|
|
||||||
Some(OverlayKind::KillConfirm(id)) => {
|
Some(OverlayKind::KillConfirm(id)) => {
|
||||||
let kc = KillConfirm {
|
let kc = KillConfirm {
|
||||||
label: id.to_string(),
|
label: id.to_string(),
|
||||||
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Focus::Pane => "pane",
|
Focus::Pane => "pane",
|
||||||
};
|
};
|
||||||
let hint = match state.mode {
|
let hint = match state.mode {
|
||||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||||
Mode::Insert => "^a: back to normal",
|
Mode::Insert => "^a: back to normal",
|
||||||
};
|
};
|
||||||
let mode_style = match state.mode {
|
let mode_style = match state.mode {
|
||||||
@@ -266,4 +266,57 @@ mod tests {
|
|||||||
assert_eq!(map.region_at(15, 10), Region::Modal);
|
assert_eq!(map.region_at(15, 10), Region::Modal);
|
||||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||||
|
/// form where the terminal would otherwise be.
|
||||||
|
#[test]
|
||||||
|
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use crate::state::PaneRef;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
let draft = match &state.tabs[0].pane {
|
||||||
|
Some(PaneRef::Draft(d)) => d,
|
||||||
|
other => panic!("expected a draft pane, got {other:?}"),
|
||||||
|
};
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let tab_bar = TabBar {
|
||||||
|
tab_names: vec!["1".into()],
|
||||||
|
active_idx: 0,
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
tab_bar: &tab_bar,
|
||||||
|
pane: None,
|
||||||
|
draft: Some(draft),
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| {
|
||||||
|
draw(f, inputs);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buf = terminal.backend().buffer().clone();
|
||||||
|
let mut out = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
out.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
|
||||||
|
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-15
@@ -1,14 +1,16 @@
|
|||||||
//! `AppState` — the top-level mutable state of the running TUI.
|
//! `AppState` — the top-level mutable state of the running TUI.
|
||||||
//!
|
//!
|
||||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
||||||
//! session), an active tab index, focus split between sidebar and pane, the
|
//! session or a draft form), an active tab index, focus split between sidebar
|
||||||
//! current input mode, and an optional overlay (spawn modal, kill confirm,
|
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
||||||
//! help, transient toast).
|
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||||
|
//! pane is a draft pane (issue #63).
|
||||||
//!
|
//!
|
||||||
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
|
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::draft::DraftPane;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -17,15 +19,31 @@ pub enum Focus {
|
|||||||
Pane,
|
Pane,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PaneRef {
|
||||||
|
Session(Uuid),
|
||||||
|
Draft(DraftPane),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Tab {
|
pub struct Tab {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub pane: Option<Uuid>,
|
pub pane: Option<PaneRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tab {
|
||||||
|
/// The session this tab shows, if it shows one. Draft panes have none.
|
||||||
|
pub fn session(&self) -> Option<Uuid> {
|
||||||
|
match &self.pane {
|
||||||
|
Some(PaneRef::Session(sid)) => Some(*sid),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OverlayKind {
|
pub enum OverlayKind {
|
||||||
Spawn,
|
|
||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
@@ -108,7 +126,8 @@ impl AppState {
|
|||||||
///
|
///
|
||||||
/// Returns the session that tab was showing, if any, so the caller can
|
/// Returns the session that tab was showing, if any, so the caller can
|
||||||
/// release its emulator. Without that the tab's WebSocket task and vt100
|
/// release its emulator. Without that the tab's WebSocket task and vt100
|
||||||
/// parser outlive the tab forever.
|
/// parser outlive the tab forever. A draft pane holds no session and
|
||||||
|
/// releases nothing.
|
||||||
pub fn close_tab(&mut self) -> Option<Uuid> {
|
pub fn close_tab(&mut self) -> Option<Uuid> {
|
||||||
if self.tabs.len() <= 1 {
|
if self.tabs.len() <= 1 {
|
||||||
return None;
|
return None;
|
||||||
@@ -117,9 +136,10 @@ impl AppState {
|
|||||||
if self.active_tab_idx >= self.tabs.len() {
|
if self.active_tab_idx >= self.tabs.len() {
|
||||||
self.active_tab_idx = self.tabs.len() - 1;
|
self.active_tab_idx = self.tabs.len() - 1;
|
||||||
}
|
}
|
||||||
|
let sid = closed.session()?;
|
||||||
// Only release it if no other tab still shows the same session.
|
// Only release it if no other tab still shows the same session.
|
||||||
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
|
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
||||||
closed.pane.filter(|_| !still_shown)
|
(!still_shown).then_some(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_tab(&mut self) {
|
pub fn next_tab(&mut self) {
|
||||||
@@ -147,11 +167,37 @@ impl AppState {
|
|||||||
/// drop straight into Insert mode so keystrokes go to the session
|
/// drop straight into Insert mode so keystrokes go to the session
|
||||||
/// without an extra `i` press. The leader returns to Normal.
|
/// without an extra `i` press. The leader returns to Normal.
|
||||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||||
self.active_tab_mut().pane = Some(session_id);
|
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
|
||||||
self.focus = Focus::Pane;
|
self.focus = Focus::Pane;
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
||||||
|
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
||||||
|
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
||||||
|
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
||||||
|
pub fn open_draft_pane(&mut self) {
|
||||||
|
if self.active_tab().pane.is_some() {
|
||||||
|
self.new_tab();
|
||||||
|
}
|
||||||
|
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||||
|
self.focus = Focus::Pane;
|
||||||
|
self.mode = Mode::Insert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A draft submit succeeded: replace whatever the active tab showed with
|
||||||
|
/// the new session in place, matching the web client's behaviour (#57).
|
||||||
|
pub fn complete_spawn(&mut self, sid: Uuid) {
|
||||||
|
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
self.focus = Focus::Pane;
|
||||||
|
self.mode = Mode::Insert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session shown by the active tab's pane, if any.
|
||||||
|
pub fn active_session(&self) -> Option<Uuid> {
|
||||||
|
self.active_tab().session()
|
||||||
|
}
|
||||||
|
|
||||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||||
@@ -288,7 +334,7 @@ mod tests {
|
|||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
let sid = Uuid::from_u128(7);
|
let sid = Uuid::from_u128(7);
|
||||||
state.attach_to_focused(sid);
|
state.attach_to_focused(sid);
|
||||||
assert_eq!(state.tabs[0].pane, Some(sid));
|
assert_eq!(state.tabs[0].session(), Some(sid));
|
||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
// The user expects to type into the session right away; the leader
|
// The user expects to type into the session right away; the leader
|
||||||
// (default ctrl-a) is the way back.
|
// (default ctrl-a) is the way back.
|
||||||
@@ -314,7 +360,7 @@ mod tests {
|
|||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
let sid = Uuid::from_u128(3);
|
let sid = Uuid::from_u128(3);
|
||||||
state.new_tab();
|
state.new_tab();
|
||||||
state.active_tab_mut().pane = Some(sid);
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
|
||||||
assert_eq!(state.close_tab(), Some(sid));
|
assert_eq!(state.close_tab(), Some(sid));
|
||||||
}
|
}
|
||||||
@@ -324,9 +370,9 @@ mod tests {
|
|||||||
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
let sid = Uuid::from_u128(4);
|
let sid = Uuid::from_u128(4);
|
||||||
state.active_tab_mut().pane = Some(sid);
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
state.new_tab();
|
state.new_tab();
|
||||||
state.active_tab_mut().pane = Some(sid);
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
|
||||||
assert_eq!(state.close_tab(), None);
|
assert_eq!(state.close_tab(), None);
|
||||||
}
|
}
|
||||||
@@ -334,7 +380,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn close_tab_is_a_no_op_on_the_last_tab() {
|
fn close_tab_is_a_no_op_on_the_last_tab() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
|
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
||||||
assert_eq!(state.close_tab(), None);
|
assert_eq!(state.close_tab(), None);
|
||||||
assert_eq!(state.tabs.len(), 1);
|
assert_eq!(state.tabs.len(), 1);
|
||||||
}
|
}
|
||||||
@@ -347,4 +393,52 @@ mod tests {
|
|||||||
assert!(state.dismiss_overlay());
|
assert!(state.dismiss_overlay());
|
||||||
assert!(state.overlay().is_none());
|
assert!(state.overlay().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #63: `s` over an empty pane turns that pane into a draft —
|
||||||
|
/// focused, Insert mode on, typing starts immediately.
|
||||||
|
#[test]
|
||||||
|
fn open_draft_reuses_the_empty_active_pane() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||||
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
|
assert_eq!(state.tabs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab already showing a session gets a fresh tab for the draft; v1
|
||||||
|
/// has no split tree, so a new tab is the "split".
|
||||||
|
#[test]
|
||||||
|
fn open_draft_over_a_session_opens_a_new_tab() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.attach_to_focused(Uuid::from_u128(9));
|
||||||
|
state.open_draft_pane();
|
||||||
|
assert_eq!(state.tabs.len(), 2);
|
||||||
|
assert_eq!(state.active_tab_idx, 1);
|
||||||
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||||
|
// The session tab is untouched.
|
||||||
|
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web parity (#57): submitting fills the pane in place.
|
||||||
|
#[test]
|
||||||
|
fn complete_spawn_replaces_the_draft_with_the_session() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
let sid = Uuid::from_u128(21);
|
||||||
|
state.complete_spawn(sid);
|
||||||
|
assert_eq!(state.active_tab().session(), Some(sid));
|
||||||
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A closed draft tab holds no session, so nothing is released.
|
||||||
|
#[test]
|
||||||
|
fn close_tab_reports_nothing_for_a_draft_pane() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
state.new_tab();
|
||||||
|
state.active_tab_idx = 0;
|
||||||
|
assert_eq!(state.close_tab(), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use ratatui::style::Style;
|
|||||||
|
|
||||||
use crate::input::InputKey;
|
use crate::input::InputKey;
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct TextInput {
|
pub struct TextInput {
|
||||||
value: String,
|
value: String,
|
||||||
/// Cursor position as a char offset into `value`.
|
/// Cursor position as a char offset into `value`.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@xterm/xterm": "^5.5.0"
|
"@xterm/xterm": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"happy-dom": "^20.11.6",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|||||||
Generated
+93
-11
@@ -15,15 +15,18 @@ importers:
|
|||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
happy-dom:
|
||||||
|
specifier: ^20.11.6
|
||||||
|
version: 20.11.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.6.3
|
specifier: ^5.6.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.10
|
specifier: ^5.4.10
|
||||||
version: 5.4.21
|
version: 5.4.21(@types/node@26.2.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.1.9
|
specifier: ^2.1.9
|
||||||
version: 2.1.9
|
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -296,6 +299,15 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/node@26.2.0':
|
||||||
|
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||||
|
|
||||||
|
'@types/whatwg-mimetype@3.0.2':
|
||||||
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||||
|
|
||||||
@@ -337,6 +349,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
buffer-image-size@0.6.4:
|
||||||
|
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
cac@6.7.14:
|
cac@6.7.14:
|
||||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -362,6 +378,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
es-module-lexer@1.7.0:
|
es-module-lexer@1.7.0:
|
||||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
||||||
@@ -382,6 +402,10 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
happy-dom@20.11.6:
|
||||||
|
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -451,6 +475,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@8.3.0:
|
||||||
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -512,11 +539,27 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
whatwg-mimetype@3.0.0:
|
||||||
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ws@8.21.3:
|
||||||
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
@@ -667,6 +710,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/node@26.2.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 8.3.0
|
||||||
|
|
||||||
|
'@types/whatwg-mimetype@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
@@ -674,13 +727,13 @@ snapshots:
|
|||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
'@vitest/mocker@2.1.9(vite@5.4.21)':
|
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.2.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@2.1.9':
|
'@vitest/pretty-format@2.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -715,6 +768,10 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
buffer-image-size@0.6.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
@@ -733,6 +790,8 @@ snapshots:
|
|||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
es-module-lexer@1.7.0: {}
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
@@ -770,6 +829,19 @@ snapshots:
|
|||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
happy-dom@20.11.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
'@types/whatwg-mimetype': 3.0.2
|
||||||
|
'@types/ws': 8.18.1
|
||||||
|
buffer-image-size: 0.6.4
|
||||||
|
entities: 7.0.1
|
||||||
|
whatwg-mimetype: 3.0.0
|
||||||
|
ws: 8.21.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
@@ -843,13 +915,15 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
vite-node@2.1.9(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.2.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- less
|
- less
|
||||||
@@ -861,18 +935,19 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
vite@5.4.21:
|
vite@5.4.21(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.15
|
postcss: 8.5.15
|
||||||
rollup: 4.60.4
|
rollup: 4.60.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
vitest@2.1.9:
|
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.9
|
'@vitest/expect': 2.1.9
|
||||||
'@vitest/mocker': 2.1.9(vite@5.4.21)
|
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
|
||||||
'@vitest/pretty-format': 2.1.9
|
'@vitest/pretty-format': 2.1.9
|
||||||
'@vitest/runner': 2.1.9
|
'@vitest/runner': 2.1.9
|
||||||
'@vitest/snapshot': 2.1.9
|
'@vitest/snapshot': 2.1.9
|
||||||
@@ -888,9 +963,12 @@ snapshots:
|
|||||||
tinyexec: 0.3.2
|
tinyexec: 0.3.2
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.2.0)
|
||||||
vite-node: 2.1.9
|
vite-node: 2.1.9(@types/node@26.2.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
happy-dom: 20.11.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- less
|
- less
|
||||||
- lightningcss
|
- lightningcss
|
||||||
@@ -902,7 +980,11 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
whatwg-mimetype@3.0.0: {}
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
ws@8.21.3: {}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
|
||||||
|
|
||||||
|
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
|
||||||
|
import { listDir } from "./api.js";
|
||||||
|
|
||||||
|
const listDirMock = vi.mocked(listDir);
|
||||||
|
|
||||||
|
let input: HTMLInputElement;
|
||||||
|
let nextField: HTMLInputElement;
|
||||||
|
let docKeydown: ReturnType<typeof vi.fn>;
|
||||||
|
let ac: DirAutocompleteHandle | undefined;
|
||||||
|
|
||||||
|
function popup(): HTMLUListElement {
|
||||||
|
return document.querySelector(".dir-ac") as HTMLUListElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(): HTMLElement[] {
|
||||||
|
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function type(value: string): Promise<void> {
|
||||||
|
input.focus();
|
||||||
|
input.value = value;
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
|
||||||
|
* default browser behaviour (focus move, form submit) was suppressed. */
|
||||||
|
function press(key: string): { defaultPrevented: boolean } {
|
||||||
|
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||||
|
input.dispatchEvent(evt);
|
||||||
|
return { defaultPrevented: evt.defaultPrevented };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
input = document.createElement("input");
|
||||||
|
nextField = document.createElement("input");
|
||||||
|
document.body.append(input, nextField);
|
||||||
|
docKeydown = vi.fn();
|
||||||
|
document.addEventListener("keydown", docKeydown);
|
||||||
|
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ac?.destroy();
|
||||||
|
document.removeEventListener("keydown", docKeydown);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirAutocomplete dismissal", () => {
|
||||||
|
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro");
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
|
||||||
|
const { defaultPrevented } = press("Escape");
|
||||||
|
|
||||||
|
expect(defaultPrevented).toBe(true);
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
expect(input.value).toBe("/tmp/pro");
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blur dismisses the popup", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro");
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
|
||||||
|
input.blur();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reopen on refocus until the operator types again", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro");
|
||||||
|
input.blur();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
|
||||||
|
await type("/tmp/ot");
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirAutocomplete keyboard model", () => {
|
||||||
|
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
|
||||||
|
|
||||||
|
nextField.focus();
|
||||||
|
const { defaultPrevented } = press("Tab");
|
||||||
|
|
||||||
|
expect(input.value).toBe("/tmp/proj-a/");
|
||||||
|
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
|
||||||
|
expect(document.activeElement).toBe(nextField); // focus was not yanked back
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Tab with nothing highlighted just moves on", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/nope-matches-nothing");
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
|
||||||
|
const { defaultPrevented } = press("Tab");
|
||||||
|
|
||||||
|
expect(defaultPrevented).toBe(false);
|
||||||
|
expect(input.value).toBe("/tmp/nope-matches-nothing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arrows move the highlight and Enter commits it without submitting", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/");
|
||||||
|
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(items()[1].classList.contains("is-active")).toBe(true);
|
||||||
|
|
||||||
|
const enter = press("Enter");
|
||||||
|
expect(enter.defaultPrevented).toBe(true); // no form submit
|
||||||
|
expect(input.value).toBe("/tmp/proj-b/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirAutocomplete opening", () => {
|
||||||
|
it("still opens on focus when not previously dismissed", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
input.focus();
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
expect(listDirMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+36
-27
@@ -4,15 +4,19 @@
|
|||||||
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
||||||
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
||||||
//
|
//
|
||||||
// Tab complete the longest common prefix; if one match, accept it (and
|
// Tab commit the highlighted entry and let focus move on; with nothing
|
||||||
// append `/` so the next level loads); if nothing to extend, cycle.
|
// highlighted, plain tab navigation
|
||||||
// ↑/↓ move the highlight
|
// ↑/↓ move the highlight
|
||||||
// Enter accept the highlighted entry (and suppress form submit)
|
// Enter accept the highlighted entry (and suppress form submit)
|
||||||
// Esc close the dropdown
|
// Esc close the dropdown, refocus the input, and swallow the key so it
|
||||||
|
// can't also discard a surrounding draft pane
|
||||||
|
//
|
||||||
|
// Once dismissed (Esc or blur) the popup stays closed until the operator types
|
||||||
|
// again — refocusing alone doesn't reopen it.
|
||||||
//
|
//
|
||||||
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
||||||
|
|
||||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
import { splitPath } from "./paths.js";
|
||||||
import { listDir } from "./api.js";
|
import { listDir } from "./api.js";
|
||||||
|
|
||||||
export interface DirAutocompleteHandle {
|
export interface DirAutocompleteHandle {
|
||||||
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
let fetchSeq = 0;
|
let fetchSeq = 0;
|
||||||
let debounce: number | undefined;
|
let debounce: number | undefined;
|
||||||
let open = false;
|
let open = false;
|
||||||
|
// Set when the operator dismissed the popup (Esc, blur, outside click); a
|
||||||
|
// refocus must not reopen it — only new typing does.
|
||||||
|
let dismissed = false;
|
||||||
|
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
window.removeEventListener("resize", reposition);
|
window.removeEventListener("resize", reposition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
dismissed = true;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
function reposition(): void {
|
function reposition(): void {
|
||||||
const r = input.getBoundingClientRect();
|
const r = input.getBoundingClientRect();
|
||||||
dropdown.style.left = `${r.left}px`;
|
dropdown.style.left = `${r.left}px`;
|
||||||
@@ -132,21 +144,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accept `name` as the next path segment: complete it and append `/` so the
|
// Accept `name` as the next path segment: complete it and append `/` so the
|
||||||
// child directory's listing loads immediately (the drill-in gesture).
|
// child directory's listing loads on the next interaction (the drill-in
|
||||||
function accept(name: string): void {
|
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
|
||||||
|
// and move to the next field in one gesture.
|
||||||
|
function commit(name: string): void {
|
||||||
const { dir } = splitPath(input.value);
|
const { dir } = splitPath(input.value);
|
||||||
input.value = `${dir}${name}/`;
|
input.value = `${dir}${name}/`;
|
||||||
input.focus();
|
// Notify other listeners (spawnForm marks the field user-edited).
|
||||||
// Notify other listeners (spawn.ts marks the field user-edited) and load
|
|
||||||
// the child level.
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function accept(name: string): void {
|
||||||
|
commit(name);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function onInput(): void {
|
function onInput(): void {
|
||||||
|
dismissed = false;
|
||||||
scheduleRefresh();
|
scheduleRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFocus(): void {
|
function onFocus(): void {
|
||||||
|
if (dismissed) return;
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,27 +173,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
if (open) {
|
if (open) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
close();
|
// Swallow the key: an Escape that bubbles would also discard the
|
||||||
|
// draft pane hosting this form.
|
||||||
|
e.stopPropagation();
|
||||||
|
dismiss();
|
||||||
|
input.focus();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
if (!open || filtered.length === 0) return; // let focus move normally
|
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
|
||||||
e.preventDefault();
|
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
|
||||||
if (filtered.length === 1) {
|
|
||||||
accept(filtered[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { dir, prefix } = splitPath(input.value);
|
|
||||||
const lcp = longestCommonPrefix(filtered);
|
|
||||||
if (lcp.length > prefix.length) {
|
|
||||||
input.value = `${dir}${lcp}`;
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
} else {
|
|
||||||
// Nothing to extend — cycle the highlight like a second shell <tab>.
|
|
||||||
highlight = (highlight + 1) % filtered.length;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -196,7 +205,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
|
|
||||||
function onBlur(): void {
|
function onBlur(): void {
|
||||||
// Delay so a dropdown mousedown can complete first.
|
// Delay so a dropdown mousedown can complete first.
|
||||||
window.setTimeout(close, 120);
|
window.setTimeout(dismiss, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener("input", onInput);
|
input.addEventListener("input", onInput);
|
||||||
|
|||||||
Reference in New Issue
Block a user