From 4e6053b1b5d5488dfcefbae0062a8ab4e6f8845b Mon Sep 17 00:00:00 2001 From: GaganSD Date: Mon, 2 Nov 2026 14:22:00 +0530 Subject: [PATCH 1/5] Consolidate Herdr Insights into one mod --- herdr-insights/mods/insights-launcher/main.js | 36 ---- .../mods/insights-launcher/mod.json | 1 - .../insights.svg | 0 herdr-insights/mods/insights/main.js | 46 ++++- herdr-insights/scripts/install.mjs | 106 +++++------- herdr-insights/scripts/lib.mjs | 5 - herdr-insights/scripts/toolbar.mjs | 4 +- herdr-insights/scripts/uninstall.mjs | 35 ++-- tests/herdr-insights/analytics-ui.test.mjs | 120 ++++++++++++- tests/herdr-insights/install-fixtures.mjs | 12 +- tests/herdr-insights/install-safety.test.mjs | 51 +++--- tests/herdr-insights/install.test.mjs | 42 +++-- tests/herdr-insights/launcher.test.mjs | 138 --------------- tests/herdr-insights/toolbar-install.test.mjs | 159 ++++++++---------- tests/herdr-insights/toolbar-layout.test.mjs | 43 +++++ 15 files changed, 383 insertions(+), 415 deletions(-) delete mode 100644 herdr-insights/mods/insights-launcher/main.js delete mode 100644 herdr-insights/mods/insights-launcher/mod.json rename herdr-insights/mods/{insights-launcher => insights}/insights.svg (100%) delete mode 100644 tests/herdr-insights/launcher.test.mjs create mode 100644 tests/herdr-insights/toolbar-layout.test.mjs diff --git a/herdr-insights/mods/insights-launcher/main.js b/herdr-insights/mods/insights-launcher/main.js deleted file mode 100644 index 848e34a..0000000 --- a/herdr-insights/mods/insights-launcher/main.js +++ /dev/null @@ -1,36 +0,0 @@ -// Composed beside Herdr's original tabs mod; never copies or patches its code. -import { View, svg } from "gpui-kit"; -import { h_flex } from "gpui-base"; -import { panes, run, spaces, theme } from "herdr"; - -const TAB = { name: "Insights", mod: "insights" }; - -/** One Insights tab per space: show it if it exists, otherwise create it. */ -function showInsights() { - const active = spaces().find((space) => space.active); - const tabIds = new Set(active?.tabs.map((tab) => tab.id) ?? []); - const open = panes().find((pane) => pane.mod === TAB.mod && tabIds.has(pane.tab)); - if (open) { - // Selecting a tab alone keeps its previous focus/zoom, which may hide this - // pane behind a terminal. Reveal the dashboard without changing split sizes. - const zoomed = panes().find((pane) => pane.tab === open.tab && pane.zoomed); - if (zoomed && zoomed.pane !== open.pane) run("pane.zoom", { pane: zoomed.pane }); - return run("pane.focus", { pane: open.pane }); - } - return run("tab.new", TAB); -} - -export default class InsightsLauncher extends View { - render() { - const t = theme(); - return h_flex().size_full().pr(10).items_center() - .bg(t.chrome).border_b(1).border_color(t.border) - .child(h_flex().id("insights-open").h(26).px(8).gap(5).flex_none() - .items_center().rounded(6).text_size(12).bg(t.chrome).text_color(t.muted) - .hover((style) => style.bg(t.hover).text_color(t.foreground)) - .on_mouse_down("left", (_event, cx) => cx.stop_propagation()) - .on_click(() => showInsights()) - .child(svg("mods/insights-launcher/insights.svg").size(14)) - .child("Insights")); - } -} diff --git a/herdr-insights/mods/insights-launcher/mod.json b/herdr-insights/mods/insights-launcher/mod.json deleted file mode 100644 index 7ef7f22..0000000 --- a/herdr-insights/mods/insights-launcher/mod.json +++ /dev/null @@ -1 +0,0 @@ -{ "place": "none", "title": "Insights" } diff --git a/herdr-insights/mods/insights-launcher/insights.svg b/herdr-insights/mods/insights/insights.svg similarity index 100% rename from herdr-insights/mods/insights-launcher/insights.svg rename to herdr-insights/mods/insights/insights.svg diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index 4d414d0..c5722ad 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -1,7 +1,7 @@ // Insights: a native, local-only usage dashboard. No DOM, no shell, no stored cx. import { View, div, svg } from "gpui-kit"; import { v_flex, h_flex, Button, Input, InputState, Editor, EditorState, Popover } from "gpui-base"; -import { exec, appHome, home, theme, settings, nextCommand, readTextFile } from "herdr"; +import { exec, appHome, home, theme, settings, nextCommand, panes, run, readTextFile, spaces } from "herdr"; import { PALETTES, clampRefreshSeconds, @@ -49,6 +49,28 @@ const MIN_CELL = 12; const MAX_CELL = 20; const CELL_GAP = 2; const DAY_LABEL_WIDTH = 30; +const TOOLBAR_MODE = "toolbar"; +const TAB = { name: "Insights", mod: "insights" }; + +function isToolbar(props) { + return props?.mode === TOOLBAR_MODE; +} + +/** One Insights tab per space: focus it if it exists, otherwise create it. */ +function showInsights() { + const active = spaces().find((space) => space.active); + const tabIds = new Set(active?.tabs.map((tab) => tab.id) ?? []); + const open = panes().find((pane) => pane.mod === "insights" && tabIds.has(pane.tab)); + if (open) { + // A layout block is not a dashboard pane: only pane info can be focused. + // Selecting a tab keeps its previous zoom, so reveal the dashboard without + // changing split sizes when another pane in that tab is zoomed. + const zoomed = panes().find((pane) => pane.tab === open.tab && pane.zoomed); + if (zoomed && zoomed.pane !== open.pane) run("pane.zoom", { pane: zoomed.pane }); + return run("pane.focus", { pane: open.pane }); + } + return run("tab.new", TAB); +} // A leaderboard row needs its rank, avatar, a readable model name, and the token // value. A preferred basis wraps columns without imposing a minimum wider than // a narrow split pane; each column can still shrink to its available width. @@ -418,8 +440,13 @@ function preferenceRow(label, control, c) { } export default class Insights extends View { - init(_props, cx) { + init(props, cx) { + this.mode = isToolbar(props) ? TOOLBAR_MODE : "dashboard"; this.closed = false; + // The toolbar is a button, not a dashboard copy: no runtime read, collector, + // command loop, timer or editor state belongs to it. + if (this.mode === TOOLBAR_MODE) return; + this.snapshot = null; this.loading = false; this.error = null; @@ -514,6 +541,7 @@ export default class Insights extends View { close() { this.closed = true; + if (this.mode === TOOLBAR_MODE) return; this.cancelDashboardWork(); this.activityGeneration = (this.activityGeneration ?? 0) + 1; try { this.activityTask?.cancel(); } catch { /* already gone */ } @@ -1282,6 +1310,7 @@ export default class Insights extends View { } render(_cx) { + if (this.mode === TOOLBAR_MODE) return this.toolbar(); const c = tokens(theme()); const width = this.width || 960; const padding = width < 620 ? G.md : width < 900 ? G.lg : G.xl; @@ -1304,6 +1333,19 @@ export default class Insights extends View { .child(v_flex().w_full().max_w(1180).gap(G.lg).children(content))); } + toolbar() { + const t = theme(); + return h_flex().size_full().pr(10).items_center() + .bg(t.chrome).border_b(1).border_color(t.border) + .child(h_flex().id("insights-open").h(26).px(8).gap(5).flex_none() + .items_center().rounded(6).text_size(12).bg(t.chrome).text_color(t.muted) + .hover((style) => style.bg(t.hover).text_color(t.foreground)) + .on_mouse_down("left", (_event, cx) => cx.stop_propagation()) + .on_click(() => showInsights()) + .child(svg("mods/insights/insights.svg").size(14)) + .child("Insights")); + } + header(c) { const updated = this.snapshot ? formatTimestamp(this.snapshot.lastScanAt || this.snapshot.generatedAt) : ""; const preparing = !this.dashboard && (this.dashboardPending || this.dashboardQueued); diff --git a/herdr-insights/scripts/install.mjs b/herdr-insights/scripts/install.mjs index b25325f..323bd44 100644 --- a/herdr-insights/scripts/install.mjs +++ b/herdr-insights/scripts/install.mjs @@ -4,8 +4,6 @@ import path from "node:path"; import { PACKAGE_ROOT, - MODS, - MOD_NAMES, RUNTIME_NAME, assertNoSymlinkComponents, assertSupportedNodeVersion, @@ -29,7 +27,7 @@ import { withInstallerLock, } from "./lib.mjs"; -import { prepareToolbar, applyToolbar } from "./toolbar.mjs"; +import { applyToolbar, prepareToolbar } from "./toolbar.mjs"; function usage() { return `Insights local installer @@ -45,11 +43,11 @@ Options: --app-home Herdr app home (default: $HERDR_APP_HOME or ~/.config/herdr-app) -h, --help Show this help -The installer copies mods/insights and mods/insights-launcher and creates a personal -layout with an Insights button beside the original tabs mod. It never edits shipped -layouts/mods, keys, terminals, or insights.json. It generates mods/insights/runtime.json -with the absolute Node executable used for installation. It does not publish or use -the network. +The installer copies mods/insights and creates a personal layout with an Insights +button beside the original tabs mod. It never edits shipped layouts/mods, keys, +terminals, or insights.json. It generates mods/insights/runtime.json with the +absolute Node executable used for installation. It does not publish or use the +network. `; } @@ -129,26 +127,18 @@ async function addParentIssues(targetDirectory, target, issues) { } } -async function collectInstallSources(sourceRoot) { - const sources = new Map(); - for (const modName of MOD_NAMES) { - if (MODS[modName].optional && !await lstatOrNull(path.join(sourceRoot, MODS[modName].rel))) continue; - const source = await collectSourceFiles(sourceRoot, modName); - // Only the dashboard needs Node; the launcher runs entirely inside Herdr. - if (MODS[modName].runtime) { - source.files = source.files.filter((file) => file.relative !== RUNTIME_NAME); - source.files.push(createRuntimeFile()); - } - source.files.sort((left, right) => left.relative.localeCompare(right.relative)); - sources.set(modName, source); - } - return sources; +async function collectInstallSource(sourceRoot) { + const source = await collectSourceFiles(sourceRoot, "insights"); + source.files = source.files.filter((file) => file.relative !== RUNTIME_NAME); + source.files.push(createRuntimeFile()); + source.files.sort((left, right) => left.relative.localeCompare(right.relative)); + return source; } -async function prepareInstall(appHome, source, modName, force) { - const targetDirectory = modDir(appHome, modName); +async function prepareInstall(appHome, source, force) { + const targetDirectory = modDir(appHome, "insights"); const targetDataDirectory = dataDir(appHome); - const targetManifest = manifestPath(appHome, modName); + const targetManifest = manifestPath(appHome, "insights"); if (path.resolve(source.modDir) === path.resolve(targetDirectory)) { throw new Error("Source and target are the same mod folder; refusing to install over itself."); @@ -164,7 +154,7 @@ async function prepareInstall(appHome, source, modName, force) { } } - const manifest = await readManifest(appHome, modName); + const manifest = await readManifest(appHome, "insights"); if (targetStat && !manifest) { throw new Error( `Refusing an unowned collision at ${targetDirectory}.\nNo ${targetManifest} proves this installer owns it. Move the folder aside or install with Herdr's own mod tools.`, @@ -210,38 +200,34 @@ async function prepareInstall(appHome, source, modName, force) { ); } - return { appHome, source, targetDirectory, targetDataDirectory, manifest, sourcePaths, modName }; + return { appHome, source, targetDirectory, targetDataDirectory, manifest, sourcePaths }; } // Validate the exact buffered source (including runtime.json) before replacing -// either live mod. A broken launcher must not partially upgrade the dashboard. -async function checkInstallSources(sources) { +// the live mod. +async function checkInstallSource(source) { const staging = await fs.mkdtemp(path.join(os.tmpdir(), "insights-install-check-")); try { - for (const [modName, source] of sources) { - for (const file of source.files) { - const target = localPath(modDir(staging, modName), file.relative); - await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.writeFile(target, file.data, { mode: file.mode }); - } + for (const file of source.files) { + const target = localPath(modDir(staging, "insights"), file.relative); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, file.data, { mode: file.mode }); } - for (const modName of sources.keys()) { - const check = await runHerdr(["check", modDir(staging, modName)], { - env: { HERDR_APP_HOME: staging, HOME: staging }, - }); - relayHerdrOutput(check); - if (check.error || check.code !== 0 || check.signal) { - throw new Error(`${herdrFailure(check, "herdr-app check")}\nNo installed mod files were changed.`); - } - console.log(`herdr-app check passed (${modName}, staged source).`); + const check = await runHerdr(["check", modDir(staging, "insights")], { + env: { HERDR_APP_HOME: staging, HOME: staging }, + }); + relayHerdrOutput(check); + if (check.error || check.code !== 0 || check.signal) { + throw new Error(`${herdrFailure(check, "herdr-app check")}\nNo installed mod files were changed.`); } + console.log("herdr-app check passed (insights, staged source)."); } finally { await fs.rm(staging, { recursive: true, force: true }); } } async function applyInstall(plan) { - const { appHome, source, targetDirectory, targetDataDirectory, manifest, sourcePaths, modName } = plan; + const { appHome, source, targetDirectory, targetDataDirectory, manifest, sourcePaths } = plan; await ensureDirectory(appHome, path.join(appHome, "mods"), "Herdr mods directory"); await ensureDirectory(appHome, targetDirectory, "Insights mod directory"); await ensureDirectory(appHome, targetDataDirectory, "Insights data directory", 0o700); @@ -307,7 +293,7 @@ async function applyInstall(plan) { ...retainedHashes, ...Object.fromEntries(source.files.map((file) => [file.relative, file.hash])), }; - await writeManifest(appHome, installedHashes, modName); + await writeManifest(appHome, installedHashes, "insights"); console.log( `Installed ${source.files.length} owned file(s) to ${targetDirectory} ` + @@ -315,10 +301,8 @@ async function applyInstall(plan) { removed.length ? `, ${removed.length} stale removed` : "" }).`, ); - if (MODS[modName].runtime) { - console.log(`Generated ${RUNTIME_NAME} for Node ${process.execPath}.`); - console.log(`Insights data stays at ${targetDataDirectory}; insights.json is untouched.`); - } + console.log(`Generated ${RUNTIME_NAME} for Node ${process.execPath}.`); + console.log(`Insights data stays at ${targetDataDirectory}; insights.json is untouched.`); for (const kept of keptStale) { console.warn(`Kept an owned file no longer in the source: ${kept}`); } @@ -342,25 +326,19 @@ async function main() { try { assertSupportedNodeVersion(); const sourceRoot = options.source ? path.resolve(expandHome(options.source)) : PACKAGE_ROOT; - // Validate every source before creating the app home. A bad --source must + // Validate the source before creating the app home. A bad --source must // not leave a new target directory behind. - const sources = await collectInstallSources(sourceRoot); + const source = await collectInstallSource(sourceRoot); const appHome = await resolveAppHome(options.appHome); await withInstallerLock(appHome, async () => { - // Preflight every target before changing either mod. - const plans = []; - for (const [modName, source] of sources) { - plans.push(await prepareInstall(appHome, source, modName, options.force)); - } - const toolbar = sources.has("insights-launcher") ? await prepareToolbar(appHome) : null; - await checkInstallSources(sources); + // Preflight every target before changing the live mod. + let plan = await prepareInstall(appHome, source, options.force); + const toolbar = await prepareToolbar(appHome); + await checkInstallSource(source); // Native checks yield to the running app. Refuse edits/collisions made // during validation before replacing any live files. - for (let index = 0; index < plans.length; index += 1) { - const plan = plans[index]; - plans[index] = await prepareInstall(appHome, plan.source, plan.modName, options.force); - } - for (const plan of plans) await applyInstall(plan); + plan = await prepareInstall(appHome, plan.source, options.force); + await applyInstall(plan); if (toolbar) await applyToolbar(toolbar); // Keep the previously documented flag working for existing install scripts. // Default installation still never opens a tab. diff --git a/herdr-insights/scripts/lib.mjs b/herdr-insights/scripts/lib.mjs index 3e90d71..5812dec 100644 --- a/herdr-insights/scripts/lib.mjs +++ b/herdr-insights/scripts/lib.mjs @@ -13,11 +13,6 @@ export const MIN_NODE_VERSION = "22.15"; export const RUNTIME_NAME = "runtime.json"; export const MODS = Object.freeze({ insights: { manifest: "install-manifest.json", rel: "mods/insights", runtime: true }, - "insights-launcher": { - manifest: "launcher-install-manifest.json", - optional: true, - rel: "mods/insights-launcher", - }, }); export const MOD_NAMES = Object.keys(MODS); export const MANIFEST_VERSION = 1; diff --git a/herdr-insights/scripts/toolbar.mjs b/herdr-insights/scripts/toolbar.mjs index e5bd034..b1b2b68 100644 --- a/herdr-insights/scripts/toolbar.mjs +++ b/herdr-insights/scripts/toolbar.mjs @@ -55,7 +55,7 @@ async function command(appHome, args) { return result.stdout; } -/** Preserve the original tabs mod and its updates; compose one button beside it. */ +/** Preserve the original tabs mod and its updates; compose one native button beside it. */ export function withInsightsToolbar(layout) { const copy = structuredClone(layout); const nodes = []; @@ -78,7 +78,7 @@ export function withInsightsToolbar(layout) { 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-launcher" } }, + { id: BUTTON_ID, type: "block", size: { px: 104 }, content: { mod: "insights", props: { mode: "toolbar" } } }, ]; return copy; } diff --git a/herdr-insights/scripts/uninstall.mjs b/herdr-insights/scripts/uninstall.mjs index 369a23c..a3d3ea4 100644 --- a/herdr-insights/scripts/uninstall.mjs +++ b/herdr-insights/scripts/uninstall.mjs @@ -2,7 +2,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { - MOD_NAMES, assertNoSymlinkComponents, isInside, lstatOrNull, @@ -29,10 +28,10 @@ Options: --app-home Herdr app home (default: $HERDR_APP_HOME or ~/.config/herdr-app) -h, --help Show this help -Only files recorded in data/insights/*install-manifest.json are removed, and only +Only files recorded in data/insights/install-manifest.json are removed, and only when their content still matches. The owned personal toolbar layout is removed -only if unchanged; Herdr returns to the original layout first. Edited/unowned files, -shipped layouts, keys and data/insights/insights.json are left alone. +only if unchanged; Herdr returns to the original layout first. Edited/unowned +files, shipped layouts, keys and data/insights/insights.json are left alone. `; } @@ -90,10 +89,10 @@ async function pruneOwnedDirectories(directories) { } } -async function prepareUninstall(appHome, modName) { - const targetDirectory = modDir(appHome, modName); - const targetManifest = manifestPath(appHome, modName); - const manifest = await readManifest(appHome, modName); +async function prepareUninstall(appHome) { + const targetDirectory = modDir(appHome, "insights"); + const targetManifest = manifestPath(appHome, "insights"); + const manifest = await readManifest(appHome, "insights"); if (!manifest) { const target = await lstatOrNull(targetDirectory); @@ -113,11 +112,11 @@ async function prepareUninstall(appHome, modName) { throw new Error(`Target mod path is not a directory: ${targetDirectory}`); } - return { appHome, targetDirectory, targetManifest, manifest, targetStat, modName }; + return { appHome, targetDirectory, targetManifest, manifest, targetStat }; } async function applyUninstall(plan) { - const { appHome, targetDirectory, targetManifest, manifest, targetStat, modName } = plan; + const { appHome, targetDirectory, targetManifest, manifest, targetStat } = plan; const removed = []; const kept = []; const ownedParents = []; @@ -161,7 +160,7 @@ async function applyUninstall(plan) { const remainingEntries = Object.entries(remaining); if (remainingEntries.length > 0) { - await writeManifest(appHome, Object.fromEntries(remainingEntries), modName); + await writeManifest(appHome, Object.fromEntries(remainingEntries), "insights"); } else { const currentManifest = await lstatOrNull(targetManifest); if (currentManifest) { @@ -197,18 +196,12 @@ async function main() { try { const appHome = await resolveAppHome(options.appHome, { create: false }); await withInstallerLock(appHome, async () => { - // Validate ownership, manifests and target types before deleting either mod. - const plans = []; - if (appHome) { - for (const modName of MOD_NAMES) { - const plan = await prepareUninstall(appHome, modName); - if (plan) plans.push(plan); - } - } + // Validate ownership, manifests and target types before deleting anything. + const plan = appHome ? await prepareUninstall(appHome) : null; const toolbar = await prepareToolbarRemoval(appHome); await removeToolbar(toolbar); - for (const plan of plans) await applyUninstall(plan); - if (plans.length === 0 && !toolbar) { + if (plan) await applyUninstall(plan); + if (!plan && !toolbar) { console.log("Insights is not installed by this package; nothing to remove."); } }); diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index 01161bd..e04afc5 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -12,13 +12,13 @@ const analyticsNames = Object.keys(analytics).join(", "); const { buildHeatmap, compactDashboard, deriveDashboard } = analytics; function fakeNativeElement() { - const node = { childNodes: [], style: {} }; + const node = { calls: [], childNodes: [], style: {} }; let element; const methods = { - child(value) { node.childNodes.push(value); return element; }, - children(values) { node.childNodes.push(...values); return element; }, + child(value) { node.calls.push(["child", [value]]); node.childNodes.push(value); return element; }, + children(values) { node.calls.push(["children", [values]]); node.childNodes.push(...values); return element; }, when(condition, branch) { if (condition) branch(element); return element; }, - w(value) { node.style.width = value; return element; }, + w(value) { node.style.width = value; node.calls.push(["w", [value]]); return element; }, whitespace_nowrap() { node.style.whitespace = "nowrap"; return element; }, items_start() { node.style.alignItems = "start"; return element; }, flex_wrap() { node.style.flexWrap = "wrap"; return element; }, @@ -31,7 +31,10 @@ function fakeNativeElement() { if (property in methods) return methods[property]; if (property in target) return target[property]; if (typeof property === "symbol") return undefined; - return () => element; + return (...args) => { + node.calls.push([property, args]); + return element; + }; }, }); return element; @@ -105,7 +108,10 @@ const theme = () => ({}); const settings = () => ({}); const nextCommand = () => globalThis.__insightsTestHerdr.nextCommand(); const exec = (...args) => globalThis.__insightsTestHerdr.exec(...args); -const readTextFile = (...args) => globalThis.__insightsTestHerdr.readTextFile(...args);`, +const readTextFile = (...args) => globalThis.__insightsTestHerdr.readTextFile(...args); +const panes = () => globalThis.__insightsTestHerdr.panes(); +const run = (...args) => globalThis.__insightsTestHerdr.run(...args); +const spaces = () => globalThis.__insightsTestHerdr.spaces();`, ) .replace( /^import \{[\s\S]*?\} from "\.\/analytics\.js";$/m, @@ -140,13 +146,16 @@ function presentationFor(snapshot, options, command = "snapshot") { } function useHerdr({ readTextFile, execResult } = {}) { - const state = { runtimeReads: [], execCalls: [] }; + const state = { commands: [], execCalls: [], panes: [], runtimeReads: [], spaces: [] }; state.herdr = { appHome: () => "/app", home: () => "/home", theme: () => ({}), settings: () => ({}), nextCommand: () => new Promise(() => {}), + panes: () => state.panes, + run: (...args) => state.commands.push(args), + spaces: () => state.spaces, readTextFile: async (path) => { state.runtimeReads.push(path); return readTextFile ? readTextFile(path) : null; @@ -326,6 +335,103 @@ test("analytics stays host-free and the UI keeps configuration JSON editable", ( assert.doesNotMatch(mainSource, /(^|[^.\w])localStorage\b/); }); +test("toolbar init returns before runtime, collector, command, timer or editor setup", async () => { + const state = useHerdr(); + globalThis.__insightsTestHostCalls.inputStates = 0; + globalThis.__insightsTestHostCalls.editorStates = 0; + const { cx, calls } = liveCx(); + const instance = new Insights(); + instance.init({ mode: "toolbar" }, cx); + + assert.equal(instance.mode, "toolbar"); + assert.deepEqual(state.runtimeReads, []); + assert.deepEqual(state.execCalls, []); + assert.deepEqual(calls.timers, []); + assert.equal(calls.spawn, 0); + assert.equal(globalThis.__insightsTestHostCalls.inputStates, 0); + assert.equal(globalThis.__insightsTestHostCalls.editorStates, 0); + assert.equal(instance.commandTask, undefined); + assert.equal(instance.runtimeTask, undefined); + assert.equal(instance.dashboard, undefined); + assert.doesNotThrow(() => instance.close()); + assert.equal(instance.closed, true); +}); + +test("toolbar render is only the native Insights button and has no side effects", () => { + const state = useHerdr(); + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; + state.panes = []; + const instance = new Insights(); + instance.init({ mode: "toolbar" }, fakeCx()); + const root = instance.render(); + const button = root.childNodes[0]; + + assert.equal(button.childNodes[1], "Insights"); + assert.equal(button.calls.find(([name]) => name === "id")[1][0], "insights-open"); + for (const [method, value] of [["h", 26], ["px", 8], ["gap", 5], ["rounded", 6], ["text_size", 12]]) { + assert.ok(button.calls.some(([name, args]) => name === method && args[0] === value), + `${method} matches Herdr's native toolbar control`); + } + assert.deepEqual(state.commands, [], "render must have no side effects"); + + let stopped = false; + button.calls.find(([name]) => name === "on_mouse_down")[1][1]({}, { + stop_propagation() { stopped = true; }, + }); + assert.equal(stopped, true); + assert.doesNotMatch(mainSource, /mods\/insights-launcher/); + assert.doesNotMatch(mainSource, /\blayout\(/); +}); + +test("toolbar reuses the active space's dashboard and reveals it behind another pane's zoom", () => { + const state = useHerdr(); + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }, { id: "t2" }] }]; + state.panes = [ + { pane: "terminal", mod: "terminal", tab: "t2", zoomed: true }, + { pane: "dashboard", mod: "insights", tab: "t2", focused: false, zoomed: false }, + ]; + const instance = new Insights(); + instance.init({ mode: "toolbar" }, fakeCx()); + const button = instance.render().childNodes[0]; + const click = button.calls.find(([name]) => name === "on_click")[1][0]; + + click(); + click(); + assert.deepEqual(state.commands, [ + ["pane.zoom", { pane: "terminal" }], + ["pane.focus", { pane: "dashboard" }], + ["pane.zoom", { pane: "terminal" }], + ["pane.focus", { pane: "dashboard" }], + ]); +}); + +test("toolbar keeps an already zoomed dashboard and opens a tab only when the space has none", () => { + const state = useHerdr(); + state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; + state.panes = [{ pane: "dashboard", mod: "insights", tab: "t1", zoomed: true }]; + const instance = new Insights(); + instance.init({ mode: "toolbar" }, fakeCx()); + const click = instance.render().childNodes[0].calls.find(([name]) => name === "on_click")[1][0]; + + click(); + assert.deepEqual(state.commands, [["pane.focus", { pane: "dashboard" }]]); + + state.panes = []; + state.commands.length = 0; + click(); + assert.deepEqual(state.commands, [["tab.new", { name: "Insights", mod: "insights" }]]); + + state.spaces = [ + { id: "s1", active: true, tabs: [{ id: "t1" }] }, + { id: "s2", active: false, tabs: [{ id: "t2" }] }, + ]; + state.panes = [{ mod: "insights", tab: "t2" }]; + state.commands.length = 0; + click(); + assert.deepEqual(state.commands, [["tab.new", { name: "Insights", mod: "insights" }]], + "an Insights tab in another space never satisfies the current space"); +}); + test("init reads the runtime config once and waits before the first snapshot", async () => { let finishRead; const pendingRead = new Promise((resolve) => { finishRead = resolve; }); diff --git a/tests/herdr-insights/install-fixtures.mjs b/tests/herdr-insights/install-fixtures.mjs index 39eadd0..9e94dfc 100644 --- a/tests/herdr-insights/install-fixtures.mjs +++ b/tests/herdr-insights/install-fixtures.mjs @@ -28,6 +28,8 @@ export const TOOLBAR_LAYOUT = JSON.stringify({ id: "root", type: "column", child { id: "main", type: "block", content: { tab: "active" } }, ] }) + "\n"; +export const SINGLE_TOOLBAR_CONTENT = { mod: "insights", props: { mode: "toolbar" } }; + export const APP_FILES = { "data/insights/insights.json": '{"version":1,"keep":"local"}\n', "data/insights/local-note.txt": "local data\n", @@ -57,13 +59,6 @@ export async function createSource(root, files = DEFAULT_MOD_FILES, name = "revi return sourceRoot; } -export async function createSourceWithLauncher(root) { - const sourceRoot = await createSource(root); - await fs.cp(path.join(PACKAGE_ROOT, "mods/insights-launcher"), - path.join(sourceRoot, "mods/insights-launcher"), { recursive: true }); - return sourceRoot; -} - export async function createFakeRunner(root) { const bin = path.join(root, "fake bin"); const runner = path.join(bin, "herdr-app"); @@ -82,8 +77,7 @@ if (log) fs.appendFileSync(log, JSON.stringify({ appHome: process.env.HERDR_APP_ if (args[0] === "check" && process.env.FAKE_HERDR_EDIT_TARGET) { fs.appendFileSync(process.env.FAKE_HERDR_EDIT_TARGET, "edit during check\\n"); } -if (args[0] === "check" && (process.env.FAKE_HERDR_FAIL === "check" || - (process.env.FAKE_HERDR_FAIL === "launcher-check" && args[1].endsWith("insights-launcher")))) { +if (args[0] === "check" && process.env.FAKE_HERDR_FAIL === "check") { console.error("fake herdr-app: check failed"); process.exit(7); } diff --git a/tests/herdr-insights/install-safety.test.mjs b/tests/herdr-insights/install-safety.test.mjs index fc79a64..a465636 100644 --- a/tests/herdr-insights/install-safety.test.mjs +++ b/tests/herdr-insights/install-safety.test.mjs @@ -8,7 +8,7 @@ import { applyToolbar, prepareToolbar, prepareToolbarRemoval, removeToolbar, } from "../../herdr-insights/scripts/toolbar.mjs"; import { - APP_FILES, assertFailure, assertSuccess, createFakeRunner, createSourceWithLauncher, + APP_FILES, assertFailure, assertSuccess, createFakeRunner, createSource, exists, makeEnvironment, readLog, runInstall, runUninstall, snapshotTree, temporaryRoot, writeTree, } from "./install-fixtures.mjs"; @@ -32,7 +32,7 @@ function switchLayout(root, appHome, fake, name) { test("the previously documented --open flag opens exactly one tab only after successful installation", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); @@ -40,7 +40,7 @@ test("the previously documented --open flag opens exactly one tab only after suc let log = await readLog(fake); assert.deepEqual(log.at(-1).args, ["do", "tab.new", '{"name":"Insights","mod":"insights"}']); assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 1); - const result = runInstall({ appHome, fake, sourceRoot, extra: ["--open"], fail: "launcher-check" }); + const result = runInstall({ appHome, fake, sourceRoot, extra: ["--open"], fail: "check" }); assertFailure(result); log = await readLog(fake); assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 1, "a failed check must never open a tab"); @@ -51,53 +51,48 @@ test("the previously documented --open flag opens exactly one tab only after suc assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); }); -test("a failed check of either mod preserves an existing installation byte-for-byte", async (t) => { +test("a failed check preserves an existing installation byte-for-byte", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); const before = await snapshotTree(appHome); await fs.writeFile(path.join(sourceRoot, "mods/insights/main.js"), "new dashboard\n"); - await fs.writeFile(path.join(sourceRoot, "mods/insights-launcher/main.js"), "new launcher\n"); - for (const fail of ["check", "launcher-check"]) { - const result = runInstall({ appHome, fake, sourceRoot, fail }); - assertFailure(result); - assert.match(result.stderr, /No installed mod files were changed/); - assert.deepEqual(await snapshotTree(appHome), before); - assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); - const checks = (await readLog(fake)).filter((entry) => entry.args[0] === "check"); - for (const entry of checks) assert.equal(await exists(entry.appHome), false, "staging is cleaned on failure"); + const result = runInstall({ appHome, fake, sourceRoot, fail: "check" }); + assertFailure(result); + assert.match(result.stderr, /No installed mod files were changed/); + assert.deepEqual(await snapshotTree(appHome), before); + assert.equal(await exists(path.join(appHome, ".insights-install.lock")), false); + for (const entry of (await readLog(fake)).filter((item) => item.args[0] === "check")) { + assert.equal(await exists(entry.appHome), false, "staging is cleaned on failure"); } }); -test("edits made during native checks are refused before either live mod is replaced", async (t) => { +test("edits made during the native check are refused before the live mod is replaced", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); const dashboard = path.join(appHome, "mods/insights/main.js"); - const launcher = path.join(appHome, "mods/insights-launcher/main.js"); - const before = await fs.readFile(dashboard, "utf8"); await fs.writeFile(path.join(sourceRoot, "mods/insights/main.js"), "reviewed dashboard update\n"); - const result = runInstall({ appHome, fake, sourceRoot, env: { FAKE_HERDR_EDIT_TARGET: launcher } }); + const result = runInstall({ appHome, fake, sourceRoot, env: { FAKE_HERDR_EDIT_TARGET: dashboard } }); assertFailure(result); assert.match(result.stderr, /Owned file was edited locally/); - assert.equal(await fs.readFile(dashboard, "utf8"), before); - assert.match(await fs.readFile(launcher, "utf8"), /edit during check/); + assert.doesNotMatch(await fs.readFile(dashboard, "utf8"), /reviewed dashboard update/); }); -test("fresh launcher check failures leave neither live mod installed", async (t) => { +test("a fresh check failure leaves no live mod installed", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const before = await snapshotTree(appHome); const fake = await createFakeRunner(root); - assertFailure(runInstall({ appHome, fake, sourceRoot, fail: "launcher-check" })); + assertFailure(runInstall({ appHome, fake, sourceRoot, fail: "check" })); assert.deepEqual(await snapshotTree(appHome), before); assert.equal(await exists(path.join(appHome, "mods/insights")), false); assert.equal(await exists(path.join(appHome, "mods/insights-launcher")), false); @@ -105,7 +100,7 @@ test("fresh launcher check failures leave neither live mod installed", async (t) test("concurrent install and uninstall fail closed while another operation holds the lock", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); @@ -147,7 +142,7 @@ test("toolbar revalidates destination state and source links after preflight", a test("uninstall rechecks the active layout instead of deleting a newly selected toolbar", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); @@ -165,7 +160,7 @@ test("uninstall rechecks the active layout instead of deleting a newly selected test("uninstall preserves a toolbar edited while the layout-switch command is running", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); @@ -174,7 +169,7 @@ test("uninstall preserves a toolbar edited while the layout-switch command is ru assertFailure(result); assert.match(result.stderr, /Toolbar layout changed during uninstall/); assert.match(await fs.readFile(path.join(appHome, "layouts/main-insights.json"), "utf8"), /local edit/); - for (const relative of ["mods/insights/main.js", "mods/insights-launcher/main.js", "data/insights/toolbar-install.json"]) { + for (const relative of ["mods/insights/main.js", "data/insights/toolbar-install.json"]) { assert.equal(await exists(path.join(appHome, relative)), true); } }); diff --git a/tests/herdr-insights/install.test.mjs b/tests/herdr-insights/install.test.mjs index ecd84d9..e9deb82 100644 --- a/tests/herdr-insights/install.test.mjs +++ b/tests/herdr-insights/install.test.mjs @@ -12,6 +12,7 @@ import { INSTALL_SCRIPT, PACKAGE_ROOT, RUNTIME_REL, + TOOLBAR_LAYOUT, UNINSTALL_SCRIPT, assertFailure, assertStagedChecks, @@ -45,12 +46,13 @@ test("manifest keys round-trip safely, including __proto__", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root, { ...DEFAULT_MOD_FILES, ["__proto__"]: "owned\n" }); const appHome = path.join(root, "app"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); assertSuccess(runInstall({ appHome, fake, sourceRoot })); const manifest = await readJson(path.join(appHome, "data/insights/install-manifest.json")); assert.equal(manifest.files.__proto__, hash("owned\n")); - assertSuccess(runUninstall({ appHome })); + assertSuccess(runUninstall({ appHome, fake })); assert.equal(await exists(path.join(appHome, "mods/insights/__proto__")), false); }); @@ -58,12 +60,13 @@ test("corrupt manifests fail closed with recovery guidance and preserve data", a const root = await temporaryRoot(t); const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); const manifest = path.join(appHome, "data/insights/install-manifest.json"); await fs.writeFile(manifest, "{broken"); const before = await snapshotTree(appHome); - for (const result of [runInstall({ appHome, fake, sourceRoot }), runUninstall({ appHome })]) { + for (const result of [runInstall({ appHome, fake, sourceRoot }), runUninstall({ appHome, fake })]) { assert.notEqual(result.status, 0); assert.match(result.stderr, /preserve or move aside both/); assert.match(result.stderr, /insights.json.*in place/); @@ -75,6 +78,7 @@ test("reinstall retains ownership proof for edited stale files", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root, { ...DEFAULT_MOD_FILES, "stale.js": "original\n" }); const appHome = path.join(root, "app"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); const target = path.join(appHome, "mods/insights/stale.js"); @@ -85,7 +89,7 @@ test("reinstall retains ownership proof for edited stale files", async (t) => { assert.equal(manifest.files["stale.js"], hash("original\n")); assert.equal(await fs.readFile(target, "utf8"), "local edit\n"); await fs.writeFile(target, "original\n"); - assertSuccess(runUninstall({ appHome })); + assertSuccess(runUninstall({ appHome, fake })); assert.equal(await exists(target), false); }); @@ -139,7 +143,7 @@ test("fresh install copies only owned mod files and preserves local app state", assert.equal(manifest.files[RUNTIME_REL], hash(runtimeContent())); const log = await readLog(fake); - assert.equal(log.length, 1); + assert.equal(log.length, 3); await assertStagedChecks(log, ["insights"], appHome); const fakeHome = path.join(root, "fake home"); @@ -161,6 +165,7 @@ test("Herdr-generated declarations and jsconfig.json are ignored by source insta ...generatedFiles, }); const appHome = path.join(root, "app home"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); const result = runInstall({ appHome, fake, sourceRoot }); @@ -196,6 +201,7 @@ test("runtime.json is generated from the installer Node, replaces a source copy, [RUNTIME_REL]: '{"node":"/definitely/not/the/installer/node"}\n', }); const appHome = path.join(root, "runtime app home"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); const result = runInstall({ appHome, fake, sourceRoot }); @@ -232,6 +238,7 @@ test("install runs only herdr-app check and never opens a tab", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root, DEFAULT_MOD_FILES, "reviewed source's tree"); const appHome = path.join(root, "herdr app's home (dev)"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); const result = runInstall({ @@ -243,7 +250,7 @@ test("install runs only herdr-app check and never opens a tab", async (t) => { assertSuccess(result); const log = await readLog(fake); - assert.equal(log.length, 1); + assert.equal(log.length, 3); await assertStagedChecks(log, ["insights"], appHome); assert.doesNotMatch(result.stdout, /Opened the Insights tab/); }); @@ -310,7 +317,7 @@ test("locally edited owned files are refused unless --force is explicit", async assertFailure(refused); assert.match(refused.stderr, /edited locally/i); assert.equal(await fs.readFile(targetMain, "utf8"), "local edit\n"); - assert.equal((await readLog(fake)).length, 1); + assert.equal((await readLog(fake)).length, 3); const forced = runInstall({ appHome, fake, sourceRoot, extra: ["--force"] }); assertSuccess(forced); @@ -418,6 +425,7 @@ test("source and target symlinks are refused without following or overwriting th await fs.unlink(path.join(sourceRoot, "mods", "insights", "leak.mjs")); const targetApp = path.join(root, "target app home"); + await writeTree(targetApp, { "layouts/main.json": TOOLBAR_LAYOUT }); assertSuccess(runInstall({ appHome: targetApp, fake, sourceRoot })); const targetMain = path.join(targetApp, "mods", "insights", "main.js"); await fs.unlink(targetMain); @@ -464,15 +472,15 @@ test("a failed herdr-app check exits nonzero, does not open, and the install can assert.equal(await exists(path.join(appHome, "mods", "insights", "main.js")), false); assert.deepEqual(await snapshotTree(appHome), Object.entries(APP_FILES).sort(([a], [b]) => a.localeCompare(b))); let log = await readLog(fake); - assert.equal(log.length, 1); - assert.equal(log[0].args[0], "check"); + assert.equal(log.length, 2); + assert.equal(log[1].args[0], "check"); const rerun = runInstall({ appHome, fake, sourceRoot }); assertSuccess(rerun); log = await readLog(fake); - assert.equal(log.length, 2); - assert.equal(log[1].args[0], "check"); - assert.equal(log.filter((entry) => entry.args[0] === "do").length, 0); + assert.equal(log.length, 5); + assert.equal(log[3].args[0], "check"); + assert.equal(log.filter((entry) => entry.args[1] === "layout.clone").length, 1); assert.equal( await fs.readFile(path.join(appHome, "data", "insights", "insights.json"), "utf8"), APP_FILES["data/insights/insights.json"], @@ -491,7 +499,7 @@ test("uninstall removes only unchanged owned files and preserves data and local await fs.writeFile(targetMain, "local edit\n"); await fs.writeFile(path.join(appHome, "mods", "insights", "local-note.txt"), "local\n"); - const result = runUninstall({ appHome }); + const result = runUninstall({ appHome, fake }); assertSuccess(result); assert.match(result.stdout, /Removed 6 unchanged owned file/); assert.match(result.stderr, /Kept .*main\.js.*edited locally/); @@ -521,7 +529,7 @@ test("uninstall removes a clean install, keeps data, and is safe without a manif const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); - assertSuccess(runUninstall({ appHome })); + assertSuccess(runUninstall({ appHome, fake })); assert.equal(await exists(path.join(appHome, "mods", "insights")), false); assert.equal(await exists(path.join(appHome, "data", "insights", "install-manifest.json")), false); assert.equal( @@ -533,7 +541,7 @@ test("uninstall removes a clean install, keeps data, and is safe without a manif "local data\n", ); - const again = runUninstall({ appHome }); + const again = runUninstall({ appHome, fake }); assertSuccess(again); assert.match(again.stdout, /nothing to remove/); @@ -561,6 +569,7 @@ test("uninstall prunes only owned-file parents and preserves nested unowned empt const root = await temporaryRoot(t); const sourceRoot = await createSource(root); const appHome = path.join(root, "app home"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); @@ -569,7 +578,7 @@ test("uninstall prunes only owned-file parents and preserves nested unowned empt await fs.mkdir(unownedNested, { recursive: true }); await fs.mkdir(unownedWithinOwnedParent, { recursive: true }); - assertSuccess(runUninstall({ appHome })); + assertSuccess(runUninstall({ appHome, fake })); assert.equal(await exists(unownedNested), true); assert.equal(await exists(unownedWithinOwnedParent), true); assert.equal(await exists(path.join(appHome, "mods", "insights", "unowned-empty")), true); @@ -703,6 +712,7 @@ test("uninstaller remains usable on an older Node runtime", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root); const appHome = path.join(root, "old node uninstall app home"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); @@ -712,7 +722,7 @@ test("uninstaller remains usable on an older Node runtime", async (t) => { { cwd: PACKAGE_ROOT, encoding: "utf8", - env: { ...process.env, HERDR_APP_HOME: appHome }, + env: makeEnvironment(root, appHome, fake), maxBuffer: 10 * 1024 * 1024, shell: false, timeout: 30_000, diff --git a/tests/herdr-insights/launcher.test.mjs b/tests/herdr-insights/launcher.test.mjs deleted file mode 100644 index a1a346b..0000000 --- a/tests/herdr-insights/launcher.test.mjs +++ /dev/null @@ -1,138 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; -import { collectSourceFiles, PACKAGE_ROOT } from "../../herdr-insights/scripts/lib.mjs"; -import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs"; - -const source = await readFile(new URL("../../herdr-insights/mods/insights-launcher/main.js", import.meta.url), "utf8"); -function element() { - const node = { calls: [], children: [] }; - let proxy; - proxy = new Proxy(node, { get(target, property) { - if (property in target) return target[property]; - return (...args) => { - node.calls.push([property, args]); - if (property === "child") node.children.push(args[0]); - return proxy; - }; - } }); - return proxy; -} -const state = { commands: [], panes: [], spaces: [], theme: { chrome: "#111111", border: "#222222", muted: "#888888" } }; -globalThis.__insightsLauncherTest = state; -const transformed = source - .replace(/^import .* from "gpui-kit";$/m, "class View {}\nconst svg = () => globalThis.__insightsLauncherTest.element();") - .replace(/^import .* from "gpui-base";$/m, "const h_flex = () => globalThis.__insightsLauncherTest.element();") - .replace(/^import .* from "herdr";$/m, `const theme = () => globalThis.__insightsLauncherTest.theme; -const run = (...args) => globalThis.__insightsLauncherTest.commands.push(args); -const spaces = () => globalThis.__insightsLauncherTest.spaces; -const panes = () => globalThis.__insightsLauncherTest.panes;`); -state.element = element; -const { default: Launcher } = await import(`data:text/javascript;base64,${Buffer.from(transformed).toString("base64")}`); - -function buttonOf(launcher) { - return launcher.render().children[0]; -} - -function click(launcher) { - buttonOf(launcher).calls.find(([name]) => name === "on_click")[1][0](); -} - -test("toolbar button matches native panel controls and render has no side effects", () => { - state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; - state.panes = []; - state.commands.length = 0; - const launcher = new Launcher(); - const button = buttonOf(launcher); - assert.equal(button.children[1], "Insights"); - for (const [method, value] of [["h", 26], ["px", 8], ["gap", 5], ["rounded", 6], ["text_size", 12]]) { - assert.ok(button.calls.some(([name, args]) => name === method && args[0] === value), `${method} matches Herdr's panelButton`); - } - assert.deepEqual(state.commands, [], "render must have no side effects"); - let stopped = false; - button.calls.find(([name]) => name === "on_mouse_down")[1][1]({}, { stop_propagation() { stopped = true; } }); - assert.equal(stopped, true); - assert.equal(launcher.init, undefined); - assert.doesNotMatch(source, /layout\.|setShared|exec\(|fetch\(|timer|Overlay|readTextFile|writeTextFile/); -}); - -test("a click shows the space's Insights tab instead of opening another", () => { - state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }, { id: "t2" }] }]; - state.panes = [{ pane: "p1", mod: "terminal", tab: "t1" }, { pane: "p2", mod: "insights", tab: "t2" }]; - state.commands.length = 0; - const launcher = new Launcher(); - click(launcher); - click(launcher); - assert.deepEqual(state.commands, [["pane.focus", { pane: "p2" }], ["pane.focus", { pane: "p2" }]]); -}); - -test("a reused dashboard is focused and revealed behind another pane's zoom", () => { - state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }, { id: "t2" }] }]; - state.panes = [ - { pane: "terminal", mod: "terminal", tab: "t2", focused: true, zoomed: true }, - { pane: "dashboard", mod: "insights", tab: "t2", focused: false, zoomed: false }, - { pane: "other", mod: "terminal", tab: "t1", zoomed: true }, - ]; - state.commands.length = 0; - click(new Launcher()); - assert.deepEqual(state.commands, [ - ["pane.zoom", { pane: "terminal" }], - ["pane.focus", { pane: "dashboard" }], - ]); -}); - -test("reusing an already zoomed dashboard preserves its zoom", () => { - state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; - state.panes = [{ pane: "dashboard", mod: "insights", tab: "t1", zoomed: true }]; - state.commands.length = 0; - click(new Launcher()); - assert.deepEqual(state.commands, [["pane.focus", { pane: "dashboard" }]]); -}); - -test("a click opens the Insights tab only when the space has none", () => { - state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; - state.panes = [{ mod: "terminal", tab: "t1" }]; - state.commands.length = 0; - click(new Launcher()); - assert.deepEqual(state.commands, [["tab.new", { name: "Insights", mod: "insights" }]]); -}); - -test("an Insights tab in another space never satisfies the current space", () => { - state.spaces = [ - { id: "s1", active: true, tabs: [{ id: "t1" }] }, - { id: "s2", active: false, tabs: [{ id: "t2" }] }, - ]; - state.panes = [{ mod: "insights", tab: "t2" }]; - state.commands.length = 0; - click(new Launcher()); - assert.deepEqual(state.commands, [["tab.new", { name: "Insights", mod: "insights" }]]); -}); - -test("launcher is a normal layout mod, never a floating window", async () => { - const launcher = await collectSourceFiles(PACKAGE_ROOT, "insights-launcher"); - assert.deepEqual(launcher.files.map((file) => file.relative), ["insights.svg", "main.js", "mod.json"]); - assert.deepEqual(launcher.modJson, { place: "none", title: "Insights" }); -}); - -test("layout composition keeps the original tabs code and preserves unrelated layout fields", () => { - 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); - assert.deepEqual(original, snapshot, "input must not be mutated"); - assert.equal(integrated.theme, "ink"); - assert.deepEqual(integrated.children[1], original.children[1]); - const row = integrated.children[0]; - assert.equal(row.id, "tabs", "keep existing height/click-state ID on wrapper"); - assert.equal(row.type, "row"); - assert.deepEqual(row.size, { px: 42 }); - 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: { mod: "insights-launcher" } }); - 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/); -}); diff --git a/tests/herdr-insights/toolbar-install.test.mjs b/tests/herdr-insights/toolbar-install.test.mjs index 8cf1aee..70a721b 100644 --- a/tests/herdr-insights/toolbar-install.test.mjs +++ b/tests/herdr-insights/toolbar-install.test.mjs @@ -7,14 +7,15 @@ import test from "node:test"; import { APP_FILES, PACKAGE_ROOT, + SINGLE_TOOLBAR_CONTENT, TOOLBAR_LAYOUT, assertFailure, assertStagedChecks, assertSuccess, createFakeRunner, - createSourceWithLauncher, + createSource, exists, - listFiles, + hash, makeEnvironment, readJson, readLog, @@ -25,7 +26,18 @@ import { writeTree, } from "./install-fixtures.mjs"; -test("standalone distribution installs toolbar without repo tooling or a floating window", async (t) => { +function toolbarButton(layout) { + const nodes = []; + const walk = (node) => { + nodes.push(node); + for (const child of node?.children ?? []) walk(child); + }; + walk(layout); + return nodes.find((node) => node.id === "insights-toolbar-button"); +} + + +test("standalone distribution installs one mod and a single-mod toolbar without a launcher", async (t) => { const root = await temporaryRoot(t); const distribution = path.join(root, "herdr-insights"); await fs.cp(PACKAGE_ROOT, distribution, { recursive: true }); @@ -42,125 +54,103 @@ test("standalone distribution installs toolbar without repo tooling or a floatin assertSuccess(result); assert.match(result.stdout, /herdr-app check passed/); const log = await readLog(fake); - const checks = await assertStagedChecks(log, ["insights", "insights-launcher"], appHome); + const checks = await assertStagedChecks(log, ["insights"], appHome); assert.deepEqual(log.map((entry) => entry.args), [ ["status"], ...checks, ["do", "layout.clone", '{"name":"main","as":"main-insights"}'], ]); - await assert.rejects(fs.lstat(path.join(appHome, "data/insights/insights.json")), { code: "ENOENT" }); - assert.deepEqual((await fs.readdir(root)).sort(), - ["app", "fake bin", "fake herdr log.jsonl", "herdr-insights"]); + assert.deepEqual((await fs.readdir(path.join(appHome, "mods"))).sort(), ["insights"]); + await assert.rejects(fs.lstat(path.join(appHome, "mods/insights-launcher")), { code: "ENOENT" }); + await assert.rejects(fs.lstat(path.join(appHome, "data/insights/launcher-install-manifest.json")), { code: "ENOENT" }); + const layout = await readJson(path.join(appHome, "layouts/main-insights.json")); + assert.deepEqual(toolbarButton(layout).content, SINGLE_TOOLBAR_CONTENT); + const record = await readJson(path.join(appHome, "data/insights/toolbar-install.json")); + const text = await fs.readFile(path.join(appHome, "layouts/main-insights.json"), "utf8"); + assert.equal(record.hash, hash(text)); + assert.equal(log.filter((entry) => entry.args[1] === "mod.open").length, 0, "no floating window"); }); -test("launcher installs, checks, reinstalls and uninstalls through the same safe ownership rules", async (t) => { +test("one mod reinstalls idempotently and uninstall preserves data and local edits", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); assertSuccess(runInstall({ appHome, fake, sourceRoot })); - const log = await readLog(fake); - const checks = await assertStagedChecks(log, ["insights", "insights-launcher"], appHome); - assert.deepEqual(log.map((entry) => entry.args), [ - ["status"], - ...checks, - ["do", "layout.clone", '{"name":"main","as":"main-insights"}'], - ]); - const launcher = path.join(appHome, "mods/insights-launcher"); - assert.deepEqual(await listFiles(launcher), ["insights.svg", "main.js", "mod.json"]); - const manifest = await readJson(path.join(appHome, "data/insights/launcher-install-manifest.json")); - assert.equal(manifest.target, "mods/insights-launcher"); - assert.deepEqual(Object.keys(manifest.files), ["insights.svg", "main.js", "mod.json"]); + let log = await readLog(fake); + await assertStagedChecks(log, ["insights"], appHome); + const manifest = await readJson(path.join(appHome, "data/insights/install-manifest.json")); + assert.equal(manifest.target, "mods/insights"); + assert.equal(await exists(path.join(appHome, "data/insights/launcher-install-manifest.json")), false); + assert.deepEqual(toolbarButton(await readJson(path.join(appHome, "layouts/main-insights.json"))).content, + SINGLE_TOOLBAR_CONTENT); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); - assert.equal((await readLog(fake)).filter((entry) => entry.args[1] === "layout.clone").length, 1, + log = await readLog(fake); + assert.equal(log.filter((entry) => entry.args[1] === "layout.clone").length, 1, "reinstall must not clone another layout"); - assert.equal((await readLog(fake)).filter((entry) => entry.args[1] === "mod.open").length, 0, + assert.equal(log.filter((entry) => entry.args[1] === "mod.open").length, 0, "no floating window"); for (const [relative, content] of Object.entries(APP_FILES)) { assert.equal(await fs.readFile(path.join(appHome, relative), "utf8"), content); } - await fs.writeFile(path.join(launcher, "main.js"), "local launcher edit\n"); - const before = await fs.readFile(path.join(appHome, "mods/insights/main.js"), "utf8"); + + const targetMain = path.join(appHome, "mods/insights/main.js"); + await fs.writeFile(targetMain, "local dashboard edit\n"); await fs.writeFile(path.join(sourceRoot, "mods/insights/main.js"), "new dashboard\n"); const refused = runInstall({ appHome, fake, sourceRoot }); assertFailure(refused); assert.match(refused.stderr, /Owned file was edited locally/); - assert.equal(await fs.readFile(path.join(appHome, "mods/insights/main.js"), "utf8"), before); + assert.equal(await fs.readFile(targetMain, "utf8"), "local dashboard edit\n"); + assertSuccess(runUninstall({ appHome, fake })); assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), false); assert.equal(await exists(path.join(appHome, "data/insights/toolbar-install.json")), false); - assert.equal(await fs.readFile(path.join(launcher, "main.js"), "utf8"), "local launcher edit\n"); - assert.equal(await exists(path.join(launcher, "mod.json")), false); - assert.equal(await exists(path.join(appHome, "mods/insights")), false); + assert.equal(await fs.readFile(targetMain, "utf8"), "local dashboard edit\n"); + assert.equal(await exists(path.join(appHome, "mods/insights/mod.json")), false); + assert.equal(await fs.readFile(path.join(appHome, "data/insights/insights.json"), "utf8"), + APP_FILES["data/insights/insights.json"]); }); -test("launcher collisions and links fail before either mod is written, even with --force", async (t) => { +test("mod collisions and links fail before the mod or toolbar is written, even with --force", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const fake = await createFakeRunner(root); for (const kind of ["unowned", "symlink"]) { const appHome = path.join(root, kind); - const target = path.join(appHome, "mods/insights-launcher"); + const target = path.join(appHome, "mods/insights"); if (kind === "unowned") await writeTree(target, { "main.js": "unowned\n" }); else { await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.symlink(path.join(sourceRoot, "mods/insights-launcher"), target); + await fs.symlink(path.join(sourceRoot, "mods/insights"), target); } const refused = runInstall({ appHome, fake, sourceRoot, extra: ["--force"] }); assertFailure(refused); assert.match(refused.stderr, /unowned collision|symbolic-link target/); - assert.equal(await exists(path.join(appHome, "mods/insights")), false); + assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), false); } assert.deepEqual(await readLog(fake), []); }); -test("launcher check or activation failures never open a dashboard tab", async (t) => { - const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); - for (const fail of ["launcher-check", "open"]) { - const fake = await createFakeRunner(path.join(root, fail)); - const appHome = path.join(root, fail, "app"); - await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); - const result = runInstall({ appHome, fake, sourceRoot, fail }); - assertFailure(result); - const log = await readLog(fake); - assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 0); - if (fail === "launcher-check") { - assert.equal(log.filter((entry) => entry.args[0] === "do").length, 0); - } else { - assert.match(result.stderr, /layout.clone.*failed/); - } - } -}); - -test("uninstall preflights both targets before deleting either mod", async (t) => { +test("a native check failure never clones a layout or opens a dashboard tab", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const fake = await createFakeRunner(root); - for (const kind of ["symlink", "unowned", "corrupt-manifest"]) { - const appHome = path.join(root, kind); - await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); - assertSuccess(runInstall({ appHome, fake, sourceRoot })); - const target = path.join(appHome, "mods/insights-launcher"); - if (kind === "symlink") { - await fs.rm(target, { recursive: true }); - await fs.symlink(path.join(sourceRoot, "mods/insights-launcher"), target); - } else if (kind === "unowned") { - await fs.unlink(path.join(appHome, "data/insights/launcher-install-manifest.json")); - } else { - await fs.writeFile(path.join(appHome, "data/insights/launcher-install-manifest.json"), "{broken"); - } - const before = await snapshotTree(appHome); - assertFailure(runUninstall({ appHome })); - assert.deepEqual(await snapshotTree(appHome), before, kind); - assert.equal(await exists(path.join(appHome, "mods/insights/main.js")), true); - } + const appHome = path.join(root, "app"); + await writeTree(appHome, { "layouts/main.json": TOOLBAR_LAYOUT }); + const result = runInstall({ appHome, fake, sourceRoot, fail: "check" }); + assertFailure(result); + const log = await readLog(fake); + assert.equal(log.filter((entry) => entry.args[0] === "check").length, 1); + assert.equal(log.filter((entry) => entry.args[0] === "do").length, 0); + assert.equal(log.filter((entry) => entry.args[1] === "tab.new").length, 0); + assert.equal(await exists(path.join(appHome, "mods/insights")), false); }); -test("toolbar refuses unsupported layouts and state symlinks before writing either mod", async (t) => { +test("toolbar refuses unsupported layouts and state symlinks before writing the mod", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const fake = await createFakeRunner(root); for (const kind of ["unsupported", "symlink", "state-link", "state-directory-link"]) { const appHome = path.join(root, kind); @@ -181,13 +171,13 @@ test("toolbar refuses unsupported layouts and state symlinks before writing eith assertFailure(refused); assert.match(refused.stderr, /cannot be integrated safely|symbolic link/); assert.equal(await exists(path.join(appHome, "mods/insights")), false); - assert.equal(await exists(path.join(appHome, "mods/insights-launcher")), false); + assert.equal(await exists(path.join(appHome, "layouts/main-insights.json")), false); } }); -test("reinstall preserves edited personal layouts and uninstall fails closed before deleting any files", async (t) => { +test("reinstall preserves edited personal layouts and uninstall fails closed before deleting anything", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); await writeTree(appHome, APP_FILES); const fake = await createFakeRunner(root); @@ -200,14 +190,11 @@ test("reinstall preserves edited personal layouts and uninstall fails closed bef await fs.writeFile(layoutFile, custom); assertSuccess(runInstall({ appHome, fake, sourceRoot })); assert.equal(await fs.readFile(layoutFile, "utf8"), custom); - const before = await Promise.all((await listFiles(appHome)).map(async (relative) => - [relative, await fs.readFile(path.join(appHome, relative), "utf8")])); + const before = await snapshotTree(appHome); const refused = runUninstall({ appHome, fake }); assertFailure(refused); assert.match(refused.stderr, /Toolbar layout was edited locally/); - const after = await Promise.all((await listFiles(appHome)).map(async (relative) => - [relative, await fs.readFile(path.join(appHome, relative), "utf8")])); - assert.deepEqual(after, before); + assert.deepEqual(await snapshotTree(appHome), before); await fs.writeFile(layoutFile, original); assertSuccess(runUninstall({ appHome, fake })); const log = await readLog(fake); @@ -219,7 +206,7 @@ test("reinstall preserves edited personal layouts and uninstall fails closed bef test("toolbar skips layout/state collisions, preserves click state, and can reinstall after uninstall", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const appHome = path.join(root, "app"); const state = '{"nodes":{"panel":{"hidden":false}},"theme":"ink"}\n'; await writeTree(appHome, { @@ -251,7 +238,7 @@ test("toolbar skips layout/state collisions, preserves click state, and can rein test("uninstall verifies the original layout exists and actually became selected", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); for (const fail of ["missing-source", "ignored-switch"]) { const appHome = path.join(root, fail); await writeTree(appHome, APP_FILES); @@ -269,7 +256,7 @@ test("uninstall verifies the original layout exists and actually became selected test("toolbar ownership records reject traversal and do not overwrite personal files", async (t) => { const root = await temporaryRoot(t); - const sourceRoot = await createSourceWithLauncher(root); + const sourceRoot = await createSource(root); const fake = await createFakeRunner(root); const appHome = path.join(root, "app"); await writeTree(appHome, { diff --git a/tests/herdr-insights/toolbar-layout.test.mjs b/tests/herdr-insights/toolbar-layout.test.mjs new file mode 100644 index 0000000..94ac034 --- /dev/null +++ b/tests/herdr-insights/toolbar-layout.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import test from "node:test"; +import { collectSourceFiles, PACKAGE_ROOT } from "../../herdr-insights/scripts/lib.mjs"; +import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs"; +import { SINGLE_TOOLBAR_CONTENT } from "./install-fixtures.mjs"; + +test("the single Insights source installs the toolbar icon and no second mod", async () => { + const source = await collectSourceFiles(PACKAGE_ROOT, "insights"); + assert.ok(source.files.some((file) => file.relative === "insights.svg")); + assert.deepEqual(source.modJson, { place: "none", title: "Insights" }); + const mods = (await fs.readdir(new URL("../../herdr-insights/mods/", import.meta.url))) + .filter((name) => name !== ".DS_Store"); + assert.deepEqual(mods, ["insights"]); +}); + +test("layout composition keeps the original tabs code and points the toolbar at one mod", () => { + 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); + assert.deepEqual(original, snapshot, "input must not be mutated"); + assert.equal(integrated.theme, "ink"); + assert.deepEqual(integrated.children[1], original.children[1]); + const row = integrated.children[0]; + assert.equal(row.id, "tabs", "keep existing height/click-state ID on wrapper"); + assert.equal(row.type, "row"); + assert.deepEqual(row.size, { px: 42 }); + 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.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/); +}); From f0599db153b335ecc79050bd76f6ac7740fa0e93 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Mon, 2 Nov 2026 14:05:16 +0530 Subject: [PATCH 2/5] Document one native Insights mod and validate single-mod distribution - Describe the merged toolbar/dashboard mod with props mode in README, RECIPE, INSTALL, and the development guide - Keep INSTALL.md installation-only and short; drop uninstall and dual-mod copy - Decouple the package validator from core MOD_NAMES and require exactly one mods/insights directory - Assert the single-mod distribution and doc contract in distribution tests --- docs/herdr-insights-development.md | 14 ++-- herdr-insights/INSTALL.md | 24 +++---- herdr-insights/README.md | 34 ++++----- herdr-insights/RECIPE.md | 84 ++++++++++++---------- scripts/herdr-insights/check.mjs | 10 +-- scripts/herdr-insights/distribution.mjs | 30 +++++++- tests/herdr-insights/distribution.test.mjs | 35 +++++++-- 7 files changed, 142 insertions(+), 89 deletions(-) diff --git a/docs/herdr-insights-development.md b/docs/herdr-insights-development.md index b30e1c5..1fdda10 100644 --- a/docs/herdr-insights-development.md +++ b/docs/herdr-insights-development.md @@ -62,17 +62,19 @@ and `insights.json`; the automated installer tests cover those boundaries. ## Sharing a build Keep `herdr-insights/` self-contained, with `RECIPE.md`, `INSTALL.md`, `README.md`, -`preview.png`, `mods/insights/`, and `mods/insights-launcher/`. Do not ship layouts, -key snippets, developer tests/fixtures, or smoke-test tooling in that folder. -The installer preflights both mod trees, checks temporary buffered copies before -replacing either live mod, generates the dashboard's local Node path, and records -ownership separately for each mod. Install/uninstall share an exclusive lock; +`preview.png`, and `mods/insights/`. Do not ship layouts, key snippets, developer +tests/fixtures, or smoke-test tooling in that folder. One native mod serves both +views: `props.mode: "toolbar"` renders the button, and ordinary panes render the +dashboard. The installer preflights the mod tree, checks a temporary buffered copy +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 [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. Validate both mods with `herdr-app check` and inspect the actual +source layout. A normal install has one `install-manifest.json` and the dashboard +`runtime.json`. Validate the mod with `herdr-app check` and inspect the actual toolbar in the existing window; do not launch a second instance. For Herdr Community discovery, publish the folder in a public GitHub repo and diff --git a/herdr-insights/INSTALL.md b/herdr-insights/INSTALL.md index fe63275..b0362e0 100644 --- a/herdr-insights/INSTALL.md +++ b/herdr-insights/INSTALL.md @@ -2,30 +2,28 @@ Requires **Node.js 22.15+** and a running Herdr. Never launch a second Herdr instance. Follow Herdr's `AGENTS.md`; this guide does not override its review requirements. -Use the existing build, not `RECIPE.md` (rebuilding). +Use this existing build, not `RECIPE.md` (rebuilding). ## Install -From the parent of `herdr-insights/`; no `npm install` needed: +From a checkout, at the repository root; no `npm install` needed: ```sh npm run install:local --prefix herdr-insights ``` -App home defaults to `$HERDR_APP_HOME` or `~/.config/herdr-app`. -The installer checks temporary copies of both mods before replacing live files. -First install creates/selects `-insights` (suffixing collisions) -and adds **Insights** beside Files/Changes. Reinstall updates owned mods without -rewriting layouts or changing the selected layout. Shipped files, tabs, terminals, -keys, click state, and usage data are preserved. +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. -Success requires two `herdr-app check passed` messages **and exit code 0**. +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 only if requested: click **Insights**, or add `-- --open` to the install command. ## Errors -Stop and report; do not force overwrites or rebuild to bypass errors. The layout -must contain the native `tabs` block. Preserve local edits and any failed layout -clone. Remove the app home's `.insights-install.lock` directory only after -confirming no installer/uninstaller is running. +Stop and report; do not force overwrites or rebuild to bypass errors. Preserve +local edits and any failed layout clone. Remove the app home's +`.insights-install.lock` directory only after confirming no installer is running. diff --git a/herdr-insights/README.md b/herdr-insights/README.md index bc84d7f..59d50dd 100644 --- a/herdr-insights/README.md +++ b/herdr-insights/README.md @@ -23,13 +23,12 @@ cd coding-agent-toolkit npm run install:local --prefix herdr-insights ``` -With Herdr running, the installer checks both mods and adds **Insights** beside -**Files / Changes**; clicking it reuses the current space's Insights tab, opening -one only when absent. It installs -`mods/insights/` and `mods/insights-launcher/`, records ownership in -`data/insights/`, and creates a personal layout clone for the button. Shipped -files, 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. +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/` +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. ## Use @@ -47,21 +46,14 @@ response text or modify source transcripts. ## Development -[RECIPE.md](RECIPE.md) is the self-contained rebuild brief required by Herdr. -The distribution folder is `herdr-insights`; its dashboard mod ID is `insights` -and its display name is **Insights**. `insights-launcher` is a separate, native -toolbar button that shows the space's existing Insights tab, opening one only -when absent; it has no polling, helper process, or overlays. No layouts or key -bindings ship; the button lives in a personal layout clone composed by the -installer (see [INSTALL.md](INSTALL.md)). +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)). +[RECIPE.md](RECIPE.md) is the self-contained rebuild brief required by Herdr. Developer tests, fixtures, and release validation live outside this distribution; see the repo's [development guide](../docs/herdr-insights-development.md). They are not installation steps. - -With Herdr running, uninstall while keeping usage data and returning to the -original layout: - -```sh -npm run uninstall:local --prefix herdr-insights -``` diff --git a/herdr-insights/RECIPE.md b/herdr-insights/RECIPE.md index 6424d19..fca7754 100644 --- a/herdr-insights/RECIPE.md +++ b/herdr-insights/RECIPE.md @@ -2,8 +2,9 @@ A native Herdr dashboard for token usage across Pi, Codex, Claude Code, and OpenCode. It reads existing local session data, shows model rankings and activity, -and opens in its own tab from an Insights button beside Files and Changes. No -built-in Herdr code, terminals, or keys are changed. +and opens in its own tab from an Insights button beside the native tabs. One mod +provides both the button and the dashboard; no built-in Herdr code, terminals, or +keys are changed. ![Insights](preview.png) @@ -11,24 +12,27 @@ Installing this existing build? Follow [INSTALL.md](INSTALL.md) and the user's Herdr `AGENTS.md`. This recipe is a rebuild brief; its **Done when** checks apply to building/releasing, not routine installation. Installation does not launch smoke-test windows or open a dashboard tab unless requested. First installation -selects a personal layout with the button in the existing toolbar row. +selects a personal layout with the button in the existing toolbar row. The button +is a `mode: "toolbar"` view of the `insights` mod; any other pane shows the +dashboard. **What you see:** A vertically scrollable dashboard centered at a maximum width of 1180px. Use Herdr theme colors, 1px borders, 8px card corners, 6px control corners, no decorative shadows, and 4/8/12/16/24px spacing. Page padding is 12px below 620px, 16px below 900px, and 24px otherwise. Support light and dark themes. -A separate `insights-launcher` mod draws the **Insights** toolbar button after -Files and Changes. Match Herdr's panel controls: 26px height, 8px horizontal -padding, 5px icon/text gap, 6px corners, 12px type, 14px theme-tinted chart icon, -chrome background, muted text, and native hover colors. Its root matches the tab -bar's chrome background and 1px bottom border. Stop mouse-down propagation on -the button so it does not drag the window. Every click shows the current space's -existing Insights tab (the tab whose pane runs the `insights` mod) instead of -stacking duplicates. Focus its pane with `pane.focus`; if a different pane in -that tab is zoomed, unzoom it first so the dashboard is visible. It calls `tab.new` with `{name: "Insights", mod: "insights"}` -only when that space has none. No floating window, overlay, polling, file access, -or helper process. +The `insights` mod's toolbar mode draws the **Insights** button after the native +tabs. Match Herdr's panel controls: 26px height, 8px horizontal padding, 5px +icon/text gap, 6px corners, 12px type, 14px theme-tinted chart icon, chrome +background, muted text, and native hover colors. Its root matches the tab bar's +chrome background and 1px bottom border. Stop mouse-down propagation on the +button so it does not drag the window. Toolbar mode starts no Node helper, scans, +tasks, timers, or editors. Every click shows the current space's existing Insights +tab (the tab whose pane runs `insights` in dashboard mode) instead of stacking +duplicates. Focus its pane with `pane.focus`; if a different pane in that tab is +zoomed, unzoom it first so the dashboard is visible. Call `tab.new` with +`{name: "Insights", mod: "insights"}` only when that space has none. No floating +window, overlay, polling, file access, or helper process. Dashboard, top to bottom: - An Insights header with last-update status, Refresh, and Settings. @@ -75,16 +79,17 @@ controls preserve custom mappings. Changing source configuration invalidates the scan cache; palette/cache/week-start/refresh changes preserve collected history. **Built from:** Put this distribution in `herdr-insights/`, with `RECIPE.md`, -`README.md`, `INSTALL.md`, `preview.png`, `mods/insights/`, and -`mods/insights-launcher/`. Keep developer +`README.md`, `INSTALL.md`, `preview.png`, and `mods/insights/`. Keep developer tests, fixtures, and release-check tooling outside this distribution folder. Do not include a `layouts/` folder or keys snippet: no bindings are needed. -The recipe heading and `mod.json` title are **Insights**; the native mod ID is `insights`. +The recipe heading and `mod.json` title are **Insights**; the native mod ID is +`insights`. `mods/insights/mod.json` contains `{"place":"none","title":"Insights"}`. -`mods/insights-launcher/mod.json` also uses `place: "none"`, `title: "Insights"`. -Its `main.js` default-exports a small native View using gpui-base flex builders, -`theme()`, and `run()`; bundle its chart icon as `insights.svg`. +`main.js` default-exports one native View using gpui-base flex builders, `theme()`, +and `run()`. Read `props.mode`: `"toolbar"` renders only the button and its click +handler; every other value renders the dashboard and runs its collectors. The +button's chart icon ships in the same tree. `scripts/toolbar.mjs` integrates through layout composition, not source patching. Use `layout.clone {name: current, as: current + "-insights"}` to preserve Herdr's @@ -94,13 +99,16 @@ already exists. Never overwrite another layout's state, including leftover state from uninstall. Do not edit or remove Herdr-managed click state. In the personal clone only, turn the existing `tabs` block into a nonresizable row, keeping its ID, height, paint, and window-drag attributes. Its first child is a weight-1 block with the original `content` -(including its props); its second is a 104px block with `insights-launcher`. +(including its props); its second is a 104px block whose content is +`{mod: "insights", props: {mode: "toolbar"}}`. Do not copy or modify the shipped `tabs` mod. Refuse unsupported/ambiguous layouts. Record the personal layout's name, source, and hash in `toolbar-install.json`. Reinstall must not rewrite this layout or switch the user's selected layout. Uninstall validates the original layout and confirms it actually became selected before deleting an active, unchanged owned clone. Locally edited clones, missing originals, or unsuccessful switching block removal before deleting any files. +Migration of older toolbar installs uses their existing ownership proof; edited +legacy layouts fail closed. `main.js` default-exports a gpui-kit `View`; use gpui-base flex/controls, `theme()` semantic colors, `on_size_change`, and fresh callback contexts. Do not retain `cx`. Cancel tasks/timers and release native editor/input state on close. Use native @@ -136,17 +144,15 @@ and executable mappings. `core/config.mjs` defines and validates the full schema Provide dependency-free Node install/uninstall scripts. The installer requires Node >=22.15 and validates it before writing anything; the uninstaller intentionally -has no Node gate so it remains usable on older runtimes. Install only these two -mod trees (plus ownership manifests under `data/insights/`) and generate the dashboard's `runtime.json` with the -absolute installer Node path; the UI uses that path, falling back to `node` on PATH -only for manual copies. Track ownership/hashes in a separate install manifest. -Keep the existing dashboard manifest compatible and use -`launcher-install-manifest.json` for the launcher. Preflight both targets before -writing either. Refuse symlinks and unowned collisions; refuse edited owned files -unless explicitly forced. Preserve usage data and unrelated files. Check buffered -copies of both mods with `herdr-app check` in a temporary app home before replacing -either live mod; clean up on success or failure. Revalidate targets after checking -to preserve edits made during validation. Serialize install/uninstall with an +has no Node gate so it remains usable on older runtimes. Install only the `insights` +mod tree (plus ownership files under `data/insights/`) and generate the dashboard's +`runtime.json` with the absolute installer Node path; the UI uses that path, falling +back to `node` on PATH only for manual copies. Track ownership/hashes in +`install-manifest.json`. Preflight every target before writing. Refuse symlinks and +unowned collisions; refuse edited owned files unless explicitly forced. Preserve +usage data and unrelated files. Check a buffered copy of the mod with `herdr-app check` +in a temporary app home before replacing live files; clean up on success or failure. +Revalidate targets after checking to preserve edits made during validation. Serialize install/uninstall with an exclusive `.insights-install.lock` directory for the full operation, and never steal a lock automatically. Recheck clone destinations and source state just before cloning, and recheck the active layout and clone hash before deleting on uninstall. @@ -168,11 +174,11 @@ data. Write only the owned personal layout clone. Preserve collisions and local edits. Do not copy an absolute runtime path from another machine. **Done when:** For a new build or release (not an installation), package tests -and checks pass, including distribution naming, PNG preview, recipe structure, +and checks pass, including single-mod distribution, PNG preview, recipe structure, no layouts, token accounting, deduplication, configuration validation, incremental recovery, and safe installation/uninstallation. -Run `herdr-app check` on both installed mods. Inspect a real screenshot: Insights -must be in the same row as Files/Changes with matching button styling, no extra +Run `herdr-app check` on the installed mod. Inspect a real screenshot: Insights +must be in the same row as the native tabs with matching button styling, no extra strip or floating window. Clicks reuse the current space's dashboard, including when it is in a split behind another pane's zoom, without creating duplicates. Confirm built-in @@ -180,6 +186,8 @@ code/layouts are unchanged and reinstall does not duplicate the toolbar. Also ru native smoke checks in temporary app homes. Inspect a native screenshot and status for render errors. Verify actual filters, heatmap modes, source settings, refresh, and empty/error states. Reinstall without changing source transcripts, existing -layouts, keys, local edits, or `insights.json`. Keep the GitHub repo public and add topic -`herdr-mod` for Community discovery after merging to the default branch; allow -up to an hour for indexing and at least 24 hours for picture screening. +layouts, keys, local edits, or `insights.json`. Confirm the current normal install +has one `install-manifest.json` and the dashboard `runtime.json`. Keep the GitHub +repo public and add topic `herdr-mod` for Community discovery after merging to the +default branch; allow up to an hour for indexing and at least 24 hours for picture +screening. diff --git a/scripts/herdr-insights/check.mjs b/scripts/herdr-insights/check.mjs index c097417..66f4978 100644 --- a/scripts/herdr-insights/check.mjs +++ b/scripts/herdr-insights/check.mjs @@ -4,13 +4,12 @@ import path from "node:path"; import { MIN_NODE_VERSION, - MOD_NAMES, PACKAGE_ROOT, assertSupportedNodeVersion, collectSourceFiles, lstatOrNull, } from "../../herdr-insights/scripts/lib.mjs"; -import { inspectDistribution } from "./distribution.mjs"; +import { INSIGHTS_MOD, inspectDistribution } from "./distribution.mjs"; const TEST_ROOT = path.resolve(PACKAGE_ROOT, "../tests/herdr-insights"); const errors = []; @@ -209,6 +208,10 @@ async function checkSourceIfPresent(modName) { fail(`mods/${modName} is required for the Herdr distribution`); return; } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + fail(`mods/${modName} must be a regular directory`); + return; + } try { const source = await collectSourceFiles(PACKAGE_ROOT, modName); notes.push(`mods/${modName} source tree validates (${source.files.length} files)`); @@ -232,8 +235,7 @@ await checkDistribution(); await checkDocs(); await checkScriptsAndTests(); await checkTestInventory(); -for (const modName of MOD_NAMES) await checkSourceIfPresent(modName); - +await checkSourceIfPresent(INSIGHTS_MOD); for (const note of notes) console.log(`Note: ${note}`); if (errors.length > 0) { for (const error of errors) console.error(`Error: ${error}`); diff --git a/scripts/herdr-insights/distribution.mjs b/scripts/herdr-insights/distribution.mjs index d150530..f8db876 100644 --- a/scripts/herdr-insights/distribution.mjs +++ b/scripts/herdr-insights/distribution.mjs @@ -4,6 +4,18 @@ import path from "node:path"; import { lstatOrNull } from "../../herdr-insights/scripts/lib.mjs"; import { validatePng } from "./png.mjs"; +/** The one native Herdr mod this distribution ships. */ +export const INSIGHTS_MOD = "insights"; + +const ALLOWED_SCRIPTS = ["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]; + +function modEntries(entries) { + return entries + .filter((entry) => entry.isDirectory() && entry.name !== ".DS_Store") + .map((entry) => entry.name) + .sort(); +} + export async function inspectDistribution(packageRoot) { const errors = []; const notes = []; @@ -24,7 +36,21 @@ export async function inspectDistribution(packageRoot) { } try { - const allowed = new Set(["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]); + const mods = modEntries(await fs.readdir(path.join(packageRoot, "mods"), { withFileTypes: true })); + if (!mods.includes(INSIGHTS_MOD)) { + errors.push(`Distribution must contain mods/${INSIGHTS_MOD}`); + } + for (const name of mods) { + if (name !== INSIGHTS_MOD) { + errors.push(`Distribution ships one native mod; remove mods/${name}`); + } + } + } catch (error) { + errors.push(`Cannot inspect mods/: ${error.message}`); + } + + try { + const allowed = new Set(ALLOWED_SCRIPTS); for (const name of await fs.readdir(path.join(packageRoot, "scripts"))) { if (!allowed.has(name)) errors.push(`Developer tooling must stay outside herdr-insights/scripts/: ${name}`); } @@ -42,7 +68,7 @@ export async function inspectDistribution(packageRoot) { try { const mod = JSON.parse( - await fs.readFile(path.join(packageRoot, "mods/insights/mod.json"), "utf8"), + await fs.readFile(path.join(packageRoot, `mods/${INSIGHTS_MOD}/mod.json`), "utf8"), ); if (mod.title !== "Insights" || mod.place !== "none") { errors.push('mod.json must use title "Insights" and place "none"'); diff --git a/tests/herdr-insights/distribution.test.mjs b/tests/herdr-insights/distribution.test.mjs index 1c6b0b4..e96dba1 100644 --- a/tests/herdr-insights/distribution.test.mjs +++ b/tests/herdr-insights/distribution.test.mjs @@ -14,15 +14,26 @@ const read = (relative) => readFile(path.join(root, relative), "utf8"); test("Herdr distribution has the requested folder and display name, with no layouts or bindings", async () => { assert.equal(path.basename(root), "herdr-insights"); assert.deepEqual(JSON.parse(await read("mods/insights/mod.json")), { place: "none", title: "Insights" }); - const modEntries = (await readdir(path.join(root, "mods"))).filter((name) => name !== ".DS_Store").sort(); - assert.deepEqual(modEntries, ["insights", "insights-launcher"]); - assert.deepEqual(JSON.parse(await read("mods/insights-launcher/mod.json")), - { place: "none", title: "Insights" }); await assert.rejects(lstat(path.join(root, "layouts")), { code: "ENOENT" }); await assert.rejects(lstat(path.join(root, "keys.json")), { code: "ENOENT" }); assert.match(await read("RECIPE.md"), /^# Insights\n/); }); +test("distribution ships one native insights mod and no separate launcher", async () => { + const modEntries = (await readdir(path.join(root, "mods"))).filter((name) => name !== ".DS_Store").sort(); + assert.deepEqual(modEntries, ["insights"], + "one mod owns the dashboard and its toolbar button; a second launcher must not ship"); + await assert.rejects(lstat(path.join(root, "mods/insights-launcher")), { code: "ENOENT" }); + + const docs = await Promise.all(["README.md", "INSTALL.md", "RECIPE.md"].map(read)); + const development = await readFile( + new URL("../../docs/herdr-insights-development.md", import.meta.url), "utf8"); + for (const text of [...docs, development]) { + assert.doesNotMatch(text, /insights-launcher|both mods|separate launcher/i, + "documentation must describe one mod with a mode property"); + } +}); + test("Community preview is a complete PNG referenced by the recipe", async () => { const image = await readFile(path.join(root, "preview.png")); const parsed = validatePng(image); @@ -78,11 +89,25 @@ test("Distribution inspection rejects keys.json and layouts without ignoring the assert.match((await inspectDistribution(packageRoot)).errors.join("\n"), /layouts/i); }); +test("Distribution inspection rejects a second mod directory", async (t) => { + const temporary = await mkdtemp(path.join(tmpdir(), "insights-second-mod-")); + t.after(async () => rm(temporary, { recursive: true, force: true })); + const packageRoot = path.join(temporary, "herdr-insights"); + await mkdir(path.join(packageRoot, "mods", "insights"), { recursive: true }); + await mkdir(path.join(packageRoot, "mods", "insights-launcher")); + await copyFile(path.join(root, "preview.png"), path.join(packageRoot, "preview.png")); + await writeFile( + path.join(packageRoot, "mods", "insights", "mod.json"), + '{"place":"none","title":"Insights"}\n', + ); + const errors = (await inspectDistribution(packageRoot)).errors.join("\n"); + assert.match(errors, /one native mod; remove mods\/insights-launcher/); +}); + test("shared distribution excludes developer tests, fixtures, and smoke tooling", async () => { await assert.rejects(lstat(path.join(root, "tests")), { code: "ENOENT" }); assert.deepEqual((await readdir(path.join(root, "scripts"))).sort(), ["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]); - assert.deepEqual((await inspectDistribution(root)).errors, []); }); test("distribution inspection rejects developer tooling in the shared folder", async (t) => { From 5e24d7eadc13e05085398f41c913835c136bc6f5 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Mon, 2 Nov 2026 14:27:25 +0530 Subject: [PATCH 3/5] Remove remaining multi-mod plumbing and migration wording --- herdr-insights/INSTALL.md | 4 +- herdr-insights/RECIPE.md | 2 - herdr-insights/mods/insights/main.js | 6 +-- herdr-insights/scripts/install.mjs | 14 +++---- herdr-insights/scripts/lib.mjs | 41 ++++++++------------ herdr-insights/scripts/uninstall.mjs | 8 ++-- scripts/herdr-insights/check.mjs | 18 ++++----- scripts/herdr-insights/distribution.mjs | 24 +++--------- tests/herdr-insights/toolbar-layout.test.mjs | 2 +- tests/herdr-insights/ui-system.test.mjs | 2 +- 10 files changed, 46 insertions(+), 75 deletions(-) diff --git a/herdr-insights/INSTALL.md b/herdr-insights/INSTALL.md index b0362e0..a4691fc 100644 --- a/herdr-insights/INSTALL.md +++ b/herdr-insights/INSTALL.md @@ -6,7 +6,7 @@ Use this existing build, not `RECIPE.md` (rebuilding). ## Install -From a checkout, at the repository root; no `npm install` needed: +From the parent of `herdr-insights/`; no `npm install` needed: ```sh npm run install:local --prefix herdr-insights @@ -26,4 +26,4 @@ only if requested: click **Insights**, or add `-- --open` to the install command Stop and report; do not force overwrites or rebuild to bypass errors. Preserve local edits and any failed layout clone. Remove the app home's -`.insights-install.lock` directory only after confirming no installer is running. +`.insights-install.lock` directory only after confirming no installer/uninstaller is running. diff --git a/herdr-insights/RECIPE.md b/herdr-insights/RECIPE.md index fca7754..377c980 100644 --- a/herdr-insights/RECIPE.md +++ b/herdr-insights/RECIPE.md @@ -107,8 +107,6 @@ Reinstall must not rewrite this layout or switch the user's selected layout. Uninstall validates the original layout and confirms it actually became selected before deleting an active, unchanged owned clone. Locally edited clones, missing originals, or unsuccessful switching block removal before deleting any files. -Migration of older toolbar installs uses their existing ownership proof; edited -legacy layouts fail closed. `main.js` default-exports a gpui-kit `View`; use gpui-base flex/controls, `theme()` semantic colors, `on_size_change`, and fresh callback contexts. Do not retain `cx`. Cancel tasks/timers and release native editor/input state on close. Use native diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index c5722ad..71252af 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -52,10 +52,6 @@ const DAY_LABEL_WIDTH = 30; const TOOLBAR_MODE = "toolbar"; const TAB = { name: "Insights", mod: "insights" }; -function isToolbar(props) { - return props?.mode === TOOLBAR_MODE; -} - /** One Insights tab per space: focus it if it exists, otherwise create it. */ function showInsights() { const active = spaces().find((space) => space.active); @@ -441,7 +437,7 @@ function preferenceRow(label, control, c) { export default class Insights extends View { init(props, cx) { - this.mode = isToolbar(props) ? TOOLBAR_MODE : "dashboard"; + this.mode = props?.mode === TOOLBAR_MODE ? TOOLBAR_MODE : "dashboard"; this.closed = false; // The toolbar is a button, not a dashboard copy: no runtime read, collector, // command loop, timer or editor state belongs to it. diff --git a/herdr-insights/scripts/install.mjs b/herdr-insights/scripts/install.mjs index 323bd44..dc22478 100644 --- a/herdr-insights/scripts/install.mjs +++ b/herdr-insights/scripts/install.mjs @@ -128,7 +128,7 @@ async function addParentIssues(targetDirectory, target, issues) { } async function collectInstallSource(sourceRoot) { - const source = await collectSourceFiles(sourceRoot, "insights"); + const source = await collectSourceFiles(sourceRoot); source.files = source.files.filter((file) => file.relative !== RUNTIME_NAME); source.files.push(createRuntimeFile()); source.files.sort((left, right) => left.relative.localeCompare(right.relative)); @@ -136,9 +136,9 @@ async function collectInstallSource(sourceRoot) { } async function prepareInstall(appHome, source, force) { - const targetDirectory = modDir(appHome, "insights"); + const targetDirectory = modDir(appHome); const targetDataDirectory = dataDir(appHome); - const targetManifest = manifestPath(appHome, "insights"); + const targetManifest = manifestPath(appHome); if (path.resolve(source.modDir) === path.resolve(targetDirectory)) { throw new Error("Source and target are the same mod folder; refusing to install over itself."); @@ -154,7 +154,7 @@ async function prepareInstall(appHome, source, force) { } } - const manifest = await readManifest(appHome, "insights"); + const manifest = await readManifest(appHome); if (targetStat && !manifest) { throw new Error( `Refusing an unowned collision at ${targetDirectory}.\nNo ${targetManifest} proves this installer owns it. Move the folder aside or install with Herdr's own mod tools.`, @@ -209,11 +209,11 @@ async function checkInstallSource(source) { const staging = await fs.mkdtemp(path.join(os.tmpdir(), "insights-install-check-")); try { for (const file of source.files) { - const target = localPath(modDir(staging, "insights"), file.relative); + const target = localPath(modDir(staging), file.relative); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, file.data, { mode: file.mode }); } - const check = await runHerdr(["check", modDir(staging, "insights")], { + const check = await runHerdr(["check", modDir(staging)], { env: { HERDR_APP_HOME: staging, HOME: staging }, }); relayHerdrOutput(check); @@ -293,7 +293,7 @@ async function applyInstall(plan) { ...retainedHashes, ...Object.fromEntries(source.files.map((file) => [file.relative, file.hash])), }; - await writeManifest(appHome, installedHashes, "insights"); + await writeManifest(appHome, installedHashes); console.log( `Installed ${source.files.length} owned file(s) to ${targetDirectory} ` + diff --git a/herdr-insights/scripts/lib.mjs b/herdr-insights/scripts/lib.mjs index 5812dec..855d94a 100644 --- a/herdr-insights/scripts/lib.mjs +++ b/herdr-insights/scripts/lib.mjs @@ -11,10 +11,7 @@ export const PACKAGE_ROOT = path.resolve( ); export const MIN_NODE_VERSION = "22.15"; export const RUNTIME_NAME = "runtime.json"; -export const MODS = Object.freeze({ - insights: { manifest: "install-manifest.json", rel: "mods/insights", runtime: true }, -}); -export const MOD_NAMES = Object.keys(MODS); +const MOD_REL = "mods/insights"; export const MANIFEST_VERSION = 1; export const MANIFEST_OWNER = "herdr-insights"; export const HASH_RE = /^[0-9a-f]{64}$/; @@ -49,22 +46,16 @@ export function createRuntimeFile(nodePath = process.execPath) { }; } -export function modInfo(modName) { - const info = MODS[modName]; - if (!info) throw new Error(`Unknown Insights mod: ${modName}`); - return info; -} - -export function modDir(appHome, modName) { - return path.join(appHome, modInfo(modName).rel); +export function modDir(appHome) { + return path.join(appHome, MOD_REL); } export function dataDir(appHome) { return path.join(appHome, "data", "insights"); } -export function manifestPath(appHome, modName) { - return path.join(dataDir(appHome), modInfo(modName).manifest); +export function manifestPath(appHome) { + return path.join(dataDir(appHome), "install-manifest.json"); } export async function lstatOrNull(target) { @@ -222,21 +213,21 @@ export async function ensureDirectory(root, target, label = "directory", mode) { } } -export async function readManifest(appHome, modName) { +export async function readManifest(appHome) { try { - return await readManifestData(appHome, modName); + return await readManifestData(appHome); } catch (error) { throw new Error( `${error.message}\nNo target files were changed. To recover, preserve or move aside both ` + - `${modDir(appHome, modName)} and ${manifestPath(appHome, modName)}, then reinstall. ` + + `${modDir(appHome)} and ${manifestPath(appHome)}, then reinstall. ` + `Leave ${path.join(dataDir(appHome), "insights.json")} in place.`, { cause: error }, ); } } -async function readManifestData(appHome, modName) { - const file = manifestPath(appHome, modName); +async function readManifestData(appHome) { + const file = manifestPath(appHome); await assertNoSymlinkComponents(appHome, file); const stat = await lstatOrNull(file); if (!stat) return null; @@ -265,7 +256,7 @@ async function readManifestData(appHome, modName) { if (parsed.owner !== MANIFEST_OWNER) { throw new Error(`Install manifest belongs to another owner: ${String(parsed.owner)}`); } - if (parsed.target !== modInfo(modName).rel) { + if (parsed.target !== MOD_REL) { throw new Error(`Install manifest targets another folder: ${String(parsed.target)}`); } if (!parsed.files || typeof parsed.files !== "object" || Array.isArray(parsed.files)) { @@ -284,7 +275,7 @@ async function readManifestData(appHome, modName) { return { ...parsed, files, path: file }; } -export async function writeManifest(appHome, files, modName) { +export async function writeManifest(appHome, files) { const directory = dataDir(appHome); await ensureDirectory(appHome, directory, "Insights data directory"); @@ -303,11 +294,11 @@ export async function writeManifest(appHome, files, modName) { const manifest = { version: MANIFEST_VERSION, owner: MANIFEST_OWNER, - target: modInfo(modName).rel, + target: MOD_REL, installedAt: new Date().toISOString(), files: sortedFiles, }; - const file = manifestPath(appHome, modName); + const file = manifestPath(appHome); await assertNoSymlinkComponents(appHome, file); await writeFileAtomic(file, `${JSON.stringify(manifest, null, 2)}\n`, 0o644); return manifest; @@ -355,8 +346,8 @@ function isGeneratedSourceFile(name) { return name === "jsconfig.json" || name.endsWith(".d.ts"); } -export async function collectSourceFiles(sourceRoot, modName) { - const relativeMod = modInfo(modName).rel; +export async function collectSourceFiles(sourceRoot) { + const relativeMod = MOD_REL; const declaredModDir = path.join(path.resolve(sourceRoot), relativeMod); const declaredStat = await lstatOrNull(declaredModDir); if (!declaredStat) { diff --git a/herdr-insights/scripts/uninstall.mjs b/herdr-insights/scripts/uninstall.mjs index a3d3ea4..2171fe4 100644 --- a/herdr-insights/scripts/uninstall.mjs +++ b/herdr-insights/scripts/uninstall.mjs @@ -90,9 +90,9 @@ async function pruneOwnedDirectories(directories) { } async function prepareUninstall(appHome) { - const targetDirectory = modDir(appHome, "insights"); - const targetManifest = manifestPath(appHome, "insights"); - const manifest = await readManifest(appHome, "insights"); + const targetDirectory = modDir(appHome); + const targetManifest = manifestPath(appHome); + const manifest = await readManifest(appHome); if (!manifest) { const target = await lstatOrNull(targetDirectory); @@ -160,7 +160,7 @@ async function applyUninstall(plan) { const remainingEntries = Object.entries(remaining); if (remainingEntries.length > 0) { - await writeManifest(appHome, Object.fromEntries(remainingEntries), "insights"); + await writeManifest(appHome, Object.fromEntries(remainingEntries)); } else { const currentManifest = await lstatOrNull(targetManifest); if (currentManifest) { diff --git a/scripts/herdr-insights/check.mjs b/scripts/herdr-insights/check.mjs index 66f4978..b7aad50 100644 --- a/scripts/herdr-insights/check.mjs +++ b/scripts/herdr-insights/check.mjs @@ -9,7 +9,7 @@ import { collectSourceFiles, lstatOrNull, } from "../../herdr-insights/scripts/lib.mjs"; -import { INSIGHTS_MOD, inspectDistribution } from "./distribution.mjs"; +import { inspectDistribution } from "./distribution.mjs"; const TEST_ROOT = path.resolve(PACKAGE_ROOT, "../tests/herdr-insights"); const errors = []; @@ -201,22 +201,22 @@ async function checkDistribution() { notes.push(...result.notes); } -async function checkSourceIfPresent(modName) { - const sourceMod = path.join(PACKAGE_ROOT, "mods", modName); +async function checkSourceIfPresent() { + const sourceMod = path.join(PACKAGE_ROOT, "mods", "insights"); const stat = await lstatOrNull(sourceMod); if (!stat) { - fail(`mods/${modName} is required for the Herdr distribution`); + fail("mods/insights is required for the Herdr distribution"); return; } if (!stat.isDirectory() || stat.isSymbolicLink()) { - fail(`mods/${modName} must be a regular directory`); + fail("mods/insights must be a regular directory"); return; } try { - const source = await collectSourceFiles(PACKAGE_ROOT, modName); - notes.push(`mods/${modName} source tree validates (${source.files.length} files)`); + const source = await collectSourceFiles(PACKAGE_ROOT); + notes.push(`mods/insights source tree validates (${source.files.length} files)`); } catch (error) { - fail(`mods/${modName} source tree is invalid: ${error.message}`); + fail(`mods/insights source tree is invalid: ${error.message}`); } } @@ -235,7 +235,7 @@ await checkDistribution(); await checkDocs(); await checkScriptsAndTests(); await checkTestInventory(); -await checkSourceIfPresent(INSIGHTS_MOD); +await checkSourceIfPresent(); for (const note of notes) console.log(`Note: ${note}`); if (errors.length > 0) { for (const error of errors) console.error(`Error: ${error}`); diff --git a/scripts/herdr-insights/distribution.mjs b/scripts/herdr-insights/distribution.mjs index f8db876..adfc025 100644 --- a/scripts/herdr-insights/distribution.mjs +++ b/scripts/herdr-insights/distribution.mjs @@ -4,18 +4,6 @@ import path from "node:path"; import { lstatOrNull } from "../../herdr-insights/scripts/lib.mjs"; import { validatePng } from "./png.mjs"; -/** The one native Herdr mod this distribution ships. */ -export const INSIGHTS_MOD = "insights"; - -const ALLOWED_SCRIPTS = ["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]; - -function modEntries(entries) { - return entries - .filter((entry) => entry.isDirectory() && entry.name !== ".DS_Store") - .map((entry) => entry.name) - .sort(); -} - export async function inspectDistribution(packageRoot) { const errors = []; const notes = []; @@ -36,12 +24,10 @@ export async function inspectDistribution(packageRoot) { } try { - const mods = modEntries(await fs.readdir(path.join(packageRoot, "mods"), { withFileTypes: true })); - if (!mods.includes(INSIGHTS_MOD)) { - errors.push(`Distribution must contain mods/${INSIGHTS_MOD}`); - } + const mods = await fs.readdir(path.join(packageRoot, "mods")); + if (!mods.includes("insights")) errors.push("Distribution must contain mods/insights"); for (const name of mods) { - if (name !== INSIGHTS_MOD) { + if (name !== "insights" && name !== ".DS_Store") { errors.push(`Distribution ships one native mod; remove mods/${name}`); } } @@ -50,7 +36,7 @@ export async function inspectDistribution(packageRoot) { } try { - const allowed = new Set(ALLOWED_SCRIPTS); + const allowed = new Set(["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]); for (const name of await fs.readdir(path.join(packageRoot, "scripts"))) { if (!allowed.has(name)) errors.push(`Developer tooling must stay outside herdr-insights/scripts/: ${name}`); } @@ -68,7 +54,7 @@ export async function inspectDistribution(packageRoot) { try { const mod = JSON.parse( - await fs.readFile(path.join(packageRoot, `mods/${INSIGHTS_MOD}/mod.json`), "utf8"), + await fs.readFile(path.join(packageRoot, "mods/insights/mod.json"), "utf8"), ); if (mod.title !== "Insights" || mod.place !== "none") { errors.push('mod.json must use title "Insights" and place "none"'); diff --git a/tests/herdr-insights/toolbar-layout.test.mjs b/tests/herdr-insights/toolbar-layout.test.mjs index 94ac034..953ce99 100644 --- a/tests/herdr-insights/toolbar-layout.test.mjs +++ b/tests/herdr-insights/toolbar-layout.test.mjs @@ -6,7 +6,7 @@ import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs"; import { SINGLE_TOOLBAR_CONTENT } from "./install-fixtures.mjs"; test("the single Insights source installs the toolbar icon and no second mod", async () => { - const source = await collectSourceFiles(PACKAGE_ROOT, "insights"); + const source = await collectSourceFiles(PACKAGE_ROOT); assert.ok(source.files.some((file) => file.relative === "insights.svg")); assert.deepEqual(source.modJson, { place: "none", title: "Insights" }); const mods = (await fs.readdir(new URL("../../herdr-insights/mods/", import.meta.url))) diff --git a/tests/herdr-insights/ui-system.test.mjs b/tests/herdr-insights/ui-system.test.mjs index 56d2d38..b86d6a8 100644 --- a/tests/herdr-insights/ui-system.test.mjs +++ b/tests/herdr-insights/ui-system.test.mjs @@ -235,6 +235,6 @@ test("the shared visual module is theme-driven, shadow-free and part of the inst assert.doesNotMatch(mainSource, /shadow_md|shadow_sm|shadow_lg/); has(mainSource, /from "\.\/ui\.js"/); - const source = await collectSourceFiles(PACKAGE_ROOT, "insights"); + const source = await collectSourceFiles(PACKAGE_ROOT); assert.equal(source.files.some((file) => file.relative === "ui.js"), true, "installer copies mods/insights recursively"); }); From e9d2623871dfed8b348934e05dd03552b3137049 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Mon, 2 Nov 2026 14:35:31 +0530 Subject: [PATCH 4/5] Read toolbar mode from Herdr per-copy props --- herdr-insights/RECIPE.md | 4 ++-- herdr-insights/mods/insights/main.js | 6 +++--- tests/herdr-insights/analytics-ui.test.mjs | 16 +++++++++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/herdr-insights/RECIPE.md b/herdr-insights/RECIPE.md index 377c980..a7ce2f0 100644 --- a/herdr-insights/RECIPE.md +++ b/herdr-insights/RECIPE.md @@ -87,8 +87,8 @@ The recipe heading and `mod.json` title are **Insights**; the native mod ID is `mods/insights/mod.json` contains `{"place":"none","title":"Insights"}`. `main.js` default-exports one native View using gpui-base flex builders, `theme()`, -and `run()`. Read `props.mode`: `"toolbar"` renders only the button and its click -handler; every other value renders the dashboard and runs its collectors. The +and `run()`. Read Herdr's `props()?.mode`: `"toolbar"` renders only the button and +its click handler; every other value renders the dashboard and runs its collectors. The button's chart icon ships in the same tree. `scripts/toolbar.mjs` integrates through layout composition, not source patching. diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index 71252af..f4ba521 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -1,7 +1,7 @@ // Insights: a native, local-only usage dashboard. No DOM, no shell, no stored cx. import { View, div, svg } from "gpui-kit"; import { v_flex, h_flex, Button, Input, InputState, Editor, EditorState, Popover } from "gpui-base"; -import { exec, appHome, home, theme, settings, nextCommand, panes, run, readTextFile, spaces } from "herdr"; +import { exec, appHome, home, theme, settings, nextCommand, panes, props, run, readTextFile, spaces } from "herdr"; import { PALETTES, clampRefreshSeconds, @@ -436,8 +436,8 @@ function preferenceRow(label, control, c) { } export default class Insights extends View { - init(props, cx) { - this.mode = props?.mode === TOOLBAR_MODE ? TOOLBAR_MODE : "dashboard"; + init(_props, cx) { + this.mode = props()?.mode === TOOLBAR_MODE ? TOOLBAR_MODE : "dashboard"; this.closed = false; // The toolbar is a button, not a dashboard copy: no runtime read, collector, // command loop, timer or editor state belongs to it. diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index e04afc5..e978ec2 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -110,6 +110,7 @@ const nextCommand = () => globalThis.__insightsTestHerdr.nextCommand(); const exec = (...args) => globalThis.__insightsTestHerdr.exec(...args); const readTextFile = (...args) => globalThis.__insightsTestHerdr.readTextFile(...args); const panes = () => globalThis.__insightsTestHerdr.panes(); +const props = () => globalThis.__insightsTestHerdr.props(); const run = (...args) => globalThis.__insightsTestHerdr.run(...args); const spaces = () => globalThis.__insightsTestHerdr.spaces();`, ) @@ -146,7 +147,7 @@ function presentationFor(snapshot, options, command = "snapshot") { } function useHerdr({ readTextFile, execResult } = {}) { - const state = { commands: [], execCalls: [], panes: [], runtimeReads: [], spaces: [] }; + const state = { commands: [], execCalls: [], panes: [], props: {}, runtimeReads: [], spaces: [] }; state.herdr = { appHome: () => "/app", home: () => "/home", @@ -154,6 +155,7 @@ function useHerdr({ readTextFile, execResult } = {}) { settings: () => ({}), nextCommand: () => new Promise(() => {}), panes: () => state.panes, + props: () => state.props, run: (...args) => state.commands.push(args), spaces: () => state.spaces, readTextFile: async (path) => { @@ -341,7 +343,8 @@ test("toolbar init returns before runtime, collector, command, timer or editor s globalThis.__insightsTestHostCalls.editorStates = 0; const { cx, calls } = liveCx(); const instance = new Insights(); - instance.init({ mode: "toolbar" }, cx); + state.props = { mode: "toolbar" }; + instance.init(undefined, cx); assert.equal(instance.mode, "toolbar"); assert.deepEqual(state.runtimeReads, []); @@ -362,7 +365,8 @@ test("toolbar render is only the native Insights button and has no side effects" state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; state.panes = []; const instance = new Insights(); - instance.init({ mode: "toolbar" }, fakeCx()); + state.props = { mode: "toolbar" }; + instance.init({}, fakeCx()); const root = instance.render(); const button = root.childNodes[0]; @@ -391,7 +395,8 @@ test("toolbar reuses the active space's dashboard and reveals it behind another { pane: "dashboard", mod: "insights", tab: "t2", focused: false, zoomed: false }, ]; const instance = new Insights(); - instance.init({ mode: "toolbar" }, fakeCx()); + state.props = { mode: "toolbar" }; + instance.init({}, fakeCx()); const button = instance.render().childNodes[0]; const click = button.calls.find(([name]) => name === "on_click")[1][0]; @@ -410,7 +415,8 @@ test("toolbar keeps an already zoomed dashboard and opens a tab only when the sp state.spaces = [{ id: "s1", active: true, tabs: [{ id: "t1" }] }]; state.panes = [{ pane: "dashboard", mod: "insights", tab: "t1", zoomed: true }]; const instance = new Insights(); - instance.init({ mode: "toolbar" }, fakeCx()); + state.props = { mode: "toolbar" }; + instance.init({}, fakeCx()); const click = instance.render().childNodes[0].calls.find(([name]) => name === "on_click")[1][0]; click(); From e97b2881f86310b292b70d14bab927f6373447eb Mon Sep 17 00:00:00 2001 From: GaganSD Date: Mon, 2 Nov 2026 15:04:18 +0530 Subject: [PATCH 5/5] Trim redundant single-mod checks and state --- herdr-insights/RECIPE.md | 8 ++------ herdr-insights/mods/insights/main.js | 9 ++++----- herdr-insights/scripts/install.mjs | 5 ++--- scripts/herdr-insights/check.mjs | 14 ++------------ tests/herdr-insights/analytics-ui.test.mjs | 8 ++++---- tests/herdr-insights/distribution.test.mjs | 2 +- tests/herdr-insights/install-safety.test.mjs | 1 - tests/herdr-insights/toolbar-install.test.mjs | 2 -- tests/herdr-insights/toolbar-layout.test.mjs | 7 +------ 9 files changed, 16 insertions(+), 40 deletions(-) diff --git a/herdr-insights/RECIPE.md b/herdr-insights/RECIPE.md index a7ce2f0..3158a1e 100644 --- a/herdr-insights/RECIPE.md +++ b/herdr-insights/RECIPE.md @@ -12,9 +12,7 @@ Installing this existing build? Follow [INSTALL.md](INSTALL.md) and the user's Herdr `AGENTS.md`. This recipe is a rebuild brief; its **Done when** checks apply to building/releasing, not routine installation. Installation does not launch smoke-test windows or open a dashboard tab unless requested. First installation -selects a personal layout with the button in the existing toolbar row. The button -is a `mode: "toolbar"` view of the `insights` mod; any other pane shows the -dashboard. +selects a personal layout with the button in the existing toolbar row. **What you see:** A vertically scrollable dashboard centered at a maximum width of 1180px. Use Herdr theme colors, 1px borders, 8px card corners, 6px control @@ -184,8 +182,6 @@ code/layouts are unchanged and reinstall does not duplicate the toolbar. Also ru native smoke checks in temporary app homes. Inspect a native screenshot and status for render errors. Verify actual filters, heatmap modes, source settings, refresh, and empty/error states. Reinstall without changing source transcripts, existing -layouts, keys, local edits, or `insights.json`. Confirm the current normal install -has one `install-manifest.json` and the dashboard `runtime.json`. Keep the GitHub -repo public and add topic `herdr-mod` for Community discovery after merging to the +layouts, keys, local edits, or `insights.json`. Keep the GitHub repo public and add topic `herdr-mod` for Community discovery after merging to the default branch; allow up to an hour for indexing and at least 24 hours for picture screening. diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index f4ba521..c48ce35 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -49,7 +49,6 @@ const MIN_CELL = 12; const MAX_CELL = 20; const CELL_GAP = 2; const DAY_LABEL_WIDTH = 30; -const TOOLBAR_MODE = "toolbar"; const TAB = { name: "Insights", mod: "insights" }; /** One Insights tab per space: focus it if it exists, otherwise create it. */ @@ -437,11 +436,11 @@ function preferenceRow(label, control, c) { export default class Insights extends View { init(_props, cx) { - this.mode = props()?.mode === TOOLBAR_MODE ? TOOLBAR_MODE : "dashboard"; + this.toolbarMode = props()?.mode === "toolbar"; this.closed = false; // The toolbar is a button, not a dashboard copy: no runtime read, collector, // command loop, timer or editor state belongs to it. - if (this.mode === TOOLBAR_MODE) return; + if (this.toolbarMode) return; this.snapshot = null; this.loading = false; @@ -537,7 +536,7 @@ export default class Insights extends View { close() { this.closed = true; - if (this.mode === TOOLBAR_MODE) return; + if (this.toolbarMode) return; this.cancelDashboardWork(); this.activityGeneration = (this.activityGeneration ?? 0) + 1; try { this.activityTask?.cancel(); } catch { /* already gone */ } @@ -1306,7 +1305,7 @@ export default class Insights extends View { } render(_cx) { - if (this.mode === TOOLBAR_MODE) return this.toolbar(); + if (this.toolbarMode) return this.toolbar(); const c = tokens(theme()); const width = this.width || 960; const padding = width < 620 ? G.md : width < 900 ? G.lg : G.xl; diff --git a/herdr-insights/scripts/install.mjs b/herdr-insights/scripts/install.mjs index dc22478..2f77334 100644 --- a/herdr-insights/scripts/install.mjs +++ b/herdr-insights/scripts/install.mjs @@ -339,9 +339,8 @@ async function main() { // during validation before replacing any live files. plan = await prepareInstall(appHome, plan.source, options.force); await applyInstall(plan); - if (toolbar) await applyToolbar(toolbar); - // Keep the previously documented flag working for existing install scripts. - // Default installation still never opens a tab. + await applyToolbar(toolbar); + // Default installation never opens a tab. if (options.open) { const opened = await runHerdr(["do", "tab.new", '{"name":"Insights","mod":"insights"}'], { env: { HERDR_APP_HOME: appHome }, diff --git a/scripts/herdr-insights/check.mjs b/scripts/herdr-insights/check.mjs index b7aad50..1f35a15 100644 --- a/scripts/herdr-insights/check.mjs +++ b/scripts/herdr-insights/check.mjs @@ -201,17 +201,7 @@ async function checkDistribution() { notes.push(...result.notes); } -async function checkSourceIfPresent() { - const sourceMod = path.join(PACKAGE_ROOT, "mods", "insights"); - const stat = await lstatOrNull(sourceMod); - if (!stat) { - fail("mods/insights is required for the Herdr distribution"); - return; - } - if (!stat.isDirectory() || stat.isSymbolicLink()) { - fail("mods/insights must be a regular directory"); - return; - } +async function checkSource() { try { const source = await collectSourceFiles(PACKAGE_ROOT); notes.push(`mods/insights source tree validates (${source.files.length} files)`); @@ -235,7 +225,7 @@ await checkDistribution(); await checkDocs(); await checkScriptsAndTests(); await checkTestInventory(); -await checkSourceIfPresent(); +await checkSource(); for (const note of notes) console.log(`Note: ${note}`); if (errors.length > 0) { for (const error of errors) console.error(`Error: ${error}`); diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index e978ec2..e3135ae 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -15,10 +15,10 @@ function fakeNativeElement() { const node = { calls: [], childNodes: [], style: {} }; let element; const methods = { - child(value) { node.calls.push(["child", [value]]); node.childNodes.push(value); return element; }, - children(values) { node.calls.push(["children", [values]]); node.childNodes.push(...values); return element; }, + child(value) { node.childNodes.push(value); return element; }, + children(values) { node.childNodes.push(...values); return element; }, when(condition, branch) { if (condition) branch(element); return element; }, - w(value) { node.style.width = value; node.calls.push(["w", [value]]); return element; }, + w(value) { node.style.width = value; return element; }, whitespace_nowrap() { node.style.whitespace = "nowrap"; return element; }, items_start() { node.style.alignItems = "start"; return element; }, flex_wrap() { node.style.flexWrap = "wrap"; return element; }, @@ -346,7 +346,7 @@ test("toolbar init returns before runtime, collector, command, timer or editor s state.props = { mode: "toolbar" }; instance.init(undefined, cx); - assert.equal(instance.mode, "toolbar"); + assert.equal(instance.toolbarMode, true); assert.deepEqual(state.runtimeReads, []); assert.deepEqual(state.execCalls, []); assert.deepEqual(calls.timers, []); diff --git a/tests/herdr-insights/distribution.test.mjs b/tests/herdr-insights/distribution.test.mjs index e96dba1..6f63f3f 100644 --- a/tests/herdr-insights/distribution.test.mjs +++ b/tests/herdr-insights/distribution.test.mjs @@ -23,7 +23,6 @@ test("distribution ships one native insights mod and no separate launcher", asyn const modEntries = (await readdir(path.join(root, "mods"))).filter((name) => name !== ".DS_Store").sort(); assert.deepEqual(modEntries, ["insights"], "one mod owns the dashboard and its toolbar button; a second launcher must not ship"); - await assert.rejects(lstat(path.join(root, "mods/insights-launcher")), { code: "ENOENT" }); const docs = await Promise.all(["README.md", "INSTALL.md", "RECIPE.md"].map(read)); const development = await readFile( @@ -108,6 +107,7 @@ test("shared distribution excludes developer tests, fixtures, and smoke tooling" await assert.rejects(lstat(path.join(root, "tests")), { code: "ENOENT" }); assert.deepEqual((await readdir(path.join(root, "scripts"))).sort(), ["install.mjs", "lib.mjs", "toolbar.mjs", "uninstall.mjs"]); + assert.deepEqual((await inspectDistribution(root)).errors, []); }); test("distribution inspection rejects developer tooling in the shared folder", async (t) => { diff --git a/tests/herdr-insights/install-safety.test.mjs b/tests/herdr-insights/install-safety.test.mjs index a465636..207472b 100644 --- a/tests/herdr-insights/install-safety.test.mjs +++ b/tests/herdr-insights/install-safety.test.mjs @@ -95,7 +95,6 @@ test("a fresh check failure leaves no live mod installed", async (t) => { assertFailure(runInstall({ appHome, fake, sourceRoot, fail: "check" })); assert.deepEqual(await snapshotTree(appHome), before); assert.equal(await exists(path.join(appHome, "mods/insights")), false); - assert.equal(await exists(path.join(appHome, "mods/insights-launcher")), false); }); test("concurrent install and uninstall fail closed while another operation holds the lock", async (t) => { diff --git a/tests/herdr-insights/toolbar-install.test.mjs b/tests/herdr-insights/toolbar-install.test.mjs index 70a721b..1906711 100644 --- a/tests/herdr-insights/toolbar-install.test.mjs +++ b/tests/herdr-insights/toolbar-install.test.mjs @@ -61,7 +61,6 @@ test("standalone distribution installs one mod and a single-mod toolbar without ["do", "layout.clone", '{"name":"main","as":"main-insights"}'], ]); assert.deepEqual((await fs.readdir(path.join(appHome, "mods"))).sort(), ["insights"]); - await assert.rejects(fs.lstat(path.join(appHome, "mods/insights-launcher")), { code: "ENOENT" }); await assert.rejects(fs.lstat(path.join(appHome, "data/insights/launcher-install-manifest.json")), { code: "ENOENT" }); const layout = await readJson(path.join(appHome, "layouts/main-insights.json")); assert.deepEqual(toolbarButton(layout).content, SINGLE_TOOLBAR_CONTENT); @@ -82,7 +81,6 @@ test("one mod reinstalls idempotently and uninstall preserves data and local edi await assertStagedChecks(log, ["insights"], appHome); const manifest = await readJson(path.join(appHome, "data/insights/install-manifest.json")); assert.equal(manifest.target, "mods/insights"); - assert.equal(await exists(path.join(appHome, "data/insights/launcher-install-manifest.json")), false); assert.deepEqual(toolbarButton(await readJson(path.join(appHome, "layouts/main-insights.json"))).content, SINGLE_TOOLBAR_CONTENT); diff --git a/tests/herdr-insights/toolbar-layout.test.mjs b/tests/herdr-insights/toolbar-layout.test.mjs index 953ce99..44fd3a1 100644 --- a/tests/herdr-insights/toolbar-layout.test.mjs +++ b/tests/herdr-insights/toolbar-layout.test.mjs @@ -1,17 +1,12 @@ import assert from "node:assert/strict"; -import { promises as fs } from "node:fs"; import test from "node:test"; import { collectSourceFiles, PACKAGE_ROOT } from "../../herdr-insights/scripts/lib.mjs"; import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs"; import { SINGLE_TOOLBAR_CONTENT } from "./install-fixtures.mjs"; -test("the single Insights source installs the toolbar icon and no second mod", async () => { +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")); - assert.deepEqual(source.modJson, { place: "none", title: "Insights" }); - const mods = (await fs.readdir(new URL("../../herdr-insights/mods/", import.meta.url))) - .filter((name) => name !== ".DS_Store"); - assert.deepEqual(mods, ["insights"]); }); test("layout composition keeps the original tabs code and points the toolbar at one mod", () => {