Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e0e8d0ce | ||
|
|
582a7ca888 | ||
|
|
d8a5d95db4 | ||
|
|
51002fece8 | ||
|
|
2319b4e47b | ||
|
|
6635cff46e | ||
|
|
f6b2cf3ab4 | ||
|
|
05e7293af2 | ||
|
|
b1527f5fc1 |
@@ -36,10 +36,16 @@ optional.
|
|||||||
|
|
||||||
## Where the work is
|
## Where the work is
|
||||||
|
|
||||||
Linear project **Maestro** under team **Naps62**.
|
Gitea issues on [`naps62/maestro`](https://git.naps.pt/naps62/maestro), grouped
|
||||||
|
into milestones.
|
||||||
|
|
||||||
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
|
`.claude/linear.json` (legacy filename, `tracker: gitea`) wires `/yolo` and
|
||||||
"available now" (status backlog/pending, not blocked).
|
`/work` to that tracker — they pick from open, unassigned issues whose
|
||||||
|
dependencies are all closed. `/blitz <milestone>` sweeps a whole milestone.
|
||||||
|
|
||||||
|
Labels carry one value per axis: `area/*`, `type/*`, `difficulty/*`.
|
||||||
|
`difficulty` drives model selection downstream, so it tracks the shape of the
|
||||||
|
work, not its size.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -72,6 +78,10 @@ before changing anything that creates or removes one.
|
|||||||
sidebars — line layout, activity colours, duration format, state precedence.
|
sidebars — line layout, activity colours, duration format, state precedence.
|
||||||
Read it before changing sidebar rendering.
|
Read it before changing sidebar rendering.
|
||||||
|
|
||||||
|
`docs/PREVIEW.md` is the contract for what the content pane shows while the
|
||||||
|
operator moves the sidebar highlight — what a preview may touch, what ends it,
|
||||||
|
what commits it. Read it before changing sidebar-driven navigation.
|
||||||
|
|
||||||
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
||||||
or is upgraded under them — the build stamp on `/api/info`, the status
|
or is upgraded under them — the build stamp on `/api/info`, the status
|
||||||
indicator's states, the blocking overlay, grace and poll timings. Read it
|
indicator's states, the blocking overlay, grace and poll timings. Read it
|
||||||
|
|||||||
+291
-14
@@ -87,6 +87,14 @@ pub struct App {
|
|||||||
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
||||||
/// mouse) is covered without each one remembering to report it.
|
/// mouse) is covered without each one remembering to report it.
|
||||||
focused_session: Option<Uuid>,
|
focused_session: Option<Uuid>,
|
||||||
|
/// A sidebar highlight waiting out its debounce before it becomes a
|
||||||
|
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
|
||||||
|
/// not open an attach per row it passes over.
|
||||||
|
pending_preview: Option<(Uuid, std::time::Instant)>,
|
||||||
|
/// Sessions attached only because they were previewed, oldest first. Kept
|
||||||
|
/// connected past the preview so stepping back onto a row is instant, and
|
||||||
|
/// bounded so a long walk doesn't leave a socket per session behind.
|
||||||
|
preview_lru: Vec<Uuid>,
|
||||||
/// Fingerprint of the last frame drawn. The loop compares a freshly
|
/// Fingerprint of the last frame drawn. The loop compares a freshly
|
||||||
/// computed key against this and skips the repaint when they match — an
|
/// computed key against this and skips the repaint when they match — an
|
||||||
/// idle TUI used to emit a full diff 30 times a second forever, which over
|
/// idle TUI used to emit a full diff 30 times a second forever, which over
|
||||||
@@ -117,6 +125,14 @@ struct FrameKey {
|
|||||||
/// else changed.
|
/// else changed.
|
||||||
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
|
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// How long a highlight has to stand still before the pane previews it
|
||||||
|
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
|
||||||
|
/// attach to every row it crosses, short enough to read as immediate.
|
||||||
|
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
|
||||||
|
|
||||||
|
/// How many preview-only attaches to keep connected behind the current one.
|
||||||
|
const PREVIEW_BUDGET: usize = 3;
|
||||||
|
|
||||||
impl std::fmt::Debug for App {
|
impl std::fmt::Debug for App {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("App")
|
f.debug_struct("App")
|
||||||
@@ -196,6 +212,8 @@ impl App {
|
|||||||
last_sent_size: HashMap::new(),
|
last_sent_size: HashMap::new(),
|
||||||
clicks: ClickTracker::default(),
|
clicks: ClickTracker::default(),
|
||||||
focused_session: None,
|
focused_session: None,
|
||||||
|
pending_preview: None,
|
||||||
|
preview_lru: Vec::new(),
|
||||||
last_frame: None,
|
last_frame: None,
|
||||||
last_drawn_at: std::time::Instant::now(),
|
last_drawn_at: std::time::Instant::now(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
@@ -234,7 +252,9 @@ impl App {
|
|||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||||
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
let now = std::time::Instant::now();
|
||||||
|
let mut effects = self.poll_due_probes_at(now);
|
||||||
|
effects.extend(self.poll_due_preview_at(now));
|
||||||
if !effects.is_empty() {
|
if !effects.is_empty() {
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
}
|
}
|
||||||
@@ -332,6 +352,7 @@ impl App {
|
|||||||
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.refresh_preview();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,8 +474,15 @@ impl App {
|
|||||||
if let Some(previous) = self.state.active_session() {
|
if let Some(previous) = self.state.active_session() {
|
||||||
self.release_session(previous);
|
self.release_session(previous);
|
||||||
}
|
}
|
||||||
effects.push(Effect::Attach(sid));
|
// A previewed session is already attached: committing
|
||||||
|
// to it keeps that stream rather than reopening one.
|
||||||
|
if self.emulators.get(sid).is_none() {
|
||||||
|
effects.push(Effect::Attach(sid));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// It is the pane's own session now, not a loan from the
|
||||||
|
// preview budget.
|
||||||
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,9 +509,89 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Every action can move the highlight or the focus, and the preview is
|
||||||
|
// a function of both (`docs/PREVIEW.md`).
|
||||||
|
self.refresh_preview();
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recompute what the pane should be previewing. A session highlighted in
|
||||||
|
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
|
||||||
|
/// group row, the pane holding the keyboard, or the highlight already
|
||||||
|
/// sitting on the attached session — clears the preview at once.
|
||||||
|
fn refresh_preview(&mut self) {
|
||||||
|
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
||||||
|
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match wanted {
|
||||||
|
Some(sid) if self.state.active_session() != Some(sid) => {
|
||||||
|
if self.state.preview != Some(sid) {
|
||||||
|
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.pending_preview = None;
|
||||||
|
self.clear_preview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The debounce elapsed: show the highlighted session, attaching it if
|
||||||
|
/// nothing already is.
|
||||||
|
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
||||||
|
let Some((sid, due)) = self.pending_preview else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if now < due {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.pending_preview = None;
|
||||||
|
if self.state.preview == Some(sid) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.state.preview = Some(sid);
|
||||||
|
// Set outside the input path, so nothing else marks the frame stale.
|
||||||
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
|
let fresh = self.emulators.get(sid).is_none();
|
||||||
|
if fresh {
|
||||||
|
self.preview_lru.retain(|s| *s != sid);
|
||||||
|
self.preview_lru.push(sid);
|
||||||
|
}
|
||||||
|
self.trim_preview_budget();
|
||||||
|
if fresh {
|
||||||
|
vec![Effect::Attach(sid)]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_preview(&mut self) {
|
||||||
|
if self.state.preview.take().is_some() {
|
||||||
|
self.epoch = self.epoch.wrapping_add(1);
|
||||||
|
}
|
||||||
|
self.trim_preview_budget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop preview-only attaches past the budget. What a tab holds, and what
|
||||||
|
/// is on screen right now, are never candidates.
|
||||||
|
fn trim_preview_budget(&mut self) {
|
||||||
|
while self.preview_lru.len() > PREVIEW_BUDGET {
|
||||||
|
let held: HashSet<Uuid> = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.filter_map(crate::state::Tab::session)
|
||||||
|
.chain(self.state.preview)
|
||||||
|
.collect();
|
||||||
|
let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let victim = self.preview_lru.remove(pos);
|
||||||
|
self.release_session(victim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a stream for `sid` if one isn't already running.
|
/// Open a stream for `sid` if one isn't already running.
|
||||||
fn attach(&mut self, sid: Uuid) {
|
fn attach(&mut self, sid: Uuid) {
|
||||||
if self.emulators.get(sid).is_some() {
|
if self.emulators.get(sid).is_some() {
|
||||||
@@ -1000,7 +1108,7 @@ impl App {
|
|||||||
/// Sample everything a frame depends on. Cheap: two atomic loads, an
|
/// Sample everything a frame depends on. Cheap: two atomic loads, an
|
||||||
/// `RwLock` read of a `u64`, and the backend's size.
|
/// `RwLock` read of a `u64`, and the backend's size.
|
||||||
fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey {
|
fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey {
|
||||||
let pane = self.state.active_session().and_then(|sid| {
|
let pane = self.state.displayed_session().and_then(|sid| {
|
||||||
self.emulators
|
self.emulators
|
||||||
.get(sid)
|
.get(sid)
|
||||||
.map(|h| (sid, h.revision(), h.state()))
|
.map(|h| (sid, h.revision(), h.state()))
|
||||||
@@ -1050,21 +1158,31 @@ impl App {
|
|||||||
highlight: self.state.sidebar_highlight.clone(),
|
highlight: self.state.sidebar_highlight.clone(),
|
||||||
attached,
|
attached,
|
||||||
};
|
};
|
||||||
let active_sid = self.state.active_session();
|
let shown_sid = self.state.displayed_session();
|
||||||
let pane = active_sid.and_then(|sid| {
|
let previewing = self.state.preview.is_some();
|
||||||
|
let pane = shown_sid.and_then(|sid| {
|
||||||
self.emulators.get(sid).map(|h| Pane {
|
self.emulators.get(sid).map(|h| Pane {
|
||||||
screen: h.screen.clone(),
|
screen: h.screen.clone(),
|
||||||
status: PaneStatus::from(h.state()),
|
status: PaneStatus::from(h.state()),
|
||||||
focused: matches!(self.state.focus, Focus::Pane),
|
// A preview never owns the keyboard, so it never draws a
|
||||||
|
// cursor either (`docs/PREVIEW.md`).
|
||||||
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let pane_title = match active_sid {
|
let pane_title = match shown_sid {
|
||||||
Some(sid) => snap
|
Some(sid) => {
|
||||||
.sessions
|
let name = snap
|
||||||
.iter()
|
.sessions
|
||||||
.find(|session| session.id == sid)
|
.iter()
|
||||||
.map(crate::sidebar::name_for)
|
.find(|session| session.id == sid)
|
||||||
.unwrap_or_else(|| "session".into()),
|
.map(crate::sidebar::name_for)
|
||||||
|
.unwrap_or_else(|| "session".into());
|
||||||
|
if previewing {
|
||||||
|
format!("{name} · preview")
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
None => "session".into(),
|
None => "session".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1092,7 +1210,7 @@ impl App {
|
|||||||
// Resize propagation: record the active pane's rect, recompute the
|
// Resize propagation: record the active pane's rect, recompute the
|
||||||
// per-dimension min for every attached session, send Resize when
|
// per-dimension min for every attached session, send Resize when
|
||||||
// changed.
|
// changed.
|
||||||
if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) {
|
if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) {
|
||||||
self.emulators.resize_tracker_mut().set_pane_size(
|
self.emulators.resize_tracker_mut().set_pane_size(
|
||||||
sid,
|
sid,
|
||||||
"active",
|
"active",
|
||||||
@@ -1501,6 +1619,165 @@ mod tests {
|
|||||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seed the catalog with `ids` as sessions in one directory.
|
||||||
|
fn app_with_sessions(ids: &[Uuid]) -> App {
|
||||||
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
|
let app = make_app();
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| Session {
|
||||||
|
id: *id,
|
||||||
|
command: "bash".into(),
|
||||||
|
cwd: std::path::PathBuf::from("/"),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_handle() -> crate::session_task::SessionHandle {
|
||||||
|
use crate::session_task::{SessionHandle, SessionState};
|
||||||
|
use std::sync::atomic::{AtomicU64, AtomicU8};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
SessionHandle {
|
||||||
|
tx: tokio::sync::mpsc::channel(1).0,
|
||||||
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
||||||
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||||
|
revision: Arc::new(AtomicU64::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after_debounce() -> std::time::Instant {
|
||||||
|
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
|
||||||
|
/// tab taking it on.
|
||||||
|
#[test]
|
||||||
|
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
|
||||||
|
let a = Uuid::from_u128(1);
|
||||||
|
let mut app = app_with_sessions(&[a]);
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
|
||||||
|
// Nothing yet: the highlight has to stand still first.
|
||||||
|
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
|
||||||
|
assert_eq!(app.state.preview, None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.poll_due_preview_at(after_debounce()),
|
||||||
|
vec![Effect::Attach(a)]
|
||||||
|
);
|
||||||
|
assert_eq!(app.state.preview, Some(a));
|
||||||
|
assert_eq!(app.state.displayed_session(), Some(a));
|
||||||
|
// The tab is untouched — the preview is a view, not a layout change.
|
||||||
|
assert_eq!(app.state.active_tab().session(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fast walk down the list attaches to where it stopped, not to every
|
||||||
|
/// row it crossed.
|
||||||
|
#[test]
|
||||||
|
fn walking_past_rows_only_previews_the_one_it_lands_on() {
|
||||||
|
let a = Uuid::from_u128(1);
|
||||||
|
let b = Uuid::from_u128(2);
|
||||||
|
let mut app = app_with_sessions(&[a, b]);
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
|
||||||
|
let effects = app.poll_due_preview_at(after_debounce());
|
||||||
|
assert_eq!(effects, vec![Effect::Attach(b)]);
|
||||||
|
assert_eq!(app.state.preview, Some(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pane's own session is not something to preview.
|
||||||
|
#[test]
|
||||||
|
fn highlighting_the_attached_session_clears_the_preview() {
|
||||||
|
let a = Uuid::from_u128(1);
|
||||||
|
let b = Uuid::from_u128(2);
|
||||||
|
let mut app = app_with_sessions(&[a, b]);
|
||||||
|
app.state.attach_to_focused(a);
|
||||||
|
app.state.focus = Focus::Sidebar;
|
||||||
|
|
||||||
|
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
||||||
|
app.refresh_preview();
|
||||||
|
app.poll_due_preview_at(after_debounce());
|
||||||
|
assert_eq!(app.state.preview, Some(b));
|
||||||
|
|
||||||
|
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
|
||||||
|
app.refresh_preview();
|
||||||
|
assert_eq!(app.state.preview, None);
|
||||||
|
assert_eq!(app.state.displayed_session(), Some(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moving off every session — onto a group row, or into the pane — puts
|
||||||
|
/// back what the tab was showing.
|
||||||
|
#[test]
|
||||||
|
fn leaving_the_session_rows_ends_the_preview() {
|
||||||
|
let a = Uuid::from_u128(1);
|
||||||
|
let b = Uuid::from_u128(2);
|
||||||
|
let mut app = app_with_sessions(&[a, b]);
|
||||||
|
app.state.attach_to_focused(a);
|
||||||
|
app.state.focus = Focus::Sidebar;
|
||||||
|
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
||||||
|
app.refresh_preview();
|
||||||
|
app.poll_due_preview_at(after_debounce());
|
||||||
|
|
||||||
|
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
|
||||||
|
assert_eq!(app.state.preview, None);
|
||||||
|
assert_eq!(app.state.displayed_session(), Some(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Committing keeps the stream the preview already opened.
|
||||||
|
#[test]
|
||||||
|
fn attaching_to_a_previewed_session_reuses_its_stream() {
|
||||||
|
let a = Uuid::from_u128(1);
|
||||||
|
let mut app = app_with_sessions(&[a]);
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
app.poll_due_preview_at(after_debounce());
|
||||||
|
// Stand in for the attach the effect would have opened.
|
||||||
|
app.emulators.insert(a, fake_handle());
|
||||||
|
|
||||||
|
let effects = app.dispatch(Action::AttachHighlighted);
|
||||||
|
assert_eq!(effects, vec![]);
|
||||||
|
assert_eq!(app.state.active_tab().session(), Some(a));
|
||||||
|
assert_eq!(app.state.preview, None);
|
||||||
|
// No longer on loan from the preview budget.
|
||||||
|
assert!(!app.preview_lru.contains(&a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview-only attaches are kept for a step back, but bounded.
|
||||||
|
#[test]
|
||||||
|
fn preview_attaches_are_dropped_past_the_budget() {
|
||||||
|
let ids: Vec<Uuid> = (1..=(PREVIEW_BUDGET as u128 + 2))
|
||||||
|
.map(Uuid::from_u128)
|
||||||
|
.collect();
|
||||||
|
let mut app = app_with_sessions(&ids);
|
||||||
|
for id in &ids {
|
||||||
|
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
|
||||||
|
app.refresh_preview();
|
||||||
|
app.poll_due_preview_at(after_debounce());
|
||||||
|
app.emulators.insert(*id, fake_handle());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET);
|
||||||
|
// The oldest rows previewed are gone; the newest are still warm.
|
||||||
|
assert!(app.emulators.get(ids[0]).is_none());
|
||||||
|
assert!(app.emulators.get(*ids.last().unwrap()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
/// Seed the catalog with one session and highlight it.
|
/// Seed the catalog with one session and highlight it.
|
||||||
fn app_with_highlighted_session(sid: Uuid) -> App {
|
fn app_with_highlighted_session(sid: Uuid) -> App {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// because Normal mode only exists while the sidebar is focused, so
|
// because Normal mode only exists while the sidebar is focused, so
|
||||||
// nothing forwards it to a pty.
|
// nothing forwards it to a pty.
|
||||||
("tab", "AttachHighlighted"),
|
("tab", "AttachHighlighted"),
|
||||||
("s", "OpenSpawnModal"),
|
// `n`/`N`, the same pair the web client binds.
|
||||||
// Fast-forward spawn (#67): the spawn key's neighbour does the same
|
("n", "OpenSpawnModal"),
|
||||||
|
// Fast-forward spawn (#67): the spawn key's shifted twin does the same
|
||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("N", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
("r", "RenameHighlighted"),
|
("r", "RenameHighlighted"),
|
||||||
// Focus
|
// Focus
|
||||||
@@ -220,11 +221,11 @@ mod tests {
|
|||||||
Some(Action::AttachHighlighted),
|
Some(Action::AttachHighlighted),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
cfg.keymap.resolve(Mode::Normal, "n"),
|
||||||
Some(Action::OpenSpawnModal),
|
Some(Action::OpenSpawnModal),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
cfg.keymap.resolve(Mode::Normal, "N"),
|
||||||
Some(Action::FastForwardSpawn),
|
Some(Action::FastForwardSpawn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -311,18 +312,18 @@ mod tests {
|
|||||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
/// Regression for #70: the previous canonical form of a real Shift+N
|
||||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
/// keypress was `"shift-N"`, which the default `"N"` binding never
|
||||||
/// matched. Drive the actual crossterm event, not the binding string,
|
/// matched. Drive the actual crossterm event, not the binding string,
|
||||||
/// so the dead-key bug can't come back unnoticed.
|
/// so the dead-key bug can't come back unnoticed.
|
||||||
#[test]
|
#[test]
|
||||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
fn real_shift_n_keypress_resolves_to_fast_forward_spawn() {
|
||||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::input::{canonicalise, Region};
|
use crate::input::{canonicalise, Region};
|
||||||
|
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
|
||||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -597,7 +597,8 @@ mod tests {
|
|||||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
/// make every uppercase binding (the default `N`) unreachable from the
|
||||||
|
/// keyboard (#70).
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||||
let k = InputKey::Char {
|
let k = InputKey::Char {
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ pub struct AppState {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub sidebar_visible: bool,
|
pub sidebar_visible: bool,
|
||||||
pub sidebar_highlight: Option<SidebarItem>,
|
pub sidebar_highlight: Option<SidebarItem>,
|
||||||
|
/// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because
|
||||||
|
/// the sidebar highlight is on it, not because a tab holds it. It never
|
||||||
|
/// enters `tabs`, is never reported to the daemon as focused, and is
|
||||||
|
/// dropped the moment the highlight moves on.
|
||||||
|
pub preview: Option<Uuid>,
|
||||||
/// Group paths whose cards are hidden. This is UI state rather than
|
/// Group paths whose cards are hidden. This is UI state rather than
|
||||||
/// catalog state, so it survives every catalog redraw.
|
/// catalog state, so it survives every catalog redraw.
|
||||||
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
||||||
@@ -91,6 +96,7 @@ impl AppState {
|
|||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
sidebar_visible: true,
|
sidebar_visible: true,
|
||||||
sidebar_highlight: None,
|
sidebar_highlight: None,
|
||||||
|
preview: None,
|
||||||
collapsed_sidebar_groups: HashSet::new(),
|
collapsed_sidebar_groups: HashSet::new(),
|
||||||
overlay: None,
|
overlay: None,
|
||||||
}
|
}
|
||||||
@@ -126,6 +132,9 @@ impl AppState {
|
|||||||
/// drop straight into Insert mode so keystrokes go to the session
|
/// drop straight into Insert mode so keystrokes go to the session
|
||||||
/// without an extra `i` press. The leader returns to Normal.
|
/// without an extra `i` press. The leader returns to Normal.
|
||||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||||
|
// Committing to what was previewed: the pane owns it now, so the
|
||||||
|
// preview has nothing left to say.
|
||||||
|
self.preview = None;
|
||||||
self.active_tab_mut().pane = Some(session_id);
|
self.active_tab_mut().pane = Some(session_id);
|
||||||
self.focus = Focus::Pane;
|
self.focus = Focus::Pane;
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
@@ -150,6 +159,13 @@ impl AppState {
|
|||||||
self.active_tab().session()
|
self.active_tab().session()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the pane actually draws: the preview when one is up, otherwise the
|
||||||
|
/// active tab's session. A preview covers the pane without replacing it,
|
||||||
|
/// so ending one gives back exactly what was there (`docs/PREVIEW.md`).
|
||||||
|
pub fn displayed_session(&self) -> Option<Uuid> {
|
||||||
|
self.preview.or_else(|| self.active_session())
|
||||||
|
}
|
||||||
|
|
||||||
/// Move the sidebar highlight one step through the rendered item
|
/// Move the sidebar highlight one step through the rendered item
|
||||||
/// sequence, in the direction of `delta` (positive = down), landing only
|
/// sequence, in the direction of `delta` (positive = down), landing only
|
||||||
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
|
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
|
||||||
|
|||||||
+2
-1
@@ -141,7 +141,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
|||||||
|
|
||||||
[tui.keymap.normal]
|
[tui.keymap.normal]
|
||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "OpenSpawnModal"
|
"n" = "OpenSpawnModal"
|
||||||
|
"N" = "FastForwardSpawn"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
"r" = "RenameHighlighted"
|
"r" = "RenameHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# PREVIEW.md — the sidebar preview contract
|
||||||
|
|
||||||
|
One rule for what the content pane shows while the operator is only moving
|
||||||
|
around the sidebar, so the web client and the TUI behave the same. Read this
|
||||||
|
before changing either. If the behaviour needs to change, change it here first.
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
Highlighting a session in the sidebar shows that session in the content pane,
|
||||||
|
as if it were focused, **without focusing it and without changing the layout**.
|
||||||
|
Moving the highlight elsewhere puts back exactly what was on screen before.
|
||||||
|
|
||||||
|
A preview is a view, not a state. Nothing about it is durable:
|
||||||
|
|
||||||
|
- The layout is byte-for-byte identical before the preview and after it ends.
|
||||||
|
- Nothing is persisted while a preview is up (the web client's layout is saved
|
||||||
|
to `localStorage`; a preview never reaches it).
|
||||||
|
- The daemon is never told the previewed pane is focused, so a preview never
|
||||||
|
clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`).
|
||||||
|
- The keyboard stays in the sidebar: the previewed terminal is on screen but
|
||||||
|
does not take focus, so the next `j`/`k` still walks the list.
|
||||||
|
|
||||||
|
## Where the preview is shown
|
||||||
|
|
||||||
|
| The previewed session is… | What happens |
|
||||||
|
|---|---|
|
||||||
|
| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). |
|
||||||
|
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
|
||||||
|
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
|
||||||
|
|
||||||
|
The throwaway tab never counts as an open session: the tab bar does not appear
|
||||||
|
because of it, the mobile shell does not switch away from the session list for
|
||||||
|
it, and the sidebar does not mark the session as open.
|
||||||
|
|
||||||
|
## What ends a preview
|
||||||
|
|
||||||
|
- The highlight moves to another session — that session is previewed instead.
|
||||||
|
- The highlight leaves every session (a folder row, or the list losing the
|
||||||
|
keyboard, or the pointer leaving the list): the previous view comes back.
|
||||||
|
- Any real layout change (split, close, new tab, tab switch, drag): the preview
|
||||||
|
is discarded first, so a throwaway tab is never baked into the arrangement.
|
||||||
|
|
||||||
|
## What commits a preview
|
||||||
|
|
||||||
|
Choosing the previewed session — clicking or pressing Enter on its row,
|
||||||
|
clicking into its pane, or handing the keyboard to the pane (web: `Tab` from
|
||||||
|
the list; TUI: the attach binding) — keeps exactly what is on screen. A
|
||||||
|
throwaway tab becomes a real one; a focus move inside an existing tab stands.
|
||||||
|
Nothing moves a second time on commit.
|
||||||
|
|
||||||
|
## Timing
|
||||||
|
|
||||||
|
A preview follows the highlight closely enough to be usable as a way to look
|
||||||
|
around, and lags a pointer enough not to flash rows crossed on the way
|
||||||
|
somewhere else.
|
||||||
|
|
||||||
|
| Trigger | Delay |
|
||||||
|
|---|---|
|
||||||
|
| Keyboard step (`j`/`k`, arrows) | 60 ms |
|
||||||
|
| Pointer hover (web, mouse only — never touch) | 150 ms |
|
||||||
|
| Clearing (highlight left every session) | same delay as the trigger that set it |
|
||||||
|
|
||||||
|
Touch has no hover, and a tap already opens a session, so touch never previews.
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
A previewed session that no pane holds needs a live attach. Both clients keep
|
||||||
|
it connected after the preview ends — the web client parks it in the connection
|
||||||
|
pool's LRU, the TUI keeps its emulator until it falls out of the preview
|
||||||
|
budget — so stepping back onto a row is instant rather than re-fetching
|
||||||
|
scrollback.
|
||||||
@@ -274,3 +274,21 @@ than jumping to the end of the list.
|
|||||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||||
`ctrl-w h`.
|
`ctrl-w h`.
|
||||||
|
|
||||||
|
Everything else is the same key on both clients:
|
||||||
|
|
||||||
|
| key | action |
|
||||||
|
|---------|---------------------------------------------|
|
||||||
|
| `j`/`k` | move the highlight (arrows too) |
|
||||||
|
| `Enter` | open the session, or expand the folder |
|
||||||
|
| `Tab` | hand the keyboard to the pane |
|
||||||
|
| `c` | collapse or expand the highlighted folder |
|
||||||
|
| `r` | rename the highlighted session |
|
||||||
|
| `x` | kill the highlighted session |
|
||||||
|
| `n` | open the spawn form |
|
||||||
|
| `N` | spawn with every default resolved |
|
||||||
|
| `?` | show the shortcut overlay |
|
||||||
|
|
||||||
|
The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||||
|
web client's are fixed. A binding added to one client belongs in this table and
|
||||||
|
in both help overlays.
|
||||||
|
|||||||
Generated
+1645
File diff suppressed because it is too large
Load Diff
+12
-5
@@ -32,8 +32,10 @@ export interface AttachConnection {
|
|||||||
readonly sessionId: Uuid;
|
readonly sessionId: Uuid;
|
||||||
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
/** The `.attach` element (header + terminal body) to mount into a pane. */
|
||||||
readonly el: HTMLElement;
|
readonly el: HTMLElement;
|
||||||
/** Becomes the visible view: fit to the pane, focus, sync size. */
|
/** Becomes the visible view: fit to the pane, focus, sync size.
|
||||||
onMount(): void;
|
* `focus: false` mounts without taking the keyboard — what a sidebar
|
||||||
|
* preview needs, so j/k keeps working while the pane is on screen. */
|
||||||
|
onMount(opts?: { focus?: boolean }): void;
|
||||||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||||||
onUnmount(): void;
|
onUnmount(): void;
|
||||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||||
@@ -309,6 +311,10 @@ export function createAttachConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
// A previewed pane is visible but must not steal the keyboard from the
|
||||||
|
// sidebar, so mounting can opt out of the focus grab (and out of the one
|
||||||
|
// the socket does when it finishes connecting).
|
||||||
|
let focusOnMount = true;
|
||||||
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
|
||||||
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
|
||||||
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
|
||||||
@@ -387,7 +393,7 @@ export function createAttachConnection(
|
|||||||
reconnectMs = INITIAL_RECONNECT_MS;
|
reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
setState("connected", "open");
|
setState("connected", "open");
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
if (mounted) term.focus();
|
if (mounted && focusOnMount) term.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
@@ -472,14 +478,15 @@ export function createAttachConnection(
|
|||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
el,
|
el,
|
||||||
onMount() {
|
onMount(mountOpts?: { focus?: boolean }) {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
focusOnMount = mountOpts?.focus !== false;
|
||||||
acquireWebgl();
|
acquireWebgl();
|
||||||
// Defer a tick so the element has its content-pane dimensions before fit.
|
// Defer a tick so the element has its content-pane dimensions before fit.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
tryFit();
|
tryFit();
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
if (ws.readyState === WebSocket.OPEN) term.focus();
|
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onUnmount() {
|
onUnmount() {
|
||||||
|
|||||||
+58
-1
@@ -364,6 +364,11 @@ export interface SidebarOptions {
|
|||||||
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||||
* design kit, which render the sidebar without a daemon. */
|
* design kit, which render the sidebar without a daemon. */
|
||||||
link?: DaemonLink;
|
link?: DaemonLink;
|
||||||
|
/** The highlight moved onto a session, or off every session (`null`): the
|
||||||
|
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
|
||||||
|
* `source` is there so a mouse sweep can be debounced differently from a
|
||||||
|
* keyboard step. */
|
||||||
|
onPreview?: (sessionId: string | null, source: "key" | "hover") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||||
@@ -528,6 +533,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sessionOf = (key: string | null): string | null =>
|
||||||
|
key?.startsWith("session:") ? key.slice("session:".length) : null;
|
||||||
|
|
||||||
const moveNav = (delta: number) => {
|
const moveNav = (delta: number) => {
|
||||||
const rows = navRows();
|
const rows = navRows();
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
@@ -539,6 +547,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
navKey = next.dataset.navKey ?? null;
|
navKey = next.dataset.navKey ?? null;
|
||||||
applyNav();
|
applyNav();
|
||||||
next.scrollIntoView({ block: "nearest" });
|
next.scrollIntoView({ block: "nearest" });
|
||||||
|
opts.onPreview?.(sessionOf(navKey), "key");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||||
@@ -563,9 +572,50 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
applyNav();
|
applyNav();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
|
||||||
|
// out: there is no hover there, and a tap already opens the session.
|
||||||
|
let hovered: string | null = null;
|
||||||
|
const setHovered = (id: string | null) => {
|
||||||
|
if (hovered === id) return;
|
||||||
|
hovered = id;
|
||||||
|
opts.onPreview?.(id, "hover");
|
||||||
|
};
|
||||||
|
treeHost.addEventListener("pointerover", (e) => {
|
||||||
|
if (e.pointerType === "touch") return;
|
||||||
|
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||||
|
setHovered(row?.dataset.sessionId ?? null);
|
||||||
|
});
|
||||||
|
treeHost.addEventListener("pointerleave", (e) => {
|
||||||
|
if (e.pointerType === "touch") return;
|
||||||
|
setHovered(null);
|
||||||
|
});
|
||||||
|
|
||||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||||
// keyboard stays here while j/k runs.
|
// keyboard stays here while j/k runs.
|
||||||
treeHost.tabIndex = -1;
|
treeHost.tabIndex = -1;
|
||||||
|
// The keyboard walk's preview belongs to the list having the keyboard: once
|
||||||
|
// it doesn't, the content pane goes back to what the operator actually opened.
|
||||||
|
treeHost.addEventListener("blur", () => opts.onPreview?.(null, "key"));
|
||||||
|
|
||||||
|
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||||
|
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||||
|
const row = rowFor(navKey);
|
||||||
|
const id = row?.dataset.sessionId;
|
||||||
|
if (!row || !id) return undefined;
|
||||||
|
const session = store.snapshot().sessions.get(id);
|
||||||
|
return session ? { row, session } : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `c`: collapse or expand the highlighted folder, the TUI's
|
||||||
|
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
|
||||||
|
* the key does nothing rather than toggling some other folder. */
|
||||||
|
const toggleHighlightedFolder = () => {
|
||||||
|
const row = rowFor(navKey);
|
||||||
|
if (!row || !row.classList.contains("tree-folder-header")) return;
|
||||||
|
row.click();
|
||||||
|
applyNav();
|
||||||
|
};
|
||||||
|
|
||||||
treeHost.addEventListener("keydown", (e) => {
|
treeHost.addEventListener("keydown", (e) => {
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
// The inline rename field lives inside a row and owns its own keys.
|
// The inline rename field lives inside a row and owns its own keys.
|
||||||
@@ -574,7 +624,14 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||||
else if (e.key === "Enter") activateNav();
|
else if (e.key === "Enter") activateNav();
|
||||||
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||||
else return;
|
else if (e.key === "c") toggleHighlightedFolder();
|
||||||
|
else if (e.key === "x") {
|
||||||
|
const hit = highlightedSession();
|
||||||
|
if (hit) void requestKill(hit.session, sessionName(hit.session));
|
||||||
|
} else if (e.key === "r") {
|
||||||
|
const hit = highlightedSession();
|
||||||
|
if (hit) beginRename(hit.row, hit.session);
|
||||||
|
} else return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export class ConnectionPool {
|
|||||||
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
|
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
|
||||||
* exists (refreshing LRU recency). Does not touch other mounted panes.
|
* exists (refreshing LRU recency). Does not touch other mounted panes.
|
||||||
*/
|
*/
|
||||||
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
|
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
|
||||||
let conn = this.cache.get(id);
|
let conn = this.cache.get(id);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
this.cache.delete(id);
|
this.cache.delete(id);
|
||||||
@@ -93,9 +93,9 @@ export class ConnectionPool {
|
|||||||
}
|
}
|
||||||
if (conn.el.parentElement !== pane) {
|
if (conn.el.parentElement !== pane) {
|
||||||
pane.replaceChildren(conn.el);
|
pane.replaceChildren(conn.el);
|
||||||
conn.onMount();
|
conn.onMount(opts);
|
||||||
} else if (!this.mounted.has(id)) {
|
} else if (!this.mounted.has(id)) {
|
||||||
conn.onMount();
|
conn.onMount(opts);
|
||||||
}
|
}
|
||||||
this.mounted.add(id);
|
this.mounted.add(id);
|
||||||
this.evict();
|
this.evict();
|
||||||
@@ -114,13 +114,17 @@ export class ConnectionPool {
|
|||||||
/**
|
/**
|
||||||
* Reconcile the visible set to exactly `entries` (in render order): park any
|
* Reconcile the visible set to exactly `entries` (in render order): park any
|
||||||
* mounted session not listed, mount/refresh each listed one in its pane.
|
* mounted session not listed, mount/refresh each listed one in its pane.
|
||||||
|
* `focus: false` shows the panes without taking the keyboard (preview).
|
||||||
*/
|
*/
|
||||||
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
|
setVisible(
|
||||||
|
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
|
||||||
|
opts?: { focus?: boolean },
|
||||||
|
): void {
|
||||||
const next = new Set(entries.map((e) => e.id));
|
const next = new Set(entries.map((e) => e.id));
|
||||||
for (const id of [...this.mounted]) {
|
for (const id of [...this.mounted]) {
|
||||||
if (!next.has(id)) this.unmount(id);
|
if (!next.has(id)) this.unmount(id);
|
||||||
}
|
}
|
||||||
for (const e of entries) this.mount(e.id, e.pane, e.info);
|
for (const e of entries) this.mount(e.id, e.pane, e.info, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export const SHORTCUTS: ShortcutGroup[] = [
|
|||||||
{ keys: ["j"], description: "Highlight the next session" },
|
{ keys: ["j"], description: "Highlight the next session" },
|
||||||
{ keys: ["k"], description: "Highlight the previous session" },
|
{ keys: ["k"], description: "Highlight the previous session" },
|
||||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||||
|
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
|
||||||
|
{ keys: ["r"], description: "Rename the highlighted session" },
|
||||||
|
{ keys: ["x"], description: "Kill the highlighted session" },
|
||||||
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
|
||||||
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
|
||||||
],
|
],
|
||||||
|
|||||||
+36
-3
@@ -104,9 +104,26 @@ shellEl.dataset.route = "catalog";
|
|||||||
// after the welcome renderer). The hook only fires on user interaction, by which
|
// after the welcome renderer). The hook only fires on user interaction, by which
|
||||||
// point `mv` is assigned.
|
// point `mv` is assigned.
|
||||||
let mv: MultiView;
|
let mv: MultiView;
|
||||||
|
|
||||||
|
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
|
||||||
|
// mouse sweep waits long enough that rows crossed on the way somewhere else
|
||||||
|
// never flash up. The same delay applies to clearing, so the gap between two
|
||||||
|
// rows doesn't blink the previous view back.
|
||||||
|
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
|
||||||
|
let previewTimer: number | undefined;
|
||||||
|
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
|
||||||
|
clearTimeout(previewTimer);
|
||||||
|
previewTimer = window.setTimeout(() => {
|
||||||
|
ensureMultiView();
|
||||||
|
mv?.previewSession(sessionId);
|
||||||
|
updateShellRoute();
|
||||||
|
}, PREVIEW_DELAY[source]);
|
||||||
|
}
|
||||||
|
|
||||||
const sidebar = renderSidebar(store, {
|
const sidebar = renderSidebar(store, {
|
||||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||||
link,
|
link,
|
||||||
|
onPreview: schedulePreview,
|
||||||
});
|
});
|
||||||
const content = document.createElement("section");
|
const content = document.createElement("section");
|
||||||
content.className = "content";
|
content.className = "content";
|
||||||
@@ -184,7 +201,9 @@ function infoFor(sessionId: string) {
|
|||||||
|
|
||||||
/** Drive the shell's responsive layout off whether any pane is open. */
|
/** Drive the shell's responsive layout off whether any pane is open. */
|
||||||
function updateShellRoute(): void {
|
function updateShellRoute(): void {
|
||||||
const hasPanes = openSessions(layout).size > 0;
|
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
|
||||||
|
// not swap the list out for a terminal on the way past a row.
|
||||||
|
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
|
||||||
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +263,18 @@ mv = createMultiView({
|
|||||||
onLayoutChange: scheduleSave,
|
onLayoutChange: scheduleSave,
|
||||||
});
|
});
|
||||||
let contentShowsMv = false;
|
let contentShowsMv = false;
|
||||||
function showMultiView(): void {
|
/** Put the multi-view in the content pane, without rendering it. A preview
|
||||||
|
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
|
||||||
|
* first re-mount the pane it is about to replace, and that mount takes the
|
||||||
|
* keyboard away from the list being walked. */
|
||||||
|
function ensureMultiView(): void {
|
||||||
if (!contentShowsMv) {
|
if (!contentShowsMv) {
|
||||||
content.replaceChildren(mv.el);
|
content.replaceChildren(mv.el);
|
||||||
contentShowsMv = true;
|
contentShowsMv = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
function showMultiView(): void {
|
||||||
|
ensureMultiView();
|
||||||
mv.render();
|
mv.render();
|
||||||
updateShellRoute();
|
updateShellRoute();
|
||||||
}
|
}
|
||||||
@@ -375,7 +401,13 @@ window.addEventListener("keydown", (e) => {
|
|||||||
|
|
||||||
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
// Tab in the session list hands the keyboard to the pane; Escape in the pane
|
||||||
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
// hands it back — the pair the TUI gets from `tab` and `esc`.
|
||||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
document.addEventListener(FOCUS_PANE_EVENT, () => {
|
||||||
|
// Handing the keyboard to a previewed pane is choosing it: the preview stops
|
||||||
|
// being provisional rather than snapping back the moment the list blurs.
|
||||||
|
const previewed = mv.previewedSessionId();
|
||||||
|
if (previewed) focusSession(previewed);
|
||||||
|
pool.focusTerminal();
|
||||||
|
});
|
||||||
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||||
drawer.open(); // 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();
|
||||||
@@ -386,6 +418,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
|||||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||||
* changing, leaving a click back onto that session's row silently ignored. */
|
* changing, leaving a click back onto that session's row silently ignored. */
|
||||||
function focusSession(sessionId: string): void {
|
function focusSession(sessionId: string): void {
|
||||||
|
clearTimeout(previewTimer);
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openSession(sessionId);
|
mv.openSession(sessionId);
|
||||||
updateShellRoute();
|
updateShellRoute();
|
||||||
|
|||||||
+181
-13
@@ -11,6 +11,7 @@ import {
|
|||||||
addTab,
|
addTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
|
freshId,
|
||||||
type Dir,
|
type Dir,
|
||||||
type Layout,
|
type Layout,
|
||||||
leaves,
|
leaves,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
openSessions,
|
openSessions,
|
||||||
removeLeaf,
|
removeLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
|
type Tab,
|
||||||
} from "./layout.js";
|
} from "./layout.js";
|
||||||
import type { AttachInfo } from "./attach.js";
|
import type { AttachInfo } from "./attach.js";
|
||||||
|
|
||||||
@@ -57,11 +59,22 @@ export interface MultiView {
|
|||||||
/** Rebuild the view from the current layout. */
|
/** Rebuild the view from the current layout. */
|
||||||
render: () => void;
|
render: () => void;
|
||||||
focusedSessionId: () => string | null;
|
focusedSessionId: () => string | null;
|
||||||
|
/** Show `sessionId` in the content pane without committing to it
|
||||||
|
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
|
||||||
|
* tab carries it. `null` puts back whatever was on screen before. */
|
||||||
|
previewSession: (sessionId: string | null) => void;
|
||||||
|
/** The session being previewed, or null. */
|
||||||
|
previewedSessionId: () => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMultiView(opts: MultiViewOptions): MultiView {
|
export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||||
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
|
||||||
const persist = () => opts.onLayoutChange?.();
|
// A preview must never reach localStorage: it is undone the moment the
|
||||||
|
// highlight moves on, and the layout it would save is not the operator's.
|
||||||
|
const persist = () => {
|
||||||
|
if (preview) return;
|
||||||
|
opts.onLayoutChange?.();
|
||||||
|
};
|
||||||
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "mv";
|
el.className = "mv";
|
||||||
@@ -83,8 +96,119 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- preview (docs/PREVIEW.md) ----
|
||||||
|
// A preview is a temporary *view* change, never a layout change: it records
|
||||||
|
// exactly what it moved so ending it puts the layout back byte for byte, and
|
||||||
|
// it neither persists nor reports pane focus to the daemon while it is up.
|
||||||
|
interface Preview {
|
||||||
|
sessionId: string;
|
||||||
|
/** Tab that was active before the preview. */
|
||||||
|
prevActiveTabId: string;
|
||||||
|
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||||
|
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||||
|
/** Throwaway tab built for a session that no pane was showing. */
|
||||||
|
tempTabId: string | null;
|
||||||
|
}
|
||||||
|
let preview: Preview | null = null;
|
||||||
|
/// Set while a preview-driven render runs: ending a preview re-mounts the
|
||||||
|
/// pane that was there, and that mount must not pull the keyboard out of the
|
||||||
|
/// sidebar the operator is still walking.
|
||||||
|
let quietRender = false;
|
||||||
|
|
||||||
|
/** Render without any mount taking the keyboard. */
|
||||||
|
function renderQuietly(): void {
|
||||||
|
quietRender = true;
|
||||||
|
try {
|
||||||
|
render();
|
||||||
|
} finally {
|
||||||
|
quietRender = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
|
||||||
|
|
||||||
|
/** Undo the preview's moves. Mutates only — the caller renders. */
|
||||||
|
function restorePreview(): boolean {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return false;
|
||||||
|
preview = null;
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||||
|
if (i >= 0) layout.tabs.splice(i, 1);
|
||||||
|
} else if (p.prevFocus) {
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||||
|
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||||
|
}
|
||||||
|
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||||
|
? p.prevActiveTabId
|
||||||
|
: layout.tabs[0].id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep what the preview is showing: its throwaway tab becomes a real one,
|
||||||
|
* and the focus it moved stands. Returns true when `sessionId` was the
|
||||||
|
* session being previewed; any other preview is simply discarded. */
|
||||||
|
function settlePreview(sessionId: string): boolean {
|
||||||
|
const p = preview;
|
||||||
|
if (!p) return false;
|
||||||
|
if (p.sessionId !== sessionId) {
|
||||||
|
restorePreview();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
preview = null;
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||||
|
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewSession(sessionId: string | null): void {
|
||||||
|
if (preview?.sessionId === sessionId) return;
|
||||||
|
if (sessionId === null) {
|
||||||
|
if (restorePreview()) renderQuietly();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restorePreview();
|
||||||
|
const prevActiveTabId = layout.activeTabId;
|
||||||
|
const loc = locateSession(layout, sessionId);
|
||||||
|
if (loc) {
|
||||||
|
preview = {
|
||||||
|
sessionId,
|
||||||
|
prevActiveTabId,
|
||||||
|
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||||
|
tempTabId: null,
|
||||||
|
};
|
||||||
|
layout.activeTabId = loc.tab.id;
|
||||||
|
loc.tab.focusedLeafId = loc.leaf.id;
|
||||||
|
} else {
|
||||||
|
// Not on screen anywhere: a throwaway tab carries it, so no existing
|
||||||
|
// pane is evicted and nothing the operator arranged moves.
|
||||||
|
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||||
|
layout.tabs.push(tab);
|
||||||
|
layout.activeTabId = tab.id;
|
||||||
|
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||||
|
}
|
||||||
|
renderQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any user action that changes the layout for real ends the preview first,
|
||||||
|
* so it never bakes a throwaway tab into the arrangement. */
|
||||||
|
function dropPreview(): void {
|
||||||
|
restorePreview();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- highlight helpers ----
|
// ---- highlight helpers ----
|
||||||
|
|
||||||
|
/** Sessions the operator actually has open — the preview's throwaway tab
|
||||||
|
* does not count, or hovering the sidebar would flip the shell's route. */
|
||||||
|
function realOpenSessions(): Set<string> {
|
||||||
|
const out = openSessions(layout);
|
||||||
|
if (preview?.tempTabId) out.delete(preview.sessionId);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function focusedLeafSession(): string | null {
|
function focusedLeafSession(): string | null {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
||||||
@@ -98,9 +222,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
|
||||||
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
|
||||||
}
|
}
|
||||||
pool.setFocus(focusedLeafSession());
|
// A preview is not a focused pane: reporting it would clear the session's
|
||||||
|
// `AwaitingInput` glow just because the operator scrolled past it.
|
||||||
|
if (!preview) pool.setFocus(focusedLeafSession());
|
||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: openSessions(layout),
|
open: realOpenSessions(),
|
||||||
focused: focusedLeafSession(),
|
focused: focusedLeafSession(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -168,6 +294,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
pane.addEventListener(
|
pane.addEventListener(
|
||||||
"pointerdown",
|
"pointerdown",
|
||||||
(e) => {
|
(e) => {
|
||||||
|
// Clicking into a previewed pane is the operator choosing it.
|
||||||
|
if (preview && leaf.sessionId === preview.sessionId) {
|
||||||
|
settlePreview(leaf.sessionId);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
focusLeaf(leaf.id);
|
focusLeaf(leaf.id);
|
||||||
// Drag a pane by its title bar (desktop only — no docking on mobile).
|
// Drag a pane by its title bar (desktop only — no docking on mobile).
|
||||||
if (isMobile) return;
|
if (isMobile) return;
|
||||||
@@ -252,24 +383,31 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
for (const t of layout.tabs) {
|
for (const t of layout.tabs) {
|
||||||
const tab = document.createElement("button");
|
const tab = document.createElement("button");
|
||||||
tab.type = "button";
|
tab.type = "button";
|
||||||
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
|
tab.className =
|
||||||
|
"mv-tab" +
|
||||||
|
(t.id === layout.activeTabId ? " is-active" : "") +
|
||||||
|
(isTempTab(t.id) ? " is-preview" : "");
|
||||||
const n = leaves(t.root).filter((l) => l.sessionId).length;
|
const n = leaves(t.root).filter((l) => l.sessionId).length;
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = t.title + (n ? ` · ${n}` : "");
|
label.textContent = t.title + (n ? ` · ${n}` : "");
|
||||||
tab.appendChild(label);
|
tab.appendChild(label);
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
|
if (isTempTab(t.id)) return; // a preview tab is not a place to go
|
||||||
|
dropPreview();
|
||||||
if (layout.activeTabId !== t.id) {
|
if (layout.activeTabId !== t.id) {
|
||||||
layout.activeTabId = t.id;
|
layout.activeTabId = t.id;
|
||||||
render();
|
|
||||||
}
|
}
|
||||||
|
render();
|
||||||
});
|
});
|
||||||
if (layout.tabs.length > 1) {
|
// A preview tab is not the operator's to close: it goes on its own.
|
||||||
|
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
|
||||||
const close = document.createElement("span");
|
const close = document.createElement("span");
|
||||||
close.className = "mv-tab-close";
|
close.className = "mv-tab-close";
|
||||||
close.textContent = "✕";
|
close.textContent = "✕";
|
||||||
close.title = "Close tab";
|
close.title = "Close tab";
|
||||||
close.addEventListener("click", (e) => {
|
close.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
dropPreview();
|
||||||
closeTab(layout, t.id);
|
closeTab(layout, t.id);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -283,6 +421,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
add.textContent = "+";
|
add.textContent = "+";
|
||||||
add.title = "New tab";
|
add.title = "New tab";
|
||||||
add.addEventListener("click", () => {
|
add.addEventListener("click", () => {
|
||||||
|
dropPreview();
|
||||||
addTab(layout);
|
addTab(layout);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -292,12 +431,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// ---- mutations ----
|
// ---- mutations ----
|
||||||
|
|
||||||
function splitFocused(leafId: string, dir: Dir): void {
|
function splitFocused(leafId: string, dir: Dir): void {
|
||||||
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePane(leafId: string): void {
|
function closePane(leafId: string): void {
|
||||||
|
dropPreview();
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
removeLeaf(tab, leafId);
|
removeLeaf(tab, leafId);
|
||||||
if (tab.focusedLeafId === leafId) {
|
if (tab.focusedLeafId === leafId) {
|
||||||
@@ -307,6 +448,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSession(sessionId: string): void {
|
function openSession(sessionId: string): void {
|
||||||
|
// Opening what is already previewed keeps exactly what is on screen: the
|
||||||
|
// throwaway tab becomes a real one instead of the session moving again.
|
||||||
|
if (settlePreview(sessionId)) {
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
layout.activeTabId = loc.tab.id;
|
layout.activeTabId = loc.tab.id;
|
||||||
@@ -337,6 +484,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
|
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
|
||||||
/// operator was reading (#100) — what the Spawn button asks for.
|
/// operator was reading (#100) — what the Spawn button asks for.
|
||||||
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
|
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
|
||||||
|
if (settlePreview(sessionId)) {
|
||||||
|
render();
|
||||||
|
opts.onOpenSessionsChange?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
layout.activeTabId = loc.tab.id;
|
layout.activeTabId = loc.tab.id;
|
||||||
@@ -384,13 +536,19 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// A lone tab has nothing to switch between, so the bar is only a strip of
|
// 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
|
// 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.
|
// own tab (#100), which brings the bar back with the "+" button on it.
|
||||||
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
|
// The preview's throwaway tab must not make the bar appear and vanish as
|
||||||
|
// the highlight moves, so it does not count towards "one tab only".
|
||||||
|
el.classList.toggle(
|
||||||
|
"mv-solo-tab",
|
||||||
|
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
|
||||||
|
);
|
||||||
|
el.classList.toggle("mv-previewing", preview !== null);
|
||||||
|
|
||||||
if (!tab.root) {
|
if (!tab.root) {
|
||||||
pool.setVisible([]);
|
pool.setVisible([]);
|
||||||
pool.setFocus(null);
|
if (!preview) pool.setFocus(null);
|
||||||
canvas.appendChild(renderWelcome());
|
canvas.appendChild(renderWelcome());
|
||||||
sidebar.setHighlights({ open: openSessions(layout), focused: null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -408,7 +566,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
|
|
||||||
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
|
||||||
canvas.appendChild(buildTree(tab.root, hosts));
|
canvas.appendChild(buildTree(tab.root, hosts));
|
||||||
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
|
// A preview keeps the keyboard in the sidebar: mounting must not focus the
|
||||||
|
// terminal, or j/k would stop walking the list after the first step.
|
||||||
|
pool.setVisible(
|
||||||
|
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
|
||||||
|
{ focus: !preview && !quietRender },
|
||||||
|
);
|
||||||
syncFocus();
|
syncFocus();
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
@@ -431,12 +594,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
// Only the focused leaf's session is live on mobile; the rest park.
|
// Only the focused leaf's session is live on mobile; the rest park.
|
||||||
pool.setVisible(
|
pool.setVisible(
|
||||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||||
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSession(sessionId: string): void {
|
function closeSession(sessionId: string): void {
|
||||||
|
dropPreview();
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
removeLeaf(loc.tab, loc.leaf.id);
|
removeLeaf(loc.tab, loc.leaf.id);
|
||||||
@@ -628,6 +793,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
|
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
|
||||||
|
dropPreview();
|
||||||
runDrag({ kind: "session", sessionId }, e, { deferred: true });
|
runDrag({ kind: "session", sessionId }, e, { deferred: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,9 +802,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
openSession,
|
openSession,
|
||||||
openSessionBeside,
|
openSessionBeside,
|
||||||
closeSession,
|
closeSession,
|
||||||
openSessions: () => openSessions(layout),
|
openSessions: realOpenSessions,
|
||||||
beginSessionDrag,
|
beginSessionDrag,
|
||||||
render,
|
render,
|
||||||
focusedSessionId: focusedLeafSession,
|
focusedSessionId: focusedLeafSession,
|
||||||
|
previewSession,
|
||||||
|
previewedSessionId: () => preview?.sessionId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
|
||||||
|
// content pane without changing the layout the operator built.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
|
||||||
|
import { serializeLayout } from "./persistence.js";
|
||||||
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
|
import type { ConnectionPool } from "./connections.js";
|
||||||
|
import type { SidebarHandle } from "./catalog.js";
|
||||||
|
|
||||||
|
function stubFetch(): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const body = url.includes("/api/info")
|
||||||
|
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||||
|
: undefined;
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Spy {
|
||||||
|
pool: ConnectionPool;
|
||||||
|
visible: () => string[];
|
||||||
|
focusCalls: (string | null)[];
|
||||||
|
mountedWithFocus: (boolean | undefined)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakePool(): Spy {
|
||||||
|
let visible: string[] = [];
|
||||||
|
const focusCalls: (string | null)[] = [];
|
||||||
|
const mountedWithFocus: (boolean | undefined)[] = [];
|
||||||
|
const pool = {
|
||||||
|
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
|
||||||
|
visible = entries.map((e) => e.id);
|
||||||
|
mountedWithFocus.push(opts?.focus);
|
||||||
|
},
|
||||||
|
setFocus(id: string | null) {
|
||||||
|
focusCalls.push(id);
|
||||||
|
},
|
||||||
|
} as unknown as ConnectionPool;
|
||||||
|
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSidebar(): SidebarHandle {
|
||||||
|
return {
|
||||||
|
el: document.createElement("aside"),
|
||||||
|
setActive() {},
|
||||||
|
setHighlights() {},
|
||||||
|
focusList() {},
|
||||||
|
hasListFocus() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
dispose() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
|
||||||
|
const mv = createMultiView({
|
||||||
|
pool,
|
||||||
|
layout,
|
||||||
|
sidebar: fakeSidebar(),
|
||||||
|
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||||
|
onDisconnect: () => {},
|
||||||
|
renderWelcome: () => document.createElement("div"),
|
||||||
|
onLayoutChange,
|
||||||
|
});
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
return mv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
|
||||||
|
function twoTabs(): Layout {
|
||||||
|
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);
|
||||||
|
layout.activeTabId = second.id;
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar preview", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings a session's own tab forward, then puts the layout back", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const before = serializeLayout(layout);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(spy.visible()).toEqual(["s1"]);
|
||||||
|
expect(mv.previewedSessionId()).toBe("s1");
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(spy.visible()).toEqual(["s2"]);
|
||||||
|
expect(mv.previewedSessionId()).toBeNull();
|
||||||
|
expect(serializeLayout(layout)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const before = serializeLayout(layout);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(spy.visible()).toEqual(["s9"]);
|
||||||
|
expect(layout.tabs).toHaveLength(3);
|
||||||
|
// The throwaway tab is not something the operator has open.
|
||||||
|
expect(mv.openSessions().has("s9")).toBe(false);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
expect(serializeLayout(layout)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepping between previews leaves only the newest one on screen", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const before = serializeLayout(layout);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
mv.previewSession("s1");
|
||||||
|
mv.previewSession("s8");
|
||||||
|
expect(spy.visible()).toEqual(["s8"]);
|
||||||
|
expect(layout.tabs).toHaveLength(3);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(serializeLayout(layout)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
mv.openSession("s9");
|
||||||
|
|
||||||
|
expect(mv.previewedSessionId()).toBeNull();
|
||||||
|
expect(layout.tabs).toHaveLength(3);
|
||||||
|
expect(mv.openSessions().has("s9")).toBe(true);
|
||||||
|
expect(spy.visible()).toEqual(["s9"]);
|
||||||
|
// Nothing the operator had open was evicted to make room.
|
||||||
|
expect(mv.openSessions().has("s2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never persists, and saves again once the preview is settled", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const saves = vi.fn();
|
||||||
|
const mv = makeMv(layout, spy.pool, saves);
|
||||||
|
saves.mockClear();
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(saves).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(saves).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reports a previewed pane as focused to the daemon", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
spy.focusCalls.length = 0;
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(spy.focusCalls).toEqual([]);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(spy.focusCalls).toEqual(["s2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts a preview without taking the keyboard", () => {
|
||||||
|
const layout = twoTabs();
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
spy.mountedWithFocus.length = 0;
|
||||||
|
|
||||||
|
mv.previewSession("s1");
|
||||||
|
expect(spy.mountedWithFocus).toEqual([false]);
|
||||||
|
|
||||||
|
mv.openSession("s1");
|
||||||
|
expect(spy.mountedWithFocus.at(-1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the tab bar hidden while a lone tab is previewing", () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const spy = fakePool();
|
||||||
|
const mv = makeMv(layout, spy.pool);
|
||||||
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||||
|
|
||||||
|
mv.previewSession("s9");
|
||||||
|
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||||
|
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
|
||||||
|
|
||||||
|
mv.previewSession(null);
|
||||||
|
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
|||||||
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
||||||
|
|
||||||
/** Two sessions in sibling directories: two folder headers, two cards. */
|
/** Two sessions in sibling directories: two folder headers, two cards. */
|
||||||
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
|
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
|
||||||
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
|
el: HTMLElement;
|
||||||
|
handle: ReturnType<typeof renderSidebar>;
|
||||||
|
} {
|
||||||
|
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
|
||||||
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
||||||
document.body.replaceChildren(handle.el);
|
document.body.replaceChildren(handle.el);
|
||||||
return { el: handle.el, handle };
|
return { el: handle.el, handle };
|
||||||
@@ -117,6 +120,43 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
expect(asked).toBe(1);
|
expect(asked).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("c collapses and expands the highlighted folder", () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
||||||
|
header.click(); // collapse /one, so the walk stops on its header
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
expect(highlighted(el)).toBe("folder:one");
|
||||||
|
press(tree(el), "c");
|
||||||
|
// Expanded: the card it was hiding is back in the walk.
|
||||||
|
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("r renames the highlighted session in place", () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "r");
|
||||||
|
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
|
||||||
|
expect(field).not.toBeNull();
|
||||||
|
// Escape settles the rename, so the module-level "a row is being renamed"
|
||||||
|
// flag doesn't leak into the next test.
|
||||||
|
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("x kills the highlighted session", async () => {
|
||||||
|
const { el } = twoFolders();
|
||||||
|
const killed: string[] = [];
|
||||||
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}) as typeof window.fetch;
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "x");
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the keyboard alone while a row is being renamed", () => {
|
it("leaves the keyboard alone while a row is being renamed", () => {
|
||||||
const { el } = twoFolders();
|
const { el } = twoFolders();
|
||||||
press(tree(el), "j");
|
press(tree(el), "j");
|
||||||
@@ -126,3 +166,43 @@ describe("sidebar keyboard navigation", () => {
|
|||||||
expect(highlighted(el)).toBe(`session:${A}`);
|
expect(highlighted(el)).toBe(`session:${A}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||||
|
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
|
||||||
|
const seen: [string | null, string][] = [];
|
||||||
|
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
||||||
|
|
||||||
|
press(tree(el), "j");
|
||||||
|
press(tree(el), "j");
|
||||||
|
expect(seen).toEqual([
|
||||||
|
[A, "key"],
|
||||||
|
[B, "key"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||||
|
expect(seen.at(-1)).toEqual([null, "key"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||||
|
const seen: [string | null, string][] = [];
|
||||||
|
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
|
||||||
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toEqual([[A, "hover"]]);
|
||||||
|
// Moving within the same card is not a new preview.
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
|
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||||
|
expect(seen.at(-1)).toEqual([null, "hover"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores touch, where a tap already opens the session", () => {
|
||||||
|
const seen: (string | null)[] = [];
|
||||||
|
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||||
|
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
|
||||||
|
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2883,6 +2883,38 @@ input.leaf-rename:focus {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
|
||||||
|
operator only highlighted, so it reads as provisional — a dashed tab and a
|
||||||
|
dashed inset ring on the canvas — and reverts when the highlight moves. */
|
||||||
|
.mv-tab.is-preview {
|
||||||
|
border-style: dashed;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.mv.mv-previewing .mv-canvas::before {
|
||||||
|
content: "preview";
|
||||||
|
position: absolute;
|
||||||
|
top: var(--sp-2);
|
||||||
|
right: var(--sp-3);
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
padding: 1px var(--sp-2);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
font: 500 var(--fs-11) / 1.5 var(--font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.mv.mv-previewing .mv-canvas::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px dashed var(--accent-ring);
|
||||||
|
border-radius: var(--r-1);
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
}
|
||||||
|
|
||||||
/* canvas + welcome */
|
/* canvas + welcome */
|
||||||
.mv-canvas {
|
.mv-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user