diff --git a/docs/keymaps.md b/docs/keymaps.md index 98f6d4c..e628b46 100644 --- a/docs/keymaps.md +++ b/docs/keymaps.md @@ -47,7 +47,7 @@ This table favors the shortcuts used by these dotfiles and Herdr defaults. It is | Herdr | `Ctrl+A r` | Resize mode. | | Herdr | `Ctrl+A b` | Toggle sidebar. | | Herdr remote | `Ctrl+V` | Remote image paste. | -| Herdr Desktop | `Ctrl+A` + Herdr TUI key | Same prefix keys as Herdr TUI (`herdr/desktop/keys.json`): `v`, `-`, `x`, `z`, `h/j/k/l`, `c`, `n/p`, `1..9`, `Shift+X/T/N/W/D`, `b`, `s`, `?`, `Option+K/J`, `w` (space/tab picker), `q` (hide the window; terminals keep running), `Ctrl+1..9` (go to the agent of space 1–9 in sidebar order, the one that needs you most; a space without agents is shown). | +| Herdr Desktop | `Ctrl+A` + Herdr TUI key | Same prefix keys as Herdr TUI (`herdr/desktop/keys.json`): `v`, `-`, `x`, `z`, `h/j/k/l`, `c`, `n/p`, `1..9`, `Shift+X/T/N/W/D`, `b`, `s`, `?`, `Option+K/J`, `w` (space/tab picker), `q` (hide the window; terminals keep running), `Ctrl+1..9` (go to the agent of visible space 1–9 in sidebar order, matching the narrow sidebar's numbers; folded machine groups are skipped; the agent that needs you most is focused, or a space without agents is shown). | | Herdr Desktop | not mapped | No equivalent for TUI pane cycling, pane rename, or resize mode. | ## AI CLIs diff --git a/herdr/desktop/mods/refactoria-spaces/main.js b/herdr/desktop/mods/refactoria-spaces/main.js index 788fc1d..afafefa 100644 --- a/herdr/desktop/mods/refactoria-spaces/main.js +++ b/herdr/desktop/mods/refactoria-spaces/main.js @@ -119,11 +119,21 @@ export default class Spaces extends View { }); } - // Spaces in the order the list shows them: this computer first, then machines by name. - spaceOrder() { + // One display order for labels and shortcuts. Keep source i for drag-and-drop. + spaceGroups() { const rows = this.ordered(); - const remote = [...new Set(rows.map((r) => r.w.machine).filter(Boolean))].sort(); - return [null, ...remote].flatMap((m) => rows.filter((r) => (r.w.machine ?? null) === m).map((r) => r.w)); + const remote = [...new Set([...rows.map(r => r.w.machine).filter(Boolean), ...this.machines.map(m => m.name)])].sort(); + let ordinal = 0; + return [null, ...remote].map((m) => { + const folded = remote.length > 0 && this.folded.has(m); + const mine = rows.filter((r) => (r.w.machine ?? null) === m) + .map((r) => ({ ...r, ordinal: folded ? null : ++ordinal })); + return { m, folded, mine }; + }); + } + + spaceOrder() { + return this.spaceGroups().flatMap(({ folded, mine }) => folded ? [] : mine.map((r) => r.w)); } focusAgent(n) { @@ -308,23 +318,23 @@ export default class Spaces extends View { // The rows, under a header per machine once one isn't this computer. grouped(t, collapsed) { - const rows = this.ordered(); + const groups = this.spaceGroups(); const draw = (r) => (collapsed ? this.compact(r, t) : this.row(r, t)); - const remote = [...new Set([...rows.map(r => r.w.machine).filter(Boolean), ...this.machines.map(m => m.name)])].sort(); const loading = m => { const layout = machines().find(k => k.name === m)?.layout; return layout === "loading" || (!layout && this.machines.some(k => k.name === m && k.state === "connected")) || spaces().some(s => (s.machine ?? null) === m && !this.shown.has(s.id)); }; const loadingRow = m => div().id(`machine-loading-${m ?? "local"}`).h(28).flex_none().px(8).text_size(12).text_color(t.dim).child(collapsed ? "…" : "Loading…"); - if (!remote.length) return loading(null) && !rows.length ? [loadingRow(null)] : rows.map(draw); - const machineNames = [null, ...remote]; - return machineNames.flatMap((m) => { - const mine = rows.filter((r) => (r.w.machine ?? null) === m); + if (groups.length === 1) { + const rows = groups[0].mine; + return loading(null) && !rows.length ? [loadingRow(null)] : rows.map(draw); + } + return groups.flatMap(({ m, folded, mine }) => { const waiting = mine.filter((r) => spaceWaits(r.w, this.seen, agents())).length; const machine = this.machines.find(k => k.name === m); const connected = !m || machine?.state === "connected"; - const folded = this.folded.has(m); + const head = h_flex().id(`machine-${m ?? "local"}`).h(30).mt(6).mb(2).flex_none().px(collapsed ? 2 : 8).gap(7).items_center().text_size(12).text_color(t.foreground) .rounded(8).bg(t.raised).border(waiting ? 2 : 1).border_color(waiting ? (this.pulse ? t.blocked : t.raised) : t.raised) .cursor_pointer().when(!waiting, el => el.hover(s => s.border_color(t.input))) @@ -431,7 +441,7 @@ export default class Spaces extends View { ); } - compact({ w, i }, t) { + compact({ w, ordinal }, t) { return h_flex() .id(`space-${w.id}`) .h(26) @@ -441,7 +451,7 @@ export default class Spaces extends View { .when(w.active, (el) => el.bg(t.raised)) .hover((s) => s.bg(t.hover)) .on_click(() => run("space.select", { id: w.id })) - .child(div().text_color(w.active ? t.foreground : t.muted).child(String(i + 1))) + .child(div().text_color(w.active ? t.foreground : t.muted).child(String(ordinal))) .child(div().text_color(spaceWaits(w, this.seen, agents()) ? tone(t, w.status) : t.dim).child(spaceWaits(w, this.seen, agents()) ? "●" : "·")); } diff --git a/tests/herdr_spaces_test.mjs b/tests/herdr_spaces_test.mjs new file mode 100644 index 0000000..0e6ca09 --- /dev/null +++ b/tests/herdr_spaces_test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import vm from "node:vm"; + +// Exercise the mod's rendered rows and commands without the native GPUI runtime. +function fixture(spaces, { git = {}, folded = [], machines = [], agents = [] } = {}) { + const calls = []; + function element() { + const node = { children: [], events: {} }; + const proxy = new Proxy(node, { + get(target, key) { + if (key in target) return target[key]; + return (...args) => { + if (key === "child") target.children.push(args[0]); + else if (key === "children") target.children.push(...args[0]); + else if (key === "id") target.id = args[0]; + else if (key === "when" && args[0]) args[1](proxy); + else if (key.startsWith("on_")) target.events[`${key}${typeof args[0] === "string" ? `_${args[0]}` : ""}`] = args.at(-1); + return proxy; + }; + }, + }); + return proxy; + } + const source = readFileSync(new URL("../herdr/desktop/mods/refactoria-spaces/main.js", import.meta.url), "utf8") + .replace(/^import .*;\n/gm, "") + .replaceAll("export function ", "function ") + .replace("export default class Spaces", "class Spaces"); + const context = vm.createContext({ + View: class {}, div: element, svg: element, v_flex: element, h_flex: element, + spaces: () => spaces, panes: () => [], machines: () => [], agents: () => agents, + shared: () => ({}), run: (...args) => calls.push(structuredClone(args)), + }); + const Spaces = vm.runInContext(`${source}\nSpaces`, context); + const view = new Spaces(); + Object.assign(view, { git, shown: new Set(spaces.map(s => s.id)), born: {}, folded: new Set(folded), machines, seen: new Set(), pressed: null }); + const theme = new Proxy({}, { get: (_, key) => key }); + const rows = (compact = true) => view.grouped(theme, compact).filter(row => row.id?.startsWith("space-")); + const labels = () => rows().map(row => [row.id.slice(6), row.children[0].children[0]]); + function assertTargets(expected) { + assert.deepEqual(structuredClone(labels()), expected.map((id, i) => [id, String(i + 1)])); + calls.length = 0; + for (let i = 1; i <= expected.length + 1; i++) view.focusAgent(i); + assert.deepEqual(calls, expected.map(id => ["space.select", { id }])); + } + return { view, calls, rows, assertTargets }; +} + +const space = (id, machine = null) => ({ id, name: id, folder: `/${id}`, machine }); +const gitKey = id => JSON.stringify([null, `/${id}`]); + +test("local-first and alphabetic machine labels match shortcut targets", () => { + fixture([space("remote", "vps"), space("local"), space("alpha", "alpha")]) + .assertTargets(["local", "alpha", "remote"]); +}); + +test("worktree created before main uses its displayed ordinal", () => { + fixture([space("worktree"), space("main")], { git: { + [gitKey("worktree")]: { repo: "repo", linked: true }, + [gitKey("main")]: { repo: "repo", linked: false }, + } }).assertTargets(["main", "worktree"]); +}); + +test("folded machine spaces occupy no ordinal and renumber on unfolding", () => { + const f = fixture([space("hidden", "alpha"), space("last", "vps"), space("local")], { folded: ["alpha"] }); + f.assertTargets(["local", "last"]); + f.view.folded.delete("alpha"); + f.assertTargets(["local", "hidden", "last"]); +}); + +test("folded local group is skipped only when machine headers exist", () => { + fixture([space("local"), space("remote", "vps")], { folded: [null] }).assertTargets(["remote"]); + fixture([space("local")], { folded: [null] }).assertTargets(["local"]); + fixture([space("local")], { folded: [null], machines: [{ name: "empty" }] }).assertTargets([]); +}); + +test("spaces waiting to be shown occupy no ordinal", () => { + const f = fixture([space("pending"), space("shown")]); + f.view.shown.delete("pending"); + f.assertTargets(["shown"]); +}); + +test("agent focus still selects by urgency, then most recent change", () => { + const f = fixture([space("local")], { agents: [ + { space: "local", status: "working", seq: 99, pane: "working" }, + { space: "local", status: "blocked", seq: 1, pane: "old" }, + { space: "local", status: "blocked", seq: 2, pane: "new" }, + ] }); + f.view.focusAgent(1); + assert.deepEqual(f.calls, [["pane.focus", { pane: "new" }]]); +}); + +test("wide rows preserve source indices for drag-and-drop and selection", () => { + const f = fixture([space("remote", "vps"), space("local")]); + const [local, remote] = f.rows(false); + const cx = { notify() {} }; + local.events.on_mouse_down_left(); + remote.events.on_mouse_up_left({}, cx); + remote.events.on_mouse_down_left(); + remote.events.on_mouse_up_left({}, cx); + assert.deepEqual(f.calls, [ + ["space.move", { id: "local", index: 1 }], + ["space.select", { id: "remote" }], + ]); +});