Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b04575ed5 | ||
|
|
80af2e6816 | ||
|
|
52abfc6eea | ||
|
|
eb9b4b4ae8 | ||
|
|
1f543d16d1 | ||
|
|
f6cfcbb2c4 | ||
|
|
5920bbf2ec | ||
|
|
eb5d28d3a6 | ||
|
|
511ee3be4e | ||
|
|
a92202302a | ||
|
|
44c67fcee7 | ||
|
|
d8d26d181a | ||
|
|
aec7f4cae6 |
@@ -273,7 +273,7 @@ impl Tmux {
|
||||
"-t",
|
||||
&name,
|
||||
"-F",
|
||||
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}",
|
||||
SCREEN_GEOMETRY_FORMAT,
|
||||
";",
|
||||
"capture-pane",
|
||||
"-p",
|
||||
@@ -515,6 +515,52 @@ fn new_session_args(
|
||||
/// frame huge.
|
||||
const CAPTURE_HISTORY_LINES: usize = 300;
|
||||
|
||||
/// `display-message -F` request behind [`parse_screen_geometry`]. tmux expands
|
||||
/// a name it does not know to the empty string rather than failing, so a field
|
||||
/// missing on an older tmux shifts everything after it — hence the parser keys
|
||||
/// the optional tail off the field count.
|
||||
const SCREEN_GEOMETRY_FORMAT: &str = concat!(
|
||||
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag} ",
|
||||
"#{mouse_standard_flag} #{mouse_button_flag} #{mouse_all_flag} ",
|
||||
"#{mouse_utf8_flag} #{mouse_sgr_flag}",
|
||||
);
|
||||
|
||||
/// Field count of [`SCREEN_GEOMETRY_FORMAT`]; fewer means an older tmux did not
|
||||
/// know one of the names, and the mouse tail cannot be trusted by position.
|
||||
const SCREEN_GEOMETRY_FIELDS: usize = 10;
|
||||
|
||||
/// Mouse-reporting modes a pane has turned on. A full-screen program sets these
|
||||
/// once, at startup, so a client attaching later never sees the sequences —
|
||||
/// [`render_screen_snapshot`] replays them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct MouseModes {
|
||||
/// `?1000` — report button press and release.
|
||||
standard: bool,
|
||||
/// `?1002` — also report motion while a button is held.
|
||||
button: bool,
|
||||
/// `?1003` — report all motion.
|
||||
all: bool,
|
||||
/// `?1005` — UTF-8 coordinates (superseded by SGR, still replayed if on).
|
||||
utf8: bool,
|
||||
/// `?1006` — SGR-encoded coordinates.
|
||||
sgr: bool,
|
||||
}
|
||||
|
||||
impl MouseModes {
|
||||
/// DECSET parameters to re-enable these modes, private-mode order.
|
||||
fn decset_params(self) -> impl Iterator<Item = u16> {
|
||||
[
|
||||
(self.standard, 1000),
|
||||
(self.button, 1002),
|
||||
(self.all, 1003),
|
||||
(self.utf8, 1005),
|
||||
(self.sgr, 1006),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(on, param)| on.then_some(param))
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor position and screen geometry of a pane, from `display-message`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ScreenGeometry {
|
||||
@@ -523,17 +569,31 @@ struct ScreenGeometry {
|
||||
pane_height: usize,
|
||||
alternate: bool,
|
||||
cursor_visible: bool,
|
||||
mouse: MouseModes,
|
||||
}
|
||||
|
||||
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
||||
let line = s.lines().next()?;
|
||||
let mut it = line.split_whitespace();
|
||||
let cursor_x = it.next()?.parse().ok()?;
|
||||
let cursor_y = it.next()?.parse().ok()?;
|
||||
let pane_height: usize = it.next()?.parse().ok()?;
|
||||
let alternate = it.next()? == "1";
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
let cursor_x = fields.first()?.parse().ok()?;
|
||||
let cursor_y = fields.get(1)?.parse().ok()?;
|
||||
let pane_height: usize = fields.get(2)?.parse().ok()?;
|
||||
let alternate = *fields.get(3)? == "1";
|
||||
// Older tmux without `cursor_flag` just means "assume visible".
|
||||
let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true);
|
||||
let cursor_visible = fields.get(4).map(|f| *f == "1").unwrap_or(true);
|
||||
// Only trust the mouse tail when every field expanded; a short line means
|
||||
// some name is unknown here and the remaining ones sit at other offsets.
|
||||
let mouse = if fields.len() >= SCREEN_GEOMETRY_FIELDS {
|
||||
MouseModes {
|
||||
standard: fields[5] == "1",
|
||||
button: fields[6] == "1",
|
||||
all: fields[7] == "1",
|
||||
utf8: fields[8] == "1",
|
||||
sgr: fields[9] == "1",
|
||||
}
|
||||
} else {
|
||||
MouseModes::default()
|
||||
};
|
||||
if pane_height == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -543,6 +603,7 @@ fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
||||
pane_height,
|
||||
alternate,
|
||||
cursor_visible,
|
||||
mouse,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -572,6 +633,12 @@ fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
|
||||
if geom.alternate {
|
||||
out.extend_from_slice(b"\x1b[?1049h");
|
||||
}
|
||||
// Mouse reporting is set up once, before this client existed. Without it
|
||||
// the client cannot tell a mouse-driven app from a plain alternate screen,
|
||||
// and its wheel and touch gestures have nowhere to go.
|
||||
for param in geom.mouse.decset_params() {
|
||||
out.extend_from_slice(format!("\x1b[?{param}h").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
|
||||
for (i, line) in lines[..=last].iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -645,7 +712,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
|
||||
let g = parse_screen_geometry("12 3 40 1 0\n").unwrap();
|
||||
let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap();
|
||||
assert_eq!(
|
||||
g,
|
||||
ScreenGeometry {
|
||||
@@ -654,6 +721,11 @@ mod tests {
|
||||
pane_height: 40,
|
||||
alternate: true,
|
||||
cursor_visible: false,
|
||||
mouse: MouseModes {
|
||||
button: true,
|
||||
sgr: true,
|
||||
..MouseModes::default()
|
||||
},
|
||||
}
|
||||
);
|
||||
let g = parse_screen_geometry("0 0 24 0").unwrap();
|
||||
@@ -663,6 +735,31 @@ mod tests {
|
||||
assert!(parse_screen_geometry("garbage").is_none());
|
||||
}
|
||||
|
||||
/// A tmux that does not know one of the mouse names expands it to nothing,
|
||||
/// so the tail is one field short and every flag in it has slid. Reading it
|
||||
/// by position would claim modes the pane never set.
|
||||
#[test]
|
||||
fn parse_screen_geometry_drops_a_short_mouse_tail() {
|
||||
let g = parse_screen_geometry("0 0 24 1 1 0 0 1 1").unwrap();
|
||||
assert_eq!(g.mouse, MouseModes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_modes_map_to_private_mode_parameters() {
|
||||
let all = MouseModes {
|
||||
standard: true,
|
||||
button: true,
|
||||
all: true,
|
||||
utf8: true,
|
||||
sgr: true,
|
||||
};
|
||||
assert_eq!(
|
||||
all.decset_params().collect::<Vec<_>>(),
|
||||
vec![1000, 1002, 1003, 1005, 1006]
|
||||
);
|
||||
assert_eq!(MouseModes::default().decset_params().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
|
||||
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
|
||||
@@ -675,6 +772,7 @@ mod tests {
|
||||
pane_height: 3,
|
||||
alternate: false,
|
||||
cursor_visible: true,
|
||||
mouse: MouseModes::default(),
|
||||
},
|
||||
);
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
@@ -695,6 +793,7 @@ mod tests {
|
||||
pane_height: 3,
|
||||
alternate: true,
|
||||
cursor_visible: false,
|
||||
mouse: MouseModes::default(),
|
||||
},
|
||||
);
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
@@ -716,12 +815,40 @@ mod tests {
|
||||
pane_height: 3,
|
||||
alternate: false,
|
||||
cursor_visible: true,
|
||||
mouse: MouseModes::default(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
|
||||
}
|
||||
|
||||
/// A client attaching to a running full-screen app never saw it turn mouse
|
||||
/// reporting on, so the snapshot has to. Without this the client reads the
|
||||
/// pane as a plain alternate screen and its scroll gestures go nowhere.
|
||||
#[test]
|
||||
fn snapshot_replays_the_panes_mouse_modes() {
|
||||
let out = String::from_utf8(render_screen_snapshot(
|
||||
&["app"],
|
||||
ScreenGeometry {
|
||||
cursor_x: 0,
|
||||
cursor_y: 0,
|
||||
pane_height: 1,
|
||||
alternate: true,
|
||||
cursor_visible: true,
|
||||
mouse: MouseModes {
|
||||
button: true,
|
||||
sgr: true,
|
||||
..MouseModes::default()
|
||||
},
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.starts_with("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every phrasing must be recognised: an unmatched one makes
|
||||
/// `list_sessions` fail, and a failed pass reaps nothing.
|
||||
#[test]
|
||||
|
||||
@@ -246,6 +246,59 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// A full-screen app turns mouse reporting on once, at startup. A client that
|
||||
/// attaches later never sees those sequences, so it reads the pane as a plain
|
||||
/// alternate screen and its wheel and touch gestures have nowhere to go. The
|
||||
/// snapshot must carry the pane's mouse modes with it.
|
||||
#[tokio::test]
|
||||
async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||
command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'"
|
||||
.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let want = b"MOUSE_MARKER";
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut replay = Vec::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
let (bytes, _rx) = fx
|
||||
.manager
|
||||
.subscribe_pty_with_replay(s.id)
|
||||
.await
|
||||
.expect("subscribe");
|
||||
replay = bytes;
|
||||
if replay.windows(want.len()).any(|w| w == want) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&replay).into_owned();
|
||||
assert!(
|
||||
text.contains("MOUSE_MARKER"),
|
||||
"replay never reached the live screen: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
||||
"replay dropped the pane's mouse modes: {text:?}"
|
||||
);
|
||||
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
||||
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
+11
-3
@@ -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 { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import type { ClientControl, Uuid } from "./types.js";
|
||||
|
||||
/**
|
||||
@@ -54,6 +55,15 @@ export interface AttachInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Header echoes the sidebar: the command's leading program becomes its tool
|
||||
* icon when we have a mark for it, remaining words stay as text. */
|
||||
function commandFragment(command: string): string {
|
||||
const { tool, rest } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? rest : command;
|
||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||
}
|
||||
|
||||
export function createAttachConnection(
|
||||
sessionId: Uuid,
|
||||
opts: AttachOptions = {},
|
||||
@@ -65,9 +75,7 @@ export function createAttachConnection(
|
||||
// tooltip for when you really need it.
|
||||
const titleHtml = info
|
||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||
(info.command && info.command !== info.name
|
||||
? `<span class="at-cmd">${escapeHtml(info.command)}</span>`
|
||||
: "")
|
||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
|
||||
el.innerHTML = `
|
||||
|
||||
+26
-19
@@ -300,8 +300,8 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
|
||||
/**
|
||||
* The global app header (full width, above the sidebar/content split): brand +
|
||||
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
||||
* stay put and reachable regardless of which pane is showing.
|
||||
* 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");
|
||||
@@ -340,23 +340,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
||||
kit.title = "Open the design kit (DEV)";
|
||||
actions.appendChild(kit);
|
||||
}
|
||||
const helpBtn = document.createElement("button");
|
||||
helpBtn.type = "button";
|
||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
||||
helpBtn.textContent = "?";
|
||||
helpBtn.title = "Keyboard shortcuts (?)";
|
||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
||||
helpBtn.addEventListener("click", () => toggleHelp());
|
||||
actions.appendChild(helpBtn);
|
||||
actions.appendChild(createThemeToggle());
|
||||
const spawnBtn = document.createElement("button");
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
}
|
||||
|
||||
@@ -425,6 +408,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
treeHost.className = "tree";
|
||||
el.appendChild(treeHost);
|
||||
|
||||
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
||||
// rather than the sidebar's own scrolling content, so they stay reachable
|
||||
// regardless of tree scroll position.
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "sidebar-footer";
|
||||
const helpBtn = document.createElement("button");
|
||||
helpBtn.type = "button";
|
||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
||||
helpBtn.textContent = "?";
|
||||
helpBtn.title = "Keyboard shortcuts (?)";
|
||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
||||
helpBtn.addEventListener("click", () => toggleHelp());
|
||||
footer.appendChild(helpBtn);
|
||||
footer.appendChild(createThemeToggle());
|
||||
const spawnBtn = document.createElement("button");
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
footer.appendChild(spawnBtn);
|
||||
el.appendChild(footer);
|
||||
|
||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
||||
treeHost.addEventListener("pointerdown", (e) => {
|
||||
|
||||
@@ -432,3 +432,94 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-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,9 @@ 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 header ☰ keeps the sidebar drawer reachable.
|
||||
el.classList.toggle("mv-mobile-solo", isMobile);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
@@ -496,7 +503,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 +513,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);
|
||||
|
||||
+26
-37
@@ -484,14 +484,27 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar's own slim header: just the session search (app-level actions live
|
||||
in the global top bar). */
|
||||
/* Sidebar's own slim header: just the session search. */
|
||||
/* The search input IS the header — it spans edge to edge, flush, with just a
|
||||
bottom rule for separation. No wrapper padding, no boxed border/radius. */
|
||||
.sidebar-search {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* App-level actions (help, theme, spawn), pinned to the sidebar's foot. */
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-footer .spawn-btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sidebar-search .search {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -761,7 +774,7 @@ textarea {
|
||||
|
||||
.tree-leaf:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
||||
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||
}
|
||||
|
||||
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
||||
@@ -1279,6 +1292,9 @@ input.leaf-rename:focus {
|
||||
|
||||
.attach-title .at-cmd {
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attach-title .at-cmd::before {
|
||||
@@ -2186,8 +2202,8 @@ 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). */
|
||||
/* 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 {
|
||||
display: none;
|
||||
}
|
||||
@@ -2353,10 +2369,9 @@ input.leaf-rename:focus {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
/* focused pane: accent ring, full strength */
|
||||
.mv-pane.is-focused {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
/* focused pane: titlebar tints instead of a ring, full strength */
|
||||
.mv-pane.is-focused .attach-header {
|
||||
background: color-mix(in oklab, var(--accent) 14%, var(--surface));
|
||||
}
|
||||
/* unfocused panes recede so the eye finds the live one */
|
||||
.mv-pane:not(.is-focused) {
|
||||
@@ -2467,8 +2482,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;
|
||||
@@ -2488,32 +2503,6 @@ 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;
|
||||
|
||||
@@ -39,6 +39,16 @@ describe("buildTree", () => {
|
||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||
});
|
||||
|
||||
it("folds the sole root folder into each of its own children when it branches", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||
});
|
||||
|
||||
it("stops collapsing where a directory branches", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||
|
||||
@@ -59,6 +59,15 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
||||
for (const c of n.children) if (c.kind === "folder") sortRec(c);
|
||||
};
|
||||
sortRec(root);
|
||||
// The root's sole child (usually `~`) has no sibling to distinguish it
|
||||
// from, so it folds into every one of its own children, not just a lone
|
||||
// one — unlike the chain merge below, and only here at the very top.
|
||||
if (root.children.length === 1 && root.children[0].kind === "folder") {
|
||||
const only = root.children[0];
|
||||
root.children = only.children.map((c) =>
|
||||
c.kind === "folder" ? { ...c, name: `${only.name}/${c.name}` } : c,
|
||||
);
|
||||
}
|
||||
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
|
||||
// one compact folder rather than four nested ones.
|
||||
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
|
||||
|
||||
Reference in New Issue
Block a user