Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02168ffd5 | ||
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c |
@@ -51,6 +51,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
@@ -236,6 +237,7 @@ mod tests {
|
|||||||
|
|
||||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
cwd: PathBuf::from(cwd),
|
cwd: PathBuf::from(cwd),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
let created = mock
|
let created = mock
|
||||||
.client()
|
.client()
|
||||||
.create_session(&maestro_protocol::CreateSessionRequest {
|
.create_session(&maestro_protocol::CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/home/mock".into(),
|
cwd: "/home/mock".into(),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ impl SessionManager {
|
|||||||
// fresh tree has nothing in the way anyway.
|
// fresh tree has nothing in the way anyway.
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
view: req.view,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
@@ -683,6 +684,9 @@ impl SessionManager {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
// Unknown on adoption without a sidecar; matches every
|
||||||
|
// other field's forward-compat default.
|
||||||
|
view: maestro_protocol::SessionView::Terminal,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
|
|||||||
@@ -111,16 +111,33 @@ impl WorktreeManager {
|
|||||||
// Clear registrations whose directory is gone first, so a
|
// Clear registrations whose directory is gone first, so a
|
||||||
// previously-leaked entry doesn't push this branch to a suffix.
|
// previously-leaked entry doesn't push this branch to a suffix.
|
||||||
prune(base_repo)?;
|
prune(base_repo)?;
|
||||||
// Anything occupying the target — a tree we left behind, one the
|
let (branch, path) = if create_branch {
|
||||||
// operator made, an unrelated directory — is sidestepped with a
|
// Worktrees are commonly abandoned without deleting their branch,
|
||||||
// numeric suffix on the directory; the branch stays whatever was
|
// so a random handle can collide with one of its own past runs.
|
||||||
// requested (#77). The daemon never adopts what it finds.
|
// Unlike the `add_existing` arm below, the suffix goes on the
|
||||||
let mut path = self.target_path(base_repo, branch);
|
// branch name itself — the path is derived from it anyway.
|
||||||
let mut n = 1;
|
let mut candidate = branch.to_string();
|
||||||
while path.symlink_metadata().is_ok() {
|
let mut path = self.target_path(base_repo, &candidate);
|
||||||
n += 1;
|
let mut n = 1;
|
||||||
path = self.target_path_suffixed(base_repo, branch, n);
|
while path.symlink_metadata().is_ok() || branch_exists(base_repo, &candidate) {
|
||||||
}
|
n += 1;
|
||||||
|
candidate = format!("{branch}-{n}");
|
||||||
|
path = self.target_path(base_repo, &candidate);
|
||||||
|
}
|
||||||
|
(candidate, path)
|
||||||
|
} else {
|
||||||
|
// Existing branch: the name must stay exact, so only the
|
||||||
|
// directory — anything occupying it, ours or the operator's — is
|
||||||
|
// sidestepped with a numeric suffix (#77). The daemon never
|
||||||
|
// adopts what it finds.
|
||||||
|
let mut path = self.target_path(base_repo, branch);
|
||||||
|
let mut n = 1;
|
||||||
|
while path.symlink_metadata().is_ok() {
|
||||||
|
n += 1;
|
||||||
|
path = self.target_path_suffixed(base_repo, branch, n);
|
||||||
|
}
|
||||||
|
(branch.to_string(), path)
|
||||||
|
};
|
||||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||||
// yet; the target itself must not.
|
// yet; the target itself must not.
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -129,10 +146,10 @@ impl WorktreeManager {
|
|||||||
let mut cmd = Command::new("git");
|
let mut cmd = Command::new("git");
|
||||||
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||||
if create_branch {
|
if create_branch {
|
||||||
cmd.arg("-b").arg(branch).arg(&path);
|
cmd.arg("-b").arg(&branch).arg(&path);
|
||||||
} else {
|
} else {
|
||||||
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||||
cmd.arg(&path).arg(branch);
|
cmd.arg(&path).arg(&branch);
|
||||||
}
|
}
|
||||||
let out = cmd.output()?;
|
let out = cmd.output()?;
|
||||||
check(out)?;
|
check(out)?;
|
||||||
@@ -266,6 +283,17 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
|||||||
Some(candidate.to_path_buf())
|
Some(candidate.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `refs/heads/<branch>` already exists in `base_repo`.
|
||||||
|
fn branch_exists(base_repo: &Path, branch: &str) -> bool {
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(base_repo)
|
||||||
|
.args(["show-ref", "--verify", "--quiet"])
|
||||||
|
.arg(format!("refs/heads/{branch}"))
|
||||||
|
.status()
|
||||||
|
.is_ok_and(|s| s.success())
|
||||||
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||||
/// no longer exists.
|
/// no longer exists.
|
||||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async fn fixture(ttl: Duration) -> Fixture {
|
|||||||
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||||
manager
|
manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
let s = h
|
let s = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd_path,
|
cwd: cwd_path,
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use tempfile::TempDir;
|
|||||||
|
|
||||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
assert!(empty.is_empty());
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -390,6 +391,7 @@ async fn patch_session_renames_it() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -432,6 +434,7 @@ async fn post_focus_stamps_last_focused_at() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -465,6 +468,7 @@ async fn delete_owned_session_kills_it() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -486,6 +490,7 @@ async fn delete_owned_session_kills_it() {
|
|||||||
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||||
h.manager
|
h.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -595,6 +600,7 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -647,6 +653,7 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -680,6 +687,7 @@ async fn attach_ws_streams_pty_bytes() {
|
|||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -748,6 +756,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|||||||
let created = h
|
let created = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -792,6 +801,7 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
|
|||||||
|
|
||||||
h.manager
|
h.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ impl Fx {
|
|||||||
|
|
||||||
fn request(&self, command: &str) -> CreateSessionRequest {
|
fn request(&self, command: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: self.tmp.path().to_path_buf(),
|
cwd: self.tmp.path().to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async fn create_then_kill_round_trip() {
|
|||||||
let session = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -63,6 +64,7 @@ async fn create_appends_to_spawn_history() {
|
|||||||
let session = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -92,6 +94,7 @@ async fn create_without_history_logs_nothing() {
|
|||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
fx.manager
|
fx.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -114,6 +117,7 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -153,6 +157,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// The shell prints the marker before doing anything else and
|
// The shell prints the marker before doing anything else and
|
||||||
// then sleeps so the session stays alive. Before NAP-253 this
|
// then sleeps so the session stays alive. Before NAP-253 this
|
||||||
@@ -200,6 +205,7 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// Print a line, then switch to the alternate screen the way a
|
// Print a line, then switch to the alternate screen the way a
|
||||||
// full-screen program does on startup. Only the second marker is
|
// full-screen program does on startup. Only the second marker is
|
||||||
@@ -258,6 +264,7 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// Alternate screen, then button-event tracking (?1002) with SGR
|
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||||
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||||
@@ -311,6 +318,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -373,6 +381,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -423,6 +432,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -488,6 +498,7 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
|
|||||||
|
|
||||||
fx.manager
|
fx.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -580,6 +591,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -617,6 +629,7 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -680,6 +693,7 @@ async fn killing_refuses_until_the_branch_is_pushed() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -729,6 +743,7 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -774,6 +789,7 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -814,6 +830,7 @@ async fn killing_an_ignored_only_worktree_succeeds() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -855,6 +872,7 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -916,6 +934,7 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -976,6 +995,7 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1010,6 +1030,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
let first = fx
|
let first = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: repo.path().to_path_buf(),
|
cwd: repo.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1027,6 +1048,7 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|||||||
let second = fx
|
let second = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: inner_worktree.clone(),
|
cwd: inner_worktree.clone(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1065,6 +1087,7 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1103,6 +1126,7 @@ async fn a_freshly_created_session_has_never_been_focused() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1128,6 +1152,7 @@ async fn set_focused_true_stamps_last_focused_at() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -1176,6 +1201,7 @@ async fn attaching_stamps_last_focused_at() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_daemon::sidecar::SidecarStore;
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
use maestro_protocol::{Session, SessionStatus};
|
use maestro_protocol::{Session, SessionStatus, SessionView};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session {
|
|||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
view: SessionView::Terminal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +104,37 @@ fn load_all_returns_empty_when_directory_missing() {
|
|||||||
assert!(store.load_all().unwrap().is_empty());
|
assert!(store.load_all().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_missing_view_key_reads_as_terminal() {
|
||||||
|
// A pre-#133 sidecar on disk has no `view` field at all.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let mut value = serde_json::to_value(sample(id)).unwrap();
|
||||||
|
value.as_object_mut().unwrap().remove("view");
|
||||||
|
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
|
||||||
|
|
||||||
|
let loaded = store.read(id).expect("read").expect("present");
|
||||||
|
assert_eq!(loaded.view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_structured_view_round_trips() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = SidecarStore::new(dir.path());
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let mut s = sample(id);
|
||||||
|
s.view = SessionView::Structured;
|
||||||
|
|
||||||
|
store.write(&s).expect("write");
|
||||||
|
let loaded = store.read(id).expect("read");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.as_ref().map(|s| s.view),
|
||||||
|
Some(SessionView::Structured)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_creates_parent_directory() {
|
fn write_creates_parent_directory() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async fn bring_up() -> (Daemon, TestClient) {
|
|||||||
|
|
||||||
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -49,6 +50,7 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
|
|||||||
/// Spawn request carrying a worktree spec against `base`.
|
/// Spawn request carrying a worktree spec against `base`.
|
||||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: base.to_path_buf(),
|
cwd: base.to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -143,6 +145,7 @@ async fn kill_removes_clean_worktree() {
|
|||||||
init_repo(base.path());
|
init_repo(base.path());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -196,6 +199,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
|
|||||||
.join("unsaved");
|
.join("unsaved");
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -254,6 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
|||||||
run_git(&["branch", "feature/existing"], base.path());
|
run_git(&["branch", "feature/existing"], base.path());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -497,6 +502,57 @@ async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A random handle can reuse the name of a branch left behind by an earlier,
|
||||||
|
/// already-removed worktree — `git worktree add -b` refuses outright in that
|
||||||
|
/// case, even though the directory is free. The daemon suffixes the branch
|
||||||
|
/// itself (not just the directory) and both land on `-2`.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn colliding_branch_name_gets_suffixed_too() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
|
||||||
|
// No worktree directory in the way, but the branch already exists.
|
||||||
|
run_git(&["branch", "handle"], base.path());
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&worktree_req(base.path(), "handle", true))
|
||||||
|
.await
|
||||||
|
.expect("spawn onto a taken branch name");
|
||||||
|
|
||||||
|
let suffixed = base.path().join("worktrees").join("handle-2");
|
||||||
|
assert_eq!(
|
||||||
|
s.cwd, suffixed,
|
||||||
|
"the directory must follow the suffixed branch name"
|
||||||
|
);
|
||||||
|
assert!(suffixed.is_dir());
|
||||||
|
|
||||||
|
let registrations = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
registrations.iter().any(|l| l.contains("handle-2")),
|
||||||
|
"the -2 branch's worktree must be registered: {registrations:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let branches = String::from_utf8(
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(base.path())
|
||||||
|
.args(["branch", "--list"])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
branches.contains("handle-2"),
|
||||||
|
"the branch itself must be named handle-2: {branches}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _origin = throwaway_origin(base.path());
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
/// #77/#88: the target path holds a plain directory the daemon never made and
|
/// #77/#88: the target path holds a plain directory the daemon never made and
|
||||||
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||||
/// beside it and leaves every byte of it alone.
|
/// beside it and leaves every byte of it alone.
|
||||||
@@ -703,6 +759,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
|
|
||||||
let _dirty = client
|
let _dirty = client
|
||||||
.spawn_session(&CreateSessionRequest {
|
.spawn_session(&CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: dirty_repo.path().to_path_buf(),
|
cwd: dirty_repo.path().to_path_buf(),
|
||||||
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -751,6 +808,7 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
|||||||
|
|
||||||
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ mod tests {
|
|||||||
|
|
||||||
fn session(cwd: &str) -> Session {
|
fn session(cwd: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(1),
|
id: Uuid::from_u128(1),
|
||||||
cwd: PathBuf::from(cwd),
|
cwd: PathBuf::from(cwd),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ pub struct Session {
|
|||||||
/// sorts a never-focused session after every session that has one.
|
/// sorts a never-focused session after every session that has one.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_focused_at: Option<DateTime<Utc>>,
|
pub last_focused_at: Option<DateTime<Utc>>,
|
||||||
|
/// Whether this session's process is a plain terminal (PTY bytes through
|
||||||
|
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
|
||||||
|
/// spawn time and never changes for the life of the session. Defaults to
|
||||||
|
/// `Terminal` on the wire so every existing client and payload from a
|
||||||
|
/// daemon that predates the field keeps working untouched.
|
||||||
|
#[serde(default)]
|
||||||
|
pub view: SessionView,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session's process shape (#133): a plain terminal, or a structured agent
|
||||||
|
/// driven over ACP rows. Chosen at spawn and immutable for the session's
|
||||||
|
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
|
||||||
|
/// written before this field existed meant a terminal session.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum SessionView {
|
||||||
|
#[default]
|
||||||
|
Terminal,
|
||||||
|
Structured,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||||
@@ -352,6 +370,11 @@ pub struct CreateSessionRequest {
|
|||||||
pub metadata: HashMap<String, Value>,
|
pub metadata: HashMap<String, Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worktree: Option<WorktreeSpec>,
|
pub worktree: Option<WorktreeSpec>,
|
||||||
|
/// The process shape to spawn (#133). Defaulted so every existing client
|
||||||
|
/// and `curl` against the API keeps requesting a `Terminal` session
|
||||||
|
/// untouched.
|
||||||
|
#[serde(default)]
|
||||||
|
pub view: SessionView,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
|
||||||
@@ -862,6 +885,7 @@ mod tests {
|
|||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
view: SessionView::Terminal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1097,7 @@ mod tests {
|
|||||||
branch: "feat/foo".into(),
|
branch: "feat/foo".into(),
|
||||||
create_branch: true,
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
|
view: SessionView::Terminal,
|
||||||
};
|
};
|
||||||
assert_eq!(req, round_trip(&req));
|
assert_eq!(req, round_trip(&req));
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1112,26 @@ mod tests {
|
|||||||
assert!(req.worktree.is_none());
|
assert!(req.worktree.is_none());
|
||||||
assert!(req.env.is_empty());
|
assert!(req.env.is_empty());
|
||||||
assert!(req.metadata.is_empty());
|
assert!(req.metadata.is_empty());
|
||||||
|
assert_eq!(req.view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_view_defaults_to_terminal_on_legacy_payload() {
|
||||||
|
// A pre-#133 daemon or client omits `view`; it must deserialize to
|
||||||
|
// `Terminal` rather than failing, and `Structured` round-trips.
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z"
|
||||||
|
});
|
||||||
|
let s: Session = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.view, SessionView::Terminal);
|
||||||
|
|
||||||
|
let mut structured = sample_session();
|
||||||
|
structured.view = SessionView::Structured;
|
||||||
|
assert_eq!(structured, round_trip(&structured));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A client that predates the new/existing discriminator sends no
|
/// A client that predates the new/existing discriminator sends no
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use uuid::Uuid;
|
|||||||
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
|
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
|
||||||
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id,
|
id,
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
|
|||||||
@@ -1882,6 +1882,7 @@ mod tests {
|
|||||||
sessions: ids
|
sessions: ids
|
||||||
.iter()
|
.iter()
|
||||||
.map(|id| Session {
|
.map(|id| Session {
|
||||||
|
view: Default::default(),
|
||||||
id: *id,
|
id: *id,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -2063,6 +2064,7 @@ mod tests {
|
|||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
|
view: Default::default(),
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -2090,6 +2092,7 @@ mod tests {
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{Session, SessionStatus};
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
@@ -2777,6 +2780,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
app.dispatch(Action::OpenSpawnModal);
|
app.dispatch(Action::OpenSpawnModal);
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: std::path::PathBuf::from("/srv/work"),
|
cwd: std::path::PathBuf::from("/srv/work"),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -3195,6 +3199,7 @@ mod tests {
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(n),
|
id: Uuid::from_u128(n),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -3475,6 +3480,7 @@ mod tests {
|
|||||||
let sid = Uuid::from_u128(7);
|
let sid = Uuid::from_u128(7);
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
|
view: Default::default(),
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
@@ -3511,6 +3517,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
|
view: Default::default(),
|
||||||
id: sid,
|
id: sid,
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ mod tests {
|
|||||||
|
|
||||||
fn session(id: u128, command: &str) -> Session {
|
fn session(id: u128, command: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
|
|||||||
@@ -793,6 +793,7 @@ mod tests {
|
|||||||
|
|
||||||
fn session(n: u128, command: &str) -> Session {
|
fn session(n: u128, command: &str) -> Session {
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(n),
|
id: Uuid::from_u128(n),
|
||||||
cwd: PathBuf::from("/home/me/dev/api"),
|
cwd: PathBuf::from("/home/me/dev/api"),
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
|
|||||||
@@ -694,6 +694,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd,
|
cwd,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ impl FastForwardDefaults {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(CreateSessionRequest {
|
Some(CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: project.path.clone(),
|
cwd: project.path.clone(),
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ mod tests {
|
|||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
@@ -1250,6 +1251,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||||
let s = Session {
|
let s = Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(1),
|
id: Uuid::from_u128(1),
|
||||||
command: "vim".into(),
|
command: "vim".into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
|
|||||||
+4
-3
@@ -307,9 +307,10 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
|
|||||||
reachable by clicking a folder header — still steps to its neighbour rather
|
reachable by clicking a folder header — still steps to its neighbour rather
|
||||||
than jumping to the end of the list.
|
than jumping to the end of the list.
|
||||||
|
|
||||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
|
||||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
by default — and the same chord back): an attached terminal owns every plain
|
||||||
`ctrl-w h`.
|
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
|
||||||
|
uses `ctrl-w h`.
|
||||||
|
|
||||||
Everything else is the same key on both clients:
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -35,10 +35,18 @@ The branch name is used verbatim as a relative path, so `feature/login` becomes
|
|||||||
back to a short hash of the branch.
|
back to a short hash of the branch.
|
||||||
|
|
||||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
||||||
a free one. The suffix names the **directory only**. The branch is whatever was
|
a free one.
|
||||||
requested; a second worktree of the same branch is not what happens here, because
|
|
||||||
git refuses to check the same branch out twice and the branch picker already
|
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
|
||||||
marks branches that are checked out elsewhere.
|
itself, and the directory follows since it is derived from the branch — a
|
||||||
|
random handle can otherwise reuse the name of a branch some earlier, already
|
||||||
|
removed worktree left behind, and `git worktree add -b` refuses outright even
|
||||||
|
though the directory is free.
|
||||||
|
|
||||||
|
For an *existing* branch (`create_branch: false`) the name must stay exact, so
|
||||||
|
only the directory is suffixed — a second worktree of the same branch is not
|
||||||
|
what happens here, because git refuses to check the same branch out twice and
|
||||||
|
the branch picker already marks branches that are checked out elsewhere.
|
||||||
|
|
||||||
### The `worktree_root` override
|
### The `worktree_root` override
|
||||||
|
|
||||||
|
|||||||
+7
-20
@@ -4,8 +4,6 @@ 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 { CommandSet, defineCommands } from "./commands.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";
|
||||||
@@ -14,15 +12,6 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
|||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
import { WebglPolicy } from "./webglRenderer.js";
|
import { WebglPolicy } from "./webglRenderer.js";
|
||||||
|
|
||||||
// The pane's half of the command registry (#148): plain Escape hands the
|
|
||||||
// keyboard back to the session list, the pair to the list's Tab.
|
|
||||||
const paneKeys = new CommandSet(
|
|
||||||
defineCommands({
|
|
||||||
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
|
|
||||||
}),
|
|
||||||
{ preventDefault: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
/** One budget for the whole page — see webglRenderer.ts for why contexts are
|
||||||
* rationed rather than handed to every connection. */
|
* rationed rather than handed to every connection. */
|
||||||
const webglPolicy = new WebglPolicy();
|
const webglPolicy = new WebglPolicy();
|
||||||
@@ -221,15 +210,6 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 —
|
|
||||||
// which is also why the set doesn't `preventDefault`: swallowing here is
|
|
||||||
// xterm's return value, not the browser's.
|
|
||||||
term.attachCustomKeyEventHandler((e) => {
|
|
||||||
if (e.type !== "keydown") return true;
|
|
||||||
return !paneKeys.handle(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -470,6 +450,12 @@ export function createAttachConnection(
|
|||||||
const resizeDisp = term.onResize(({ cols, rows }) => {
|
const resizeDisp = term.onResize(({ cols, rows }) => {
|
||||||
sendControl({ type: "Resize", cols, rows });
|
sendControl({ type: "Resize", cols, rows });
|
||||||
});
|
});
|
||||||
|
// xterm has no built-in copy-on-select; mirror the selection to the
|
||||||
|
// clipboard ourselves (best-effort — insecure contexts lack the API).
|
||||||
|
const selectionDisp = term.onSelectionChange(() => {
|
||||||
|
const text = term.getSelection();
|
||||||
|
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
const visual = window.visualViewport ?? null;
|
const visual = window.visualViewport ?? null;
|
||||||
@@ -525,6 +511,7 @@ export function createAttachConnection(
|
|||||||
visual?.removeEventListener("resize", onResize);
|
visual?.removeEventListener("resize", onResize);
|
||||||
dataDisp.dispose();
|
dataDisp.dispose();
|
||||||
resizeDisp.dispose();
|
resizeDisp.dispose();
|
||||||
|
selectionDisp.dispose();
|
||||||
releaseWebgl();
|
releaseWebgl();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ export function requestOpenCommandBar(): void {
|
|||||||
* 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";
|
||||||
|
|||||||
+12
-14
@@ -133,31 +133,28 @@ describe("CommandSet", () => {
|
|||||||
expect(run).toHaveBeenCalledTimes(2);
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
|
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
|
||||||
const plain = vi.fn();
|
const plain = vi.fn();
|
||||||
const chorded = vi.fn();
|
const chorded = vi.fn();
|
||||||
const set = new CommandSet(
|
const set = new CommandSet(
|
||||||
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
|
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
|
||||||
|
{ leader: DEFAULT_LEADER },
|
||||||
);
|
);
|
||||||
const input = el("<input>");
|
const input = el("<input>");
|
||||||
expect(pressOn(set, input, "n")).toBe(false);
|
expect(pressOn(set, input, "n")).toBe(false);
|
||||||
expect(plain).not.toHaveBeenCalled();
|
expect(plain).not.toHaveBeenCalled();
|
||||||
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
|
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
|
||||||
|
// left for the pane's shell/app instead.
|
||||||
|
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
|
pressOn(set, terminal, "a", { ctrlKey: true });
|
||||||
|
expect(pressOn(set, terminal, "h")).toBe(true);
|
||||||
expect(chorded).toHaveBeenCalledTimes(1);
|
expect(chorded).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps pane commands live inside the terminal's textarea", () => {
|
it("leaves a plain Escape for the pane instead of binding it", () => {
|
||||||
const run = vi.fn();
|
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
|
||||||
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
|
|
||||||
preventDefault: false,
|
|
||||||
});
|
|
||||||
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||||
const e = key("Escape");
|
expect(pressOn(set, textarea, "Escape")).toBe(false);
|
||||||
Object.defineProperty(e, "target", { value: textarea });
|
|
||||||
expect(set.handle(e)).toBe(true);
|
|
||||||
expect(run).toHaveBeenCalledTimes(1);
|
|
||||||
// xterm is told to swallow by the return value, not by the browser default.
|
|
||||||
expect(e.defaultPrevented).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +170,7 @@ describe("the leader", () => {
|
|||||||
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
|
||||||
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
|
||||||
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
|
||||||
|
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
|
||||||
// Commands that do not hang off the leader are untouched.
|
// Commands that do not hang off the leader are untouched.
|
||||||
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-8
@@ -93,7 +93,8 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
id: "focus.toggle",
|
id: "focus.toggle",
|
||||||
label: "Move the keyboard between the session list and the pane",
|
label: "Move the keyboard between the session list and the pane",
|
||||||
group: "Session list",
|
group: "Session list",
|
||||||
keys: ["ctrl-shift-h"],
|
keys: [`${DEFAULT_LEADER} h`],
|
||||||
|
leaderKeys: (leader) => [`${leader} h`],
|
||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,13 +146,6 @@ const SPECS: readonly SpecSource[] = [
|
|||||||
keys: ["tab"],
|
keys: ["tab"],
|
||||||
scope: "list",
|
scope: "list",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "pane.focusList",
|
|
||||||
label: "Hand the keyboard back to the session list, from the pane",
|
|
||||||
group: "Session list",
|
|
||||||
keys: ["esc"],
|
|
||||||
scope: "pane",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "help.toggle",
|
id: "help.toggle",
|
||||||
label: "Show or hide this help",
|
label: "Show or hide this help",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
emptyLayout,
|
emptyLayout,
|
||||||
|
equalizeRatios,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
openSessions,
|
openSessions,
|
||||||
@@ -44,6 +45,23 @@ describe("splitLeaf", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("equalizeRatios", () => {
|
||||||
|
it("resets nested splits to equal width, not just the newest one", () => {
|
||||||
|
const tab = tabWithOneLeaf("s1");
|
||||||
|
const first = leaves(tab.root)[0].id;
|
||||||
|
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
|
||||||
|
const second = leaves(tab.root)[1].id;
|
||||||
|
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
|
||||||
|
|
||||||
|
equalizeRatios(tab.root);
|
||||||
|
|
||||||
|
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
|
||||||
|
expect(root.ratio).toBeCloseTo(1 / 3);
|
||||||
|
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
|
||||||
|
expect(inner.ratio).toBeCloseTo(1 / 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("removeLeaf", () => {
|
describe("removeLeaf", () => {
|
||||||
it("collapses the parent split into the surviving sibling", () => {
|
it("collapses the parent split into the surviving sibling", () => {
|
||||||
const tab = tabWithOneLeaf("s1");
|
const tab = tabWithOneLeaf("s1");
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ export function removeLeaf(tab: Tab, leafId: string): void {
|
|||||||
tab.root = tab.root ? rec(tab.root) : null;
|
tab.root = tab.root ? rec(tab.root) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset every split ratio under `node` so all leaves get equal width,
|
||||||
|
* regardless of split direction or nesting order. Returns the leaf count. */
|
||||||
|
export function equalizeRatios(node: Node | null): number {
|
||||||
|
if (!node) return 0;
|
||||||
|
if (node.kind === "leaf") return 1;
|
||||||
|
const na = equalizeRatios(node.a);
|
||||||
|
const nb = equalizeRatios(node.b);
|
||||||
|
node.ratio = na / (na + nb);
|
||||||
|
return na + nb;
|
||||||
|
}
|
||||||
|
|
||||||
export function addTab(layout: Layout): Tab {
|
export function addTab(layout: Layout): Tab {
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: freshId("t"),
|
id: freshId("t"),
|
||||||
|
|||||||
+7
-11
@@ -35,7 +35,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
|
|||||||
import { createPreviewSchedule } from "./previewSchedule.js";
|
import { createPreviewSchedule } from "./previewSchedule.js";
|
||||||
import {
|
import {
|
||||||
FOCUS_PANE_EVENT,
|
FOCUS_PANE_EVENT,
|
||||||
FOCUS_SIDEBAR_EVENT,
|
|
||||||
OPEN_COMMAND_BAR_EVENT,
|
OPEN_COMMAND_BAR_EVENT,
|
||||||
OPEN_SPAWN_EVENT,
|
OPEN_SPAWN_EVENT,
|
||||||
SESSION_SELECT_EVENT,
|
SESSION_SELECT_EVENT,
|
||||||
@@ -440,10 +439,11 @@ const spawnKeys = new CommandSet(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
// Leader-h moves the keyboard between the session list and the pane, the web
|
||||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
|
||||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
|
||||||
// the focus back out of it.
|
// is already claimed by the browser (history), so nothing unmodified — or
|
||||||
|
// modified with a common browser chord — could get the focus back out of it.
|
||||||
//
|
//
|
||||||
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
|
||||||
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
// on `/api/info`): it holds the key as a partial chord and takes select-all off
|
||||||
@@ -528,8 +528,8 @@ window.addEventListener(
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
|
||||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
// above) hands it back the other way.
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||||
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||||
// being provisional rather than snapping back the moment the list blurs.
|
// being provisional rather than snapping back the moment the list blurs.
|
||||||
@@ -537,10 +537,6 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
|
|||||||
if (previewed) focusSession(previewed);
|
if (previewed) focusSession(previewed);
|
||||||
pool.focusTerminal();
|
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
|
/** 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
|
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
activeTab,
|
activeTab,
|
||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
equalizeRatios,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
freshId,
|
freshId,
|
||||||
type Dir,
|
type Dir,
|
||||||
@@ -457,6 +458,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
dropPreview();
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +544,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
target.sessionId = sessionId;
|
target.sessionId = sessionId;
|
||||||
} else {
|
} else {
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
opts.onOpenSessionsChange?.();
|
opts.onOpenSessionsChange?.();
|
||||||
@@ -815,6 +818,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
if (source.kind === "pane" && self) removeLeaf(tab, self);
|
||||||
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
|
||||||
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user