Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c72577b9 | ||
|
|
aed9a5eec0 | ||
|
|
2ad2858fd5 | ||
|
|
fa2a26faa9 | ||
|
|
b5feb3c39a | ||
|
|
e12baa23c6 | ||
|
|
63191c7be1 | ||
|
|
acb84b91ef |
@@ -608,7 +608,19 @@ impl App {
|
|||||||
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
||||||
/// rule as web/src/spawnSimilar.ts.
|
/// rule as web/src/spawnSimilar.ts.
|
||||||
fn open_spawn_modal(&mut self) {
|
fn open_spawn_modal(&mut self) {
|
||||||
let seed_cwd = self.state.active_session().and_then(|sid| {
|
let mut draft = DraftPane::default();
|
||||||
|
draft.seed_cwd = self.focused_seed_cwd();
|
||||||
|
self.draft = Some(draft);
|
||||||
|
self.state.open_spawn_modal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The project of the session the operator was last on — its base
|
||||||
|
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||||
|
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
||||||
|
/// both should land on "where I currently am", not just "whatever's
|
||||||
|
/// freshest".
|
||||||
|
fn focused_seed_cwd(&self) -> Option<PathBuf> {
|
||||||
|
self.state.active_session().and_then(|sid| {
|
||||||
self.catalog
|
self.catalog
|
||||||
.snapshot()
|
.snapshot()
|
||||||
.sessions
|
.sessions
|
||||||
@@ -618,11 +630,7 @@ impl App {
|
|||||||
Some(wt) => wt.base_repo.clone(),
|
Some(wt) => wt.base_repo.clone(),
|
||||||
None => s.cwd.clone(),
|
None => s.cwd.clone(),
|
||||||
})
|
})
|
||||||
});
|
})
|
||||||
let mut draft = DraftPane::default();
|
|
||||||
draft.seed_cwd = seed_cwd;
|
|
||||||
self.draft = Some(draft);
|
|
||||||
self.state.open_spawn_modal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close the form and drop its half-filled state — reopening starts clean,
|
/// Close the form and drop its half-filled state — reopening starts clean,
|
||||||
@@ -767,9 +775,11 @@ impl App {
|
|||||||
info,
|
info,
|
||||||
projects,
|
projects,
|
||||||
names,
|
names,
|
||||||
|
focused_cwd,
|
||||||
} = update
|
} = update
|
||||||
{
|
{
|
||||||
self.fast_forward_spawn(info, projects, names).await;
|
self.fast_forward_spawn(info, projects, names, focused_cwd)
|
||||||
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let want = match &update {
|
let want = match &update {
|
||||||
@@ -836,6 +846,7 @@ impl App {
|
|||||||
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
||||||
/// freeze keys, frames and attached panes until it returns.
|
/// freeze keys, frames and attached panes until it returns.
|
||||||
fn spawn_fast_forward(&self) {
|
fn spawn_fast_forward(&self) {
|
||||||
|
let focused_cwd = self.focused_seed_cwd();
|
||||||
let (daemon, http, base, tx) = self.loader_handles();
|
let (daemon, http, base, tx) = self.loader_handles();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||||
@@ -844,6 +855,7 @@ impl App {
|
|||||||
info: info.ok(),
|
info: info.ok(),
|
||||||
projects: projects.ok(),
|
projects: projects.ok(),
|
||||||
names,
|
names,
|
||||||
|
focused_cwd,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -853,6 +865,7 @@ impl App {
|
|||||||
info: Option<maestro_protocol::DaemonInfo>,
|
info: Option<maestro_protocol::DaemonInfo>,
|
||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
names: Vec<String>,
|
||||||
|
focused_cwd: Option<PathBuf>,
|
||||||
) {
|
) {
|
||||||
// Keep the home dir: without it the pre-filled draft renders
|
// Keep the home dir: without it the pre-filled draft renders
|
||||||
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
||||||
@@ -866,8 +879,13 @@ impl App {
|
|||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
let discovered = projects.unwrap_or_default();
|
let discovered = projects.unwrap_or_default();
|
||||||
let resolved =
|
let resolved = crate::fast_forward::resolve(
|
||||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
&discovered,
|
||||||
|
&presets,
|
||||||
|
names.first().cloned(),
|
||||||
|
Utc::now(),
|
||||||
|
focused_cwd.as_deref(),
|
||||||
|
);
|
||||||
if let Some(req) = resolved.build_request() {
|
if let Some(req) = resolved.build_request() {
|
||||||
self.submit_spawn(req).await;
|
self.submit_spawn(req).await;
|
||||||
return;
|
return;
|
||||||
@@ -1166,6 +1184,7 @@ enum DraftUpdate {
|
|||||||
info: Option<maestro_protocol::DaemonInfo>,
|
info: Option<maestro_protocol::DaemonInfo>,
|
||||||
projects: Option<Vec<maestro_protocol::Project>>,
|
projects: Option<Vec<maestro_protocol::Project>>,
|
||||||
names: Vec<String>,
|
names: Vec<String>,
|
||||||
|
focused_cwd: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("c", "ToggleSidebarGroup"),
|
("c", "ToggleSidebarGroup"),
|
||||||
// Catalog actions
|
// Catalog actions
|
||||||
("enter", "AttachHighlighted"),
|
("enter", "AttachHighlighted"),
|
||||||
|
// Tab focuses the highlighted pane, mirroring the web keybind — bare
|
||||||
|
// because Normal mode only exists while the sidebar is focused, so
|
||||||
|
// nothing forwards it to a pty.
|
||||||
|
("tab", "AttachHighlighted"),
|
||||||
("s", "OpenSpawnModal"),
|
("s", "OpenSpawnModal"),
|
||||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
@@ -63,14 +67,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("ctrl-w b", "ToggleSidebar"),
|
("ctrl-w b", "ToggleSidebar"),
|
||||||
("?", "ShowHelp"),
|
("?", "ShowHelp"),
|
||||||
("ctrl-w q", "Quit"),
|
("ctrl-w q", "Quit"),
|
||||||
|
// Bare quit, AoE-style: only reachable from the sidebar (Normal mode
|
||||||
|
// never forwards keys to a pty), same reasoning as bare `tab` above.
|
||||||
|
("q", "Quit"),
|
||||||
// Mouse
|
// Mouse
|
||||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
|
||||||
/// is the leader, which is inserted dynamically by [`TuiConfig::load`] so
|
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
|
||||||
/// rebinding the leader rebinds its Insert escape automatically.
|
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
|
||||||
const DEFAULT_INSERT: &[(&str, &str)] = &[];
|
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
|
||||||
|
|
||||||
impl TuiConfig {
|
impl TuiConfig {
|
||||||
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
|
||||||
@@ -222,6 +229,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
||||||
|
/// mode only exists while the sidebar is focused, so neither can leak
|
||||||
|
/// into a pty; `esc` is a second way out of Insert alongside the leader.
|
||||||
|
#[test]
|
||||||
|
fn aoe_style_defaults_are_bound() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(cfg.keymap.resolve(Mode::Normal, "q"), Some(Action::Quit));
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, "tab"),
|
||||||
|
Some(Action::AttachHighlighted),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Insert, "esc"),
|
||||||
|
Some(Action::LeaveInsert),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_leader_is_ctrl_a_and_escapes_insert() {
|
fn default_leader_is_ctrl_a_and_escapes_insert() {
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
|||||||
@@ -26,16 +26,25 @@ pub struct FastForwardDefaults {
|
|||||||
|
|
||||||
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
||||||
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
||||||
/// caller falls back to the built-ins it already has).
|
/// caller falls back to the built-ins it already has). `focused_cwd` is the
|
||||||
|
/// project of the session the operator was last on ([`crate::app`]'s
|
||||||
|
/// `focused_seed_cwd`) — when it matches a known project, that project wins
|
||||||
|
/// over the global recency ranking, so `N` lands where the operator
|
||||||
|
/// currently is rather than wherever was freshest.
|
||||||
pub fn resolve(
|
pub fn resolve(
|
||||||
projects: &[Project],
|
projects: &[Project],
|
||||||
presets: &[SpawnPreset],
|
presets: &[SpawnPreset],
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
now: DateTime<Utc>,
|
now: DateTime<Utc>,
|
||||||
|
focused_cwd: Option<&std::path::Path>,
|
||||||
) -> FastForwardDefaults {
|
) -> FastForwardDefaults {
|
||||||
let project = crate::projects::sort_projects(projects.to_vec(), now)
|
let project = focused_cwd
|
||||||
.into_iter()
|
.and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned())
|
||||||
.next();
|
.or_else(|| {
|
||||||
|
crate::projects::sort_projects(projects.to_vec(), now)
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
});
|
||||||
let command = project
|
let command = project
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.last_command.clone())
|
.and_then(|p| p.last_command.clone())
|
||||||
@@ -155,7 +164,13 @@ mod tests {
|
|||||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||||
];
|
];
|
||||||
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
||||||
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&presets,
|
||||||
|
Some("amber-falcon".into()),
|
||||||
|
Utc::now(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.project.as_ref().unwrap().path,
|
r.project.as_ref().unwrap().path,
|
||||||
PathBuf::from("/repos/fresh")
|
PathBuf::from("/repos/fresh")
|
||||||
@@ -176,6 +191,46 @@ mod tests {
|
|||||||
assert!(wt.create_branch, "fresh branch named after the session");
|
assert!(wt.create_branch, "fresh branch named after the session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_focused_sessions_project_wins_over_the_freshest_one() {
|
||||||
|
let projects = vec![
|
||||||
|
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||||
|
project("/repos/current", 5, Some("zsh")),
|
||||||
|
];
|
||||||
|
let presets = vec![preset("claude", "claude")];
|
||||||
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&presets,
|
||||||
|
Some("n".into()),
|
||||||
|
Utc::now(),
|
||||||
|
Some(std::path::Path::new("/repos/current")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.project.as_ref().unwrap().path,
|
||||||
|
PathBuf::from("/repos/current")
|
||||||
|
);
|
||||||
|
assert_eq!(r.command.as_deref(), Some("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_focused_cwd_falls_back_to_the_freshest_project() {
|
||||||
|
let projects = vec![
|
||||||
|
project("/repos/old", 30, Some("zsh")),
|
||||||
|
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||||
|
];
|
||||||
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
Some("n".into()),
|
||||||
|
Utc::now(),
|
||||||
|
Some(std::path::Path::new("/repos/gone")),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.project.as_ref().unwrap().path,
|
||||||
|
PathBuf::from("/repos/fresh")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
||||||
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
||||||
@@ -184,6 +239,7 @@ mod tests {
|
|||||||
&[preset("zsh", "zsh")],
|
&[preset("zsh", "zsh")],
|
||||||
Some("s".into()),
|
Some("s".into()),
|
||||||
Utc::now(),
|
Utc::now(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let wt = r.build_request().unwrap().worktree.unwrap();
|
let wt = r.build_request().unwrap().worktree.unwrap();
|
||||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
||||||
@@ -193,7 +249,7 @@ mod tests {
|
|||||||
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
||||||
let projects = vec![project("/repos/p", 0, None)];
|
let projects = vec![project("/repos/p", 0, None)];
|
||||||
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
||||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
|
||||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +257,7 @@ mod tests {
|
|||||||
fn an_empty_last_command_is_no_command_at_all() {
|
fn an_empty_last_command_is_no_command_at_all() {
|
||||||
let projects = vec![project("/repos/p", 0, Some(""))];
|
let projects = vec![project("/repos/p", 0, Some(""))];
|
||||||
let presets = vec![preset("custom", "")];
|
let presets = vec![preset("custom", "")];
|
||||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
|
||||||
assert_eq!(r.command, None);
|
assert_eq!(r.command, None);
|
||||||
assert!(r.build_request().is_none());
|
assert!(r.build_request().is_none());
|
||||||
}
|
}
|
||||||
@@ -213,6 +269,7 @@ mod tests {
|
|||||||
&[preset("claude", "claude")],
|
&[preset("claude", "claude")],
|
||||||
Some("n".into()),
|
Some("n".into()),
|
||||||
Utc::now(),
|
Utc::now(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(r.project, None);
|
assert_eq!(r.project, None);
|
||||||
// No project → no directory → no request, even with command + name.
|
// No project → no directory → no request, even with command + name.
|
||||||
@@ -222,14 +279,26 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_missing_generated_name_downgrades_to_a_draft() {
|
fn a_missing_generated_name_downgrades_to_a_draft() {
|
||||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
None,
|
||||||
|
Utc::now(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
||||||
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
||||||
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&[preset("aider", "aider")],
|
||||||
|
None,
|
||||||
|
Utc::now(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
r.apply_to_draft(&mut draft);
|
r.apply_to_draft(&mut draft);
|
||||||
@@ -251,6 +320,7 @@ mod tests {
|
|||||||
&[preset("claude", "claude")],
|
&[preset("claude", "claude")],
|
||||||
Some("amber-falcon".into()),
|
Some("amber-falcon".into()),
|
||||||
Utc::now(),
|
Utc::now(),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
named.apply_to_draft(&mut draft);
|
named.apply_to_draft(&mut draft);
|
||||||
@@ -263,7 +333,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
||||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
None,
|
||||||
|
Utc::now(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
r.apply_to_draft(&mut draft);
|
r.apply_to_draft(&mut draft);
|
||||||
draft.seed_names(vec!["late-name".into()]);
|
draft.seed_names(vec!["late-name".into()]);
|
||||||
@@ -273,7 +349,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
||||||
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
||||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
let r = resolve(
|
||||||
|
&projects,
|
||||||
|
&[preset("claude", "claude")],
|
||||||
|
None,
|
||||||
|
Utc::now(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
let mut draft = DraftPane::default();
|
let mut draft = DraftPane::default();
|
||||||
r.apply_to_draft(&mut draft);
|
r.apply_to_draft(&mut draft);
|
||||||
assert_eq!(draft.command(), "nvim -d");
|
assert_eq!(draft.command(), "nvim -d");
|
||||||
|
|||||||
+9
-7
@@ -51,13 +51,15 @@ command. `<duration>` follows in the rail's colour when shown. There is no
|
|||||||
state word and no second badge: the rail exists so the state is still legible
|
state word and no second badge: the rail exists so the state is still legible
|
||||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||||
|
|
||||||
In the **web client** the rail is a 5px bar positioned inside the row's own
|
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||||
indent gutter — it shifts outward with nesting depth exactly as the gutter
|
edge, square corners, spanning the full card height edge to edge — it
|
||||||
does — spanning the full card height. It carries motion, reserved for states
|
shifts outward with nesting depth along with the row's own padding, since
|
||||||
that need attention (DESIGN.md's "living signals" rule): a slow breathing
|
it is positioned relative to the row. It carries motion only for states that
|
||||||
opacity while `Active`, a hard blink while `AwaitingInput`, a fast flash while
|
need attention (DESIGN.md's "living signals" rule): a hard blink while
|
||||||
`Error`. `Idle` is solid and still, on purpose. `prefers-reduced-motion`
|
`AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both
|
||||||
leaves every state as a solid bar.
|
solid and still — colour alone (green vs. the idle grey) is the signal for
|
||||||
|
`Active`, so it does not also pulse. `prefers-reduced-motion` leaves every
|
||||||
|
state as a solid bar.
|
||||||
|
|
||||||
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
|
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
|
||||||
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
|
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
import { attachWsUrl } from "./routes.js";
|
import { attachWsUrl } from "./routes.js";
|
||||||
|
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
|
||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||||
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
|
||||||
@@ -134,6 +135,15 @@ export function createAttachConnection(
|
|||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
term.open(body);
|
term.open(body);
|
||||||
|
|
||||||
|
// Plain Escape hands the keyboard back to the session list instead of the
|
||||||
|
// shell/app. `false` tells xterm to skip its own handling of this event.
|
||||||
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
|
if (e.type !== "keydown" || e.key !== "Escape") return true;
|
||||||
|
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true;
|
||||||
|
document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
|
||||||
// line at a time and stall), so map finger drags ourselves. Two routes:
|
// line at a time and stall), so map finger drags ourselves. Two routes:
|
||||||
// • normal buffer → term.scrollLines walks the scrollback.
|
// • normal buffer → term.scrollLines walks the scrollback.
|
||||||
|
|||||||
+23
-10
@@ -36,14 +36,26 @@ export function requestOpenSpawn(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||||
* what Escape in the sidebar list does. Handled in main.ts, which owns the
|
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||||
* connection pool. */
|
* connection pool. */
|
||||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||||
|
|
||||||
|
/** Ask the shell to hand the keyboard back to the session list — what Escape
|
||||||
|
* does when the pane's terminal is focused. Handled in main.ts. */
|
||||||
|
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
|
||||||
|
|
||||||
/** Ask the shell to open the spawn modal seeded from an existing session
|
/** Ask the shell to open the spawn modal seeded from an existing session
|
||||||
* ("Spawn similar"). Detail is the session being cloned. */
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
|
|
||||||
|
/** A session row was clicked (issue #133). The row's `href` drives normal
|
||||||
|
* hash navigation, but a split/drag can move focus without ever touching
|
||||||
|
* `location.hash` — so a click back onto a session whose hash still
|
||||||
|
* matches fires no `hashchange` and the app never hears about it. This
|
||||||
|
* event is the click-driven path that doesn't depend on the hash actually
|
||||||
|
* changing. */
|
||||||
|
export const SESSION_SELECT_EVENT = "catalog-session-select";
|
||||||
|
|
||||||
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
||||||
* folder headers are skipped: a directory is not something to focus, and the
|
* folder headers are skipped: a directory is not something to focus, and the
|
||||||
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
||||||
@@ -68,13 +80,11 @@ const STATE_WORDS: Record<string, string> = {
|
|||||||
error: "Error",
|
error: "Error",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the activity rail sits inside the row's own indent
|
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||||
* gutter, so it shifts outward with nesting depth exactly as that gutter
|
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||||
* does. `depth * 16 + 8` is the same expression `renderNode` uses for the
|
* nesting depth, carrying the rail with it. */
|
||||||
* row's own padding — the rail sits just inside it. */
|
function activityRailMarkup(slug: string, stateWord: string): string {
|
||||||
function activityRailMarkup(slug: string, stateWord: string, depth: number): string {
|
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
||||||
const left = depth * 16 + 2;
|
|
||||||
return `<span class="activity-rail" data-activity="${slug}" style="left:${left}px" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaLine(s: Session, now: number): string {
|
function metaLine(s: Session, now: number): string {
|
||||||
@@ -172,7 +182,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
);
|
);
|
||||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
${activityRailMarkup(slug, stateWord, depth)}
|
${activityRailMarkup(slug, stateWord)}
|
||||||
<span class="card-line1">
|
<span class="card-line1">
|
||||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||||
<span class="leaf-actions">
|
<span class="leaf-actions">
|
||||||
@@ -194,6 +204,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void requestKill(s, name);
|
void requestKill(s, name);
|
||||||
});
|
});
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
||||||
|
});
|
||||||
row.addEventListener("contextmenu", (ev) => {
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@@ -559,7 +572,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
||||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||||
else if (e.key === "Enter") activateNav();
|
else if (e.key === "Enter") activateNav();
|
||||||
else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||||
else return;
|
else return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,30 @@ describe("resolveFastForward (#67)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers the focused session's project over the freshest one", () => {
|
||||||
|
const projects = [
|
||||||
|
project({ path: "/repos/fresh", name: "fresh", last_command: "claude" }),
|
||||||
|
project({
|
||||||
|
path: "/repos/current",
|
||||||
|
name: "current",
|
||||||
|
last_activity: "2020-01-01T00:00:00Z",
|
||||||
|
last_command: "zsh",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const res = resolveFastForward(projects, presets, "n", "/repos/current");
|
||||||
|
expect(res.project?.path).toBe("/repos/current");
|
||||||
|
expect(res.command).toBe("zsh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the freshest project when the focused cwd is unknown", () => {
|
||||||
|
const projects = [
|
||||||
|
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
|
||||||
|
project({ path: "/repos/fresh", name: "fresh" }),
|
||||||
|
];
|
||||||
|
const res = resolveFastForward(projects, presets, "n", "/repos/gone");
|
||||||
|
expect(res.project?.path).toBe("/repos/fresh");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the first non-custom preset when there is no last command", () => {
|
it("falls back to the first non-custom preset when there is no last command", () => {
|
||||||
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
||||||
expect(res.command).toBe("claude");
|
expect(res.command).toBe("claude");
|
||||||
|
|||||||
@@ -13,15 +13,20 @@ export interface FastForwardResolution {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The defaults: most recent project (fresh-first, same ordering the picker
|
/** The defaults: the currently focused session's project when it's still a
|
||||||
* shows), its last command falling back to the first non-custom preset, and
|
* known project, else the most recent one (fresh-first, same ordering the
|
||||||
* a freshly generated name when the daemon supplied one. */
|
* picker shows) — so `N` lands where the operator currently is, matching
|
||||||
|
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||||
|
* falls back to the first non-custom preset, and the name is freshly
|
||||||
|
* generated when the daemon supplied one. */
|
||||||
export function resolveFastForward(
|
export function resolveFastForward(
|
||||||
projects: Project[],
|
projects: Project[],
|
||||||
presets: SpawnPreset[],
|
presets: SpawnPreset[],
|
||||||
name?: string,
|
name?: string,
|
||||||
|
focusedCwd?: string,
|
||||||
): FastForwardResolution {
|
): FastForwardResolution {
|
||||||
const project = sortProjects(projects, new Date())[0];
|
const project =
|
||||||
|
projects.find((p) => p.path === focusedCwd) ?? sortProjects(projects, new Date())[0];
|
||||||
const lastCommand = project?.last_command?.trim();
|
const lastCommand = project?.last_command?.trim();
|
||||||
const command =
|
const command =
|
||||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ export const SHORTCUTS: ShortcutGroup[] = [
|
|||||||
{ keys: ["j"], description: "Highlight the next session" },
|
{ keys: ["j"], description: "Highlight the next session" },
|
||||||
{ keys: ["k"], description: "Highlight the previous session" },
|
{ keys: ["k"], description: "Highlight the previous session" },
|
||||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||||
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
|
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
||||||
|
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+46
-8
@@ -29,7 +29,13 @@ import { mountHud, type Hud } from "./hud.js";
|
|||||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import { loadLayout, saveLayout } from "./persistence.js";
|
import { loadLayout, saveLayout } from "./persistence.js";
|
||||||
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
import {
|
||||||
|
FOCUS_PANE_EVENT,
|
||||||
|
FOCUS_SIDEBAR_EVENT,
|
||||||
|
OPEN_SPAWN_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "./catalog.js";
|
||||||
import { initTheme } from "./theme.js";
|
import { initTheme } from "./theme.js";
|
||||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||||
import { installViewportSizing } from "./viewport.js";
|
import { installViewportSizing } from "./viewport.js";
|
||||||
@@ -257,13 +263,28 @@ function requestOpenSpawn(): void {
|
|||||||
// beside it is the point.
|
// beside it is the point.
|
||||||
// The session you were last on preselects its project: `n` then Enter spawns
|
// The session you were last on preselects its project: `n` then Enter spawns
|
||||||
// another session there without touching the picker.
|
// another session there without touching the picker.
|
||||||
const focused = mv?.focusedSessionId() ?? null;
|
const current = focusedSession();
|
||||||
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
|
|
||||||
openSpawnModal({
|
openSpawnModal({
|
||||||
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
||||||
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The session the operator was last on, if any. Shared by the plain spawn
|
||||||
|
* modal seed and fast-forward's project pick: both should land on "where I
|
||||||
|
* currently am". */
|
||||||
|
function focusedSession(): Session | undefined {
|
||||||
|
const focused = mv?.focusedSessionId() ?? null;
|
||||||
|
return focused ? store.snapshot().sessions.get(focused) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The focused session's project — its base repo for a worktree session, its
|
||||||
|
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
|
||||||
|
function focusedProjectCwd(): string | undefined {
|
||||||
|
const s = focusedSession();
|
||||||
|
if (!s) return undefined;
|
||||||
|
return s.worktree ? s.worktree.base_repo : s.cwd;
|
||||||
|
}
|
||||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||||
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
@@ -298,7 +319,7 @@ async function requestFastForward(): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
/* keep fallbacks */
|
/* keep fallbacks */
|
||||||
}
|
}
|
||||||
const resolved = resolveFastForward(discovered, presets, name);
|
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||||
const req = buildFastForwardRequest(resolved);
|
const req = buildFastForwardRequest(resolved);
|
||||||
if (req) {
|
if (req) {
|
||||||
try {
|
try {
|
||||||
@@ -352,8 +373,27 @@ window.addEventListener("keydown", (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Escape in the session list hands the keyboard back to the pane.
|
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||||
|
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
||||||
|
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||||
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
|
sidebar.focusList();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
||||||
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||||
|
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||||
|
* changing, leaving a click back onto that session's row silently ignored. */
|
||||||
|
function focusSession(sessionId: string): void {
|
||||||
|
showMultiView();
|
||||||
|
mv.openSession(sessionId);
|
||||||
|
updateShellRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
|
||||||
|
focusSession((e as CustomEvent<string>).detail);
|
||||||
|
});
|
||||||
|
|
||||||
type Route =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
@@ -373,9 +413,7 @@ function dispatch(): void {
|
|||||||
switch (route.kind) {
|
switch (route.kind) {
|
||||||
case "attach":
|
case "attach":
|
||||||
// Deep-link: open/focus the session, then show the layout.
|
// Deep-link: open/focus the session, then show the layout.
|
||||||
showMultiView();
|
focusSession(route.sessionId);
|
||||||
mv.openSession(route.sessionId);
|
|
||||||
updateShellRoute();
|
|
||||||
return;
|
return;
|
||||||
case "not-found":
|
case "not-found":
|
||||||
shellEl.dataset.route = "notfound";
|
shellEl.dataset.route = "notfound";
|
||||||
|
|||||||
@@ -109,11 +109,11 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
expect(handle.hasListFocus()).toBe(true);
|
expect(handle.hasListFocus()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape asks the shell for the pane back", () => {
|
it("Tab asks the shell for the pane back", () => {
|
||||||
const { el } = twoFolders();
|
const { el } = twoFolders();
|
||||||
let asked = 0;
|
let asked = 0;
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
|
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
|
||||||
press(tree(el), "Escape");
|
press(tree(el), "Tab");
|
||||||
expect(asked).toBe(1);
|
expect(asked).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+5
-7
@@ -1393,23 +1393,21 @@ input.leaf-rename:focus {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It sits
|
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It is
|
||||||
inside the row's own indent gutter (`.tree-leaf` is already positioned),
|
pinned to the row's own left edge, full height, square corners — see
|
||||||
at a `left` set inline per nesting depth — see `activityRailMarkup` in
|
|
||||||
catalog.ts. Idle stays solid and still on purpose: motion is reserved for
|
catalog.ts. Idle stays solid and still on purpose: motion is reserved for
|
||||||
states that need attention (DESIGN.md's "living signals" rule). */
|
states that need attention (DESIGN.md's "living signals" rule). */
|
||||||
.activity-rail {
|
.activity-rail {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 0;
|
||||||
bottom: 4px;
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
border-radius: 2px;
|
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-rail[data-activity="active"] {
|
.activity-rail[data-activity="active"] {
|
||||||
color: var(--live);
|
color: var(--live);
|
||||||
animation: pulse 2.4s var(--ease-out) infinite;
|
|
||||||
}
|
}
|
||||||
.activity-rail[data-activity="idle"] {
|
.activity-rail[data-activity="idle"] {
|
||||||
color: var(--border-strong);
|
color: var(--border-strong);
|
||||||
|
|||||||
Reference in New Issue
Block a user