Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc21b4306 | ||
|
|
19162f9952 | ||
|
|
2fcfc06c3c | ||
|
|
f82ab45766 | ||
|
|
d304171d7c | ||
|
|
95b459bd65 | ||
|
|
c9cfdb5668 | ||
|
|
31158cdb1a | ||
|
|
7b04575ed5 | ||
|
|
80af2e6816 |
@@ -232,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
|
||||
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) {
|
||||
for name in &batch.names {
|
||||
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
||||
panic!("handle {name:?} is not adjective-noun");
|
||||
});
|
||||
assert!(!name.contains('-'), "handle {name:?} is not one word");
|
||||
assert!(
|
||||
maestro_names::ADJECTIVES.contains(&adj),
|
||||
"{adj} not in ADJECTIVES"
|
||||
maestro_names::WORDS.contains(&name.as_str()),
|
||||
"{name} not in WORDS"
|
||||
);
|
||||
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
//! from here, served by `GET /api/names`.
|
||||
|
||||
/// Musical adjectives — tempo, dynamics, and articulation markings.
|
||||
pub const ADJECTIVES: &[&str] = &[
|
||||
/// Every word a handle can be: instruments, forms, and the vocabulary of
|
||||
/// 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",
|
||||
"andante",
|
||||
"adagio",
|
||||
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
|
||||
"lento",
|
||||
"moderato",
|
||||
"crescendo",
|
||||
"diminuendo",
|
||||
"staccato",
|
||||
"legato",
|
||||
"forte",
|
||||
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
|
||||
"sotto",
|
||||
"vivo",
|
||||
"feroce",
|
||||
];
|
||||
|
||||
/// Musical nouns — instruments and forms from the concert hall.
|
||||
pub const NOUNS: &[&str] = &[
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
|
||||
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
|
||||
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
|
||||
"motif", "overture",
|
||||
"giocoso",
|
||||
"affettuoso",
|
||||
"risoluto",
|
||||
"leggiero",
|
||||
"pesante",
|
||||
"morendo",
|
||||
"accelerando",
|
||||
"ritardando",
|
||||
];
|
||||
|
||||
/// Upper bound for a `GET /api/names?count=N` batch.
|
||||
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 {
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
format!(
|
||||
"{}-{}",
|
||||
ADJECTIVES.choose(&mut rng).unwrap(),
|
||||
NOUNS.choose(&mut rng).unwrap()
|
||||
)
|
||||
(*WORDS.choose(&mut rng).unwrap()).to_string()
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
(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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn generated_name_is_adjective_dash_noun() {
|
||||
fn generated_name_is_a_single_known_word() {
|
||||
let name = generate();
|
||||
let (adj, noun) = name.split_once('-').expect("name contains a dash");
|
||||
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
|
||||
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
assert!(!name.contains('-'), "{name} is not one word");
|
||||
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||
}
|
||||
|
||||
#[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(50).len(), 50);
|
||||
for name in generate_batch(10) {
|
||||
let (adj, noun) = name.split_once('-').unwrap();
|
||||
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
|
||||
let batch = generate_batch(MAX_BATCH);
|
||||
assert_eq!(batch.len(), MAX_BATCH);
|
||||
for name in &batch {
|
||||
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]
|
||||
fn rerolls_can_differ() {
|
||||
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
|
||||
let first = generate();
|
||||
let differs = (0..20).any(|_| generate() != first);
|
||||
assert!(differs);
|
||||
|
||||
@@ -506,7 +506,7 @@ pub struct DaemonInfo {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct NameBatch {
|
||||
pub names: Vec<String>,
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
//!
|
||||
//! The picker itself is two steps (#96), verbatim from the rules in
|
||||
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
|
||||
//! 1. Step one is one box per repository (name, then full path). A worktree
|
||||
//! is a box only when its main checkout is missing from the scan.
|
||||
//! 1. Step one is one box per repository (name, then its parent directory).
|
||||
//! 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
|
||||
//! worktrees, so a worktree name still finds its box.
|
||||
//! 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
@@ -32,6 +33,8 @@
|
||||
//! these are separate acts.
|
||||
//! 6. Neither step probes `/api/gitroot`; the scan already reports
|
||||
//! 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::path::{Path, PathBuf};
|
||||
@@ -39,6 +42,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
|
||||
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
|
||||
/// `projects::group_projects` groups by, kept separately because the main
|
||||
/// 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.picked_cwd = Some(target.path.clone());
|
||||
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 {
|
||||
WorktreeChoice::New => {
|
||||
self.worktree_enabled = true;
|
||||
@@ -1200,9 +1206,9 @@ impl DraftPane {
|
||||
cursor = Some((col, filter_row.y));
|
||||
}
|
||||
|
||||
// Step one (#96 rule 1): one box per repository, name then the FULL
|
||||
// path — not the abbreviated form other lists use — so two projects
|
||||
// sharing a basename stay distinguishable.
|
||||
// Step one (#96 rule 1): one box per repository, name then the parent
|
||||
// directory it sits in. The last segment is the name column already;
|
||||
// the stem is what keeps two projects sharing a basename apart.
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
let count = visible.len() + 1;
|
||||
@@ -1241,7 +1247,10 @@ impl DraftPane {
|
||||
Span::styled(if active { ">" } else { " " }, base),
|
||||
Span::raw(" "),
|
||||
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 {
|
||||
let mut style = Style::default();
|
||||
@@ -1741,8 +1750,9 @@ mod tests {
|
||||
let stale_pos = rendered.find("stale-repo").unwrap();
|
||||
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
||||
assert!(
|
||||
rendered.contains("/home/u/new/repo"),
|
||||
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
|
||||
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
|
||||
"the parent directory only — the name column is the last segment \
|
||||
(#96 rule 1):\n{rendered}"
|
||||
);
|
||||
|
||||
// Staleness dims the row's cells.
|
||||
@@ -1787,7 +1797,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
"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));
|
||||
|
||||
assert!(
|
||||
rendered.contains("/home/u/repos/main"),
|
||||
"step one shows the full repository path:\n{rendered}"
|
||||
rendered.contains("~/repos"),
|
||||
"step one shows the directory holding the repository:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("+ new"),
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import { attachWsUrl } from "./routes.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.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";
|
||||
|
||||
@@ -80,7 +81,7 @@ export function createAttachConnection(
|
||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||
el.innerHTML = `
|
||||
<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-state" data-state="connecting">connecting…</span>
|
||||
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
|
||||
@@ -100,6 +101,12 @@ export function createAttachConnection(
|
||||
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", () => {
|
||||
opts.onRequestClose?.(sessionId);
|
||||
});
|
||||
|
||||
+16
-59
@@ -37,6 +37,10 @@ export function requestOpenSpawn(): void {
|
||||
* connection pool. */
|
||||
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
|
||||
* 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 —
|
||||
@@ -169,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
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;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
@@ -185,7 +189,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
{
|
||||
label: "Spawn similar",
|
||||
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) },
|
||||
]);
|
||||
@@ -262,18 +266,6 @@ function beginRename(row: HTMLElement, s: Session): void {
|
||||
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 {
|
||||
const el = document.createElement("div");
|
||||
el.className = "empty";
|
||||
@@ -298,51 +290,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* The global app header (full width, above the sidebar/content split): brand +
|
||||
* session tally. App-level actions (help, theme, spawn) live pinned to the
|
||||
* sidebar's foot instead — see {@link renderSidebar}.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||
* pane (quiet), and which one is focused (loud). */
|
||||
export interface SidebarHighlights {
|
||||
@@ -422,6 +369,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
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";
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,6 @@ export function renderKit(): HTMLElement {
|
||||
);
|
||||
activeLine.setAttribute("data-activity", "active");
|
||||
body.append(
|
||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
||||
specimen("folder-count", h("span", "folder-count", "4")),
|
||||
specimen("card-line2 · active", activeLine),
|
||||
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/600.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
import { renderSidebar, renderTopbar } from "./catalog.js";
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.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 { createMultiView, type MultiView } from "./multiview.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 { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
@@ -92,63 +93,15 @@ const content = document.createElement("section");
|
||||
content.className = "content";
|
||||
|
||||
// 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
|
||||
// session closes it. On desktop these are inert (CSS gates the drawer styling).
|
||||
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
|
||||
// similar, backdrop); on desktop the class it toggles is inert.
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "mv-backdrop";
|
||||
function closeDrawer(): void {
|
||||
shellEl.classList.remove("drawer-open");
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
|
||||
installDrawerSwipe(drawer);
|
||||
|
||||
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
|
||||
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
|
||||
@@ -228,13 +181,22 @@ function renderWelcome(): HTMLElement {
|
||||
</p>
|
||||
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
|
||||
<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");
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
actions.appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -272,7 +234,7 @@ function showMultiView(): void {
|
||||
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
|
||||
// cloned session (see spawnSimilar.ts).
|
||||
function requestOpenSpawn(): void {
|
||||
@@ -284,7 +246,7 @@ function requestOpenSpawn(): void {
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
}
|
||||
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;
|
||||
showMultiView();
|
||||
mv.openDraft(spawnSimilarSeed(s));
|
||||
@@ -358,7 +320,7 @@ window.addEventListener("keydown", (e) => {
|
||||
if (sidebar.hasListFocus()) {
|
||||
pool.focusTerminal();
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
|
||||
+127
-1
@@ -17,7 +17,7 @@ function stubFetch(): void {
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -432,3 +432,129 @@ describe("multiview closeSession tab cleanup (#137)", () => {
|
||||
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";
|
||||
el.append(tabBar, canvas);
|
||||
|
||||
// Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time
|
||||
// strip of pills — splits/drag/resize are desktop-only. The tree is untouched,
|
||||
// so widening restores the splits.
|
||||
// Mobile (issue #35): below 860px the layout collapses to exactly one visible
|
||||
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
|
||||
// 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)");
|
||||
let isMobile = mq.matches;
|
||||
mq.addEventListener("change", (e) => {
|
||||
@@ -391,7 +393,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return;
|
||||
}
|
||||
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.draft = null;
|
||||
} else {
|
||||
@@ -403,7 +407,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||
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
|
||||
// (#100). The empty-pane reuse below still applies within a tab.
|
||||
const fresh = addTab(layout);
|
||||
@@ -421,9 +427,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return;
|
||||
}
|
||||
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
|
||||
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||
target.sessionId = null;
|
||||
target.draft = draft();
|
||||
const stale = draftForms.get(target.id);
|
||||
if (stale) {
|
||||
@@ -462,9 +469,13 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
|
||||
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
|
||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
|
||||
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
|
||||
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) {
|
||||
pruneForms(tab);
|
||||
@@ -496,7 +507,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
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 {
|
||||
const ls = leaves(tab.root);
|
||||
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");
|
||||
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);
|
||||
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
||||
wrap.appendChild(pane.el);
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on the last slash, keeping it with the directory", () => {
|
||||
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
||||
? { names: ["cello", "fugue", "lyre"] }
|
||||
: undefined;
|
||||
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);
|
||||
});
|
||||
|
||||
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 form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
// Only the stem: repeating the last segment would just echo the name. Not
|
||||
// `~/t`, since the stem is what distinguishes two projects sharing a
|
||||
// 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 () => {
|
||||
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
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({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
last_command: "aider",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
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 last = onChange.mock.lastCall![0];
|
||||
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(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
|
||||
+13
-11
@@ -8,8 +8,10 @@ import {
|
||||
gitRoot,
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { parentDir } from "./paths.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import { abbreviateCwd } 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
|
||||
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// 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
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 1. Step one is one box per repository (name, then its parent directory).
|
||||
// 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
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 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.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports 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.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
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`. */
|
||||
@@ -634,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
// The parent directory only — the last segment is the name line above it.
|
||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||
// sharing a basename apart (#95).
|
||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
||||
box.innerHTML = `
|
||||
<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>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
|
||||
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
|
||||
const BATCH_A = ["cello", "fugue", "lyre"];
|
||||
const BATCH_B = ["oboe", "tuba", "harp"];
|
||||
|
||||
/** Stub the three fetches the form's initial load makes. Records every
|
||||
* /api/names URL so tests can assert how many batches were fetched. */
|
||||
|
||||
+11
-153
@@ -365,73 +365,6 @@ textarea {
|
||||
|
||||
/* ---------- 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 {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -479,9 +412,6 @@ textarea {
|
||||
.sidebar-resize {
|
||||
display: none;
|
||||
}
|
||||
.app-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar's own slim header: just the session search. */
|
||||
@@ -638,42 +568,6 @@ textarea {
|
||||
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 ---------- */
|
||||
|
||||
.tree {
|
||||
@@ -1247,16 +1141,17 @@ input.leaf-rename:focus {
|
||||
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);
|
||||
width: 28px;
|
||||
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) {
|
||||
.attach-header .back {
|
||||
.attach-header .sessions-btn,
|
||||
.welcome-sessions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -2087,9 +1982,6 @@ input.leaf-rename:focus {
|
||||
max-width: none;
|
||||
order: -1;
|
||||
}
|
||||
.statusbar .spacer {
|
||||
display: none;
|
||||
}
|
||||
.tree-leaf .leaf-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -2202,9 +2094,10 @@ input.leaf-rename:focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Mobile, single tab: the tab bar is pure chrome — collapse it (a floating ☰
|
||||
inside the pane keeps the sidebar drawer reachable). */
|
||||
.mv-mobile-solo > .mv-tabs {
|
||||
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating
|
||||
☰ inside the pane keeps the sidebar drawer reachable). */
|
||||
.mv-mobile-solo > .mv-tabs,
|
||||
.mv-solo-tab > .mv-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2383,11 +2276,6 @@ input.leaf-rename:focus {
|
||||
.mv-pane:not(.is-focused) .attach-header {
|
||||
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.
|
||||
The host is already a flex column; the form fills and scrolls within it. */
|
||||
.mv-pane-host > .mv-draft {
|
||||
@@ -2482,8 +2370,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
multi-view mobile (#35): below 860px the split tree projects to a pill strip
|
||||
with one pane visible; the sidebar becomes an off-canvas drawer (☰).
|
||||
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
||||
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
||||
========================================================================= */
|
||||
.mv-backdrop {
|
||||
display: none;
|
||||
@@ -2503,42 +2391,12 @@ input.leaf-rename:focus {
|
||||
border-radius: 0;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 859px) {
|
||||
.app-menu {
|
||||
display: inline-grid;
|
||||
}
|
||||
|
||||
/* sidebar → off-canvas drawer (override the route-based show/hide) */
|
||||
.app-shell > .sidebar {
|
||||
display: flex !important;
|
||||
|
||||
Reference in New Issue
Block a user