Merge remote-tracking branch 'origin/main' into dominant
This commit is contained in:
+74
-11
@@ -164,6 +164,10 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// The row outlives the snapshot it was built from: a refresh that changes
|
||||
// nothing structural patches it in place rather than replacing it, so the
|
||||
// handlers below have to read the current session, not the one captured here.
|
||||
let cur = node.session;
|
||||
const s = node.session;
|
||||
const now = Date.now();
|
||||
const row = document.createElement("a");
|
||||
@@ -197,33 +201,74 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void requestKill(s, name);
|
||||
void requestKill(cur, sessionName(cur));
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
onSelect: () => void requestKill(cur, sessionName(cur)),
|
||||
},
|
||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
||||
]);
|
||||
});
|
||||
leafPatches.set(row, (next) => {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
if (meta) meta.outerHTML = metaLine(next, Date.now());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Refresh a standing card from a newer snapshot of its session, keeping the
|
||||
* element (and so the pointer's `:hover`) exactly where it is. Registered by
|
||||
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
|
||||
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
|
||||
|
||||
/**
|
||||
* Everything about the tree that a patch cannot fix up.
|
||||
*
|
||||
* The daemon re-sends a session on every activity, foreground or cwd shift —
|
||||
* a couple of times a minute per live session — and rebuilding the sidebar for
|
||||
* each one drops `:hover` from the row under the pointer. Refreshes whose
|
||||
* signature is unchanged are patched into the standing rows instead; anything
|
||||
* that moves, renames, appears or dies changes the signature and rebuilds.
|
||||
*/
|
||||
function treeSignature(node: TreeNode): string {
|
||||
if (node.kind === "folder") {
|
||||
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
|
||||
if (node.name && collapsed.has(node.path)) return head;
|
||||
return `${head}(${node.children.map(treeSignature).join(",")})`;
|
||||
}
|
||||
const s = node.session;
|
||||
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill on the first click (#81): the daemon is the one that decides whether a
|
||||
* session may go, and it refuses over a worktree holding unsaved work. Only
|
||||
@@ -371,7 +416,7 @@ export interface SidebarOptions {
|
||||
* 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;
|
||||
onPreview?: (sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||
@@ -555,7 +600,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
opts.onPreview?.(sessionOf(navKey), "key");
|
||||
opts.onPreview?.(sessionOf(navKey));
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
@@ -586,7 +631,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
hovered = id;
|
||||
opts.onPreview?.(id, "hover");
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
@@ -603,7 +648,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
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"));
|
||||
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
|
||||
|
||||
/** The session behind the highlighted row, if the highlight is on a card. */
|
||||
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
|
||||
@@ -643,12 +688,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
/** Signature of what is currently on screen — see `treeSignature`. */
|
||||
let renderedSignature: string | null = null;
|
||||
|
||||
const refresh = () => {
|
||||
// Rebuilding the tree now would blow away the field being typed into.
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
if (s) leafPatches.get(row)?.(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
@@ -672,7 +729,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
const rerenderHandler = () => refresh();
|
||||
// Dispatched when something outside the store changed what the tree should
|
||||
// look like — a folder collapsed, a rename ended — none of which the store
|
||||
// snapshot can show, so the signature check has to be bypassed.
|
||||
const rerenderHandler = () => {
|
||||
renderedSignature = null;
|
||||
refresh();
|
||||
};
|
||||
document.addEventListener("catalog-rerender", rerenderHandler);
|
||||
|
||||
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
const ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: ID,
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A store whose updates actually reach subscribers, unlike the one-shot
|
||||
* harness in catalog.test.ts. */
|
||||
function liveStore(initial: Session[]) {
|
||||
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
|
||||
const listeners = new Set<CatalogListener>();
|
||||
const store = {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
listeners.add(fn);
|
||||
fn(state);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
const push = (sessions: Session[]) => {
|
||||
state.sessions = new Map(sessions.map((s) => [s.id, s]));
|
||||
for (const fn of listeners) fn(state);
|
||||
};
|
||||
return { store, push };
|
||||
}
|
||||
|
||||
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
|
||||
|
||||
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
|
||||
it("patches an activity change into the standing row instead of rebuilding it", () => {
|
||||
const { store, push } = liveStore([session({ activity: "Idle" })]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ activity: "AwaitingInput" })]);
|
||||
|
||||
// Same element: a rebuild under the pointer would drop `:hover` and restart
|
||||
// the row's transitions, which is the flicker this avoids.
|
||||
expect(rowOf(el)).toBe(before);
|
||||
// …and the card still tells the truth about the session.
|
||||
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
});
|
||||
|
||||
it("rebuilds when the row set changes", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
|
||||
|
||||
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
});
|
||||
|
||||
it("rebuilds when a session dies", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ status: "Dead" })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).dataset.status).toBe("dead");
|
||||
});
|
||||
|
||||
it("rebuilds when a session is renamed", () => {
|
||||
const { store, push } = liveStore([session()]);
|
||||
const { el } = renderSidebar(store, {});
|
||||
const before = rowOf(el);
|
||||
|
||||
push([session({ metadata: { name: "harmony" } })]);
|
||||
|
||||
expect(rowOf(el)).not.toBe(before);
|
||||
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
|
||||
});
|
||||
});
|
||||
+10
-15
@@ -29,6 +29,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 { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
@@ -105,20 +106,14 @@ shellEl.dataset.route = "catalog";
|
||||
// point `mv` is assigned.
|
||||
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]);
|
||||
}
|
||||
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
|
||||
// — j held down, or the pointer sweeping the whole list — is coalesced so it
|
||||
// costs a couple of pane mounts instead of one per row walked.
|
||||
const schedulePreview = createPreviewSchedule((sessionId) => {
|
||||
ensureMultiView();
|
||||
mv?.previewSession(sessionId);
|
||||
updateShellRoute();
|
||||
});
|
||||
|
||||
const sidebar = renderSidebar(store, {
|
||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||
@@ -418,7 +413,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
|
||||
* 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. */
|
||||
function focusSession(sessionId: string): void {
|
||||
clearTimeout(previewTimer);
|
||||
schedulePreview.cancel();
|
||||
showMultiView();
|
||||
mv.openSession(sessionId);
|
||||
updateShellRoute();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createPreviewSchedule, PREVIEW_MIN_GAP_MS } from "./previewSchedule.js";
|
||||
|
||||
/** Hand-driven clock + timer queue, so the schedule is asserted without waiting. */
|
||||
function harness() {
|
||||
let now = 1_000;
|
||||
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||
let nextHandle = 1;
|
||||
const emitted: (string | null)[] = [];
|
||||
|
||||
const schedule = createPreviewSchedule((id) => emitted.push(id), {
|
||||
now: () => now,
|
||||
setTimer: (fn, ms) => {
|
||||
const h = nextHandle++;
|
||||
timers.set(h, { at: now + ms, fn });
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
});
|
||||
|
||||
/** Advance the clock, firing any timer that comes due. */
|
||||
const advance = (ms: number) => {
|
||||
now += ms;
|
||||
for (const [h, t] of [...timers]) {
|
||||
if (t.at <= now) {
|
||||
timers.delete(h);
|
||||
t.fn();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { schedule, emitted, advance };
|
||||
}
|
||||
|
||||
describe("preview schedule", () => {
|
||||
it("shows the first preview immediately", () => {
|
||||
const { schedule, emitted } = harness();
|
||||
schedule("s1");
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("coalesces a burst into one trailing preview", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
// A pointer sweep across the list, or j held down: every row in between
|
||||
// is walked well inside one gap.
|
||||
schedule("s1");
|
||||
schedule("s2");
|
||||
schedule("s3");
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
expect(emitted).toEqual(["s1", "s3"]);
|
||||
});
|
||||
|
||||
it("shows the next preview immediately once the gap has passed", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
schedule("s2");
|
||||
expect(emitted).toEqual(["s1", "s2"]);
|
||||
});
|
||||
|
||||
it("drops a superseded trailing preview rather than replaying it", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
schedule("s2");
|
||||
schedule("s1"); // back where we started inside the same gap
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
// s1 is already on screen, so the trailing edge has nothing to do.
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("clears through the same gap, so leaving and re-entering does not blink", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
schedule(null); // pointer crosses the gap between two rows
|
||||
schedule("s2");
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
expect(emitted).toEqual(["s1", "s2"]);
|
||||
});
|
||||
|
||||
it("cancel drops a pending trailing preview", () => {
|
||||
const { schedule, emitted, advance } = harness();
|
||||
schedule("s1");
|
||||
schedule("s2");
|
||||
schedule.cancel();
|
||||
advance(PREVIEW_MIN_GAP_MS);
|
||||
expect(emitted).toEqual(["s1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* When the content pane follows the sidebar highlight (docs/PREVIEW.md).
|
||||
*
|
||||
* The highlight itself is never scheduled — that is a class on a row and lands
|
||||
* on the same frame as the input. This governs only the pane swap behind it,
|
||||
* which mounts a terminal and, for a session the connection pool isn't already
|
||||
* holding, opens an attach socket the daemon answers with a `tmux capture-pane`.
|
||||
*
|
||||
* A plain debounce would put that cost in front of every preview, including the
|
||||
* one preview the operator actually meant. Instead the first move shows at once
|
||||
* and the rest of a burst is coalesced: walking a list with `j` held down, or
|
||||
* sweeping the pointer from the top of the sidebar to the bottom, costs one
|
||||
* preview at the start and one at the row you stopped on, not one per row.
|
||||
*/
|
||||
|
||||
/** Shortest gap between two pane swaps. Long enough that a fast walk mounts a
|
||||
* couple of terminals rather than one per row; short enough that a deliberate
|
||||
* second move never waits on it. */
|
||||
export const PREVIEW_MIN_GAP_MS = 120;
|
||||
|
||||
export interface PreviewScheduleDeps {
|
||||
now?: () => number;
|
||||
setTimer?: (fn: () => void, ms: number) => number;
|
||||
clearTimer?: (handle: number) => void;
|
||||
/** Override the gap; tests and callers otherwise take {@link PREVIEW_MIN_GAP_MS}. */
|
||||
minGapMs?: number;
|
||||
}
|
||||
|
||||
export interface PreviewSchedule {
|
||||
(sessionId: string | null): void;
|
||||
/** Drop a pending trailing preview without showing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `show` so it fires on the leading edge and at most once per gap.
|
||||
*
|
||||
* `show` is called with the session to preview, or `null` to put back whatever
|
||||
* the operator actually opened.
|
||||
*/
|
||||
export function createPreviewSchedule(
|
||||
show: (sessionId: string | null) => void,
|
||||
deps: PreviewScheduleDeps = {},
|
||||
): PreviewSchedule {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const setTimer = deps.setTimer ?? ((fn, ms) => window.setTimeout(fn, ms));
|
||||
const clearTimer = deps.clearTimer ?? ((h) => window.clearTimeout(h));
|
||||
const minGap = deps.minGapMs ?? PREVIEW_MIN_GAP_MS;
|
||||
|
||||
let lastShownAt = Number.NEGATIVE_INFINITY;
|
||||
let shown: string | null | undefined;
|
||||
let pendingTimer: number | undefined;
|
||||
let pendingId: string | null = null;
|
||||
|
||||
const emit = (id: string | null) => {
|
||||
// Nothing to do when the burst ended on the view already up: re-showing it
|
||||
// would tear down and re-mount the pane for no visible change.
|
||||
if (shown === id) return;
|
||||
shown = id;
|
||||
lastShownAt = now();
|
||||
show(id);
|
||||
};
|
||||
|
||||
const cancelPending = () => {
|
||||
if (pendingTimer === undefined) return;
|
||||
clearTimer(pendingTimer);
|
||||
pendingTimer = undefined;
|
||||
};
|
||||
|
||||
const schedule = ((sessionId: string | null) => {
|
||||
pendingId = sessionId;
|
||||
const waited = now() - lastShownAt;
|
||||
if (waited >= minGap) {
|
||||
cancelPending();
|
||||
emit(sessionId);
|
||||
return;
|
||||
}
|
||||
if (pendingTimer !== undefined) return; // already waiting; `pendingId` wins
|
||||
pendingTimer = setTimer(() => {
|
||||
pendingTimer = undefined;
|
||||
emit(pendingId);
|
||||
}, minGap - waited);
|
||||
}) as PreviewSchedule;
|
||||
|
||||
schedule.cancel = cancelPending;
|
||||
return schedule;
|
||||
}
|
||||
@@ -169,33 +169,30 @@ describe("sidebar keyboard navigation", () => {
|
||||
|
||||
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]) });
|
||||
const seen: (string | null)[] = [];
|
||||
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
|
||||
|
||||
press(tree(el), "j");
|
||||
press(tree(el), "j");
|
||||
expect(seen).toEqual([
|
||||
[A, "key"],
|
||||
[B, "key"],
|
||||
]);
|
||||
expect(seen).toEqual([A, B]);
|
||||
|
||||
tree(el).dispatchEvent(new FocusEvent("blur"));
|
||||
expect(seen.at(-1)).toEqual([null, "key"]);
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
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 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: "mouse" }));
|
||||
expect(seen).toEqual([[A, "hover"]]);
|
||||
expect(seen).toEqual([A]);
|
||||
// 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"]);
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("ignores touch, where a tap already opens the session", () => {
|
||||
|
||||
+8
-7
@@ -1091,7 +1091,6 @@ textarea {
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.tree-folder-header:hover {
|
||||
@@ -1103,9 +1102,9 @@ textarea {
|
||||
text-align: center;
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
transition:
|
||||
transform var(--dur) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
/* The rotation is the collapse animation and stays; the hover colour is
|
||||
immediate, like the row's own hover. */
|
||||
transition: transform var(--dur) var(--ease-out);
|
||||
}
|
||||
|
||||
.tree-folder-header[aria-expanded="false"] .chevron {
|
||||
@@ -1142,9 +1141,11 @@ textarea {
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink);
|
||||
position: relative;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
/* No transition on `background`: it is what hover paints, and easing it puts
|
||||
a visible delay between the pointer landing on a row and the row admitting
|
||||
it. The ring (`.is-highlighted`, `.is-active`) still eases — that one
|
||||
follows a keyboard walk, not the pointer. */
|
||||
transition: box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
|
||||
|
||||
Reference in New Issue
Block a user