Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/herdr-insights-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ dashboard. The installer preflights the mod tree, checks a temporary buffered co
before replacing live files, generates the dashboard's local Node path, and records
ownership in `install-manifest.json`. Install/uninstall share an exclusive lock;
interrupted operations require confirming the process stopped before removing it.
The toolbar is a personal layout clone wrapping Herdr's original `tabs` block;
no built-in mod source is cloned, patched, or vendored. See
The toolbar is a personal layout clone wrapping the tab-bar block (Herdr's `tabs`
or a personal clone of it); no built-in mod source is cloned, patched, or
vendored. Windows and Linux place the button before the bar so the window
buttons the bar draws stay last; macOS places it after. See
[INSTALL.md](../herdr-insights/INSTALL.md) for the clone, click-state, and
ownership rules. A clone does not inherit future structural changes to its
source layout. A normal install has one `install-manifest.json` and the dashboard
Expand Down
12 changes: 9 additions & 3 deletions herdr-insights/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ npm run install:local --prefix herdr-insights

The installer checks `mods/insights` before copying it to the app home, which
defaults to `$HERDR_APP_HOME` or `~/.config/herdr-app`. First install creates and
selects a personal layout whose **Insights** button sits beside the native tabs.
Reinstall updates owned files without rewriting layouts. Shipped tabs, keys, and
usage data are preserved.
selects a personal layout whose **Insights** button sits in the tab bar row.
The tab bar may be Herdr's `tabs` or a personal clone of it (`tabs-graph`, ...);
the clone is wrapped, never edited. Windows and Linux have no system window
buttons, so the tab bar draws minimize/maximize/close at its own end: there the
button is placed before the bar so those stay last. On macOS the button follows
the bar, clear of the traffic lights. Reinstall updates owned files without
rewriting layouts, so an existing install keeps its original placement: uninstall
and install again to move the button. Shipped tabs, keys, and usage data are
preserved.

Success requires one `herdr-app check passed` message **and exit code 0**.
Do not run developer tests or change tabs during installation. Open the dashboard
Expand Down
13 changes: 9 additions & 4 deletions herdr-insights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ npm run install:local --prefix herdr-insights
```

With Herdr running, the installer checks the `insights` mod and adds an
**Insights** button beside the native tabs; clicking it reuses the current
space's dashboard tab, opening one only when absent. It installs `mods/insights/`
**Insights** button to the tab bar row; clicking it reuses the current
space's dashboard tab, opening one only when absent. On Windows and Linux the
button comes before the tab bar, because that bar draws minimize/maximize/close
at its own end and those stay last; on macOS it follows the bar, clear of the
traffic lights. The bar can be Herdr's `tabs` or a personal clone of it
(`tabs-graph`, ...): both are wrapped, never edited. It installs `mods/insights/`
and records ownership in `data/insights/`. Shipped files, tabs, keys, terminals,
and usage data are unchanged. See [INSTALL.md](INSTALL.md) for the layout and
click-state rules. Set `HERDR_APP_HOME` to use a different app home.
Expand All @@ -50,8 +54,9 @@ One native mod, `mods/insights`, provides both views. A pane whose content is
`{mod: "insights", props: {mode: "toolbar"}}` renders only the button; any other
pane shows the dashboard. Toolbar mode starts no Node helper, scans, tasks,
timers, or editors. No layouts or key bindings ship; the button lives in a
personal layout clone composed by the installer, which wraps the native tabs
without modifying them (see [INSTALL.md](INSTALL.md)).
personal layout clone composed by the installer, which wraps the tab-bar block —
Herdr's `tabs` or a personal clone such as `tabs-graph` — without modifying it
(see [INSTALL.md](INSTALL.md)).

[RECIPE.md](RECIPE.md) is the self-contained rebuild brief required by Herdr.
Developer tests, fixtures, and release validation live outside this distribution;
Expand Down
25 changes: 16 additions & 9 deletions herdr-insights/scripts/toolbar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ async function command(appHome, args) {
return result.stdout;
}

/** Preserve the original tabs mod and its updates; compose one native button beside it. */
export function withInsightsToolbar(layout) {
/**
* Preserve the original tab bar and its updates; compose one native button beside it.
* The bar may be Herdr's `tabs` or a personal clone of it: both are mod blocks, and
* neither is patched. `platform` is injectable so tests cover both placements.
*/
export function withInsightsToolbar(layout, { platform = process.platform } = {}) {
const copy = structuredClone(layout);
const nodes = [];
function walk(node) {
Expand All @@ -67,19 +71,22 @@ export function withInsightsToolbar(layout) {
if (nodes.some((node) => node.id === BUTTON_ID || node.id === "insights-original-tabs")) {
throw new Error("This layout already contains Insights toolbar blocks.");
}
const matches = nodes.filter((node) => node.id === "tabs" && node.type === "block" && node.content?.mod === "tabs");
const matches = nodes.filter((node) => node.id === "tabs" && node.type === "block" && typeof node.content?.mod === "string");
if (matches.length !== 1) {
throw new Error('Expected one native "tabs" block. This layout cannot be integrated safely.');
throw new Error('Expected one "tabs" block showing a tab bar. This layout cannot be integrated safely.');
}
const tabs = matches[0];
const content = tabs.content;
delete tabs.content;
tabs.type = "row";
tabs.resizable = false;
tabs.children = [
{ id: "insights-original-tabs", type: "block", size: { weight: 1 }, content },
{ id: BUTTON_ID, type: "block", size: { px: 104 }, content: { mod: "insights", props: { mode: "toolbar" } } },
];
const bar = { id: "insights-original-tabs", type: "block", size: { weight: 1 }, content };
const button = { id: BUTTON_ID, type: "block", size: { px: 104 }, content: { mod: "insights", props: { mode: "toolbar" } } };
// Windows and Linux have no system window buttons: the tab bar draws
// minimize/maximize/close at its own end (window().buttons), so the Insights
// button goes first and those stay last. macOS keeps its traffic lights on the
// left, where a leading button would collide with them, so the button follows.
tabs.children = platform === "darwin" ? [bar, button] : [button, bar];
return copy;
}

Expand Down Expand Up @@ -139,7 +146,7 @@ export async function applyToolbar(plan) {
try { await command(appHome, ["do", "layout.switch", JSON.stringify({ name: source })]); } catch { /* retain original failure */ }
throw error;
}
console.log(`Added Insights beside Files/Changes in personal layout ${layout}; Herdr's tabs mod is unchanged.`);
console.log(`Added the Insights button to personal layout ${layout}; the tab bar and Herdr's tabs mod are unchanged.`);
}

export async function prepareToolbarRemoval(appHome) {
Expand Down
7 changes: 7 additions & 0 deletions tests/herdr-insights/install-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export const TOOLBAR_LAYOUT = JSON.stringify({ id: "root", type: "column", child

export const SINGLE_TOOLBAR_CONTENT = { mod: "insights", props: { mode: "toolbar" } };

// A personal clone of the tab bar (`mod.clone tabs --as tabs-graph`): same block
// ID, but the content is not Herdr's own `tabs` mod.
export const CLONED_TOOLBAR_LAYOUT = JSON.stringify({ id: "root", type: "column", children: [
{ id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs-graph" } },
{ id: "main", type: "block", content: { tab: "active" } },
] }) + "\n";

export const APP_FILES = {
"data/insights/insights.json": '{"version":1,"keep":"local"}\n',
"data/insights/local-note.txt": "local data\n",
Expand Down
22 changes: 22 additions & 0 deletions tests/herdr-insights/toolbar-install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import test from "node:test";

import {
APP_FILES,
CLONED_TOOLBAR_LAYOUT,
PACKAGE_ROOT,
SINGLE_TOOLBAR_CONTENT,
TOOLBAR_LAYOUT,
Expand Down Expand Up @@ -70,6 +71,27 @@ test("standalone distribution installs one mod and a single-mod toolbar without
assert.equal(log.filter((entry) => entry.args[1] === "mod.open").length, 0, "no floating window");
});

test("a personal tab-bar clone installs, and the tab bar keeps the window buttons last", async (t) => {
const root = await temporaryRoot(t);
const sourceRoot = await createSource(root);
const appHome = path.join(root, "app");
await writeTree(appHome, { ...APP_FILES, "layouts/main.json": CLONED_TOOLBAR_LAYOUT });
const fake = await createFakeRunner(root);
assertSuccess(runInstall({ appHome, fake, sourceRoot }));
const layout = await readJson(path.join(appHome, "layouts/main-insights.json"));
const row = layout.children[0];
assert.equal(row.id, "tabs");
assert.equal(row.type, "row");
const ids = row.children.map((child) => child.id);
assert.deepEqual(ids, process.platform === "darwin"
? ["insights-original-tabs", "insights-toolbar-button"]
: ["insights-toolbar-button", "insights-original-tabs"]);
assert.deepEqual(row.children.find((child) => child.id === "insights-original-tabs").content,
{ mod: "tabs-graph" }, "the clone is preserved, not replaced by Herdr's tabs");
assert.deepEqual(row.children.find((child) => child.id === "insights-toolbar-button").content,
SINGLE_TOOLBAR_CONTENT);
});

test("one mod reinstalls idempotently and uninstall preserves data and local edits", async (t) => {
const root = await temporaryRoot(t);
const sourceRoot = await createSource(root);
Expand Down
45 changes: 36 additions & 9 deletions tests/herdr-insights/toolbar-layout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { collectSourceFiles, PACKAGE_ROOT } from "../../herdr-insights/scripts/l
import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs";
import { SINGLE_TOOLBAR_CONTENT } from "./install-fixtures.mjs";

const BUTTON = {
id: "insights-toolbar-button",
type: "block",
size: { px: 104 },
content: SINGLE_TOOLBAR_CONTENT,
};

function preservedBar(tabs) {
return { id: "insights-original-tabs", type: "block", size: { weight: 1 }, content: tabs.content };
}

test("the Insights source includes its toolbar icon", async () => {
const source = await collectSourceFiles(PACKAGE_ROOT);
assert.ok(source.files.some((file) => file.relative === "insights.svg"));
Expand All @@ -13,7 +24,7 @@ test("layout composition keeps the original tabs code and points the toolbar at
const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs", props: { custom: true } } };
const original = { id: "root", type: "column", theme: "ink", children: [tabs, { id: "main", type: "block", content: { tab: "active" } }] };
const snapshot = structuredClone(original);
const integrated = withInsightsToolbar(original);
const integrated = withInsightsToolbar(original, { platform: "darwin" });
assert.deepEqual(original, snapshot, "input must not be mutated");
assert.equal(integrated.theme, "ink");
assert.deepEqual(integrated.children[1], original.children[1]);
Expand All @@ -24,15 +35,31 @@ test("layout composition keeps the original tabs code and points the toolbar at
assert.equal(row.window_drag, true);
assert.equal(row.resizable, false);
assert.equal(row.content, undefined);
assert.deepEqual(row.children[0].content, tabs.content);
assert.deepEqual(row.children[0].size, { weight: 1 });
assert.deepEqual(row.children[1], {
id: "insights-toolbar-button",
type: "block",
size: { px: 104 },
content: SINGLE_TOOLBAR_CONTENT,
});
assert.deepEqual(row.children, [preservedBar(tabs), BUTTON], "macOS keeps the button after the tab bar");
assert.throws(() => withInsightsToolbar(integrated), /already contains/);
assert.throws(() => withInsightsToolbar({ id: "root", type: "row", children: [] }), /cannot be integrated safely/);
assert.throws(() => withInsightsToolbar({ id: "root", type: "row", children: [tabs, tabs] }), /cannot be integrated safely/);
});

test("Windows and Linux put the button before the tab bar so the window buttons stay last", () => {
for (const platform of ["win32", "linux"]) {
const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs" } };
const integrated = withInsightsToolbar({ id: "root", type: "column", children: [tabs] }, { platform });
const row = integrated.children[0];
assert.deepEqual(row.children, [BUTTON, preservedBar(tabs)], platform);
assert.equal(row.children.at(-1).id, "insights-original-tabs",
"the tab bar, which draws minimize/maximize/close, must be the last item");
}
});

test("a personal tab-bar clone is composed exactly like the native one", () => {
const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs-graph" } };
const integrated = withInsightsToolbar({ id: "root", type: "column", children: [tabs] }, { platform: "win32" });
assert.deepEqual(integrated.children[0].children, [BUTTON, preservedBar(tabs)]);
});

test("a tabs block that is not a tab-bar mod is refused", () => {
const tabs = { id: "tabs", type: "block", content: { tab: "active" } };
assert.throws(() => withInsightsToolbar({ id: "root", type: "column", children: [tabs] }),
/cannot be integrated safely/);
});