Merge remote-tracking branch 'origin/main' into largo-piano
ci / web (push) Successful in 40m34s
ci / rust (push) Successful in 44m56s
ci / flake (push) Has been cancelled

This commit is contained in:
Miguel Palhas
2026-08-31 22:40:53 +01:00
4 changed files with 136 additions and 57 deletions
+6 -2
View File
@@ -37,6 +37,10 @@ export function requestOpenSpawn(): void {
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to open a draft pane seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
* folder headers are skipped: a directory is not something to focus, and the
* cards under it are one row away anyway. A *collapsed* folder still counts —
@@ -169,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
@@ -185,7 +189,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
},
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
]);
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
const shell = document.createElement("div");
const sidebar = document.createElement("aside");
const backdrop = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-leaf";
sidebar.appendChild(row);
shell.append(sidebar, backdrop);
document.body.replaceChildren(shell);
const drawer = installDrawer(shell, sidebar, backdrop);
drawer.open();
return { shell, sidebar, drawer };
}
describe("mobile drawer", () => {
beforeEach(() => document.body.replaceChildren());
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
// inert (CSS gates the drawer styling).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
// similar" sits inside a session row but stops propagation, so it never reaches
// the row's own close — both spawn paths arrive here via document events.
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
export interface Drawer {
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
}
export function installDrawer(
shellEl: HTMLElement,
sidebarEl: HTMLElement,
backdropEl: HTMLElement,
): Drawer {
const drawer: Drawer = {
open: () => shellEl.classList.add("drawer-open"),
close: () => shellEl.classList.remove("drawer-open"),
toggle: () => shellEl.classList.toggle("drawer-open"),
isOpen: () => shellEl.classList.contains("drawer-open"),
};
backdropEl.addEventListener("click", () => drawer.close());
sidebarEl.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
});
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
return drawer;
}
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
* the browser's back/forward navigation. Only a clearly horizontal gesture past
* a threshold counts, so vertical terminal scrolling (which owns its own touch
* handler) is untouched. */
export function installDrawerSwipe(drawer: Drawer): void {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0 && !drawer.isOpen()) drawer.open();
else if (dx < 0 && drawer.isOpen()) drawer.close();
},
{ passive: true },
);
}
+9 -55
View File
@@ -4,6 +4,7 @@ import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
import { CatalogStore } from "./events.js";
@@ -25,7 +26,7 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT } from "./catalog.js";
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
@@ -92,60 +93,13 @@ const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the header; a backdrop covers the content while it's open, and tapping a
// session closes it. On desktop these are inert (CSS gates the drawer styling).
// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn
// similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
function closeDrawer(): void {
shellEl.classList.remove("drawer-open");
}
function openDrawer(): void {
shellEl.classList.add("drawer-open");
}
function toggleDrawer(): void {
shellEl.classList.toggle("drawer-open");
}
backdrop.addEventListener("click", closeDrawer);
sidebar.el.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
});
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
// than the browser's back/forward navigation. We only act on a clearly
// horizontal gesture past a threshold, so vertical terminal scrolling (which
// owns its own touch handler) is untouched.
{
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const open = shellEl.classList.contains("drawer-open");
if (dx > 0 && !open) openDrawer();
else if (dx < 0 && open) closeDrawer();
},
{ passive: true },
);
}
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
installDrawerSwipe(drawer);
const toggleDrawer = () => drawer.toggle();
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
@@ -284,7 +238,7 @@ function requestOpenSpawn(): void {
mv.openDraft(undefined, { newTab: true });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
showMultiView();
mv.openDraft(spawnSimilarSeed(s));
@@ -358,7 +312,7 @@ window.addEventListener("keydown", (e) => {
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
openDrawer(); // no-op on desktop; on mobile the list is off-canvas
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
});