Compare commits
24
Commits
feroce-violin
...
duduk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67fa2bd4e | ||
|
|
88b6bba9f2 | ||
|
|
1c349e5858 | ||
|
|
9fc21b4306 | ||
|
|
19162f9952 | ||
|
|
2fcfc06c3c | ||
|
|
f82ab45766 | ||
|
|
d304171d7c | ||
|
|
95b459bd65 | ||
|
|
c9cfdb5668 | ||
|
|
31158cdb1a | ||
|
|
7b04575ed5 | ||
|
|
80af2e6816 | ||
|
|
52abfc6eea | ||
|
|
eb9b4b4ae8 | ||
|
|
1f543d16d1 | ||
|
|
f6cfcbb2c4 | ||
|
|
5920bbf2ec | ||
|
|
eb5d28d3a6 | ||
|
|
511ee3be4e | ||
|
|
a92202302a | ||
|
|
44c67fcee7 | ||
|
|
d8d26d181a | ||
|
|
aec7f4cae6 |
@@ -7,6 +7,10 @@ on:
|
|||||||
- 'linear/**'
|
- 'linear/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ impl Tmux {
|
|||||||
"-t",
|
"-t",
|
||||||
&name,
|
&name,
|
||||||
"-F",
|
"-F",
|
||||||
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}",
|
SCREEN_GEOMETRY_FORMAT,
|
||||||
";",
|
";",
|
||||||
"capture-pane",
|
"capture-pane",
|
||||||
"-p",
|
"-p",
|
||||||
@@ -515,6 +515,52 @@ fn new_session_args(
|
|||||||
/// frame huge.
|
/// frame huge.
|
||||||
const CAPTURE_HISTORY_LINES: usize = 300;
|
const CAPTURE_HISTORY_LINES: usize = 300;
|
||||||
|
|
||||||
|
/// `display-message -F` request behind [`parse_screen_geometry`]. tmux expands
|
||||||
|
/// a name it does not know to the empty string rather than failing, so a field
|
||||||
|
/// missing on an older tmux shifts everything after it — hence the parser keys
|
||||||
|
/// the optional tail off the field count.
|
||||||
|
const SCREEN_GEOMETRY_FORMAT: &str = concat!(
|
||||||
|
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag} ",
|
||||||
|
"#{mouse_standard_flag} #{mouse_button_flag} #{mouse_all_flag} ",
|
||||||
|
"#{mouse_utf8_flag} #{mouse_sgr_flag}",
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Field count of [`SCREEN_GEOMETRY_FORMAT`]; fewer means an older tmux did not
|
||||||
|
/// know one of the names, and the mouse tail cannot be trusted by position.
|
||||||
|
const SCREEN_GEOMETRY_FIELDS: usize = 10;
|
||||||
|
|
||||||
|
/// Mouse-reporting modes a pane has turned on. A full-screen program sets these
|
||||||
|
/// once, at startup, so a client attaching later never sees the sequences —
|
||||||
|
/// [`render_screen_snapshot`] replays them.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
struct MouseModes {
|
||||||
|
/// `?1000` — report button press and release.
|
||||||
|
standard: bool,
|
||||||
|
/// `?1002` — also report motion while a button is held.
|
||||||
|
button: bool,
|
||||||
|
/// `?1003` — report all motion.
|
||||||
|
all: bool,
|
||||||
|
/// `?1005` — UTF-8 coordinates (superseded by SGR, still replayed if on).
|
||||||
|
utf8: bool,
|
||||||
|
/// `?1006` — SGR-encoded coordinates.
|
||||||
|
sgr: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MouseModes {
|
||||||
|
/// DECSET parameters to re-enable these modes, private-mode order.
|
||||||
|
fn decset_params(self) -> impl Iterator<Item = u16> {
|
||||||
|
[
|
||||||
|
(self.standard, 1000),
|
||||||
|
(self.button, 1002),
|
||||||
|
(self.all, 1003),
|
||||||
|
(self.utf8, 1005),
|
||||||
|
(self.sgr, 1006),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(on, param)| on.then_some(param))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cursor position and screen geometry of a pane, from `display-message`.
|
/// Cursor position and screen geometry of a pane, from `display-message`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
struct ScreenGeometry {
|
struct ScreenGeometry {
|
||||||
@@ -523,17 +569,31 @@ struct ScreenGeometry {
|
|||||||
pane_height: usize,
|
pane_height: usize,
|
||||||
alternate: bool,
|
alternate: bool,
|
||||||
cursor_visible: bool,
|
cursor_visible: bool,
|
||||||
|
mouse: MouseModes,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
||||||
let line = s.lines().next()?;
|
let line = s.lines().next()?;
|
||||||
let mut it = line.split_whitespace();
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||||
let cursor_x = it.next()?.parse().ok()?;
|
let cursor_x = fields.first()?.parse().ok()?;
|
||||||
let cursor_y = it.next()?.parse().ok()?;
|
let cursor_y = fields.get(1)?.parse().ok()?;
|
||||||
let pane_height: usize = it.next()?.parse().ok()?;
|
let pane_height: usize = fields.get(2)?.parse().ok()?;
|
||||||
let alternate = it.next()? == "1";
|
let alternate = *fields.get(3)? == "1";
|
||||||
// Older tmux without `cursor_flag` just means "assume visible".
|
// Older tmux without `cursor_flag` just means "assume visible".
|
||||||
let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true);
|
let cursor_visible = fields.get(4).map(|f| *f == "1").unwrap_or(true);
|
||||||
|
// Only trust the mouse tail when every field expanded; a short line means
|
||||||
|
// some name is unknown here and the remaining ones sit at other offsets.
|
||||||
|
let mouse = if fields.len() >= SCREEN_GEOMETRY_FIELDS {
|
||||||
|
MouseModes {
|
||||||
|
standard: fields[5] == "1",
|
||||||
|
button: fields[6] == "1",
|
||||||
|
all: fields[7] == "1",
|
||||||
|
utf8: fields[8] == "1",
|
||||||
|
sgr: fields[9] == "1",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MouseModes::default()
|
||||||
|
};
|
||||||
if pane_height == 0 {
|
if pane_height == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -543,6 +603,7 @@ fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
|||||||
pane_height,
|
pane_height,
|
||||||
alternate,
|
alternate,
|
||||||
cursor_visible,
|
cursor_visible,
|
||||||
|
mouse,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +633,12 @@ fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
|
|||||||
if geom.alternate {
|
if geom.alternate {
|
||||||
out.extend_from_slice(b"\x1b[?1049h");
|
out.extend_from_slice(b"\x1b[?1049h");
|
||||||
}
|
}
|
||||||
|
// Mouse reporting is set up once, before this client existed. Without it
|
||||||
|
// the client cannot tell a mouse-driven app from a plain alternate screen,
|
||||||
|
// and its wheel and touch gestures have nowhere to go.
|
||||||
|
for param in geom.mouse.decset_params() {
|
||||||
|
out.extend_from_slice(format!("\x1b[?{param}h").as_bytes());
|
||||||
|
}
|
||||||
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
|
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
|
||||||
for (i, line) in lines[..=last].iter().enumerate() {
|
for (i, line) in lines[..=last].iter().enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -645,7 +712,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
|
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
|
||||||
let g = parse_screen_geometry("12 3 40 1 0\n").unwrap();
|
let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
g,
|
g,
|
||||||
ScreenGeometry {
|
ScreenGeometry {
|
||||||
@@ -654,6 +721,11 @@ mod tests {
|
|||||||
pane_height: 40,
|
pane_height: 40,
|
||||||
alternate: true,
|
alternate: true,
|
||||||
cursor_visible: false,
|
cursor_visible: false,
|
||||||
|
mouse: MouseModes {
|
||||||
|
button: true,
|
||||||
|
sgr: true,
|
||||||
|
..MouseModes::default()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
let g = parse_screen_geometry("0 0 24 0").unwrap();
|
let g = parse_screen_geometry("0 0 24 0").unwrap();
|
||||||
@@ -663,6 +735,31 @@ mod tests {
|
|||||||
assert!(parse_screen_geometry("garbage").is_none());
|
assert!(parse_screen_geometry("garbage").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tmux that does not know one of the mouse names expands it to nothing,
|
||||||
|
/// so the tail is one field short and every flag in it has slid. Reading it
|
||||||
|
/// by position would claim modes the pane never set.
|
||||||
|
#[test]
|
||||||
|
fn parse_screen_geometry_drops_a_short_mouse_tail() {
|
||||||
|
let g = parse_screen_geometry("0 0 24 1 1 0 0 1 1").unwrap();
|
||||||
|
assert_eq!(g.mouse, MouseModes::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_modes_map_to_private_mode_parameters() {
|
||||||
|
let all = MouseModes {
|
||||||
|
standard: true,
|
||||||
|
button: true,
|
||||||
|
all: true,
|
||||||
|
utf8: true,
|
||||||
|
sgr: true,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
all.decset_params().collect::<Vec<_>>(),
|
||||||
|
vec![1000, 1002, 1003, 1005, 1006]
|
||||||
|
);
|
||||||
|
assert_eq!(MouseModes::default().decset_params().count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
|
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
|
||||||
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
|
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
|
||||||
@@ -675,6 +772,7 @@ mod tests {
|
|||||||
pane_height: 3,
|
pane_height: 3,
|
||||||
alternate: false,
|
alternate: false,
|
||||||
cursor_visible: true,
|
cursor_visible: true,
|
||||||
|
mouse: MouseModes::default(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let out = String::from_utf8(out).unwrap();
|
let out = String::from_utf8(out).unwrap();
|
||||||
@@ -695,6 +793,7 @@ mod tests {
|
|||||||
pane_height: 3,
|
pane_height: 3,
|
||||||
alternate: true,
|
alternate: true,
|
||||||
cursor_visible: false,
|
cursor_visible: false,
|
||||||
|
mouse: MouseModes::default(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let out = String::from_utf8(out).unwrap();
|
let out = String::from_utf8(out).unwrap();
|
||||||
@@ -716,12 +815,40 @@ mod tests {
|
|||||||
pane_height: 3,
|
pane_height: 3,
|
||||||
alternate: false,
|
alternate: false,
|
||||||
cursor_visible: true,
|
cursor_visible: true,
|
||||||
|
mouse: MouseModes::default(),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
|
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A client attaching to a running full-screen app never saw it turn mouse
|
||||||
|
/// reporting on, so the snapshot has to. Without this the client reads the
|
||||||
|
/// pane as a plain alternate screen and its scroll gestures go nowhere.
|
||||||
|
#[test]
|
||||||
|
fn snapshot_replays_the_panes_mouse_modes() {
|
||||||
|
let out = String::from_utf8(render_screen_snapshot(
|
||||||
|
&["app"],
|
||||||
|
ScreenGeometry {
|
||||||
|
cursor_x: 0,
|
||||||
|
cursor_y: 0,
|
||||||
|
pane_height: 1,
|
||||||
|
alternate: true,
|
||||||
|
cursor_visible: true,
|
||||||
|
mouse: MouseModes {
|
||||||
|
button: true,
|
||||||
|
sgr: true,
|
||||||
|
..MouseModes::default()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.starts_with("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
||||||
|
"{out:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Every phrasing must be recognised: an unmatched one makes
|
/// Every phrasing must be recognised: an unmatched one makes
|
||||||
/// `list_sessions` fail, and a failed pass reaps nothing.
|
/// `list_sessions` fail, and a failed pass reaps nothing.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -232,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
|
|||||||
assert!(projects.is_empty());
|
assert!(projects.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #53: every returned handle splits into a known adjective and noun.
|
/// #53: every returned handle is one word from the shared list.
|
||||||
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
||||||
for name in &batch.names {
|
for name in &batch.names {
|
||||||
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
assert!(!name.contains('-'), "handle {name:?} is not one word");
|
||||||
panic!("handle {name:?} is not adjective-noun");
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
maestro_names::ADJECTIVES.contains(&adj),
|
maestro_names::WORDS.contains(&name.as_str()),
|
||||||
"{adj} not in ADJECTIVES"
|
"{name} not in WORDS"
|
||||||
);
|
);
|
||||||
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,59 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|||||||
fx.manager.kill(s.id, false).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A full-screen app turns mouse reporting on once, at startup. A client that
|
||||||
|
/// attaches later never sees those sequences, so it reads the pane as a plain
|
||||||
|
/// alternate screen and its wheel and touch gestures have nowhere to go. The
|
||||||
|
/// snapshot must carry the pane's mouse modes with it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||||
|
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||||
|
command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'"
|
||||||
|
.into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let want = b"MOUSE_MARKER";
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut replay = Vec::new();
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
let (bytes, _rx) = fx
|
||||||
|
.manager
|
||||||
|
.subscribe_pty_with_replay(s.id)
|
||||||
|
.await
|
||||||
|
.expect("subscribe");
|
||||||
|
replay = bytes;
|
||||||
|
if replay.windows(want.len()).any(|w| w == want) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&replay).into_owned();
|
||||||
|
assert!(
|
||||||
|
text.contains("MOUSE_MARKER"),
|
||||||
|
"replay never reached the live screen: {text:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
||||||
|
"replay dropped the pane's mouse modes: {text:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||||
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
||||||
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
||||||
|
|||||||
+233
-29
@@ -1,10 +1,195 @@
|
|||||||
//! The one home of the session-handle wordlists (#53).
|
//! The one home of the session-handle wordlist (#53).
|
||||||
//!
|
//!
|
||||||
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
|
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
|
||||||
//! from here, served by `GET /api/names`.
|
//! from here, served by `GET /api/names`.
|
||||||
|
|
||||||
/// Musical adjectives — tempo, dynamics, and articulation markings.
|
/// Every word a handle can be: instruments, forms, and the vocabulary of
|
||||||
pub const ADJECTIVES: &[&str] = &[
|
/// tempo, dynamics, notation and theory. One word per handle — the list is
|
||||||
|
/// wide enough that a single word still reads as distinct.
|
||||||
|
pub const WORDS: &[&str] = &[
|
||||||
|
// Instruments
|
||||||
|
"cello",
|
||||||
|
"violin",
|
||||||
|
"viola",
|
||||||
|
"oboe",
|
||||||
|
"flute",
|
||||||
|
"piccolo",
|
||||||
|
"clarinet",
|
||||||
|
"bassoon",
|
||||||
|
"trumpet",
|
||||||
|
"trombone",
|
||||||
|
"tuba",
|
||||||
|
"horn",
|
||||||
|
"cornet",
|
||||||
|
"bugle",
|
||||||
|
"flugelhorn",
|
||||||
|
"euphonium",
|
||||||
|
"saxophone",
|
||||||
|
"recorder",
|
||||||
|
"timpani",
|
||||||
|
"harp",
|
||||||
|
"piano",
|
||||||
|
"organ",
|
||||||
|
"lyre",
|
||||||
|
"lute",
|
||||||
|
"mandolin",
|
||||||
|
"banjo",
|
||||||
|
"ukulele",
|
||||||
|
"guitar",
|
||||||
|
"sitar",
|
||||||
|
"koto",
|
||||||
|
"shamisen",
|
||||||
|
"balalaika",
|
||||||
|
"zither",
|
||||||
|
"dulcimer",
|
||||||
|
"kantele",
|
||||||
|
"erhu",
|
||||||
|
"guzheng",
|
||||||
|
"pipa",
|
||||||
|
"duduk",
|
||||||
|
"ocarina",
|
||||||
|
"kalimba",
|
||||||
|
"harmonica",
|
||||||
|
"accordion",
|
||||||
|
"bandoneon",
|
||||||
|
"bagpipe",
|
||||||
|
"harpsichord",
|
||||||
|
"clavichord",
|
||||||
|
"celesta",
|
||||||
|
"theremin",
|
||||||
|
"marimba",
|
||||||
|
"xylophone",
|
||||||
|
"vibraphone",
|
||||||
|
"glockenspiel",
|
||||||
|
"snare",
|
||||||
|
"cymbal",
|
||||||
|
"tambourine",
|
||||||
|
"castanet",
|
||||||
|
"djembe",
|
||||||
|
"tabla",
|
||||||
|
"bodhran",
|
||||||
|
"gong",
|
||||||
|
"chimes",
|
||||||
|
"contrabass",
|
||||||
|
"shawm",
|
||||||
|
"crumhorn",
|
||||||
|
"sackbut",
|
||||||
|
"didgeridoo",
|
||||||
|
// Forms and pieces
|
||||||
|
"sonata",
|
||||||
|
"fugue",
|
||||||
|
"canon",
|
||||||
|
"prelude",
|
||||||
|
"overture",
|
||||||
|
"rondo",
|
||||||
|
"nocturne",
|
||||||
|
"scherzo",
|
||||||
|
"etude",
|
||||||
|
"ballade",
|
||||||
|
"barcarolle",
|
||||||
|
"bagatelle",
|
||||||
|
"caprice",
|
||||||
|
"chaconne",
|
||||||
|
"passacaglia",
|
||||||
|
"toccata",
|
||||||
|
"intermezzo",
|
||||||
|
"interlude",
|
||||||
|
"minuet",
|
||||||
|
"gavotte",
|
||||||
|
"sarabande",
|
||||||
|
"gigue",
|
||||||
|
"allemande",
|
||||||
|
"courante",
|
||||||
|
"mazurka",
|
||||||
|
"polonaise",
|
||||||
|
"waltz",
|
||||||
|
"bolero",
|
||||||
|
"habanera",
|
||||||
|
"tarantella",
|
||||||
|
"fandango",
|
||||||
|
"serenade",
|
||||||
|
"aubade",
|
||||||
|
"requiem",
|
||||||
|
"cantata",
|
||||||
|
"oratorio",
|
||||||
|
"madrigal",
|
||||||
|
"motet",
|
||||||
|
"chorale",
|
||||||
|
"hymn",
|
||||||
|
"anthem",
|
||||||
|
"lullaby",
|
||||||
|
"elegy",
|
||||||
|
"rhapsody",
|
||||||
|
"symphony",
|
||||||
|
"concerto",
|
||||||
|
"fantasia",
|
||||||
|
"divertimento",
|
||||||
|
"quartet",
|
||||||
|
"quintet",
|
||||||
|
"sextet",
|
||||||
|
"septet",
|
||||||
|
"octet",
|
||||||
|
"trio",
|
||||||
|
// Structure, notation and theory
|
||||||
|
"octave",
|
||||||
|
"chord",
|
||||||
|
"cadence",
|
||||||
|
"cadenza",
|
||||||
|
"coda",
|
||||||
|
"motif",
|
||||||
|
"refrain",
|
||||||
|
"verse",
|
||||||
|
"chorus",
|
||||||
|
"bridge",
|
||||||
|
"finale",
|
||||||
|
"arpeggio",
|
||||||
|
"glissando",
|
||||||
|
"ostinato",
|
||||||
|
"counterpoint",
|
||||||
|
"descant",
|
||||||
|
"harmony",
|
||||||
|
"melody",
|
||||||
|
"rhythm",
|
||||||
|
"tempo",
|
||||||
|
"timbre",
|
||||||
|
"treble",
|
||||||
|
"clef",
|
||||||
|
"staff",
|
||||||
|
"quaver",
|
||||||
|
"crotchet",
|
||||||
|
"semibreve",
|
||||||
|
"fermata",
|
||||||
|
"trill",
|
||||||
|
"mordent",
|
||||||
|
"appoggiatura",
|
||||||
|
"portamento",
|
||||||
|
"vibrato",
|
||||||
|
"triplet",
|
||||||
|
"tuplet",
|
||||||
|
"downbeat",
|
||||||
|
"upbeat",
|
||||||
|
"anacrusis",
|
||||||
|
"syncopation",
|
||||||
|
"modulation",
|
||||||
|
"transposition",
|
||||||
|
"unison",
|
||||||
|
"semitone",
|
||||||
|
"tritone",
|
||||||
|
"diapason",
|
||||||
|
"tonic",
|
||||||
|
"dominant",
|
||||||
|
"mediant",
|
||||||
|
"ionian",
|
||||||
|
"dorian",
|
||||||
|
"phrygian",
|
||||||
|
"lydian",
|
||||||
|
"mixolydian",
|
||||||
|
"aeolian",
|
||||||
|
"locrian",
|
||||||
|
"pentatonic",
|
||||||
|
"chromatic",
|
||||||
|
"diatonic",
|
||||||
|
// Tempo, dynamics and articulation
|
||||||
"allegro",
|
"allegro",
|
||||||
"andante",
|
"andante",
|
||||||
"adagio",
|
"adagio",
|
||||||
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
|
|||||||
"lento",
|
"lento",
|
||||||
"moderato",
|
"moderato",
|
||||||
"crescendo",
|
"crescendo",
|
||||||
|
"diminuendo",
|
||||||
"staccato",
|
"staccato",
|
||||||
"legato",
|
"legato",
|
||||||
"forte",
|
"forte",
|
||||||
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
|
|||||||
"sotto",
|
"sotto",
|
||||||
"vivo",
|
"vivo",
|
||||||
"feroce",
|
"feroce",
|
||||||
];
|
"giocoso",
|
||||||
|
"affettuoso",
|
||||||
/// Musical nouns — instruments and forms from the concert hall.
|
"risoluto",
|
||||||
pub const NOUNS: &[&str] = &[
|
"leggiero",
|
||||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
|
"pesante",
|
||||||
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
|
"morendo",
|
||||||
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
|
"accelerando",
|
||||||
"motif", "overture",
|
"ritardando",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Upper bound for a `GET /api/names?count=N` batch.
|
/// Upper bound for a `GET /api/names?count=N` batch.
|
||||||
pub const MAX_BATCH: usize = 100;
|
pub const MAX_BATCH: usize = 100;
|
||||||
|
|
||||||
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
|
/// A fresh one-word handle, e.g. `nocturne`.
|
||||||
pub fn generate() -> String {
|
pub fn generate() -> String {
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
format!(
|
(*WORDS.choose(&mut rng).unwrap()).to_string()
|
||||||
"{}-{}",
|
|
||||||
ADJECTIVES.choose(&mut rng).unwrap(),
|
|
||||||
NOUNS.choose(&mut rng).unwrap()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `count` fresh handles (the caller clamps; this only trusts its input).
|
/// `count` fresh handles, distinct while the wordlist lasts (the caller
|
||||||
|
/// clamps; this only trusts its input).
|
||||||
pub fn generate_batch(count: usize) -> Vec<String> {
|
pub fn generate_batch(count: usize) -> Vec<String> {
|
||||||
(0..count).map(|_| generate()).collect()
|
use rand::seq::SliceRandom;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let mut pool: Vec<&str> = WORDS.to_vec();
|
||||||
|
pool.shuffle(&mut rng);
|
||||||
|
(0..count)
|
||||||
|
.map(|i| match pool.get(i) {
|
||||||
|
// Past the wordlist a batch has to repeat itself.
|
||||||
|
Some(word) => (*word).to_string(),
|
||||||
|
None => generate(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_name_is_adjective_dash_noun() {
|
fn generated_name_is_a_single_known_word() {
|
||||||
let name = generate();
|
let name = generate();
|
||||||
let (adj, noun) = name.split_once('-').expect("name contains a dash");
|
assert!(!name.contains('-'), "{name} is not one word");
|
||||||
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
|
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||||
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_returns_the_requested_count() {
|
fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() {
|
||||||
|
let unique: HashSet<&&str> = WORDS.iter().collect();
|
||||||
|
assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates");
|
||||||
|
assert!(
|
||||||
|
WORDS.len() >= MAX_BATCH,
|
||||||
|
"a full batch would have to repeat"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_returns_the_requested_count_without_repeats() {
|
||||||
assert_eq!(generate_batch(1).len(), 1);
|
assert_eq!(generate_batch(1).len(), 1);
|
||||||
assert_eq!(generate_batch(50).len(), 50);
|
let batch = generate_batch(MAX_BATCH);
|
||||||
for name in generate_batch(10) {
|
assert_eq!(batch.len(), MAX_BATCH);
|
||||||
let (adj, noun) = name.split_once('-').unwrap();
|
for name in &batch {
|
||||||
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
|
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||||
}
|
}
|
||||||
|
let unique: HashSet<&String> = batch.iter().collect();
|
||||||
|
assert_eq!(unique.len(), batch.len(), "batch repeated a handle");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rerolls_can_differ() {
|
fn rerolls_can_differ() {
|
||||||
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
|
|
||||||
let first = generate();
|
let first = generate();
|
||||||
let differs = (0..20).any(|_| generate() != first);
|
let differs = (0..20).any(|_| generate() != first);
|
||||||
assert!(differs);
|
assert!(differs);
|
||||||
|
|||||||
@@ -140,6 +140,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|||||||
cur
|
cur
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Command wrappers that run another program: the program is what follows.
|
||||||
|
const WRAPPERS: [&str; 8] = [
|
||||||
|
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||||
|
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||||
|
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||||
|
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
||||||
|
pub fn command_program(command: &str) -> String {
|
||||||
|
let words: Vec<&str> = command.split_whitespace().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < words.len() {
|
||||||
|
let word = words[i];
|
||||||
|
if is_env_assignment(word) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let wrapper = basename(word).to_lowercase();
|
||||||
|
if WRAPPERS.contains(&wrapper.as_str()) {
|
||||||
|
i += 1;
|
||||||
|
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match words.get(i) {
|
||||||
|
Some(word) => basename(word).to_string(),
|
||||||
|
// Nothing but wrappers and assignments: say what was there rather than
|
||||||
|
// rendering an empty fragment.
|
||||||
|
None => command.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
let rest: String = chars.collect();
|
||||||
|
match rest.split_once('=') {
|
||||||
|
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn basename(word: &str) -> &str {
|
||||||
|
match word.rsplit_once('/') {
|
||||||
|
Some((_, tail)) => tail,
|
||||||
|
None => word,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -318,4 +375,28 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(nested.name, "a/b");
|
assert_eq!(nested.name, "a/b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_program_drops_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
command_program("claude --dangerously-skip-permissions"),
|
||||||
|
"claude"
|
||||||
|
);
|
||||||
|
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||||
|
assert_eq!(command_program("hx src/main.rs"), "hx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_program_looks_through_paths_env_and_wrappers() {
|
||||||
|
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
||||||
|
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
||||||
|
assert_eq!(command_program("npx opencode"), "opencode");
|
||||||
|
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
||||||
|
assert_eq!(command_program("sudo"), "sudo");
|
||||||
|
assert_eq!(command_program(""), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ pub struct DaemonInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||||
/// `adjective-noun` session handles generated by the `maestro-names` crate.
|
/// one-word session handles generated by the `maestro-names` crate.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct NameBatch {
|
pub struct NameBatch {
|
||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
//!
|
//!
|
||||||
//! The picker itself is two steps (#96), verbatim from the rules in
|
//! The picker itself is two steps (#96), verbatim from the rules in
|
||||||
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
|
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
|
||||||
//! 1. Step one is one box per repository (name, then full path). A worktree
|
//! 1. Step one is one box per repository (name, then its parent directory).
|
||||||
//! is a box only when its main checkout is missing from the scan.
|
//! A worktree is a box only when its main checkout is missing from the
|
||||||
|
//! scan.
|
||||||
//! 2. The filter keeps a repository when the query matches it or any of its
|
//! 2. The filter keeps a repository when the query matches it or any of its
|
||||||
//! worktrees, so a worktree name still finds its box.
|
//! worktrees, so a worktree name still finds its box.
|
||||||
//! 3. Picking a box reveals step two: "+ new", then that repository's
|
//! 3. Picking a box reveals step two: "+ new", then that repository's
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
//! these are separate acts.
|
//! these are separate acts.
|
||||||
//! 6. Neither step probes `/api/gitroot`; the scan already reports
|
//! 6. Neither step probes `/api/gitroot`; the scan already reports
|
||||||
//! repository membership. Only the free-text hatch pays for a probe.
|
//! repository membership. Only the free-text hatch pays for a probe.
|
||||||
|
//! 7. Neither step touches the command: the directory and what runs in it
|
||||||
|
//! are independent choices.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -39,6 +42,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
|
use maestro_protocol::display::abbreviate_dir;
|
||||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
|
|||||||
pub stale: bool,
|
pub stale: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A box's second column: the directory holding the project, `~`-abbreviated.
|
||||||
|
/// Only the stem — the last segment is the name column beside it.
|
||||||
|
fn parent_dir_label(path: &Path, home: &str) -> String {
|
||||||
|
match path.parent() {
|
||||||
|
Some(parent) => abbreviate_dir(parent, home),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The repository key a [`DraftGroup`] was formed under — same key
|
/// The repository key a [`DraftGroup`] was formed under — same key
|
||||||
/// `projects::group_projects` groups by, kept separately because the main
|
/// `projects::group_projects` groups by, kept separately because the main
|
||||||
/// box's own path only equals it when the main checkout itself was found.
|
/// box's own path only equals it when the main checkout itself was found.
|
||||||
@@ -893,13 +906,6 @@ impl DraftPane {
|
|||||||
self.worktree_choice = choice.clone();
|
self.worktree_choice = choice.clone();
|
||||||
self.picked_cwd = Some(target.path.clone());
|
self.picked_cwd = Some(target.path.clone());
|
||||||
self.cwd_raw_visible = false;
|
self.cwd_raw_visible = false;
|
||||||
if let Some(cmd) = target.last_command.as_deref() {
|
|
||||||
if !cmd.is_empty() {
|
|
||||||
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
|
|
||||||
self.selected_preset = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match choice {
|
match choice {
|
||||||
WorktreeChoice::New => {
|
WorktreeChoice::New => {
|
||||||
self.worktree_enabled = true;
|
self.worktree_enabled = true;
|
||||||
@@ -1200,9 +1206,9 @@ impl DraftPane {
|
|||||||
cursor = Some((col, filter_row.y));
|
cursor = Some((col, filter_row.y));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step one (#96 rule 1): one box per repository, name then the FULL
|
// Step one (#96 rule 1): one box per repository, name then the parent
|
||||||
// path — not the abbreviated form other lists use — so two projects
|
// directory it sits in. The last segment is the name column already;
|
||||||
// sharing a basename stay distinguishable.
|
// the stem is what keeps two projects sharing a basename apart.
|
||||||
let visible = self.visible_groups();
|
let visible = self.visible_groups();
|
||||||
let sel = self.selection();
|
let sel = self.selection();
|
||||||
let count = visible.len() + 1;
|
let count = visible.len() + 1;
|
||||||
@@ -1241,7 +1247,10 @@ impl DraftPane {
|
|||||||
Span::styled(if active { ">" } else { " " }, base),
|
Span::styled(if active { ">" } else { " " }, base),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(format!("{:<name_width$}", group.main.name), base),
|
Span::styled(format!("{:<name_width$}", group.main.name), base),
|
||||||
Span::styled(format!(" {}", group.main.path.display()), base),
|
Span::styled(
|
||||||
|
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
|
||||||
|
base,
|
||||||
|
),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
let mut style = Style::default();
|
let mut style = Style::default();
|
||||||
@@ -1741,8 +1750,9 @@ mod tests {
|
|||||||
let stale_pos = rendered.find("stale-repo").unwrap();
|
let stale_pos = rendered.find("stale-repo").unwrap();
|
||||||
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("/home/u/new/repo"),
|
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
|
||||||
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
|
"the parent directory only — the name column is the last segment \
|
||||||
|
(#96 rule 1):\n{rendered}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Staleness dims the row's cells.
|
// Staleness dims the row's cells.
|
||||||
@@ -1787,7 +1797,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft.command(),
|
draft.command(),
|
||||||
"claude",
|
"claude",
|
||||||
"pick pre-selects the last command"
|
"the preset is the operator's own choice — picking a project leaves it alone"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2640,8 +2650,8 @@ mod tests {
|
|||||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("/home/u/repos/main"),
|
rendered.contains("~/repos"),
|
||||||
"step one shows the full repository path:\n{rendered}"
|
"step one shows the directory holding the repository:\n{rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("+ new"),
|
rendered.contains("+ new"),
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
|
use maestro_protocol::display::{
|
||||||
|
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
|
||||||
|
};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
|
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
@@ -297,7 +299,8 @@ impl Sidebar {
|
|||||||
if dead {
|
if dead {
|
||||||
tail.push(Span::styled(" exited", muted));
|
tail.push(Span::styled(" exited", muted));
|
||||||
}
|
}
|
||||||
let command = Span::styled(format!(" {}", s.command), muted);
|
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||||
|
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||||
|
|
||||||
// Reserve the triggered status fragments before detail and command.
|
// Reserve the triggered status fragments before detail and command.
|
||||||
// If the row is exceptionally narrow, keep one column for the
|
// If the row is exceptionally narrow, keep one column for the
|
||||||
@@ -874,12 +877,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||||
|
#[test]
|
||||||
|
fn meta_line_drops_the_commands_arguments() {
|
||||||
|
let mut s = session_named(1, "handle");
|
||||||
|
s.command = "claude --dangerously-skip-permissions".into();
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 60, 6));
|
||||||
|
let meta = content(&all[3]);
|
||||||
|
assert!(
|
||||||
|
meta.contains("claude"),
|
||||||
|
"line 2 must name the program: {meta:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!meta.contains("dangerously"),
|
||||||
|
"line 2 must not carry arguments: {meta:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
||||||
/// onto a third row (docs/SIDEBAR.md).
|
/// onto a third row (docs/SIDEBAR.md).
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_command_truncates_instead_of_wrapping() {
|
fn meta_line_command_truncates_instead_of_wrapping() {
|
||||||
let mut s = session_named(1, "claude");
|
let mut s = session_named(1, "claude");
|
||||||
s.command = "a command that cannot fit".into();
|
s.command = "a-program-name-that-cannot-fit".into();
|
||||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
s.blocked_work = Some(InTheWay {
|
s.blocked_work = Some(InTheWay {
|
||||||
uncommitted_files: 120,
|
uncommitted_files: 120,
|
||||||
@@ -1099,7 +1124,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blocked_warning_survives_before_a_truncated_command() {
|
fn blocked_warning_survives_before_a_truncated_command() {
|
||||||
let mut s = session_named(1, "name");
|
let mut s = session_named(1, "name");
|
||||||
s.command = "a command that cannot fit".into();
|
s.command = "a-program-name-that-cannot-fit".into();
|
||||||
s.blocked_work = Some(InTheWay {
|
s.blocked_work = Some(InTheWay {
|
||||||
uncommitted_files: 1,
|
uncommitted_files: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+19
-14
@@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
|
|||||||
card's tooltip and the wording of the kill refusal, so no two clients describe
|
card's tooltip and the wording of the kill refusal, so no two clients describe
|
||||||
the same tree differently.
|
the same tree differently.
|
||||||
|
|
||||||
`<command>` is unconditional and muted: the session's command line, shown on
|
`<command>` is unconditional and muted: it names the **program** the session
|
||||||
every card in both clients — no omission in the TUI, no name-vs-command
|
runs — never its arguments. `claude --dangerously-skip-permissions` renders as
|
||||||
comparison gating it. A session named by its command line then shows the same
|
`claude`. Flags say how a session was invoked, which is not what a card is for,
|
||||||
string on both lines; that is accepted, the price of a row that never changes
|
and a long one crowds out every other fragment on the line. It shows on every
|
||||||
height.
|
card in both clients — no omission in the TUI, no name-vs-command comparison
|
||||||
|
gating it. A session named by its program then shows the same string on both
|
||||||
|
lines; that is accepted, the price of a row that never changes height.
|
||||||
|
|
||||||
In the **web client** the command's leading program is replaced by that tool's
|
The program is found by looking through a path, leading `FOO=bar` assignments
|
||||||
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini
|
and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
|
||||||
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the
|
`/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
|
||||||
command line follow as text. Recognition looks through a path, leading
|
is shown as written rather than as an empty fragment.
|
||||||
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
|
|
||||||
unrecognised program keeps its full text, so the card never shows a mark
|
In the **web client** the program is replaced by that tool's icon when it is
|
||||||
standing for the wrong tool. The full command line stays in the card's tooltip
|
one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
|
||||||
either way. The **TUI** keeps the command as text: a terminal cannot be assumed
|
Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
|
||||||
to have a font carrying tool glyphs.
|
program keeps its name as text, so the card never shows a mark standing for the
|
||||||
|
wrong tool. The full command line, arguments and all, stays in the card's
|
||||||
|
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
|
||||||
|
assumed to have a font carrying tool glyphs.
|
||||||
|
|
||||||
## Colour per activity state
|
## Colour per activity state
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
|
|||||||
forced kill removes it as usual — force changes what happens to blocked work,
|
forced kill removes it as usual — force changes what happens to blocked work,
|
||||||
not ordinary cleanup.
|
not ordinary cleanup.
|
||||||
|
|
||||||
|
In the web client the kill button sends the unforced `DELETE` straight away —
|
||||||
|
there is no pre-confirmation, because the daemon is the one that decides. Only
|
||||||
|
the refusal raises a prompt, and that prompt names what is in the way and offers
|
||||||
|
"Force kill", which repeats the request with `force=true`.
|
||||||
|
|
||||||
Ending a session and destroying its work are two separate acts. Maestro only
|
Ending a session and destroying its work are two separate acts. Maestro only
|
||||||
performs the first one. Removing a tree it left behind is the operator's own
|
performs the first one. Removing a tree it left behind is the operator's own
|
||||||
`git worktree remove`, chosen while looking at exactly what would be lost.
|
`git worktree remove`, chosen while looking at exactly what would be lost.
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
fetcherVersion = 3;
|
fetcherVersion = 3;
|
||||||
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
|
||||||
};
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
|
|||||||
+32
-7
@@ -1,6 +1,7 @@
|
|||||||
import * as routes from "./routes.js";
|
import * as routes from "./routes.js";
|
||||||
import type {
|
import type {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
InTheWay,
|
||||||
Branches,
|
Branches,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
DaemonInfo,
|
DaemonInfo,
|
||||||
@@ -13,19 +14,38 @@ import type {
|
|||||||
Uuid,
|
Uuid,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
|
||||||
|
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
|
||||||
|
* render `unsavedWork` rather than re-parsing the message. */
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string;
|
||||||
|
readonly unsavedWork: InTheWay | null;
|
||||||
|
|
||||||
|
constructor(res: Response, info?: ApiError["error"], text?: string) {
|
||||||
|
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
|
||||||
|
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||||||
|
this.name = "ApiRequestError";
|
||||||
|
this.status = res.status;
|
||||||
|
this.code = info?.code ?? "";
|
||||||
|
this.detail = info?.message ?? text ?? "";
|
||||||
|
this.unsavedWork = info?.unsaved_work ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 204) return undefined as unknown as T;
|
if (res.status === 204) return undefined as unknown as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
let detail = "";
|
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as ApiError;
|
const body = (await res.json()) as ApiError;
|
||||||
detail = `${body.error.code}: ${body.error.message}`;
|
throw new ApiRequestError(res, body.error);
|
||||||
} catch {
|
} catch (e) {
|
||||||
detail = await res.text();
|
if (e instanceof ApiRequestError) throw e;
|
||||||
|
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
|
||||||
}
|
}
|
||||||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSessions(): Promise<Session[]> {
|
export async function listSessions(): Promise<Session[]> {
|
||||||
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function killSession(id: Uuid): Promise<void> {
|
/** Kill a session. With `force` (#81) the daemon overrides its refusal over a
|
||||||
|
* worktree holding unsaved work and leaves that worktree on disk. */
|
||||||
|
export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||||
|
const url = force
|
||||||
|
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
|
||||||
|
: routes.session(id);
|
||||||
await jsonOrError(
|
await jsonOrError(
|
||||||
await fetch(routes.session(id), {
|
await fetch(url, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
}),
|
}),
|
||||||
|
|||||||
+20
-4
@@ -6,6 +6,8 @@ import { attachWsUrl } from "./routes.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 } from "./keys.js";
|
import { applyModsToChar, cursorSeq } from "./keys.js";
|
||||||
|
import { requestToggleDrawer } from "./drawer.js";
|
||||||
|
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||||
import type { ClientControl, Uuid } from "./types.js";
|
import type { ClientControl, Uuid } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +56,16 @@ export interface AttachInfo {
|
|||||||
command: string;
|
command: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||||||
|
* mark for it and stays as its bare name when we do not. Arguments are
|
||||||
|
* dropped, as on the card. */
|
||||||
|
function commandFragment(command: string): string {
|
||||||
|
const { tool, program } = splitCommand(command);
|
||||||
|
const icon = tool ? toolIconSvg(tool) : null;
|
||||||
|
const text = icon ? "" : program;
|
||||||
|
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createAttachConnection(
|
export function createAttachConnection(
|
||||||
sessionId: Uuid,
|
sessionId: Uuid,
|
||||||
opts: AttachOptions = {},
|
opts: AttachOptions = {},
|
||||||
@@ -65,14 +77,12 @@ export function createAttachConnection(
|
|||||||
// tooltip for when you really need it.
|
// tooltip for when you really need it.
|
||||||
const titleHtml = info
|
const titleHtml = info
|
||||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||||
(info.command && info.command !== info.name
|
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||||
? `<span class="at-cmd">${escapeHtml(info.command)}</span>`
|
|
||||||
: "")
|
|
||||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<header class="attach-header">
|
<header class="attach-header">
|
||||||
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
|
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
|
||||||
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
|
||||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||||
@@ -92,6 +102,12 @@ export function createAttachConnection(
|
|||||||
stateEl.dataset.state = kind;
|
stateEl.dataset.state = kind;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
|
||||||
|
// the session list is an off-canvas drawer and this is what opens it.
|
||||||
|
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
|
||||||
|
requestToggleDrawer(),
|
||||||
|
);
|
||||||
|
|
||||||
disconnectBtn.addEventListener("click", () => {
|
disconnectBtn.addEventListener("click", () => {
|
||||||
opts.onRequestClose?.(sessionId);
|
opts.onRequestClose?.(sessionId);
|
||||||
});
|
});
|
||||||
|
|||||||
+79
-28
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { renderSidebar } from "./catalog.js";
|
import { renderSidebar } from "./catalog.js";
|
||||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
@@ -16,6 +16,10 @@ beforeAll(() => {
|
|||||||
window.fetch = () => Promise.reject(new Error("offline"));
|
window.fetch = () => Promise.reject(new Error("offline"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector(".kill-dialog")?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
function session(overrides: Partial<Session>): Session {
|
function session(overrides: Partial<Session>): Session {
|
||||||
return {
|
return {
|
||||||
id: "11111111-2222-3333-4444-555555555555",
|
id: "11111111-2222-3333-4444-555555555555",
|
||||||
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
|||||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Let the kill chain's awaits (fetch, its json body) settle. */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
describe("blocked-work indicator (#83)", () => {
|
describe("blocked-work indicator (#83)", () => {
|
||||||
it("marks the row of a blocked session in diff notation", () => {
|
it("marks the row of a blocked session in diff notation", () => {
|
||||||
const blocked = session({
|
const blocked = session({
|
||||||
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
|
|||||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restates the warning at the kill confirm", async () => {
|
it("kills straight away, without a first confirmation", async () => {
|
||||||
const confirmations: string[] = [];
|
const calls: string[] = [];
|
||||||
window.confirm = (msg?: string) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
confirmations.push(msg ?? "");
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
return false;
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
};
|
}) as typeof fetch;
|
||||||
|
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||||
|
const el = renderSidebar(storeWith([plain])).el;
|
||||||
|
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the override when the daemon refuses a guarded kill", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "kill_guarded",
|
||||||
|
message: "worktree has unsaved work",
|
||||||
|
unsaved_work: { untracked_files: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
const blocked = session({
|
const blocked = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||||
command: "bash",
|
command: "bash",
|
||||||
blocked_work: { untracked_files: 1 },
|
blocked_work: { untracked_files: 1 },
|
||||||
});
|
});
|
||||||
const el = renderSidebar(storeWith([blocked])).el;
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
kill?.click();
|
await flush();
|
||||||
await Promise.resolve();
|
const dialog = document.querySelector(".kill-dialog");
|
||||||
expect(confirmations).toHaveLength(1);
|
expect(dialog?.textContent).toContain("1 untracked file");
|
||||||
expect(confirmations[0]).toContain("1 untracked file");
|
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
|
||||||
expect(confirmations[0]).toContain("refused unless forced");
|
await flush();
|
||||||
|
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("confirms plainly when nothing blocks", async () => {
|
it("closes the override prompt without killing on cancel", async () => {
|
||||||
const confirmations: string[] = [];
|
const calls: string[] = [];
|
||||||
window.confirm = (msg?: string) => {
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
confirmations.push(msg ?? "");
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
return false;
|
return Promise.resolve(
|
||||||
};
|
new Response(
|
||||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
JSON.stringify({
|
||||||
const el = renderSidebar(storeWith([plain])).el;
|
error: { code: "kill_guarded", message: "worktree has unsaved work" },
|
||||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
}),
|
||||||
await Promise.resolve();
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const blocked = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000006",
|
||||||
|
blocked_work: { unpushed_commits: 2 },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([blocked])).el;
|
||||||
|
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||||
|
await flush();
|
||||||
|
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
|
||||||
|
expect(document.querySelector(".kill-dialog")).toBeNull();
|
||||||
|
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,15 +258,16 @@ describe("session cards (#107)", () => {
|
|||||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the command's arguments as text beside the icon", () => {
|
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||||
command: "claude --resume",
|
command: "claude --dangerously-skip-permissions",
|
||||||
});
|
});
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume");
|
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||||
|
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an unknown command as plain text", () => {
|
it("keeps an unknown command as plain text", () => {
|
||||||
@@ -228,7 +278,8 @@ describe("session cards (#107)", () => {
|
|||||||
const el = renderSidebar(storeWith([s])).el;
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs");
|
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
||||||
|
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps each activity state to its contract slug", () => {
|
it("maps each activity state to its contract slug", () => {
|
||||||
|
|||||||
+66
-91
@@ -1,5 +1,6 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
|
import { openForceKillDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { isTypingTarget, toggleHelp } from "./help.js";
|
import { isTypingTarget, toggleHelp } from "./help.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
@@ -37,6 +38,10 @@ export function requestOpenSpawn(): 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 open a draft pane seeded from an existing session
|
||||||
|
* ("Spawn similar"). Detail is the session being cloned. */
|
||||||
|
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||||
|
|
||||||
/** 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 —
|
||||||
@@ -92,13 +97,14 @@ function blockedFragment(blockers: InTheWay): string {
|
|||||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
|
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
||||||
* we have a mark for it, and only the remaining words stay as text. The full
|
* the card. The program becomes its tool icon when we have a mark for it, and
|
||||||
* command line stays in the tooltip either way. */
|
* stays as its bare name when we do not. The full command line, arguments and
|
||||||
|
* all, stays in the tooltip either way. */
|
||||||
function commandFragment(command: string): string {
|
function commandFragment(command: string): string {
|
||||||
const { tool, rest } = splitCommand(command);
|
const { tool, program } = splitCommand(command);
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
const icon = tool ? toolIconSvg(tool) : null;
|
||||||
const text = icon ? rest : command;
|
const text = icon ? "" : program;
|
||||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +175,13 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
||||||
});
|
});
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void confirmAndKill(s, name);
|
void requestKill(s, name);
|
||||||
});
|
});
|
||||||
row.addEventListener("contextmenu", (ev) => {
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -185,24 +191,33 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
{
|
{
|
||||||
label: "Spawn similar",
|
label: "Spawn similar",
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||||
},
|
},
|
||||||
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** #83: the confirm restates the warning at the moment of decision. */
|
/**
|
||||||
async function confirmAndKill(s: Session, name: string): Promise<void> {
|
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||||
const blockers = sessionBlockers(s);
|
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||||
const question = blockers
|
* that refusal is worth a prompt, and it needs one `confirm()` cannot give —
|
||||||
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
* a forced kill. Anything else surfaces as an alert.
|
||||||
: `Kill session "${name}"?`;
|
*/
|
||||||
if (!confirm(question)) return;
|
async function requestKill(s: Session, name: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await killSession(s.id);
|
await killSession(s.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
|
||||||
|
openForceKillDialog({
|
||||||
|
name,
|
||||||
|
blockers: e.unsavedWork ?? sessionBlockers(s),
|
||||||
|
reason: e.detail,
|
||||||
|
onForce: () => killSession(s.id, true),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
alert(`Failed to kill: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,18 +277,6 @@ function beginRename(row: HTMLElement, s: Session): void {
|
|||||||
input.select();
|
input.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatusbar(sessions: Session[]): HTMLElement {
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "statusbar";
|
|
||||||
|
|
||||||
const live = sessions.filter((s) => s.status === "Running").length;
|
|
||||||
const tally = document.createElement("span");
|
|
||||||
tally.className = "session-tally";
|
|
||||||
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
|
|
||||||
wrap.appendChild(tally);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "empty";
|
el.className = "empty";
|
||||||
@@ -298,68 +301,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The global app header (full width, above the sidebar/content split): brand +
|
|
||||||
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
|
||||||
* stay put and reachable regardless of which pane is showing.
|
|
||||||
*/
|
|
||||||
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
|
|
||||||
const header = document.createElement("header");
|
|
||||||
header.className = "app-header";
|
|
||||||
header.innerHTML = `
|
|
||||||
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
|
|
||||||
<a class="brand" href="#/" aria-label="maestro — clear selection">
|
|
||||||
<span class="brand-dot" aria-hidden="true"></span>
|
|
||||||
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
|
|
||||||
</a>
|
|
||||||
<div class="app-status"></div>
|
|
||||||
<div class="app-actions"></div>
|
|
||||||
`;
|
|
||||||
// Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer.
|
|
||||||
(header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () =>
|
|
||||||
onToggleSidebar?.(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Session tally lives centered in the header (not the sidebar).
|
|
||||||
const statusHost = header.querySelector(".app-status") as HTMLElement;
|
|
||||||
const renderStatus = () => {
|
|
||||||
const state = store.snapshot();
|
|
||||||
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
|
|
||||||
};
|
|
||||||
store.subscribe(renderStatus);
|
|
||||||
renderStatus();
|
|
||||||
|
|
||||||
const actions = header.querySelector(".app-actions") as HTMLElement;
|
|
||||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
|
||||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const kit = document.createElement("a");
|
|
||||||
kit.className = "btn btn-ghost";
|
|
||||||
kit.href = "?kit";
|
|
||||||
kit.textContent = "kit";
|
|
||||||
kit.title = "Open the design kit (DEV)";
|
|
||||||
actions.appendChild(kit);
|
|
||||||
}
|
|
||||||
const helpBtn = document.createElement("button");
|
|
||||||
helpBtn.type = "button";
|
|
||||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
|
||||||
helpBtn.textContent = "?";
|
|
||||||
helpBtn.title = "Keyboard shortcuts (?)";
|
|
||||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
|
||||||
helpBtn.addEventListener("click", () => toggleHelp());
|
|
||||||
actions.appendChild(helpBtn);
|
|
||||||
actions.appendChild(createThemeToggle());
|
|
||||||
const spawnBtn = document.createElement("button");
|
|
||||||
spawnBtn.type = "button";
|
|
||||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
|
||||||
spawnBtn.textContent = "+ Spawn";
|
|
||||||
// The keyboard pair sits right here in the tooltip (#67).
|
|
||||||
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
|
||||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
|
||||||
actions.appendChild(spawnBtn);
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||||
* pane (quiet), and which one is focused (loud). */
|
* pane (quiet), and which one is focused (loud). */
|
||||||
export interface SidebarHighlights {
|
export interface SidebarHighlights {
|
||||||
@@ -425,6 +366,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
treeHost.className = "tree";
|
treeHost.className = "tree";
|
||||||
el.appendChild(treeHost);
|
el.appendChild(treeHost);
|
||||||
|
|
||||||
|
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
||||||
|
// rather than the sidebar's own scrolling content, so they stay reachable
|
||||||
|
// regardless of tree scroll position.
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.className = "sidebar-footer";
|
||||||
|
const helpBtn = document.createElement("button");
|
||||||
|
helpBtn.type = "button";
|
||||||
|
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
||||||
|
helpBtn.textContent = "?";
|
||||||
|
helpBtn.title = "Keyboard shortcuts (?)";
|
||||||
|
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
||||||
|
helpBtn.addEventListener("click", () => toggleHelp());
|
||||||
|
footer.appendChild(helpBtn);
|
||||||
|
footer.appendChild(createThemeToggle());
|
||||||
|
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||||
|
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const kit = document.createElement("a");
|
||||||
|
kit.className = "btn btn-ghost";
|
||||||
|
kit.href = "?kit";
|
||||||
|
kit.textContent = "kit";
|
||||||
|
kit.title = "Open the design kit (DEV)";
|
||||||
|
footer.appendChild(kit);
|
||||||
|
}
|
||||||
|
const spawnBtn = document.createElement("button");
|
||||||
|
spawnBtn.type = "button";
|
||||||
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||||
|
spawnBtn.textContent = "+ Spawn";
|
||||||
|
// The keyboard pair sits right here in the tooltip (#67).
|
||||||
|
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
||||||
|
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||||
|
footer.appendChild(spawnBtn);
|
||||||
|
el.appendChild(footer);
|
||||||
|
|
||||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
||||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
||||||
treeHost.addEventListener("pointerdown", (e) => {
|
treeHost.addEventListener("pointerdown", (e) => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||||
|
import { installDrawer, type Drawer } from "./drawer.js";
|
||||||
|
|
||||||
|
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
|
||||||
|
const shell = document.createElement("div");
|
||||||
|
const sidebar = document.createElement("aside");
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "tree-leaf";
|
||||||
|
sidebar.appendChild(row);
|
||||||
|
shell.append(sidebar, backdrop);
|
||||||
|
document.body.replaceChildren(shell);
|
||||||
|
const drawer = installDrawer(shell, sidebar, backdrop);
|
||||||
|
drawer.open();
|
||||||
|
return { shell, sidebar, drawer };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mobile drawer", () => {
|
||||||
|
beforeEach(() => document.body.replaceChildren());
|
||||||
|
|
||||||
|
it("closes when a session row is tapped", () => {
|
||||||
|
const { shell, sidebar } = mount();
|
||||||
|
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
|
||||||
|
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when a spawn is requested", () => {
|
||||||
|
const { shell } = mount();
|
||||||
|
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||||
|
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when spawn similar is requested", () => {
|
||||||
|
const { shell } = mount();
|
||||||
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
|
||||||
|
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the backdrop click closing it", () => {
|
||||||
|
const { shell } = mount();
|
||||||
|
(shell.lastElementChild as HTMLElement).click();
|
||||||
|
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
|
||||||
|
// inert (CSS gates the drawer styling).
|
||||||
|
//
|
||||||
|
// Anything that puts a new view in the content area must close the drawer, or
|
||||||
|
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
|
||||||
|
// similar" sits inside a session row but stops propagation, so it never reaches
|
||||||
|
// the row's own close — both spawn paths arrive here via document events.
|
||||||
|
|
||||||
|
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||||
|
|
||||||
|
/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile),
|
||||||
|
* without threading the controller through every view. */
|
||||||
|
export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
|
||||||
|
|
||||||
|
export function requestToggleDrawer(): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Drawer {
|
||||||
|
open(): void;
|
||||||
|
close(): void;
|
||||||
|
toggle(): void;
|
||||||
|
isOpen(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installDrawer(
|
||||||
|
shellEl: HTMLElement,
|
||||||
|
sidebarEl: HTMLElement,
|
||||||
|
backdropEl: HTMLElement,
|
||||||
|
): Drawer {
|
||||||
|
const drawer: Drawer = {
|
||||||
|
open: () => shellEl.classList.add("drawer-open"),
|
||||||
|
close: () => shellEl.classList.remove("drawer-open"),
|
||||||
|
toggle: () => shellEl.classList.toggle("drawer-open"),
|
||||||
|
isOpen: () => shellEl.classList.contains("drawer-open"),
|
||||||
|
};
|
||||||
|
|
||||||
|
backdropEl.addEventListener("click", () => drawer.close());
|
||||||
|
sidebarEl.addEventListener("click", (e) => {
|
||||||
|
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
|
||||||
|
});
|
||||||
|
document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle());
|
||||||
|
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
|
||||||
|
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
|
||||||
|
|
||||||
|
return drawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
|
||||||
|
* the browser's back/forward navigation. Only a clearly horizontal gesture past
|
||||||
|
* a threshold counts, so vertical terminal scrolling (which owns its own touch
|
||||||
|
* handler) is untouched. */
|
||||||
|
export function installDrawerSwipe(drawer: Drawer): void {
|
||||||
|
const mobileMq = window.matchMedia("(max-width: 859px)");
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let tracking = false;
|
||||||
|
document.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
tracking = mobileMq.matches && e.touches.length === 1;
|
||||||
|
if (tracking) {
|
||||||
|
sx = e.touches[0].clientX;
|
||||||
|
sy = e.touches[0].clientY;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
(e) => {
|
||||||
|
if (!tracking) return;
|
||||||
|
tracking = false;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - sx;
|
||||||
|
const dy = t.clientY - sy;
|
||||||
|
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||||
|
if (dx > 0 && !drawer.isOpen()) drawer.open();
|
||||||
|
else if (dx < 0 && drawer.isOpen()) drawer.close();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Forced-kill prompt (#81).
|
||||||
|
*
|
||||||
|
* The daemon refuses to kill a session whose worktree still holds uncommitted
|
||||||
|
* or unpushed work. `confirm()` can only ask the same question twice, so the
|
||||||
|
* refusal gets its own dialog: it names what is in the way and offers the
|
||||||
|
* override the daemon's `force=true` already implements. A kill the daemon
|
||||||
|
* accepts never reaches here — there is nothing to warn about.
|
||||||
|
*/
|
||||||
|
import { formatInTheWay, type InTheWay } from "./types.js";
|
||||||
|
|
||||||
|
export interface ForceKillPrompt {
|
||||||
|
/** Session name, as the sidebar shows it. */
|
||||||
|
name: string;
|
||||||
|
/** What the daemon reported in the way, when it reported anything. */
|
||||||
|
blockers: InTheWay | null;
|
||||||
|
/** Message from the daemon's refusal, shown when there is no breakdown. */
|
||||||
|
reason: string;
|
||||||
|
/** Run the forced kill. A rejection is rendered in the dialog, which stays
|
||||||
|
* open so the override can be retried. */
|
||||||
|
onForce: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
|
||||||
|
|
||||||
|
export function closeForceKillDialog(): void {
|
||||||
|
if (!open) return;
|
||||||
|
window.removeEventListener("keydown", open.onKeydown, true);
|
||||||
|
open.el.remove();
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
|
||||||
|
* can never stack two dialogs. */
|
||||||
|
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
|
||||||
|
closeForceKillDialog();
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "kill-dialog";
|
||||||
|
el.setAttribute("role", "dialog");
|
||||||
|
el.setAttribute("aria-modal", "true");
|
||||||
|
el.setAttribute("aria-label", `Kill ${p.name}`);
|
||||||
|
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "kill-dialog-panel";
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.className = "kill-dialog-title";
|
||||||
|
title.textContent = `Kill "${p.name}"?`;
|
||||||
|
|
||||||
|
const body = document.createElement("p");
|
||||||
|
body.className = "kill-dialog-body";
|
||||||
|
body.textContent = p.blockers
|
||||||
|
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
|
||||||
|
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "kill-dialog-error";
|
||||||
|
error.hidden = true;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "kill-dialog-actions";
|
||||||
|
const cancel = document.createElement("button");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.className = "btn btn-ghost kill-dialog-cancel";
|
||||||
|
cancel.textContent = "Cancel";
|
||||||
|
cancel.addEventListener("click", closeForceKillDialog);
|
||||||
|
const force = document.createElement("button");
|
||||||
|
force.type = "button";
|
||||||
|
force.className = "btn btn-danger kill-dialog-force";
|
||||||
|
force.textContent = "Force kill";
|
||||||
|
force.addEventListener("click", () => {
|
||||||
|
force.disabled = true;
|
||||||
|
void p
|
||||||
|
.onForce()
|
||||||
|
.then(closeForceKillDialog)
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
force.disabled = false;
|
||||||
|
error.hidden = false;
|
||||||
|
error.textContent = `Failed to kill: ${(e as Error).message}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.append(cancel, force);
|
||||||
|
|
||||||
|
panel.append(title, body, error, actions);
|
||||||
|
|
||||||
|
// A press on the scrim cancels; one inside the panel is left alone.
|
||||||
|
el.addEventListener("click", (ev) => {
|
||||||
|
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
|
||||||
|
});
|
||||||
|
el.appendChild(panel);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
const onKeydown = (ev: KeyboardEvent): void => {
|
||||||
|
if (ev.key !== "Escape") return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeForceKillDialog();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
|
open = { el, onKeydown };
|
||||||
|
cancel.focus();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -205,7 +205,6 @@ export function renderKit(): HTMLElement {
|
|||||||
);
|
);
|
||||||
activeLine.setAttribute("data-activity", "active");
|
activeLine.setAttribute("data-activity", "active");
|
||||||
body.append(
|
body.append(
|
||||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
|
||||||
specimen("folder-count", h("span", "folder-count", "4")),
|
specimen("folder-count", h("span", "folder-count", "4")),
|
||||||
specimen("card-line2 · active", activeLine),
|
specimen("card-line2 · active", activeLine),
|
||||||
specimen("attach-state · open", h("span", "attach-state", "live")),
|
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||||
|
|||||||
+21
-59
@@ -2,8 +2,9 @@ import "@fontsource/jetbrains-mono/400.css";
|
|||||||
import "@fontsource/jetbrains-mono/500.css";
|
import "@fontsource/jetbrains-mono/500.css";
|
||||||
import "@fontsource/jetbrains-mono/600.css";
|
import "@fontsource/jetbrains-mono/600.css";
|
||||||
import "@fontsource/jetbrains-mono/700.css";
|
import "@fontsource/jetbrains-mono/700.css";
|
||||||
import { renderSidebar, renderTopbar } from "./catalog.js";
|
import { renderSidebar } from "./catalog.js";
|
||||||
import { ConnectionPool } from "./connections.js";
|
import { ConnectionPool } from "./connections.js";
|
||||||
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
@@ -25,7 +26,7 @@ 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 } from "./catalog.js";
|
import { FOCUS_PANE_EVENT, OPEN_SPAWN_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";
|
||||||
@@ -92,63 +93,15 @@ const content = document.createElement("section");
|
|||||||
content.className = "content";
|
content.className = "content";
|
||||||
|
|
||||||
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
|
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
|
||||||
// the header; a backdrop covers the content while it's open, and tapping a
|
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
|
||||||
// session closes it. On desktop these are inert (CSS gates the drawer styling).
|
// similar, backdrop); on desktop the class it toggles is inert.
|
||||||
const backdrop = document.createElement("div");
|
const backdrop = document.createElement("div");
|
||||||
backdrop.className = "mv-backdrop";
|
backdrop.className = "mv-backdrop";
|
||||||
function closeDrawer(): void {
|
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
|
||||||
shellEl.classList.remove("drawer-open");
|
installDrawerSwipe(drawer);
|
||||||
}
|
|
||||||
function openDrawer(): void {
|
|
||||||
shellEl.classList.add("drawer-open");
|
|
||||||
}
|
|
||||||
function toggleDrawer(): void {
|
|
||||||
shellEl.classList.toggle("drawer-open");
|
|
||||||
}
|
|
||||||
backdrop.addEventListener("click", closeDrawer);
|
|
||||||
sidebar.el.addEventListener("click", (e) => {
|
|
||||||
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
|
|
||||||
// than the browser's back/forward navigation. We only act on a clearly
|
|
||||||
// horizontal gesture past a threshold, so vertical terminal scrolling (which
|
|
||||||
// owns its own touch handler) is untouched.
|
|
||||||
{
|
|
||||||
const mobileMq = window.matchMedia("(max-width: 859px)");
|
|
||||||
let sx = 0;
|
|
||||||
let sy = 0;
|
|
||||||
let tracking = false;
|
|
||||||
document.addEventListener(
|
|
||||||
"touchstart",
|
|
||||||
(e) => {
|
|
||||||
tracking = mobileMq.matches && e.touches.length === 1;
|
|
||||||
if (tracking) {
|
|
||||||
sx = e.touches[0].clientX;
|
|
||||||
sy = e.touches[0].clientY;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
document.addEventListener(
|
|
||||||
"touchend",
|
|
||||||
(e) => {
|
|
||||||
if (!tracking) return;
|
|
||||||
tracking = false;
|
|
||||||
const t = e.changedTouches[0];
|
|
||||||
const dx = t.clientX - sx;
|
|
||||||
const dy = t.clientY - sy;
|
|
||||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
|
||||||
const open = shellEl.classList.contains("drawer-open");
|
|
||||||
if (dx > 0 && !open) openDrawer();
|
|
||||||
else if (dx < 0 && open) closeDrawer();
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
shellEl.append(sidebar.el, content, backdrop);
|
shellEl.append(sidebar.el, content, backdrop);
|
||||||
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
|
root.replaceChildren(shellEl);
|
||||||
|
|
||||||
// The pool owns attach connections independently of the route: navigating away
|
// The pool owns attach connections independently of the route: navigating away
|
||||||
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
|
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
|
||||||
@@ -228,13 +181,22 @@ function renderWelcome(): HTMLElement {
|
|||||||
</p>
|
</p>
|
||||||
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
||||||
<div class="empty-actions"></div>`;
|
<div class="empty-actions"></div>`;
|
||||||
|
const actions = el.querySelector(".empty-actions") as HTMLElement;
|
||||||
|
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
|
||||||
|
// the drawer would be swipe-only. CSS hides this on desktop.
|
||||||
|
const sessions = document.createElement("button");
|
||||||
|
sessions.type = "button";
|
||||||
|
sessions.className = "btn btn-ghost welcome-sessions";
|
||||||
|
sessions.textContent = "☰ Sessions";
|
||||||
|
sessions.addEventListener("click", () => requestToggleDrawer());
|
||||||
|
actions.appendChild(sessions);
|
||||||
const spawn = document.createElement("button");
|
const spawn = document.createElement("button");
|
||||||
spawn.type = "button";
|
spawn.type = "button";
|
||||||
spawn.className = "btn btn-primary";
|
spawn.className = "btn btn-primary";
|
||||||
spawn.textContent = "+ Spawn session";
|
spawn.textContent = "+ Spawn session";
|
||||||
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
||||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
actions.appendChild(spawn);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +234,7 @@ function showMultiView(): void {
|
|||||||
updateShellRoute();
|
updateShellRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn affordances (#62): the header button, the sidebar's empty state, and
|
// Spawn affordances (#62): the sidebar's footer button, its empty state, and
|
||||||
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
|
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
|
||||||
// cloned session (see spawnSimilar.ts).
|
// cloned session (see spawnSimilar.ts).
|
||||||
function requestOpenSpawn(): void {
|
function requestOpenSpawn(): void {
|
||||||
@@ -284,7 +246,7 @@ function requestOpenSpawn(): void {
|
|||||||
mv.openDraft(undefined, { newTab: true });
|
mv.openDraft(undefined, { newTab: true });
|
||||||
}
|
}
|
||||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||||
const s = (ev as CustomEvent<Session>).detail;
|
const s = (ev as CustomEvent<Session>).detail;
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openDraft(spawnSimilarSeed(s));
|
mv.openDraft(spawnSimilarSeed(s));
|
||||||
@@ -358,7 +320,7 @@ window.addEventListener("keydown", (e) => {
|
|||||||
if (sidebar.hasListFocus()) {
|
if (sidebar.hasListFocus()) {
|
||||||
pool.focusTerminal();
|
pool.focusTerminal();
|
||||||
} else {
|
} else {
|
||||||
openDrawer(); // no-op on desktop; on mobile the list is off-canvas
|
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||||
sidebar.focusList();
|
sidebar.focusList();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+127
-1
@@ -17,7 +17,7 @@ function stubFetch(): void {
|
|||||||
: url.includes("/api/projects")
|
: url.includes("/api/projects")
|
||||||
? []
|
? []
|
||||||
: url.includes("/api/names")
|
: url.includes("/api/names")
|
||||||
? { names: ["allegro-cello"] }
|
? { names: ["cello"] }
|
||||||
: undefined;
|
: undefined;
|
||||||
return new Response(JSON.stringify(body), { status: 200 });
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
}),
|
}),
|
||||||
@@ -432,3 +432,129 @@ describe("multiview closeSession tab cleanup (#137)", () => {
|
|||||||
expect(layout.activeTabId).toBe(tab.id);
|
expect(layout.activeTabId).toBe(tab.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("multiview mobile single pane", () => {
|
||||||
|
/** Force the mobile media query on, whatever happy-dom's viewport says. */
|
||||||
|
function forceMobile(): void {
|
||||||
|
vi.spyOn(window, "matchMedia").mockImplementation(
|
||||||
|
(query: string) =>
|
||||||
|
({
|
||||||
|
matches: query.includes("859"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
addListener() {},
|
||||||
|
removeListener() {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as unknown as MediaQueryList,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
forceMobile();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the focused pane instead of splitting beside it", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openSessionBeside("s2");
|
||||||
|
|
||||||
|
const ls = leaves(tab.root);
|
||||||
|
expect(ls).toHaveLength(1);
|
||||||
|
expect(ls[0].sessionId).toBe("s2");
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a spawn draft in the current pane rather than a new tab", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
const ls = leaves(tab.root);
|
||||||
|
expect(ls).toHaveLength(1);
|
||||||
|
expect(ls[0].sessionId).toBeNull();
|
||||||
|
expect(ls[0].draft).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one pane and no pane pills for a multi-leaf tab", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1);
|
||||||
|
expect(mv.el.querySelector(".mv-panebar")).toBeNull();
|
||||||
|
expect(mv.el.querySelector(".mv-pill")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses the tab bar even with several tabs", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const first = layout.tabs[0];
|
||||||
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
||||||
|
const second = addTab(layout);
|
||||||
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiview solo tab bar", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the tab bar while only one tab exists", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the tab bar again once a second tab appears", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const first = layout.tabs[0];
|
||||||
|
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
||||||
|
const second = addTab(layout);
|
||||||
|
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+23
-27
@@ -84,9 +84,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
canvas.className = "mv-canvas";
|
canvas.className = "mv-canvas";
|
||||||
el.append(tabBar, canvas);
|
el.append(tabBar, canvas);
|
||||||
|
|
||||||
// Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time
|
// Mobile (issue #35): below 860px the layout collapses to exactly one visible
|
||||||
// strip of pills — splits/drag/resize are desktop-only. The tree is untouched,
|
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
|
||||||
// so widening restores the splits.
|
// spawning replaces the pane rather than adding a second thing to navigate.
|
||||||
|
// The sidebar drawer is the only navigation. The tree is untouched, so
|
||||||
|
// widening restores whatever splits and tabs a desktop session built.
|
||||||
const mq = window.matchMedia("(max-width: 859px)");
|
const mq = window.matchMedia("(max-width: 859px)");
|
||||||
let isMobile = mq.matches;
|
let isMobile = mq.matches;
|
||||||
mq.addEventListener("change", (e) => {
|
mq.addEventListener("change", (e) => {
|
||||||
@@ -391,7 +393,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||||
if (!target.sessionId) {
|
// Mobile shows one pane, so there is no "beside": take over the pane. The
|
||||||
|
// evicted session keeps running and stays a tap away in the sidebar.
|
||||||
|
if (!target.sessionId || isMobile) {
|
||||||
target.sessionId = sessionId;
|
target.sessionId = sessionId;
|
||||||
target.draft = null;
|
target.draft = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -403,7 +407,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||||
if (opts?.newTab) {
|
// Mobile keeps a single tab and a single pane: the spawn form takes over the
|
||||||
|
// current pane instead of opening a tab or a split.
|
||||||
|
if (opts?.newTab && !isMobile) {
|
||||||
// Spawn opens its own tab rather than halving whatever you were reading
|
// Spawn opens its own tab rather than halving whatever you were reading
|
||||||
// (#100). The empty-pane reuse below still applies within a tab.
|
// (#100). The empty-pane reuse below still applies within a tab.
|
||||||
const fresh = addTab(layout);
|
const fresh = addTab(layout);
|
||||||
@@ -421,9 +427,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||||
if (!target.sessionId) {
|
if (!target.sessionId || isMobile) {
|
||||||
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||||
// picked defaults into `draft` once ready, so that's never reliably null.
|
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||||
|
target.sessionId = null;
|
||||||
target.draft = draft();
|
target.draft = draft();
|
||||||
const stale = draftForms.get(target.id);
|
const stale = draftForms.get(target.id);
|
||||||
if (stale) {
|
if (stale) {
|
||||||
@@ -462,9 +469,13 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
renderTabs();
|
renderTabs();
|
||||||
canvas.replaceChildren();
|
canvas.replaceChildren();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
|
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
|
||||||
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
|
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
|
||||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
el.classList.toggle("mv-mobile-solo", isMobile);
|
||||||
|
// A lone tab has nothing to switch between, so the bar is only a strip of
|
||||||
|
// chrome; CSS hides it until a second tab exists. Spawning still opens its
|
||||||
|
// own tab (#100), which brings the bar back with the "+" button on it.
|
||||||
|
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
|
||||||
|
|
||||||
if (!tab.root) {
|
if (!tab.root) {
|
||||||
pruneForms(tab);
|
pruneForms(tab);
|
||||||
@@ -496,7 +507,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: a pill strip of the active tab's leaves + one full-screen pane.
|
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
|
||||||
|
// behind stay in the tree but are reached only by tapping the session in the
|
||||||
|
// sidebar drawer — mobile shows no pane switcher of its own.
|
||||||
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
|
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
|
||||||
const ls = leaves(tab.root);
|
const ls = leaves(tab.root);
|
||||||
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
|
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
|
||||||
@@ -504,23 +517,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "mv-mobile";
|
wrap.className = "mv-mobile";
|
||||||
|
|
||||||
if (ls.length > 1) {
|
|
||||||
const bar = document.createElement("div");
|
|
||||||
bar.className = "mv-panebar";
|
|
||||||
for (const l of ls) {
|
|
||||||
const pill = document.createElement("button");
|
|
||||||
pill.type = "button";
|
|
||||||
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
|
|
||||||
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
|
|
||||||
pill.addEventListener("click", () => {
|
|
||||||
tab.focusedLeafId = l.id;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
bar.appendChild(pill);
|
|
||||||
}
|
|
||||||
wrap.appendChild(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pane = buildPane(focused);
|
const pane = buildPane(focused);
|
||||||
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
||||||
wrap.appendChild(pane.el);
|
wrap.appendChild(pane.el);
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
|
||||||
|
|
||||||
describe("splitPath", () => {
|
describe("splitPath", () => {
|
||||||
it("splits on the last slash, keeping it with the directory", () => {
|
it("splits on the last slash, keeping it with the directory", () => {
|
||||||
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
|
|||||||
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
|
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parentDir", () => {
|
||||||
|
it("drops the last segment", () => {
|
||||||
|
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
|
||||||
|
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the root for a top-level path", () => {
|
||||||
|
expect(parentDir("/maestro")).toBe("/");
|
||||||
|
expect(parentDir("/")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty for a bare name", () => {
|
||||||
|
expect(parentDir("maestro")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
|
|||||||
}
|
}
|
||||||
return lcp;
|
return lcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
|
||||||
|
* A top-level path keeps `/`, and a trailing slash is ignored. */
|
||||||
|
export function parentDir(path: string): string {
|
||||||
|
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||||
|
const slash = trimmed.lastIndexOf("/");
|
||||||
|
if (slash <= 0) return slash === 0 ? "/" : "";
|
||||||
|
return trimmed.slice(0, slash);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
|||||||
: url.includes("/api/projects")
|
: url.includes("/api/projects")
|
||||||
? projects
|
? projects
|
||||||
: url.includes("/api/names")
|
: url.includes("/api/names")
|
||||||
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
? { names: ["cello", "fugue", "lyre"] }
|
||||||
: undefined;
|
: undefined;
|
||||||
return new Response(JSON.stringify(body), { status: 200 });
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
}),
|
}),
|
||||||
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
|||||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
it("shows the name as the primary line and its parent directory below", async () => {
|
||||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||||
const form = (await mount([p])).el;
|
const form = (await mount([p])).el;
|
||||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
// Only the stem: repeating the last segment would just echo the name. Not
|
||||||
// projects sharing a basename (#95).
|
// `~/t`, since the stem is what distinguishes two projects sharing a
|
||||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
// basename (#95).
|
||||||
|
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last command out of the box", async () => {
|
||||||
|
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
|
||||||
|
const form = (await mount([p])).el;
|
||||||
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||||
|
expect(box.textContent).not.toContain("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||||
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
|||||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
it("picking a project selects its cwd and leaves the command alone", async () => {
|
||||||
const p = project({
|
const p = project({
|
||||||
name: "maestro",
|
name: "maestro",
|
||||||
path: "/home/user/maestro",
|
path: "/home/user/maestro",
|
||||||
last_command: "claude",
|
last_command: "aider",
|
||||||
});
|
});
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
stubFetch(
|
stubFetch(
|
||||||
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
|||||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||||
const last = onChange.mock.lastCall![0];
|
const last = onChange.mock.lastCall![0];
|
||||||
expect(last.cwd).toBe("/home/user/maestro");
|
expect(last.cwd).toBe("/home/user/maestro");
|
||||||
|
// The directory and the command are independent choices: the preset stays
|
||||||
|
// where it was, whatever the project was last run with.
|
||||||
expect(last.command).toBe("claude");
|
expect(last.command).toBe("claude");
|
||||||
expect(values).toBeTruthy();
|
expect(values).toBeTruthy();
|
||||||
// The raw input stays hidden — the picked box is the source of truth.
|
// The raw input stays hidden — the picked box is the source of truth.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
|
|||||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||||
export const PATH_QUERY = "path";
|
export const PATH_QUERY = "path";
|
||||||
|
|
||||||
|
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
|
||||||
|
* `force=true` overrides the kill refusal over a guarded worktree. */
|
||||||
|
export const FORCE_QUERY = "force";
|
||||||
|
|
||||||
/** Name of the query parameter `NAMES` takes. */
|
/** Name of the query parameter `NAMES` takes. */
|
||||||
export const COUNT_QUERY = "count";
|
export const COUNT_QUERY = "count";
|
||||||
|
|
||||||
|
|||||||
+13
-11
@@ -8,8 +8,10 @@ import {
|
|||||||
gitRoot,
|
gitRoot,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||||
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
|
|
||||||
// ---- working-directory picker (#95) ----------------------------------
|
// ---- working-directory picker (#95) ----------------------------------
|
||||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
// 1. Step one is one box per repository (name, then its parent directory).
|
||||||
// is a box only when its main checkout is missing from the scan.
|
// A worktree is a box only when its main checkout is missing from the
|
||||||
|
// scan.
|
||||||
// 2. The filter keeps a repository when the query matches it or any of its
|
// 2. The filter keeps a repository when the query matches it or any of its
|
||||||
// worktrees, so a worktree name still finds its box.
|
// worktrees, so a worktree name still finds its box.
|
||||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||||
@@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// these are separate acts — #91's rule, now an explicit choice.
|
// these are separate acts — #91's rule, now an explicit choice.
|
||||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||||
// membership. Only the free-text hatch pays for a probe.
|
// membership. Only the free-text hatch pays for a probe.
|
||||||
|
// 7. Neither step touches the command: the directory and what runs in it
|
||||||
|
// are independent choices.
|
||||||
|
|
||||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||||
type WorktreeChoice = "new" | string;
|
type WorktreeChoice = "new" | string;
|
||||||
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
wtHint.hidden = true;
|
wtHint.hidden = true;
|
||||||
}
|
}
|
||||||
emit();
|
emit();
|
||||||
// The chosen directory pre-selects the command it was last used with.
|
|
||||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
|
||||||
if (match) selectPreset(match.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||||
@@ -634,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||||
box.dataset.cwd = project.path;
|
box.dataset.cwd = project.path;
|
||||||
const trees = worktreesOf(group).length;
|
const trees = worktreesOf(group).length;
|
||||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
// The parent directory only — the last segment is the name line above it.
|
||||||
// tells two projects sharing a basename apart (#95).
|
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
// sharing a basename apart (#95).
|
||||||
.filter(Boolean)
|
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||||
.join(" · ");
|
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
<span class="project-path">${escapeHtml(parent)}</span>
|
||||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||||
box.addEventListener("click", () => selectProject(group));
|
box.addEventListener("click", () => selectProject(group));
|
||||||
return box;
|
return box;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
|
||||||
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
|
const BATCH_A = ["cello", "fugue", "lyre"];
|
||||||
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
|
const BATCH_B = ["oboe", "tuba", "harp"];
|
||||||
|
|
||||||
/** Stub the three fetches the form's initial load makes. Records every
|
/** Stub the three fetches the form's initial load makes. Records every
|
||||||
* /api/names URL so tests can assert how many batches were fetched. */
|
* /api/names URL so tests can assert how many batches were fetched. */
|
||||||
|
|||||||
+91
-160
@@ -365,73 +365,6 @@ textarea {
|
|||||||
|
|
||||||
/* ---------- app shell: sidebar + content ---------- */
|
/* ---------- app shell: sidebar + content ---------- */
|
||||||
|
|
||||||
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
|
|
||||||
the full width above the split. */
|
|
||||||
.app-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: var(--sp-1) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: color-mix(in oklab, var(--surface) 82%, transparent);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hamburger to the left of the brand — toggles the off-canvas session drawer.
|
|
||||||
Mobile only (desktop keeps the sidebar permanently in view). */
|
|
||||||
.app-menu {
|
|
||||||
display: none;
|
|
||||||
place-items: center;
|
|
||||||
flex: none;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--r-1);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: var(--fs-17);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.app-menu:hover {
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Three-part header: brand (left), status (centered), actions (right). Equal
|
|
||||||
flex on the outer two keeps the status group truly centered. */
|
|
||||||
.app-header .brand {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-status {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-status .statusbar {
|
|
||||||
border-bottom: 0;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-status .statusbar .spacer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
flex: 1 1 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -479,19 +412,29 @@ textarea {
|
|||||||
.sidebar-resize {
|
.sidebar-resize {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.app-status {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar's own slim header: just the session search (app-level actions live
|
/* Sidebar's own slim header: just the session search. */
|
||||||
in the global top bar). */
|
|
||||||
/* The search input IS the header — it spans edge to edge, flush, with just a
|
/* The search input IS the header — it spans edge to edge, flush, with just a
|
||||||
bottom rule for separation. No wrapper padding, no boxed border/radius. */
|
bottom rule for separation. No wrapper padding, no boxed border/radius. */
|
||||||
.sidebar-search {
|
.sidebar-search {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* App-level actions (help, theme, spawn), pinned to the sidebar's foot. */
|
||||||
|
.sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer .spawn-btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-search .search {
|
.sidebar-search .search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -625,42 +568,6 @@ textarea {
|
|||||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- status bar ---------- */
|
|
||||||
|
|
||||||
.statusbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--sp-2) var(--sp-3);
|
|
||||||
padding: var(--sp-2) var(--sp-3);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
font-size: var(--fs-12);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusbar .spacer {
|
|
||||||
/* zero basis: claim leftover space without inflating the bar's min width */
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-tally {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-tally::before {
|
|
||||||
content: "";
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--live);
|
|
||||||
box-shadow: 0 0 5px var(--live-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- session tree ---------- */
|
/* ---------- session tree ---------- */
|
||||||
|
|
||||||
.tree {
|
.tree {
|
||||||
@@ -761,7 +668,7 @@ textarea {
|
|||||||
|
|
||||||
.tree-leaf:focus-visible {
|
.tree-leaf:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
||||||
@@ -1151,6 +1058,64 @@ input.leaf-rename:focus {
|
|||||||
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- forced-kill prompt (#81) ---------- */
|
||||||
|
.kill-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: oklch(0 0 0 / 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-panel {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-3);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-title {
|
||||||
|
margin: 0 0 var(--sp-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-15);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-error {
|
||||||
|
margin: var(--sp-2) 0 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- empty / loading ---------- */
|
/* ---------- empty / loading ---------- */
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -1234,16 +1199,17 @@ input.leaf-rename:focus {
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attach-header .back {
|
/* Opens the off-canvas session drawer. Desktop keeps the sidebar permanently in
|
||||||
|
view, so it is mobile-only — same rule as the welcome state's ☰. */
|
||||||
|
.attach-header .sessions-btn {
|
||||||
font-size: var(--fs-15);
|
font-size: var(--fs-15);
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop keeps the session list permanently in view, so the in-header "back"
|
|
||||||
arrow is redundant — the sidebar is the nav. Mobile (single-pane) keeps it. */
|
|
||||||
@media (min-width: 860px) {
|
@media (min-width: 860px) {
|
||||||
.attach-header .back {
|
.attach-header .sessions-btn,
|
||||||
|
.welcome-sessions {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1279,6 +1245,9 @@ input.leaf-rename:focus {
|
|||||||
|
|
||||||
.attach-title .at-cmd {
|
.attach-title .at-cmd {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attach-title .at-cmd::before {
|
.attach-title .at-cmd::before {
|
||||||
@@ -2071,9 +2040,6 @@ input.leaf-rename:focus {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
.statusbar .spacer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tree-leaf .leaf-actions {
|
.tree-leaf .leaf-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -2186,9 +2152,10 @@ input.leaf-rename:focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
/* Mobile, single tab: the tab bar is pure chrome — collapse it (a floating ☰
|
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating
|
||||||
inside the pane keeps the sidebar drawer reachable). */
|
☰ inside the pane keeps the sidebar drawer reachable). */
|
||||||
.mv-mobile-solo > .mv-tabs {
|
.mv-mobile-solo > .mv-tabs,
|
||||||
|
.mv-solo-tab > .mv-tabs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2353,10 +2320,9 @@ input.leaf-rename:focus {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
/* focused pane: accent ring, full strength */
|
/* focused pane: titlebar tints instead of a ring, full strength */
|
||||||
.mv-pane.is-focused {
|
.mv-pane.is-focused .attach-header {
|
||||||
border-color: var(--accent);
|
background: color-mix(in oklab, var(--accent) 14%, var(--surface));
|
||||||
box-shadow: inset 0 0 0 1px var(--accent);
|
|
||||||
}
|
}
|
||||||
/* unfocused panes recede so the eye finds the live one */
|
/* unfocused panes recede so the eye finds the live one */
|
||||||
.mv-pane:not(.is-focused) {
|
.mv-pane:not(.is-focused) {
|
||||||
@@ -2368,11 +2334,6 @@ input.leaf-rename:focus {
|
|||||||
.mv-pane:not(.is-focused) .attach-header {
|
.mv-pane:not(.is-focused) .attach-header {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
/* per-pane back button is meaningless in multi-view (use Close pane instead) */
|
|
||||||
.mv-pane .attach-header .back {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Draft pane (#57): an empty pane renders the shared spawn form inline.
|
/* Draft pane (#57): an empty pane renders the shared spawn form inline.
|
||||||
The host is already a flex column; the form fills and scrolls within it. */
|
The host is already a flex column; the form fills and scrolls within it. */
|
||||||
.mv-pane-host > .mv-draft {
|
.mv-pane-host > .mv-draft {
|
||||||
@@ -2467,8 +2428,8 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
multi-view mobile (#35): below 860px the split tree projects to a pill strip
|
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
||||||
with one pane visible; the sidebar becomes an off-canvas drawer (☰).
|
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
.mv-backdrop {
|
.mv-backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2488,42 +2449,12 @@ input.leaf-rename:focus {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.mv-panebar {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: var(--sp-1) var(--sp-2);
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.mv-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
padding: 7px var(--sp-3);
|
|
||||||
border-radius: var(--r-1);
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--muted);
|
|
||||||
font: 600 var(--fs-12) / 1 var(--font-mono);
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.mv-pill.is-active {
|
|
||||||
background: var(--accent-tint);
|
|
||||||
color: var(--accent-strong);
|
|
||||||
border-color: var(--accent-ring);
|
|
||||||
}
|
|
||||||
.mv-mobile > .mv-pane {
|
.mv-mobile > .mv-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 859px) {
|
@media (max-width: 859px) {
|
||||||
.app-menu {
|
|
||||||
display: inline-grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||||
.app-shell > .sidebar {
|
.app-shell > .sidebar {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps everything after the program as text", () => {
|
it("drops everything after the program", () => {
|
||||||
expect(splitCommand("claude --resume -p hi")).toEqual({
|
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
|
||||||
tool: "claude",
|
tool: "claude",
|
||||||
rest: "--resume -p hi",
|
program: "claude",
|
||||||
});
|
});
|
||||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
|
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("looks through paths, env assignments and wrappers", () => {
|
it("looks through paths, env assignments and wrappers", () => {
|
||||||
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
|||||||
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
|
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
|
||||||
expect(splitCommand("npx opencode").tool).toBe("opencode");
|
expect(splitCommand("npx opencode").tool).toBe("opencode");
|
||||||
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
|
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
|
||||||
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
|
expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves an unrecognised program as text, mark-less", () => {
|
it("leaves an unrecognised program as its bare name, mark-less", () => {
|
||||||
expect(splitCommand("hx src/main.rs")).toEqual({
|
expect(splitCommand("hx src/main.rs")).toEqual({
|
||||||
tool: null,
|
tool: null,
|
||||||
rest: "hx src/main.rs",
|
program: "hx",
|
||||||
});
|
});
|
||||||
// A wrapper with nothing after it is not a tool either.
|
// A wrapper with nothing after it is not a tool either.
|
||||||
expect(splitCommand("sudo").tool).toBeNull();
|
expect(splitCommand("sudo").tool).toBeNull();
|
||||||
|
|||||||
+17
-18
@@ -2,11 +2,11 @@
|
|||||||
*
|
*
|
||||||
* A session's command line starts with the program that runs it, and that
|
* A session's command line starts with the program that runs it, and that
|
||||||
* program is nearly always one of a handful of agents or a shell. Rendering it
|
* program is nearly always one of a handful of agents or a shell. Rendering it
|
||||||
* as its own mark buys back most of the fragment's width, which the sidebar
|
* as its own mark buys back the whole fragment's width — the arguments are
|
||||||
* spends on the rest of the command line instead.
|
* dropped, so a card says which tool runs, not how it was invoked.
|
||||||
*
|
*
|
||||||
* Recognition is deliberately narrow: an unrecognised program keeps its text,
|
* Recognition is deliberately narrow: an unrecognised program keeps its name
|
||||||
* so the fragment never renders a mark that stands for the wrong tool.
|
* as text, so the fragment never renders a mark that stands for the wrong tool.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Command wrappers that run another program: the tool is what follows. */
|
/** Command wrappers that run another program: the tool is what follows. */
|
||||||
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
|
|||||||
export interface CommandParts {
|
export interface CommandParts {
|
||||||
/** Icon id, or `null` when the program is not one we have a mark for. */
|
/** Icon id, or `null` when the program is not one we have a mark for. */
|
||||||
tool: string | null;
|
tool: string | null;
|
||||||
/** What is left of the command line once the icon stands for the program.
|
/** The program the command runs, basenamed and stripped of its arguments —
|
||||||
* Empty when the icon says everything the command line said. */
|
* what a card shows when there is no icon to show instead. */
|
||||||
rest: string;
|
program: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a command line into the tool its icon stands for and the remainder.
|
/** Split a command line into the tool its icon stands for and the program name.
|
||||||
*
|
*
|
||||||
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
|
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
|
||||||
* `hx src/` keeps the whole line as `rest` with no tool. */
|
* `hx src/` keeps `hx` as the program with no tool. */
|
||||||
export function splitCommand(command: string): CommandParts {
|
export function splitCommand(command: string): CommandParts {
|
||||||
const words = command.trim().split(/\s+/).filter(Boolean);
|
const words = command.trim().split(/\s+/).filter(Boolean);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
|
|||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const program = basename(word);
|
const wrapper = basename(word).toLowerCase();
|
||||||
if (WRAPPERS.has(program)) {
|
if (WRAPPERS.has(wrapper)) {
|
||||||
i += 1;
|
i += 1;
|
||||||
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
|
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (i >= words.length) return { tool: null, rest: command.trim() };
|
if (i >= words.length) return { tool: null, program: command.trim() };
|
||||||
const tool = BY_PROGRAM[basename(words[i])] ?? null;
|
const program = basename(words[i]);
|
||||||
if (!tool) return { tool: null, rest: command.trim() };
|
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
|
||||||
return { tool, rest: words.slice(i + 1).join(" ") };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function basename(word: string): string {
|
function basename(word: string): string {
|
||||||
const cut = word.lastIndexOf("/");
|
const cut = word.lastIndexOf("/");
|
||||||
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
|
return cut >= 0 ? word.slice(cut + 1) : word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ describe("buildTree", () => {
|
|||||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("folds the sole root folder into each of its own children when it branches", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("stops collapsing where a directory branches", () => {
|
it("stops collapsing where a directory branches", () => {
|
||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
|||||||
for (const c of n.children) if (c.kind === "folder") sortRec(c);
|
for (const c of n.children) if (c.kind === "folder") sortRec(c);
|
||||||
};
|
};
|
||||||
sortRec(root);
|
sortRec(root);
|
||||||
|
// The root's sole child (usually `~`) has no sibling to distinguish it
|
||||||
|
// from, so it folds into every one of its own children, not just a lone
|
||||||
|
// one — unlike the chain merge below, and only here at the very top.
|
||||||
|
if (root.children.length === 1 && root.children[0].kind === "folder") {
|
||||||
|
const only = root.children[0];
|
||||||
|
root.children = only.children.map((c) =>
|
||||||
|
c.kind === "folder" ? { ...c, name: `${only.name}/${c.name}` } : c,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
|
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
|
||||||
// one compact folder rather than four nested ones.
|
// one compact folder rather than four nested ones.
|
||||||
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
|
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
|
||||||
|
|||||||
+6
-1
@@ -253,7 +253,12 @@ export type ClientControl =
|
|||||||
| { type: "Error"; code: string; message: string };
|
| { type: "Error"; code: string; message: string };
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string };
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
/** Set only by the kill guard (#79): what blocked the unforced kill. */
|
||||||
|
unsaved_work?: InTheWay;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionName(s: Session): string {
|
export function sessionName(s: Session): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user