From 0979ca818eea768fd0d8e3b3820ee32929df256e Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 00:40:43 -0400 Subject: [PATCH 1/9] Add a Power Efficiency control for scan workers A header gauge next to Rescan sets how many workers the native scanner walks with: Miser 2, Balanced 4, Aggressive 8, Drain My Battery every logical CPU, each capped by the CPU count. A preset that would run no more workers than the one below it is shown but not offered. The same control is in Settings > Scanning. The choice is saved as scanning.powerEfficiency through its own IPC call, and main reads it when a scan starts, so a running scan keeps its workers. Main passes it as --workers; both the Unix walker and the Windows parallel walker take it, and DISKHOUND_PARALLEL_THREADS still overrides it. SettingsView now follows settings pushes, so a save there can't write an older preset back. Toasts can carry one action button, used for "Rescan now". --- e2e/power-efficiency.spec.ts | 39 ++ native/diskhound-native-scanner/src/main.rs | 64 +++- .../src/test_support.rs | 1 + .../main.powerEfficiency.ioBudget.test.ts | 142 +++++++ .../renderer.powerEfficiency.ioBudget.test.ts | 274 ++++++++++++++ src/main.ts | 22 ++ src/nativeScanner.ts | 3 + src/preload.ts | 4 + src/renderer/App.tsx | 7 + .../components/PowerEfficiencyControl.tsx | 357 ++++++++++++++++++ src/renderer/components/SettingsView.tsx | 30 +- src/renderer/components/Toasts.tsx | 34 +- src/renderer/index.css | 166 +++++++- src/renderer/nativeApi.ts | 3 + src/shared/__tests__/powerEfficiency.test.ts | 89 +++++ src/shared/contracts.ts | 24 ++ src/shared/powerEfficiency.ts | 92 +++++ 17 files changed, 1331 insertions(+), 20 deletions(-) create mode 100644 e2e/power-efficiency.spec.ts create mode 100644 src/__tests__/main.powerEfficiency.ioBudget.test.ts create mode 100644 src/__tests__/renderer.powerEfficiency.ioBudget.test.ts create mode 100644 src/renderer/components/PowerEfficiencyControl.tsx create mode 100644 src/shared/__tests__/powerEfficiency.test.ts create mode 100644 src/shared/powerEfficiency.ts diff --git a/e2e/power-efficiency.spec.ts b/e2e/power-efficiency.spec.ts new file mode 100644 index 00000000..02de3eab --- /dev/null +++ b/e2e/power-efficiency.spec.ts @@ -0,0 +1,39 @@ +import { availableParallelism } from "node:os"; + +import { expect, test } from "./fixtures/electron-app"; +import { scanFolderFromPicker } from "./fixtures/steps"; + +// The header's Power Efficiency menu saves a preset, and the next scan's +// walker runs that many workers. The Unix walker logs its count to +// crash.log. The Windows walkers hand a tree this small to the +// sequential walker, which logs none. +test("a Power Efficiency choice reaches the next scan's walker", async ({ launch, scanTree }) => { + test.skip(process.platform === "win32", "the Windows walker logs no worker count for a small tree"); + const cpus = availableParallelism(); + const handle = await launch(); + const { page } = handle; + const walking = async () => ((await page.evaluate(() => window.diskhound.getCrashLog())).text + .match(/walking with dua-core, \d+ threads/g) ?? []); + + await scanFolderFromPicker(handle, scanTree.root); + // Aggressive by default: 8 workers, or every CPU on a smaller machine. + expect(await walking()).toEqual([`walking with dua-core, ${Math.min(8, cpus)} threads`]); + + const gauge = page.locator(".header .power-btn"); + await expect(gauge).toHaveAttribute("aria-label", /^Power Efficiency: (Aggressive|Balanced|Miser), /); + await gauge.click(); + await expect(page.getByRole("menu", { name: "Power Efficiency" })).toBeVisible(); + await page.getByRole("menuitemradio", { name: /^Miser/ }).click(); + await expect(page.getByRole("menu", { name: "Power Efficiency" })).toBeHidden(); + const miser = Math.min(2, cpus); + await expect(page.locator(".toast-title")).toHaveText(`Miser (${miser} worker${miser === 1 ? "" : "s"}) from the next scan`); + await expect(gauge.locator(".power-gauge i.lit")).toHaveCount(1); + + await page.getByRole("button", { name: "Rescan now" }).click(); + await expect.poll(walking, { timeout: 45_000 }).toEqual([ + `walking with dua-core, ${Math.min(8, cpus)} threads`, + `walking with dua-core, ${miser} threads`, + ]); + const settings = await page.evaluate(() => window.diskhound.getSettings()); + expect(settings.scanning.powerEfficiency).toBe("miser"); +}); diff --git a/native/diskhound-native-scanner/src/main.rs b/native/diskhound-native-scanner/src/main.rs index ee07dc1a..d6f69cdf 100644 --- a/native/diskhound-native-scanner/src/main.rs +++ b/native/diskhound-native-scanner/src/main.rs @@ -118,6 +118,10 @@ struct ScanInput { /// which it has no other use for. #[cfg_attr(windows, allow(dead_code))] expected_total_files: Option, + /// Walker workers, from the app's Power Efficiency setting + /// (`--workers`). None keeps the walker's own default. See + /// [`walk_workers`]. + workers: Option, } /// Disk touches one scan makes. The visit-once tests check these against @@ -1542,11 +1546,7 @@ fn scan_generic_with_plan( // 8, but 16 used 31% more CPU time (995 s vs 759 s) and peaked near // 1,000% CPU instead of 650%. Past 8, extra threads mostly add kernel // time. DISKHOUND_PARALLEL_THREADS overrides it. - let thread_override = std::env::var("DISKHOUND_PARALLEL_THREADS") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&n| n >= 1); - let thread_count = thread_override.unwrap_or_else(|| { + let thread_count = walk_workers(state.input.workers, { let logical = num_cpus::get().max(1); if logical <= 2 { logical @@ -3018,11 +3018,7 @@ fn try_scan_windows_parallel(root_path: &Path, state: &mut ScanState) -> Paralle } // Step 2 — set up parallel walk. - let worker_count = std::env::var("DISKHOUND_PARALLEL_THREADS") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&n| n >= 1) - .unwrap_or_else(|| num_cpus::get().clamp(2, 8)); + let worker_count = walk_workers(state.input.workers, num_cpus::get().clamp(2, 8)); // Don't clamp to root_children.len(). Extra workers block on the // Condvar waiting for subdirs to appear in the queue, then steal // them as the initial workers enumerate. Clamping here would starve @@ -3980,6 +3976,7 @@ fn parse_args() -> Result { let mut folder_tree_output: Option = None; let mut dev_artifacts_output: Option = None; let mut expected_total_files: Option = None; + let mut workers: Option = None; let mut args = std::env::args().skip(1); while let Some(argument) = args.next() { @@ -4038,6 +4035,18 @@ fn parse_args() -> Result { value.parse::().map_err(|_| format!("Invalid --expected-files: {value}"))?, ); } + "--workers" => { + let value = args + .next() + .ok_or_else(|| String::from("Expected a number after --workers"))?; + workers = Some( + value + .parse::() + .ok() + .filter(|&n| n >= 1) + .ok_or_else(|| format!("Invalid --workers: {value}"))?, + ); + } unknown => { return Err(format!("Unknown argument: {unknown}")); } @@ -4058,9 +4067,25 @@ fn parse_args() -> Result { folder_tree_output, dev_artifacts_output, expected_total_files, + workers, }) } +/// How many workers a walker runs. `DISKHOUND_PARALLEL_THREADS` wins, for +/// experiments; then `--workers`, the app's Power Efficiency choice; then +/// the walker's own `default`. +fn walk_workers(requested: Option, default: usize) -> usize { + let env = std::env::var("DISKHOUND_PARALLEL_THREADS").ok(); + choose_walk_workers(env.as_deref(), requested, default) +} + +fn choose_walk_workers(env: Option<&str>, requested: Option, default: usize) -> usize { + let from_env = env + .and_then(|s| s.trim().parse::().ok()) + .filter(|&n| n >= 1); + from_env.or(requested).unwrap_or(default).max(1) +} + fn file_extension(file_name: &str) -> String { Path::new(file_name) .extension() @@ -4850,6 +4875,24 @@ fn windows_extended_path(path: &Path) -> String { format!(r"\\?\{}", normalized) } +#[cfg(test)] +mod walk_workers_tests { + use super::choose_walk_workers; + + #[test] + fn the_env_override_wins_then_the_app_choice_then_the_walker_default() { + assert_eq!(choose_walk_workers(None, None, 8), 8); + assert_eq!(choose_walk_workers(None, Some(2), 8), 2); + assert_eq!(choose_walk_workers(None, Some(18), 8), 18); + assert_eq!(choose_walk_workers(Some("12"), Some(2), 8), 12); + assert_eq!(choose_walk_workers(Some(" 3 "), None, 8), 3); + // A bad or zero override is ignored, as before. + assert_eq!(choose_walk_workers(Some("0"), Some(4), 8), 4); + assert_eq!(choose_walk_workers(Some("lots"), None, 8), 8); + assert_eq!(choose_walk_workers(None, None, 0), 1); + } +} + #[cfg(test)] mod index_line_parse_tests { use super::index_line::parse_index_line; @@ -4989,6 +5032,7 @@ mod scaling_tests { folder_tree_output: None, dev_artifacts_output: None, expected_total_files: None, + workers: None, }; let mut state = ScanState::new(input, root, None, None, Arc::new(IoStats::default())); // Never emit: tests call refresh_hottest_directories directly. diff --git a/native/diskhound-native-scanner/src/test_support.rs b/native/diskhound-native-scanner/src/test_support.rs index c196c36d..39f042a3 100644 --- a/native/diskhound-native-scanner/src/test_support.rs +++ b/native/diskhound-native-scanner/src/test_support.rs @@ -73,6 +73,7 @@ pub fn scan_input(root: &Path, index_output: &Path) -> ScanInput { folder_tree_output: None, dev_artifacts_output: None, expected_total_files: None, + workers: None, } } diff --git a/src/__tests__/main.powerEfficiency.ioBudget.test.ts b/src/__tests__/main.powerEfficiency.ioBudget.test.ts new file mode 100644 index 00000000..a937e93e --- /dev/null +++ b/src/__tests__/main.powerEfficiency.ioBudget.test.ts @@ -0,0 +1,142 @@ +import * as FS from "node:fs"; +import "node:fs/promises"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { AppSettings } from "../shared/contracts"; +import { powerEfficiencyWorkers } from "../shared/powerEfficiency"; +import { expectIoBudget, measureFsIo } from "../test/ioBudget"; +import { bootMainProcess, type MainProcess } from "../test/mainProcessHarness"; + +vi.mock("node:fs", async (importOriginal) => + (await import("../test/ioBudget")).instrumentFs(await importOriginal())); +vi.mock("node:fs/promises", async (importOriginal) => + (await import("../test/ioBudget")).instrumentFsPromises(await importOriginal())); +vi.mock("node:child_process", async (importOriginal) => + (await import("../test/ioBudget")).instrumentChildProcess(await importOriginal())); +vi.mock("node:worker_threads", async (importOriginal) => + (await import("../test/ioBudget")).instrumentWorkerThreads(await importOriginal())); +vi.mock("electron", async () => + (await import("../test/mainProcessHarness")).fakeElectron()); +vi.mock("../shared/crashLog", async (importOriginal) => + (await import("../test/mainProcessHarness")).settledCrashLog(await importOriginal())); + +const CPUS = OS.availableParallelism(); + +let main: MainProcess; + +beforeAll(async () => { + main = await bootMainProcess(); +}, 60_000); + +const settingsOnDisk = (): AppSettings => + JSON.parse(FS.readFileSync(Path.join(main.userData, "settings.json"), "utf8")) as AppSettings; + +describe("choosing a Power Efficiency preset", () => { + it("saves it once, and a repeat or a bad value writes nothing", async () => { + await main.invoke("diskhound:set-power-efficiency", "miser"); + + const choose = await measureFsIo( + () => main.invoke("diskhound:set-power-efficiency", "balanced"), + { countProcesses: true }, + ); + expect(choose.result.scanning.powerEfficiency).toBe("balanced"); + expect(settingsOnDisk().scanning.powerEfficiency).toBe("balanced"); + expectIoBudget({ + scenario: "main-power-efficiency-choose", + note: "a menu choice: one settings.json rewrite (~2 KB), no process; only on a click, so a handful of writes a day at most, at any monitoring interval", + io: choose.io, + }); + + const same = await measureFsIo(async () => { + await main.invoke("diskhound:set-power-efficiency", "balanced"); + await expect(main.invoke("diskhound:set-power-efficiency", "turbo")).rejects.toThrow(/Unknown Power Efficiency/); + }, { countProcesses: true }); + expect(settingsOnDisk().scanning.powerEfficiency).toBe("balanced"); + expectIoBudget({ + scenario: "main-power-efficiency-choose-same", + note: "choosing the saved preset again, then an unknown one: 0 writes", + io: same.io, + }); + }); +}); + +// The fake scanner is a shell script, which Windows can't spawn without a +// shell. What it checks, when main reads the setting, is the same there. +describe.skipIf(process.platform === "win32")("a scan and the Power Efficiency setting", () => { + let scratch: string; + let log: string; + const saved = { + path: process.env.DISKHOUND_NATIVE_SCANNER_PATH, + log: process.env.DISKHOUND_FAKE_SCANNER_LOG, + }; + + beforeAll(() => { + scratch = FS.mkdtempSync(Path.join(OS.tmpdir(), "diskhound-power-")); + log = Path.join(scratch, "spawns.log"); + const scanner = Path.join(scratch, "fake-scanner"); + // Records its pid and arguments, then waits to be stopped, as a + // scanner on a big drive would. + FS.writeFileSync(scanner, '#!/bin/sh\necho "$$ $*" >> "$DISKHOUND_FAKE_SCANNER_LOG"\nexec sleep 600\n', { mode: 0o755 }); + FS.mkdirSync(Path.join(scratch, "root")); + process.env.DISKHOUND_NATIVE_SCANNER_PATH = scanner; + process.env.DISKHOUND_FAKE_SCANNER_LOG = log; + }); + + afterAll(() => { + for (const [name, value] of [["DISKHOUND_NATIVE_SCANNER_PATH", saved.path], ["DISKHOUND_FAKE_SCANNER_LOG", saved.log]] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + FS.rmSync(scratch, { recursive: true, force: true }); + }); + + const spawns = () => (FS.existsSync(log) ? FS.readFileSync(log, "utf8").trim().split("\n") : []) + .map((line) => { + const [pid, ...args] = line.split(" "); + const at = args.indexOf("--workers"); + return { pid: Number(pid), workers: at >= 0 ? Number(args[at + 1]) : null }; + }); + const waitForSpawns = async (count: number) => { + const startedAt = Date.now(); + while (spawns().length < count) { + if (Date.now() - startedAt > 10_000) throw new Error(`expected ${count} scanner spawns, saw ${spawns().length}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return spawns(); + }; + const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + it("leaves a running scan alone and gives the next one the new workers", async () => { + const root = Path.join(scratch, "root"); + await main.invoke("diskhound:set-power-efficiency", "miser"); + await main.invoke("diskhound:start-scan", root, {}); + const [running] = await waitForSpawns(1); + expect(running!.workers).toBe(powerEfficiencyWorkers("miser", CPUS)); + + await main.invoke("diskhound:set-power-efficiency", "drain-my-battery"); + // Give a restart, if there were one, time to happen. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(spawns()).toHaveLength(1); + expect(alive(running!.pid)).toBe(true); + expect(await main.invoke("diskhound:get-active-scan-roots")).toEqual([root]); + + // The user's rescan, after the choice. + await main.invoke("diskhound:start-scan", root, {}); + const [, next] = await waitForSpawns(2); + expect(next!.workers).toBe(powerEfficiencyWorkers("drain-my-battery", CPUS)); + expect(alive(running!.pid)).toBe(false); + + await main.invoke("diskhound:cancel-scan", root); + expect(await main.invoke("diskhound:get-active-scan-roots")).toEqual([]); + }); +}); diff --git a/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts b/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts new file mode 100644 index 00000000..e5d18ce9 --- /dev/null +++ b/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts @@ -0,0 +1,274 @@ +// @vitest-environment happy-dom +import * as FS from "node:fs"; +import "node:fs/promises"; +import * as Path from "node:path"; + +import { h } from "preact"; +import { act } from "preact/test-utils"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { + PowerEfficiencyControl, + PowerEfficiencyMenuButton, +} from "../renderer/components/PowerEfficiencyControl"; +import { ToastProvider } from "../renderer/components/Toasts"; +import type { AppSettings } from "../shared/contracts"; +import { + DEFAULT_POWER_EFFICIENCY, + powerEfficiencyLabel, + powerEfficiencySignal, + powerEfficiencyWorkers, + type PowerEfficiency, +} from "../shared/powerEfficiency"; +import { expectIoBudget, measureFsIo } from "../test/ioBudget"; +import { bootMainProcess, type MainProcess } from "../test/mainProcessHarness"; +import { bootRenderer, button, click, one, type Renderer } from "../test/rendererHarness"; + +vi.mock("node:fs", async (importOriginal) => + (await import("../test/ioBudget")).instrumentFs(await importOriginal())); +vi.mock("node:fs/promises", async (importOriginal) => + (await import("../test/ioBudget")).instrumentFsPromises(await importOriginal())); +vi.mock("node:child_process", async (importOriginal) => + (await import("../test/ioBudget")).instrumentChildProcess(await importOriginal())); +vi.mock("node:worker_threads", async (importOriginal) => + (await import("../test/ioBudget")).instrumentWorkerThreads(await importOriginal())); +vi.mock("electron", async () => + (await import("../test/mainProcessHarness")).fakeElectron()); +vi.mock("../shared/crashLog", async (importOriginal) => + (await import("../test/mainProcessHarness")).settledCrashLog(await importOriginal())); + +let main: MainProcess; +let ui: Renderer; + +beforeAll(async () => { + main = await bootMainProcess(); + ui = await bootRenderer(); +}, 60_000); + +const menu = (root: ParentNode) => root.querySelector(".power-menu"); +const row = (root: ParentNode, name: string) => { + const match = [...root.querySelectorAll(".power-menu-item")] + .find((item) => item.querySelector(".power-menu-name")?.textContent === name); + if (!match) throw new Error(`no ${name} row`); + return match; +}; +const highlighted = (root: ParentNode) => + root.querySelector(".power-menu-item.highlighted .power-menu-name")?.textContent ?? null; +const litBars = (root: ParentNode) => root.querySelectorAll(".power-gauge i.lit").length; + +async function key(target: Element, name: string): Promise { + const event = new KeyboardEvent("keydown", { key: name, bubbles: true, cancelable: true }); + await act(() => { + target.dispatchEvent(event); + }); + return event; +} +async function pointer(target: Element, kind: "mousedown" | "mouseenter" | "mouseleave"): Promise { + await act(() => { + target.dispatchEvent(new MouseEvent(kind, { bubbles: kind === "mousedown", cancelable: true })); + }); +} +/** A real press: mousedown (where outside-click handlers look), then click. */ +async function press(target: Element): Promise { + await pointer(target, "mousedown"); + await click(target); +} +const wait = (ms: number) => act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); + +function mountButton(preset: PowerEfficiency, cpus: number) { + const chosen: PowerEfficiency[] = []; + const view = ui.mount(h(PowerEfficiencyMenuButton, { preset, cpus, platform: "darwin", onChoose: (p: PowerEfficiency) => chosen.push(p) })); + return { view, chosen, trigger: one(view, ".power-btn") }; +} + +describe("the Power Efficiency button", () => { + it("lights one bar per offered preset up to the workers it runs", () => { + expect(litBars(mountButton("miser", 18).view)).toBe(1); + expect(litBars(mountButton("balanced", 18).view)).toBe(2); + expect(litBars(mountButton("aggressive", 18).view)).toBe(3); + expect(litBars(mountButton("drain-my-battery", 18).view)).toBe(4); + // At 4 CPUs Balanced already runs every CPU: as full as the menu goes. + expect(litBars(mountButton("drain-my-battery", 4).view)).toBe(2); + const { trigger } = mountButton("balanced", 18); + expect(trigger.getAttribute("aria-label")).toBe("Power Efficiency: Balanced, 4 workers of 18 CPUs"); + expect(trigger.textContent).toBe(""); + }); + + it("shows a tooltip on hover, and hides it while the menu is open", async () => { + const { view, trigger } = mountButton("balanced", 18); + await pointer(trigger, "mouseenter"); + await wait(500); + const tip = one(view, ".power-tip"); + expect(tip.textContent).toContain("Power Efficiency: Balanced"); + expect(tip.textContent).toContain("4 workers of 18 CPUs"); + expect(tip.textContent).toContain("Above Balanced"); + expect(tip.textContent).toContain("runs the fans at full speed"); + expect(tip.textContent).toContain("Used from the next scan, and remembered."); + expect(tip.textContent).not.toMatch(/thread/i); + + await press(trigger); + expect(menu(view)).not.toBeNull(); + expect(view.querySelector(".power-tip")).toBeNull(); + await pointer(trigger, "mouseleave"); + }); + + it("opens with a caption and a row per preset, and a second press closes it", async () => { + const { view, trigger, chosen } = mountButton("aggressive", 18); + await press(trigger); + const open = menu(view)!; + expect(open.querySelector(".power-menu-head")?.textContent).toBe("Scan workers18 CPUs"); + expect([...open.querySelectorAll(".power-menu-item")].map((item) => item.textContent)).toEqual([ + "Miser2 workers", "Balanced4 workers", "Aggressive8 workers", "Drain My Battery18 workers", + ]); + expect(open.querySelector(".power-menu-item.current")?.textContent).toContain("Aggressive"); + expect(highlighted(view)).toBe("Aggressive"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + expect(document.activeElement).toBe(open); + + // The press lands inside the control, so the outside-press handler + // leaves it to the click, which closes the menu and doesn't reopen it. + await press(trigger); + expect(menu(view)).toBeNull(); + expect(chosen).toEqual([]); + + await press(trigger); + await pointer(document.body, "mousedown"); + expect(menu(view)).toBeNull(); + }); + + it("chooses with the mouse, and a row this machine can't offer does nothing", async () => { + const { view, trigger, chosen } = mountButton("aggressive", 8); + await press(trigger); + const drain = row(view, "Drain My Battery"); + expect(drain.getAttribute("aria-disabled")).toBe("true"); + expect(drain.title).toBe("Same as Aggressive with 8 CPUs"); + await pointer(drain, "mouseenter"); + expect(highlighted(view)).toBe("Aggressive"); + await click(drain); + expect(chosen).toEqual([]); + + const miser = row(view, "Miser"); + await pointer(miser, "mouseenter"); + expect(highlighted(view)).toBe("Miser"); + await click(miser); + expect(chosen).toEqual(["miser"]); + expect(menu(view)).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it("follows the keyboard, skipping presets this machine can't offer", async () => { + const { view, trigger, chosen } = mountButton("miser", 4); + trigger.focus(); + await key(trigger, "ArrowDown"); + const open = menu(view)!; + expect(highlighted(view)).toBe("Miser"); + expect(row(view, "Aggressive").getAttribute("aria-disabled")).toBe("true"); + expect(row(view, "Drain My Battery").getAttribute("aria-disabled")).toBe("true"); + + await key(open, "ArrowDown"); + expect(highlighted(view)).toBe("Balanced"); + // Aggressive and Drain My Battery run 4 workers here, like Balanced. + await key(open, "ArrowDown"); + expect(highlighted(view)).toBe("Balanced"); + await key(open, "k"); + expect(highlighted(view)).toBe("Miser"); + await key(open, "End"); + expect(highlighted(view)).toBe("Balanced"); + await key(open, "Home"); + expect(highlighted(view)).toBe("Miser"); + await key(open, "j"); + await key(open, "Enter"); + expect(chosen).toEqual(["balanced"]); + expect(menu(view)).toBeNull(); + expect(document.activeElement).toBe(trigger); + + await click(trigger); + await key(menu(view)!, " "); + expect(chosen).toEqual(["balanced", "miser"]); + + await click(trigger); + await key(menu(view)!, "Escape"); + expect(menu(view)).toBeNull(); + expect(chosen).toHaveLength(2); + expect(document.activeElement).toBe(trigger); + }); + + it("closes on any other key and lets the key through", async () => { + const { view, trigger, chosen } = mountButton("drain-my-battery", 18); + const seen: string[] = []; + const onKey = (event: KeyboardEvent) => seen.push(event.key); + window.addEventListener("keydown", onKey); + try { + await click(trigger); + await key(menu(view)!, "ArrowUp"); + expect(seen).toEqual([]); + const passed = await key(menu(view)!, "f"); + expect(menu(view)).toBeNull(); + expect(passed.defaultPrevented).toBe(false); + expect(seen).toEqual(["f"]); + expect(chosen).toEqual([]); + } finally { + window.removeEventListener("keydown", onKey); + } + }); + + it("browses the menu without IPC or disk", async () => { + const { view, trigger } = mountButton("aggressive", 18); + ui.takeIpc(); + const { io } = await measureFsIo(async () => { + for (let i = 0; i < 5; i++) { + await press(trigger); + for (const name of ["Miser", "Balanced", "Aggressive", "Drain My Battery"]) { + await pointer(row(view, name), "mouseenter"); + } + await key(menu(view)!, "Home"); + await key(menu(view)!, "End"); + await key(menu(view)!, "Escape"); + } + }, { countProcesses: true }); + expect(ui.takeIpc()).toEqual([]); + expectIoBudget({ + scenario: "renderer-power-menu-browse", + note: "open the Power Efficiency menu 5 times, hover every row and arrow through it, close: 0 IPC, 0 reads, 0 writes", + io, + }); + }); +}); + +describe("the saved Power Efficiency control", () => { + it("saves a choice through main, says when it applies, and offers a rescan", async () => { + const cpus = window.diskhound!.cpuCount; + const rescans: string[] = []; + const view = ui.mount(h(ToastProvider, null, h(PowerEfficiencyControl, { onRescan: () => rescans.push("rescan") }))); + await ui.settle(); + expect(ui.takeIpc()).toEqual(["diskhound:get-settings"]); + expect(litBars(view)).toBe(powerEfficiencySignal(powerEfficiencyWorkers(DEFAULT_POWER_EFFICIENCY, cpus), cpus)); + + await press(one(view, ".power-btn")); + await click(row(view, "Miser")); + await ui.settle(); + expect(ui.takeIpc()).toEqual(["diskhound:set-power-efficiency"]); + const saved = JSON.parse(FS.readFileSync(Path.join(main.userData, "settings.json"), "utf8")) as AppSettings; + expect(saved.scanning.powerEfficiency).toBe("miser"); + expect(litBars(view)).toBe(1); + + const title = one(view, ".toast-title").textContent; + expect(title).toBe(`${powerEfficiencyLabel("miser", cpus)} from the next scan`); + await click(button(view, "Rescan now")); + expect(rescans).toEqual(["rescan"]); + }); + + it("says a running scan keeps its workers", async () => { + const cpus = window.diskhound!.cpuCount; + const view = ui.mount(h(ToastProvider, null, h(PowerEfficiencyControl, { scanning: true, onRescan: () => {} }))); + await ui.settle(); + ui.takeIpc(); + await press(one(view, ".power-btn")); + await click(row(view, "Miser")); + await ui.settle(); + // Miser was already saved: still confirmed, and main writes nothing. + expect(one(view, ".toast-title").textContent).toBe(`${powerEfficiencyLabel("miser", cpus)} from the next scan`); + expect(one(view, ".toast-body").textContent).toBe("The running scan keeps its workers."); + expect(view.querySelector(".toast-action")).toBeNull(); + }); +}); diff --git a/src/main.ts b/src/main.ts index f97ed66b..0dc31a81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import * as FS from "node:fs/promises"; import * as FS_SYNC from "node:fs"; +import * as OS from "node:os"; import * as Path from "node:path"; import { getHeapStatistics } from "node:v8"; import { Worker } from "node:worker_threads"; @@ -53,6 +54,7 @@ import { type WorkerToMainMessage, } from "./shared/contracts"; import { platformTerminology } from "./shared/platformTerminology"; +import { isPowerEfficiency, powerEfficiencyWorkers } from "./shared/powerEfficiency"; import { checkDiskDeltas, flushDiskMonitor, @@ -1365,6 +1367,12 @@ void (async () => { ? undefined : getScanHistory(rootPath).find((entry) => entry.filesVisited > 0)?.filesVisited; + // Power Efficiency, read here so a new choice reaches the next scan + // and never the one already running. + const cpus = OS.availableParallelism(); + const powerEfficiency = settingsStore?.get().scanning.powerEfficiency; + const workers = powerEfficiency ? powerEfficiencyWorkers(powerEfficiency, cpus) : undefined; + // Buffer for messages that arrive before the session is fully wired const earlyMessages: WorkerToMainMessage[] = []; let earlyErrors: Error[] = []; @@ -1380,6 +1388,7 @@ void (async () => { expectedTotalFiles, folderTreeOutput: tempFolderTreePath, devArtifactsOutput: tempDevArtifactsPath, + workers, }, { onMessage: (message) => { @@ -2242,6 +2251,19 @@ void (async () => { handleUpdateSettingsChanged?.(previousSettings, normalizedSettings); }); + // Only the one field, so a renderer's older copy of the rest can't + // overwrite it. Nothing restarts: createPreferredScanSession reads it + // when the next scan starts. + ipcMain.handle("diskhound:set-power-efficiency", async (_event, preset: unknown) => { + if (!isPowerEfficiency(preset)) { + throw new Error(`Unknown Power Efficiency preset: ${String(preset)}`); + } + return settingsStore!.update((current) => ({ + ...current, + scanning: { ...current.scanning, powerEfficiency: preset }, + })); + }); + ipcMain.handle("diskhound:get-recent-scans", () => settingsStore!.get().recentScans ?? []); // ── IPC: Easy Move ─────────────────────────────────────── diff --git a/src/nativeScanner.ts b/src/nativeScanner.ts index 871e1bea..1a905d15 100644 --- a/src/nativeScanner.ts +++ b/src/nativeScanner.ts @@ -102,6 +102,9 @@ export function createNativeScannerSession( if (input.devArtifactsOutput) { args.push("--dev-artifacts-output", input.devArtifactsOutput); } + if (input.workers) { + args.push("--workers", String(input.workers)); + } let child: ChildProcessByStdio; try { diff --git a/src/preload.ts b/src/preload.ts index d2ec0f15..839b60f1 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,3 +1,5 @@ +import * as OS from "node:os"; + import { contextBridge, ipcRenderer } from "electron"; import type { @@ -40,6 +42,7 @@ const WINDOW_SHOWN_CHANNEL = "diskhound:window-shown"; const api: DiskhoundNativeApi = { platform, + cpuCount: OS.availableParallelism(), // Scan pickRootPath: () => ipcRenderer.invoke("diskhound:pick-root"), @@ -79,6 +82,7 @@ const api: DiskhoundNativeApi = { // Settings getSettings: () => ipcRenderer.invoke("diskhound:get-settings"), updateSettings: (settings) => ipcRenderer.invoke("diskhound:update-settings", settings), + setPowerEfficiency: (preset) => ipcRenderer.invoke("diskhound:set-power-efficiency", preset), getRecentScans: () => ipcRenderer.invoke("diskhound:get-recent-scans"), // Monitoring diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ba4a99db..0a02e8b8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -42,6 +42,7 @@ import { EasyMoveView } from "./components/EasyMoveView"; import { FileList } from "./components/FileList"; import { FolderList } from "./components/FolderList"; import { Overview } from "./components/Overview"; +import { PowerEfficiencyControl } from "./components/PowerEfficiencyControl"; import { StartupSplash } from "./components/StartupSplash"; import { SettingsView } from "./components/SettingsView"; import { ToastProvider, dismissToast, toast } from "./components/Toasts"; @@ -1121,6 +1122,12 @@ export function App() { New Scan )} + {/* Power Efficiency: how many workers the next scan walks + * with. Beside Rescan, the action it changes. */} + void doScan() : undefined} + />
void; + platform?: string; +} + +/** + * A framed signal gauge and a chevron. The menu lists the four presets + * with a share-of-CPUs bar and the worker count each runs here; a preset + * this machine can't tell apart from the one below it is shown but not + * offered. + */ +export function PowerEfficiencyMenuButton({ preset, cpus, onChoose, platform = nativeApi.platform }: MenuButtonProps) { + const current = effectivePowerEfficiency(preset, cpus); + const workers = powerEfficiencyWorkers(preset, cpus); + const name = powerEfficiencyName(current); + const lit = powerEfficiencySignal(workers, cpus); + + const wrapRef = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + const tipTimer = useRef | null>(null); + /** Focus handed back by the menu is not a reason to show the tooltip. */ + const refocusing = useRef(false); + /** The highlighted row while the menu is open, or null when closed. */ + const [highlighted, setHighlighted] = useState(null); + const [tipOpen, setTipOpen] = useState(false); + const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null); + const open = highlighted !== null; + + const measure = useCallback(() => { + const rect = buttonRef.current?.getBoundingClientRect(); + if (!rect) return; + setAnchor({ top: rect.bottom + 4, right: Math.max(EDGE, window.innerWidth - rect.right) }); + }, []); + + const clearTipTimer = () => { + if (tipTimer.current) clearTimeout(tipTimer.current); + tipTimer.current = null; + }; + + const close = useCallback((refocus: boolean) => { + setHighlighted(null); + if (refocus) { + refocusing.current = true; + buttonRef.current?.focus(); + refocusing.current = false; + } + }, []); + + const openMenu = useCallback(() => { + clearTipTimer(); + setTipOpen(false); + measure(); + setHighlighted(current); + }, [current, measure]); + + const choose = useCallback((next: PowerEfficiency) => { + if (!powerEfficiencyAvailable(next, cpus)) return; + close(true); + onChoose(next); + }, [close, cpus, onChoose]); + + // An open menu takes the keys; it hands focus back when it closes. + useLayoutEffect(() => { + if (open) menuRef.current?.focus(); + }, [open]); + + // A press anywhere else closes it. The button and the menu are both in + // the wrapper, so pressing the button again leaves it to the click, + // which closes it, instead of closing here and reopening on the click. + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!wrapRef.current?.contains(event.target as Node)) close(false); + }; + const onResize = () => close(false); + document.addEventListener("mousedown", onPointerDown, true); + window.addEventListener("resize", onResize); + window.addEventListener("blur", onResize); + return () => { + document.removeEventListener("mousedown", onPointerDown, true); + window.removeEventListener("resize", onResize); + window.removeEventListener("blur", onResize); + }; + }, [open, close]); + + useEffect(() => clearTipTimer, []); + + const onMenuKey = (event: KeyboardEvent) => { + if (highlighted === null) return; + const offered = offeredPowerEfficiencies(cpus); + const at = POWER_EFFICIENCY_PRESETS.indexOf(highlighted); + const step = (forward: boolean): PowerEfficiency => { + const next = forward + ? offered.find((p) => POWER_EFFICIENCY_PRESETS.indexOf(p) > at) + : [...offered].reverse().find((p) => POWER_EFFICIENCY_PRESETS.indexOf(p) < at); + return next ?? highlighted; + }; + switch (event.key) { + case "ArrowDown": + case "j": + setHighlighted(step(true)); + break; + case "ArrowUp": + case "k": + setHighlighted(step(false)); + break; + case "Home": + setHighlighted(offered[0]); + break; + case "End": + setHighlighted(offered[offered.length - 1]); + break; + case "Enter": + case " ": + choose(highlighted); + break; + case "Escape": + close(true); + break; + case "Shift": + case "Control": + case "Alt": + case "Meta": + // The first half of a chord, not a key of its own. + return; + default: + // Anything else closes the menu and carries on to the app. + close(true); + return; + } + event.preventDefault(); + event.stopPropagation(); + }; + + const onButtonKey = (event: KeyboardEvent) => { + if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault(); + openMenu(); + } + }; + + const showTipSoon = () => { + if (open) return; + clearTipTimer(); + tipTimer.current = setTimeout(() => { + measure(); + setTipOpen(true); + }, TOOLTIP_DELAY_MS); + }; + const hideTip = () => { + clearTipTimer(); + setTipOpen(false); + }; + + const summary = `${formatWorkers(workers)} of ${cpus} CPUs`; + + return ( +
+ + + {/* An open menu says it all; the tooltip would only cover it. */} + {tipOpen && !open && anchor && ( +
+
Power Efficiency: {name}
+
{summary}
+
{powerTradeoff(platform)}
+
Used from the next scan, and remembered.
+
+ )} + + {open && anchor && ( +
+ + {POWER_EFFICIENCY_PRESETS.map((p, index) => { + const offered = powerEfficiencyAvailable(p, cpus); + const count = powerEfficiencyWorkers(p, cpus); + const below = index > 0 ? powerEfficiencyName(POWER_EFFICIENCY_PRESETS[index - 1]) : ""; + const classes = [ + "power-menu-item", + p === current ? "current" : "", + p === highlighted && offered ? "highlighted" : "", + ].filter(Boolean).join(" "); + return ( +
{ if (offered) setHighlighted(p); }} + onMouseDown={(event) => event.preventDefault()} + onClick={() => choose(p)} + > + {powerEfficiencyName(p)} + + {formatWorkers(count)} +
+ ); + })} +
+ )} +
+ ); +} + +/** The saved Power Efficiency choice, kept current from settings pushes. */ +export function usePowerEfficiency(): PowerEfficiency | null { + const [preset, setPreset] = useState(null); + useEffect(() => { + let cancelled = false; + void nativeApi.getSettings().then((s) => { + if (!cancelled && s) setPreset(s.scanning.powerEfficiency ?? DEFAULT_POWER_EFFICIENCY); + }); + const apply = (next: AppSettings | null | undefined) => { + if (next) setPreset(next.scanning.powerEfficiency ?? DEFAULT_POWER_EFFICIENCY); + }; + const onLocal = (event: Event) => apply((event as CustomEvent).detail); + const unsubscribe = nativeApi.onSettingsUpdated(apply); + window.addEventListener(SETTINGS_UPDATED_EVENT, onLocal as EventListener); + return () => { + cancelled = true; + unsubscribe(); + window.removeEventListener(SETTINGS_UPDATED_EVENT, onLocal as EventListener); + }; + }, []); + return preset; +} + +interface ControlProps { + /** Rescans the selected root now. Omitted when there's nothing to rescan. */ + onRescan?: () => void; + /** True while a scan runs: it keeps its workers, so the notice says so. */ + scanning?: boolean; +} + +/** + * The saved control. Choosing saves the preset and leaves any running + * scan alone; the next scan, scheduled or manual, uses it. + */ +export function PowerEfficiencyControl({ onRescan, scanning = false }: ControlProps) { + const saved = usePowerEfficiency(); + const cpus = nativeApi.cpuCount || 1; + const [preset, setPreset] = useState(null); + useEffect(() => { setPreset(saved); }, [saved]); + + const onChoose = useCallback(async (next: PowerEfficiency) => { + const previous = preset; + setPreset(next); + const label = powerEfficiencyLabel(next, cpus); + try { + const settings = await nativeApi.setPowerEfficiency(next); + if (settings) dispatchSettingsUpdated(settings); + } catch (error) { + setPreset(previous); + toast("error", "Couldn't save Power Efficiency", error instanceof Error ? error.message : String(error)); + return; + } + if (scanning) { + toast("success", `${label} from the next scan`, "The running scan keeps its workers.", { id: "power-efficiency" }); + } else if (onRescan) { + toast("success", `${label} from the next scan`, undefined, { + id: "power-efficiency", + action: { label: "Rescan now", run: onRescan }, + }); + } else { + toast("success", `${label} from the next scan`, undefined, { id: "power-efficiency" }); + } + }, [cpus, onRescan, preset, scanning]); + + if (!preset) return null; + return void onChoose(p)} />; +} diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index 8297915d..f2499ada 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -14,8 +14,9 @@ import { formatDriveSpace, driveUsedPercent } from "../lib/driveSpace"; import { formatBytes } from "../lib/format"; import { reportPollFailure } from "../lib/pollFailure"; import { nativeApi } from "../nativeApi"; -import { dispatchSettingsUpdated } from "../lib/uiEvents"; +import { dispatchSettingsUpdated, SETTINGS_UPDATED_EVENT } from "../lib/uiEvents"; import { startVisiblePoll } from "../lib/visiblePoll"; +import { PowerEfficiencyControl } from "./PowerEfficiencyControl"; import { toast } from "./Toasts"; import { normPath } from "../../shared/pathUtils"; import { @@ -37,6 +38,19 @@ export function SettingsView() { setSettings(s); setLoaded(true); }); + // Settings also change outside this view (the header's Power + // Efficiency control, a finished scan's recent-scans list). Keep this + // copy current so a save here doesn't write an older value back. + const apply = (next: AppSettings | null | undefined) => { + if (next) setSettings(next); + }; + const onLocal = (event: Event) => apply((event as CustomEvent).detail); + const unsubscribe = nativeApi.onSettingsUpdated(apply); + window.addEventListener(SETTINGS_UPDATED_EVENT, onLocal as EventListener); + return () => { + unsubscribe(); + window.removeEventListener(SETTINGS_UPDATED_EVENT, onLocal as EventListener); + }; }, []); useEffect(() => { @@ -191,6 +205,20 @@ export function SettingsView() { * disappears entirely on non-Windows instead of half-working. */} {nativeApi.platform === "win32" && } + {/* ── Scanning ── */} +
+
Scanning
+
+
+
Power Efficiency
+
+ How many folders a scan reads at once, up to one per CPU. Used from the next scan, manual or scheduled; a running scan keeps its workers. +
+
+ +
+
+ void save(next)} diff --git a/src/renderer/components/Toasts.tsx b/src/renderer/components/Toasts.tsx index b1503e6c..4bc3f9b9 100644 --- a/src/renderer/components/Toasts.tsx +++ b/src/renderer/components/Toasts.tsx @@ -14,21 +14,32 @@ import { nativeApi } from "../nativeApi"; // Pass `opts.dismissAfterMs: 0` to make the toast sticky (no auto- // dismiss). Progress toasts use this since they dismiss themselves // on the final "done" phase. -let externalAddToast: ((toast: ToastMessage) => void) | null = null; +// +// Pass `opts.action` for one button under the body (e.g. "Rescan now"). +// Pressing it runs the action and dismisses the toast. Renderer-only: a +// toast from main has no action. +interface ToastAction { + label: string; + run: () => void; +} +type RendererToast = ToastMessage & { action?: ToastAction }; + +let externalAddToast: ((toast: RendererToast) => void) | null = null; let externalDismissToast: ((id: string) => void) | null = null; let toastSeq = 0; export function toast( level: ToastMessage["level"], title: string, body?: string, - opts?: { id?: string; dismissAfterMs?: number }, + opts?: { id?: string; dismissAfterMs?: number; action?: ToastAction }, ) { - const msg: ToastMessage = { + const msg: RendererToast = { id: opts?.id ?? `local-${++toastSeq}`, level, title, body, - dismissAfterMs: opts?.dismissAfterMs ?? 4000, + dismissAfterMs: opts?.dismissAfterMs ?? (opts?.action ? 8000 : 4000), + action: opts?.action, }; externalAddToast?.(msg); } @@ -38,14 +49,14 @@ export function dismissToast(id: string): void { } export function ToastProvider({ children }: { children: any }) { - const [toasts, setToasts] = useState<(ToastMessage & { exiting?: boolean })[]>([]); + const [toasts, setToasts] = useState<(RendererToast & { exiting?: boolean })[]>([]); const dismiss = useCallback((id: string) => { setToasts((t) => t.map((x) => (x.id === id ? { ...x, exiting: true } : x))); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 220); }, []); - const addToast = useCallback((t: ToastMessage) => { + const addToast = useCallback((t: RendererToast) => { setToasts((prev) => { // Upsert by id: if the id already exists, replace the entry // in-place. Keeps progress toasts to a single visible card. @@ -81,6 +92,17 @@ export function ToastProvider({ children }: { children: any }) {
{t.title}
{t.body &&
{t.body}
} + {t.action && ( + + )}
diff --git a/src/renderer/index.css b/src/renderer/index.css index 3792d2a5..c00fcc0a 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -929,8 +929,9 @@ button, input, select, textarea { font: inherit; } * Pills are the column that shrinks and scrolls. Scan controls * used to be `1fr` with min-width: 0, so on a Linux window full of * mount pills the Stop button overflowed and painted on top of the - * root pill's "free" label. */ - grid-template-columns: auto minmax(168px, 320px) minmax(0, 1fr) auto; + * root pill's "free" label. The column also holds the 44px Power + * Efficiency gauge and its gap, hence 168 + 52 and 320 + 52. */ + grid-template-columns: auto minmax(220px, 372px) minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 0 12px; @@ -1058,6 +1059,147 @@ button, input, select, textarea { font: inherit; } background: rgba(245,158,11,0.08); } +/* ── Power Efficiency (components/PowerEfficiencyControl.tsx) ── + * A 28px framed button like the scan buttons: four rising bars, lit in + * amber up to the saved preset, and a chevron. The menu and tooltip are + * fixed-position so the header's overflow can't clip them. */ +.power-control { + position: relative; + display: inline-flex; + flex-shrink: 0; +} +.power-btn { + height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 7px 0 9px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--text-muted); + cursor: pointer; + transition: all 0.12s; +} +.power-btn:hover, +.power-btn.open { + background: var(--bg-row-hover); + color: var(--text-secondary); +} +.power-btn.open { border-color: var(--border-selected); } +.power-btn:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; } +.power-gauge { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 12px; +} +.power-gauge i { + display: block; + width: 3px; + border-radius: 1px; + background: var(--text-muted); + opacity: 0.45; +} +.power-gauge i:nth-child(1) { height: 3px; } +.power-gauge i:nth-child(2) { height: 6px; } +.power-gauge i:nth-child(3) { height: 9px; } +.power-gauge i:nth-child(4) { height: 12px; } +.power-gauge i.lit { background: var(--amber); opacity: 1; } +.power-chevron { flex-shrink: 0; } + +.power-menu, +.power-tip { + position: fixed; + z-index: 60; + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); + -webkit-app-region: no-drag; + animation: power-pop-in 0.12s ease-out; +} +:root.light .power-menu, +:root.light .power-tip { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1); } +@keyframes power-pop-in { + from { opacity: 0; } + to { opacity: 1; } +} +.power-menu { + width: 288px; + padding: 4px; + background: var(--bg); + outline: none; + user-select: none; +} +.power-menu-head { + display: flex; + justify-content: space-between; + padding: 4px 8px 6px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} +.power-menu-item { + display: grid; + grid-template-columns: minmax(0, 1fr) 72px 72px; + align-items: center; + gap: 12px; + padding: 6px 8px; + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--text); + cursor: pointer; +} +.power-menu-item.highlighted { background: var(--bg-row-hover); } +.power-menu-item.current .power-menu-name { font-weight: 700; } +.power-menu-item[aria-disabled="true"] { opacity: 0.4; cursor: default; } +.power-menu-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.power-menu-share { + height: 4px; + border-radius: 2px; + background: var(--bg-row-hover); + overflow: hidden; +} +.power-menu-share i { + display: block; + height: 100%; + border-radius: 2px; + background: var(--amber); +} +.power-menu-count { + text-align: right; + font-family: var(--font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); + white-space: nowrap; +} + +.power-tip { + width: 280px; + padding: 8px 10px; + background: var(--bg-panel); + backdrop-filter: blur(12px); + pointer-events: none; + display: flex; + flex-direction: column; + gap: 3px; +} +.power-tip-title { font-size: 12px; font-weight: 600; color: var(--text); } +.power-tip-count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.power-tip-body { font-size: 11px; line-height: 1.45; color: var(--text-tertiary); } + /* Middle-ellipsis path label (components/MiddleEllipsis.tsx). The head * shrinks with "…" and the tail keeps the last segment, so long macOS * mount points read as `/Library/Developer/…/iOS_21A342` instead of @@ -1209,7 +1351,7 @@ button, input, select, textarea { font: inherit; } * left, so the chip shrinks, down to its min-width, before the pills * start to scroll. With no chip the header keeps its release layout. */ .header:has(> .dev-branch-chip) { - grid-template-columns: auto minmax(168px, 320px) minmax(0, max-content) 1fr auto; + grid-template-columns: auto minmax(220px, 372px) minmax(0, max-content) 1fr auto; } .dev-branch-chip { justify-self: end; @@ -5760,6 +5902,24 @@ button, input, select, textarea { font: inherit; } } .toast-close:hover { background: rgba(255,255,255,0.06); color: var(--text); } +/* One action under the body, e.g. "Rescan now" after a Power Efficiency + * change. The scan-btn-primary palette at the toast's scale. */ +.toast-action { + margin-top: 8px; + height: 24px; + padding: 0 10px; + border: 1px solid var(--border-selected); + border-radius: var(--radius-sm); + background: var(--amber-glow); + color: var(--amber); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.12s; +} +.toast-action:hover { background: color-mix(in srgb, var(--amber) 25%, transparent); } +.toast-action:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; } + @keyframes toast-in { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: translateX(0); } diff --git a/src/renderer/nativeApi.ts b/src/renderer/nativeApi.ts index 9c2cb091..a335f574 100644 --- a/src/renderer/nativeApi.ts +++ b/src/renderer/nativeApi.ts @@ -17,6 +17,9 @@ export const nativeApi: DiskhoundNativeApi = new Proxy( if (prop === "platform") { return guessPlatformFromUserAgent(); } + if (prop === "cpuCount") { + return typeof navigator === "undefined" ? 1 : navigator.hardwareConcurrency || 1; + } // Event listeners: return no-op unsubscriber if (prop.startsWith("on")) { return (_listener: unknown) => () => {}; diff --git a/src/shared/__tests__/powerEfficiency.test.ts b/src/shared/__tests__/powerEfficiency.test.ts new file mode 100644 index 00000000..cabaac55 --- /dev/null +++ b/src/shared/__tests__/powerEfficiency.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { defaultSettings, normalizeAppSettings } from "../contracts"; +import { + DEFAULT_POWER_EFFICIENCY, + effectivePowerEfficiency, + offeredPowerEfficiencies, + POWER_EFFICIENCY_PRESETS, + powerEfficiencyLabel, + powerEfficiencySignal, + powerEfficiencyWorkers, + type PowerEfficiency, +} from "../powerEfficiency"; + +/** Offered presets at each CPU count, 1 to 18: each must add workers. */ +const OFFERED: Record = {}; +for (let cpus = 1; cpus <= 18; cpus++) { + OFFERED[cpus] = cpus <= 2 ? ["miser"] + : cpus <= 4 ? ["miser", "balanced"] + : cpus <= 8 ? ["miser", "balanced", "aggressive"] + : ["miser", "balanced", "aggressive", "drain-my-battery"]; +} + +describe("Power Efficiency presets", () => { + it("run 2, 4, 8 and every CPU, capped by the CPU count", () => { + expect(POWER_EFFICIENCY_PRESETS.map((p) => powerEfficiencyWorkers(p, 18))).toEqual([2, 4, 8, 18]); + expect(POWER_EFFICIENCY_PRESETS.map((p) => powerEfficiencyWorkers(p, 6))).toEqual([2, 4, 6, 6]); + expect(POWER_EFFICIENCY_PRESETS.map((p) => powerEfficiencyWorkers(p, 3))).toEqual([2, 3, 3, 3]); + expect(POWER_EFFICIENCY_PRESETS.map((p) => powerEfficiencyWorkers(p, 1))).toEqual([1, 1, 1, 1]); + // No count detected still runs one worker. + expect(powerEfficiencyWorkers("drain-my-battery", 0)).toBe(1); + expect(powerEfficiencyLabel("drain-my-battery", 18)).toBe("Drain My Battery (18 workers)"); + expect(powerEfficiencyLabel("miser", 1)).toBe("Miser (1 worker)"); + }); + + it.each(Object.entries(OFFERED).map(([cpus, offered]) => [Number(cpus), offered] as const))( + "at %i CPUs offer %j", + (cpus, offered) => { + expect(offeredPowerEfficiencies(cpus)).toEqual(offered); + // Each offered preset runs more workers than the one before it, + // and each one left out runs no more than the one below it. + const counts = offered.map((p) => powerEfficiencyWorkers(p, cpus)); + expect(counts).toEqual([...new Set(counts)].sort((a, b) => a - b)); + for (const [index, preset] of POWER_EFFICIENCY_PRESETS.entries()) { + if (offered.includes(preset)) continue; + expect(powerEfficiencyWorkers(preset, cpus)) + .toBe(powerEfficiencyWorkers(POWER_EFFICIENCY_PRESETS[index - 1], cpus)); + } + }, + ); + + it("light the gauge by offered presets at or below the worker count", () => { + expect(powerEfficiencySignal(2, 18)).toBe(1); + expect(powerEfficiencySignal(4, 18)).toBe(2); + expect(powerEfficiencySignal(8, 18)).toBe(3); + expect(powerEfficiencySignal(18, 18)).toBe(4); + // A custom count reads on the same scale. + expect(powerEfficiencySignal(12, 18)).toBe(3); + expect(powerEfficiencySignal(1, 18)).toBe(1); + // A small machine running all it has is as full as its menu allows. + expect(powerEfficiencySignal(4, 4)).toBe(2); + expect(powerEfficiencySignal(8, 8)).toBe(3); + expect(powerEfficiencySignal(2, 2)).toBe(1); + }); + + it("run a preset this machine doesn't offer as the one below it", () => { + expect(effectivePowerEfficiency("drain-my-battery", 8)).toBe("aggressive"); + expect(effectivePowerEfficiency("drain-my-battery", 4)).toBe("balanced"); + expect(effectivePowerEfficiency("aggressive", 2)).toBe("miser"); + expect(effectivePowerEfficiency("aggressive", 18)).toBe("aggressive"); + expect(effectivePowerEfficiency("miser", 1)).toBe("miser"); + }); +}); + +describe("the saved Power Efficiency setting", () => { + it("defaults, survives normalizing, and drops unknown values", () => { + expect(defaultSettings().scanning.powerEfficiency).toBe(DEFAULT_POWER_EFFICIENCY); + // Settings saved before the setting existed. + const old = defaultSettings() as unknown as { scanning: Record }; + delete old.scanning.powerEfficiency; + expect(normalizeAppSettings(old as never).scanning.powerEfficiency).toBe(DEFAULT_POWER_EFFICIENCY); + for (const preset of POWER_EFFICIENCY_PRESETS) { + const saved = { ...defaultSettings(), scanning: { ...defaultSettings().scanning, powerEfficiency: preset } }; + expect(normalizeAppSettings(JSON.parse(JSON.stringify(saved))).scanning.powerEfficiency).toBe(preset); + } + const bad = { ...defaultSettings(), scanning: { ...defaultSettings().scanning, powerEfficiency: "turbo" } }; + expect(normalizeAppSettings(bad as never).scanning.powerEfficiency).toBe(DEFAULT_POWER_EFFICIENCY); + }); +}); diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 9f0dc869..3870e1c9 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -4,6 +4,11 @@ import { getDefaultExcludedFolderPaths, normalizeExcludedFolderPaths, } from "./pathProtection"; +import { + DEFAULT_POWER_EFFICIENCY, + isPowerEfficiency, + type PowerEfficiency, +} from "./powerEfficiency"; export type ScanStatus = "idle" | "running" | "done" | "cancelled" | "error"; export type ScanEngine = "js-worker" | "native-sidecar" | "usn-journal"; @@ -326,6 +331,12 @@ export interface ScanStartInput { folderTreeOutput?: string; /** Compact Dev Artifacts sidecar written during the scan. */ devArtifactsOutput?: string; + /** + * How many workers the native scanner walks with (Power Efficiency). + * Unset leaves the scanner's own default. DISKHOUND_PARALLEL_THREADS + * still overrides both, for experiments. + */ + workers?: number; } export type WorkerToMainMessage = @@ -428,6 +439,8 @@ export interface ScanningSettings { excludedFolderPaths: string[]; /** Hide excluded folders from Folders while still counting their bytes. */ hideExcludedFoldersFromFolderResults: boolean; + /** How many workers the native scanner walks with, from the next scan. */ + powerEfficiency: PowerEfficiency; } export interface MonitoringSettings { @@ -1285,6 +1298,9 @@ export interface DiskhoundNativeApi { * renderer gate Windows-only UI (MFT elevation, CPU affinity rules, * GPU counters) without relying on user-agent sniffing. */ platform: DiskhoundPlatform; + /** Logical CPUs, resolved once at preload time. The Power Efficiency + * presets are capped by it, as main caps the scanner's workers. */ + cpuCount: number; // Scan pickRootPath: () => Promise; @@ -1364,6 +1380,10 @@ export interface DiskhoundNativeApi { // Settings getSettings: () => Promise; updateSettings: (settings: AppSettings) => Promise; + /** Saves the Power Efficiency choice on its own, so a stale copy of + * the rest of the settings can't overwrite a newer one. The next scan + * uses it; a running scan keeps its workers. */ + setPowerEfficiency: (preset: PowerEfficiency) => Promise; getRecentScans: () => Promise; // Monitoring @@ -1642,6 +1662,7 @@ export function defaultSettings(): AppSettings { defaultRootPath: "", excludedFolderPaths: getDefaultExcludedFolderPaths(), hideExcludedFoldersFromFolderResults: true, + powerEfficiency: DEFAULT_POWER_EFFICIENCY, }, monitoring: { enabled: true, // cheap free-space polls; full rescans are opt-in-cadence @@ -1767,6 +1788,9 @@ export function normalizeAppSettings(input?: Partial | null): AppSe merged.scanning.hideExcludedFoldersFromFolderResults === undefined ? defaults.scanning.hideExcludedFoldersFromFolderResults : Boolean(merged.scanning.hideExcludedFoldersFromFolderResults), + powerEfficiency: isPowerEfficiency(merged.scanning.powerEfficiency) + ? merged.scanning.powerEfficiency + : defaults.scanning.powerEfficiency, }, monitoring: { enabled: Boolean(merged.monitoring.enabled), diff --git a/src/shared/powerEfficiency.ts b/src/shared/powerEfficiency.ts new file mode 100644 index 00000000..5d10f100 --- /dev/null +++ b/src/shared/powerEfficiency.ts @@ -0,0 +1,92 @@ +// Power Efficiency: how many workers the native scanner walks with. +// +// Four fixed presets, each capped by the machine's logical CPU count. +// The choice is saved in settings.json and read when a scan starts, so it +// changes the next scan and never a running one. + +export type PowerEfficiency = "miser" | "balanced" | "aggressive" | "drain-my-battery"; + +/** Least to most workers. The gauge and the menu both read this order. */ +export const POWER_EFFICIENCY_PRESETS: readonly PowerEfficiency[] = [ + "miser", + "balanced", + "aggressive", + "drain-my-battery", +]; + +/** Settings without a saved choice get this: the scanner's old cap of 8. */ +export const DEFAULT_POWER_EFFICIENCY: PowerEfficiency = "aggressive"; + +const NAMES: Record = { + miser: "Miser", + balanced: "Balanced", + aggressive: "Aggressive", + "drain-my-battery": "Drain My Battery", +}; + +export function isPowerEfficiency(value: unknown): value is PowerEfficiency { + return typeof value === "string" && (POWER_EFFICIENCY_PRESETS as readonly string[]).includes(value); +} + +export function powerEfficiencyName(preset: PowerEfficiency): string { + return NAMES[preset]; +} + +/** Workers the preset runs on a machine with `cpus` logical CPUs. */ +export function powerEfficiencyWorkers(preset: PowerEfficiency, cpus: number): number { + const limit = Math.max(1, Math.floor(cpus) || 1); + switch (preset) { + case "miser": return Math.min(2, limit); + case "balanced": return Math.min(4, limit); + case "aggressive": return Math.min(8, limit); + case "drain-my-battery": return limit; + } +} + +/** + * A preset that would run no more workers than the one below it on this + * machine is a duplicate here: shown, so the scale reads the same on every + * machine, but not offered. + */ +export function powerEfficiencyAvailable(preset: PowerEfficiency, cpus: number): boolean { + const index = POWER_EFFICIENCY_PRESETS.indexOf(preset); + if (index <= 0) return true; + return powerEfficiencyWorkers(preset, cpus) + > powerEfficiencyWorkers(POWER_EFFICIENCY_PRESETS[index - 1], cpus); +} + +export function offeredPowerEfficiencies(cpus: number): PowerEfficiency[] { + return POWER_EFFICIENCY_PRESETS.filter((preset) => powerEfficiencyAvailable(preset, cpus)); +} + +/** + * The saved choice, or the nearest offered preset below it. A choice made + * on a bigger machine (settings copied over, or a VM given fewer CPUs) + * runs as what it is here. + */ +export function effectivePowerEfficiency(preset: PowerEfficiency, cpus: number): PowerEfficiency { + for (let index = POWER_EFFICIENCY_PRESETS.indexOf(preset); index > 0; index--) { + const candidate = POWER_EFFICIENCY_PRESETS[index]; + if (powerEfficiencyAvailable(candidate, cpus)) return candidate; + } + return POWER_EFFICIENCY_PRESETS[0]; +} + +/** + * How many of the four gauge bars `workers` lights: the offered presets + * that run no more than that, so a custom count reads on the same scale. + */ +export function powerEfficiencySignal(workers: number, cpus: number): number { + const lit = offeredPowerEfficiencies(cpus) + .filter((preset) => powerEfficiencyWorkers(preset, cpus) <= workers) + .length; + return Math.max(1, lit); +} + +export function formatWorkers(count: number): string { + return `${count} ${count === 1 ? "worker" : "workers"}`; +} + +export function powerEfficiencyLabel(preset: PowerEfficiency, cpus: number): string { + return `${powerEfficiencyName(preset)} (${formatWorkers(powerEfficiencyWorkers(preset, cpus))})`; +} From f4d6fa0dcf2b91c5c443930ba79105529f616249 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 01:04:41 -0400 Subject: [PATCH 2/9] Default Power Efficiency to Balanced, from measurement Whole `/` on an 18-CPU M5 Max (20.8M files, index and sidecars written, two warm rounds): 4 workers took 258 s and 674 CPU-s, 8 took 248 s and 849 CPU-s, 18 took 264 s and 1,085 CPU-s, 2 took 344 s and 572 CPU-s. Past 4 the walk barely gets faster while CPU time keeps climbing, so a scan without a saved choice now runs 4 workers instead of the old cap of 8. The tooltip quotes the cost above Balanced. --- e2e/power-efficiency.spec.ts | 8 ++++---- native/diskhound-native-scanner/src/main.rs | 12 +++++++----- src/renderer/components/PowerEfficiencyControl.tsx | 8 +++++--- src/shared/powerEfficiency.ts | 10 ++++++++-- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/e2e/power-efficiency.spec.ts b/e2e/power-efficiency.spec.ts index 02de3eab..06538215 100644 --- a/e2e/power-efficiency.spec.ts +++ b/e2e/power-efficiency.spec.ts @@ -16,11 +16,11 @@ test("a Power Efficiency choice reaches the next scan's walker", async ({ launch .match(/walking with dua-core, \d+ threads/g) ?? []); await scanFolderFromPicker(handle, scanTree.root); - // Aggressive by default: 8 workers, or every CPU on a smaller machine. - expect(await walking()).toEqual([`walking with dua-core, ${Math.min(8, cpus)} threads`]); + // Balanced by default: 4 workers, or every CPU on a smaller machine. + expect(await walking()).toEqual([`walking with dua-core, ${Math.min(4, cpus)} threads`]); const gauge = page.locator(".header .power-btn"); - await expect(gauge).toHaveAttribute("aria-label", /^Power Efficiency: (Aggressive|Balanced|Miser), /); + await expect(gauge).toHaveAttribute("aria-label", /^Power Efficiency: (Balanced|Miser), /); await gauge.click(); await expect(page.getByRole("menu", { name: "Power Efficiency" })).toBeVisible(); await page.getByRole("menuitemradio", { name: /^Miser/ }).click(); @@ -31,7 +31,7 @@ test("a Power Efficiency choice reaches the next scan's walker", async ({ launch await page.getByRole("button", { name: "Rescan now" }).click(); await expect.poll(walking, { timeout: 45_000 }).toEqual([ - `walking with dua-core, ${Math.min(8, cpus)} threads`, + `walking with dua-core, ${Math.min(4, cpus)} threads`, `walking with dua-core, ${miser} threads`, ]); const settings = await page.evaluate(() => window.diskhound.getSettings()); diff --git a/native/diskhound-native-scanner/src/main.rs b/native/diskhound-native-scanner/src/main.rs index d6f69cdf..c45442e6 100644 --- a/native/diskhound-native-scanner/src/main.rs +++ b/native/diskhound-native-scanner/src/main.rs @@ -1541,11 +1541,13 @@ fn scan_generic_with_plan( // Directory enumeration is embarrassingly parallel at the I/O layer, // since reads of separate directories hit different inode blocks. // - // At most 8 threads, like the Windows walker. On an 18-core M5 Max a - // full `/` scan (21.4M files) took 2m 51s at 16 threads and 3m 22s at - // 8, but 16 used 31% more CPU time (995 s vs 759 s) and peaked near - // 1,000% CPU instead of 650%. Past 8, extra threads mostly add kernel - // time. DISKHOUND_PARALLEL_THREADS overrides it. + // The app passes the Power Efficiency choice as --workers (Balanced, + // 4, by default). On an 18-core M5 Max a full `/` scan (20.8M files, + // index and sidecars written) took 258 s and 674 CPU-s at 4 workers, + // 248 s and 849 CPU-s at 8, 264 s and 1,085 CPU-s at 18, and 344 s + // and 572 CPU-s at 2. Past 4, extra workers mostly add kernel time. + // Without --workers (the CLI), at most 8, like the Windows walker. + // DISKHOUND_PARALLEL_THREADS overrides both. let thread_count = walk_workers(state.input.workers, { let logical = num_cpus::get().max(1); if logical <= 2 { diff --git a/src/renderer/components/PowerEfficiencyControl.tsx b/src/renderer/components/PowerEfficiencyControl.tsx index 8e54e2cd..9adb0620 100644 --- a/src/renderer/components/PowerEfficiencyControl.tsx +++ b/src/renderer/components/PowerEfficiencyControl.tsx @@ -20,12 +20,14 @@ import { toast } from "./Toasts"; /** * What the tooltip says the choice costs, where the gauge alone cannot. - * Measured with the native scanner on an 18-CPU M5 Max, whole `/` - * (20.8M files); elsewhere the fans are a likely cost, not a measured one. + * Measured with the native scanner on an 18-CPU M5 Max, whole `/` (20.8M + * files): against Balanced's 4 workers, 8 finished 4% sooner on 26% more + * CPU time, and 18 finished 2% later on 61% more (DEFAULT_POWER_EFFICIENCY + * has the numbers). Elsewhere the fans are a likely cost, not a measured one. */ export function powerTradeoff(platform: string): string { const fans = platform === "darwin" ? "runs the fans at full speed" : "can run the fans at full speed"; - return `Above Balanced, a scan finishes about 40% sooner but uses about twice the CPU, and ${fans}.`; + return `Above Balanced, a scan finishes at most about 5% sooner but uses 25–60% more CPU, and ${fans}.`; } const TOOLTIP_DELAY_MS = 450; diff --git a/src/shared/powerEfficiency.ts b/src/shared/powerEfficiency.ts index 5d10f100..4b2de595 100644 --- a/src/shared/powerEfficiency.ts +++ b/src/shared/powerEfficiency.ts @@ -14,8 +14,14 @@ export const POWER_EFFICIENCY_PRESETS: readonly PowerEfficiency[] = [ "drain-my-battery", ]; -/** Settings without a saved choice get this: the scanner's old cap of 8. */ -export const DEFAULT_POWER_EFFICIENCY: PowerEfficiency = "aggressive"; +/** + * Settings without a saved choice get this. On an 18-CPU M5 Max, a whole + * `/` scan (20.8M files, two warm rounds) took 258 s and 674 CPU-s at 4 + * workers, 248 s and 849 CPU-s at 8 (the scanner's old cap), 264 s and + * 1,085 CPU-s at 18, and 344 s and 572 CPU-s at 2. Past 4 the walk + * barely gets faster; the CPU keeps climbing. + */ +export const DEFAULT_POWER_EFFICIENCY: PowerEfficiency = "balanced"; const NAMES: Record = { miser: "Miser", From fabcb77ce7a2c1dca9b38bb998647201bd1ca786 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 02:23:14 -0400 Subject: [PATCH 3/9] Record the Power Efficiency I/O budgets A menu choice rewrites settings.json once (~1.2 KB) and starts no process; choosing the saved preset again, an unknown preset, or browsing the menu costs no IPC, reads, writes or processes. --- .../main.powerEfficiency.ioBudget.test.ts | 2 +- src/test/io-budgets.json | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/__tests__/main.powerEfficiency.ioBudget.test.ts b/src/__tests__/main.powerEfficiency.ioBudget.test.ts index a937e93e..cda0cb1d 100644 --- a/src/__tests__/main.powerEfficiency.ioBudget.test.ts +++ b/src/__tests__/main.powerEfficiency.ioBudget.test.ts @@ -46,7 +46,7 @@ describe("choosing a Power Efficiency preset", () => { expect(settingsOnDisk().scanning.powerEfficiency).toBe("balanced"); expectIoBudget({ scenario: "main-power-efficiency-choose", - note: "a menu choice: one settings.json rewrite (~2 KB), no process; only on a click, so a handful of writes a day at most, at any monitoring interval", + note: "a menu choice: one settings.json rewrite (~1.2 KB) and its mkdir, no process; only on a click, so a handful of writes a day at most, at any monitoring interval", io: choose.io, }); diff --git a/src/test/io-budgets.json b/src/test/io-budgets.json index 5cebaf2d..8083cafa 100644 --- a/src/test/io-budgets.json +++ b/src/test/io-budgets.json @@ -1125,6 +1125,50 @@ "worker": 0, "observedBytesWritten": 0 }, + "main-power-efficiency-choose": { + "note": "a menu choice: one settings.json rewrite (~1.2 KB) and its mkdir, no process; only on a click, so a handful of writes a day at most, at any monitoring interval", + "writeFile": 1, + "appendFile": 0, + "rename": 0, + "copyFile": 0, + "link": 0, + "mkdir": 1, + "createWriteStream": 0, + "unlink": 0, + "readFile": 0, + "readdir": 0, + "stat": 0, + "lstat": 0, + "access": 0, + "existsSync": 0, + "createReadStream": 0, + "open": 0, + "spawn": 0, + "worker": 0, + "observedBytesWritten": 1192 + }, + "main-power-efficiency-choose-same": { + "note": "choosing the saved preset again, then an unknown one: 0 writes", + "writeFile": 0, + "appendFile": 0, + "rename": 0, + "copyFile": 0, + "link": 0, + "mkdir": 0, + "createWriteStream": 0, + "unlink": 0, + "readFile": 0, + "readdir": 0, + "stat": 0, + "lstat": 0, + "access": 0, + "existsSync": 0, + "createReadStream": 0, + "open": 0, + "spawn": 0, + "worker": 0, + "observedBytesWritten": 0 + }, "main-search-typing": { "note": "7 queries as a user types, refines, backs up and repeats one: each streams, gunzips and parses the whole index (~330 MB gz at 7M files)", "writeFile": 0, @@ -1384,6 +1428,28 @@ "worker": 0, "observedBytesWritten": 0 }, + "renderer-power-menu-browse": { + "note": "open the Power Efficiency menu 5 times, hover every row and arrow through it, close: 0 IPC, 0 reads, 0 writes", + "writeFile": 0, + "appendFile": 0, + "rename": 0, + "copyFile": 0, + "link": 0, + "mkdir": 0, + "createWriteStream": 0, + "unlink": 0, + "readFile": 0, + "readdir": 0, + "stat": 0, + "lstat": 0, + "access": 0, + "existsSync": 0, + "createReadStream": 0, + "open": 0, + "spawn": 0, + "worker": 0, + "observedBytesWritten": 0 + }, "scan-completed-json-files": { "note": "one completed scan's JSON: the history snapshot and last-scan.json hold the same ~2.8 MB, 5.6 MB per scan and ~22 MB/day at the 6 h default, half of it the duplicate; the NDJSON index and sidecar renames are not in this scenario", "writeFile": 3, From 20ee5f8abda3bc89bd4a3c27d852a5d32b9949c6 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 08:28:10 -0400 Subject: [PATCH 4/9] Add the scan-worker benchmark behind the Power Efficiency table scripts/bench-scan-workers.py builds the release scanner and runs it the way a preset does: --workers N with the index, folder-tree and Dev Artifacts outputs, deleted after each run. It runs an untimed warm-up, then rotating rounds under /usr/bin/time for the scan's own CPU seconds, samples whole-machine busy time every 0.5 s (and 10 s before each run), and prints the median table with every delta against the largest worker count. --lock holds a shared benchmark lock for the whole block. Same shape as disktree's script, so the two apps' tables compare. macOS and Linux. --- scripts/bench-scan-workers.py | 214 ++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100755 scripts/bench-scan-workers.py diff --git a/scripts/bench-scan-workers.py b/scripts/bench-scan-workers.py new file mode 100755 index 00000000..62cb13de --- /dev/null +++ b/scripts/bench-scan-workers.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Time whole scans at several worker counts and print the Power Efficiency table. + +Each run is the release native scanner, started the way the app starts a scan +with a Power Efficiency preset: `--workers N` plus the index, folder-tree and +Dev Artifacts outputs, written to a temp dir and deleted after the run. It runs +under `/usr/bin/time`, so CPU time is the scan's own user + system seconds. +Whole-machine busy time is sampled every half second while it runs (macOS +`host_statistics`, Linux `/proc/stat`), and for 10 s before it. Rounds rotate +the order so drift in the machine or the cache does not favour one count. The +first scan is an untimed warm-up. + + scripts/bench-scan-workers.py / --workers 18 8 4 2 --rounds 3 + +Hold the machine quiet: a VM, an indexer or a build skews every column. +macOS and Linux only; the Windows walkers have no /usr/bin/time here. +""" + +import argparse +import ctypes +import fcntl +import json +import os +import pathlib +import platform +import re +import shutil +import statistics +import subprocess +import sys +import tempfile +import threading +import time + +REPO = pathlib.Path(__file__).resolve().parent.parent +CRATE = REPO / "native/diskhound-native-scanner" +SCANNER = CRATE / "target/release/diskhound-native-scanner" + + +def cpu_ticks(): + """(busy, total) ticks for the whole machine since boot.""" + if platform.system() == "Darwin": + lib = ctypes.CDLL("/usr/lib/libSystem.B.dylib") + lib.mach_host_self.restype = ctypes.c_uint + ticks = (ctypes.c_uint * 4)() + count = ctypes.c_uint(4) + # HOST_CPU_LOAD_INFO: user, system, idle, nice. + if lib.host_statistics(lib.mach_host_self(), 3, ticks, ctypes.byref(count)): + raise OSError("host_statistics failed") + return sum(ticks) - ticks[2], sum(ticks) + fields = [int(x) for x in open("/proc/stat").readline().split()[1:]] + idle = fields[3] + fields[4] + return sum(fields) - idle, sum(fields) + + +def busy_percent(before, after): + # The Mach counters are 32-bit and wrap. + busy, total = ((a - b) % 2**32 for a, b in zip(after, before)) + return 100 * busy / total if total else None + + +def percent(value): + return "n/a" if value is None else f"{value:.0f}%" + + +def cpu_seconds(time_output): + """User + system seconds from BSD or GNU `/usr/bin/time` output.""" + user = re.search(r"([\d.]+) user|User time \(seconds\): ([\d.]+)", time_output) + system = re.search(r"([\d.]+) sys|System time \(seconds\): ([\d.]+)", time_output) + return sum(float(next(g for g in m.groups() if g)) for m in (user, system)) + + +def peak_rss_gib(time_output): + match = re.search(r"(\d+)\s+maximum resident set size|Maximum resident set size \(kbytes\): (\d+)", time_output) + if not match: + return None + # BSD reports bytes, GNU kilobytes. + return int(match.group(1)) / 2**30 if match.group(1) else int(match.group(2)) / 2**20 + + +def scan(root, workers): + before_idle = cpu_ticks() + time.sleep(10) + idle_before = busy_percent(before_idle, cpu_ticks()) + + out_dir = pathlib.Path(tempfile.mkdtemp(prefix="diskhound-bench-")) + flag = "-l" if platform.system() == "Darwin" else "-v" + env = dict(os.environ) + # It would override --workers. + env.pop("DISKHOUND_PARALLEL_THREADS", None) + before = cpu_ticks() + started = time.monotonic() + process = subprocess.Popen( + [ + "/usr/bin/time", flag, str(SCANNER), "--root", root, "--workers", str(workers), + "--index-output", str(out_dir / "scan.ndjson.gz"), + "--folder-tree-output", str(out_dir / "scan.folder-tree.ndjson.gz"), + "--dev-artifacts-output", str(out_dir / "scan.dev-artifacts.json.gz"), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + # Its own group, so an interrupt never leaves a scan running. + start_new_session=True, + ) + # A whole drive streams gigabytes of progress snapshots; keep the last line. + tail = {"last": b""} + + def drain(): + buf = b"" + for chunk in iter(lambda: process.stdout.read(1 << 20), b""): + buf = (buf + chunk)[-(16 << 20):] + lines = [line for line in buf.split(b"\n") if line.strip()] + tail["last"] = lines[-1] if lines else b"" + + reader = threading.Thread(target=drain) + reader.start() + try: + err = process.stderr.read().decode("utf8", "replace") + process.wait() + reader.join() + except BaseException: + os.killpg(process.pid, 9) + raise + finally: + shutil.rmtree(out_dir, ignore_errors=True) + wall = time.monotonic() - started + if process.returncode: + sys.exit(f"scan failed:\n{err}") + done = json.loads(tail["last"]) + if done.get("type") != "done": + sys.exit(f"scan did not finish:\n{err}") + snapshot = done["snapshot"] + return { + "workers": workers, + "wall": wall, + "cpu_s": cpu_seconds(err), + "rss_gib": peak_rss_gib(err), + "machine": busy_percent(before, cpu_ticks()), + "idle_before": idle_before, + "files": snapshot["filesVisited"], + "dirs": snapshot["directoriesVisited"], + "bytes": snapshot["bytesSeen"], + "skipped": snapshot["skippedEntries"], + "walking": next((line for line in err.splitlines() if "walking with" in line), None), + } + + +def preset(workers, cores): + if workers == cores and workers > 8: + return "Drain My Battery" + return {2: "Miser", 4: "Balanced", 8: "Aggressive"}.get(workers, "") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("root", help="directory or volume to scan, e.g. / (what the app scans for the startup disk)") + parser.add_argument("--workers", type=int, nargs="+", default=[os.cpu_count() or 8, 8, 4, 2]) + parser.add_argument("--rounds", type=int, default=3) + parser.add_argument("--out", type=pathlib.Path, help="write every run as JSON here") + parser.add_argument("--lock", help="flock this file for the whole block") + args = parser.parse_args() + + subprocess.run(["cargo", "build", "--release"], cwd=CRATE, check=True) + lock = open(args.lock, "w") if args.lock else None + if lock: + print(f"waiting for {args.lock}", file=sys.stderr) + fcntl.flock(lock, fcntl.LOCK_EX) + cores = os.cpu_count() or 1 + base = max(args.workers) + print(f"warm-up: {base} workers", file=sys.stderr) + scan(args.root, base) + runs = [] + for round_ in range(args.rounds): + shift = round_ % len(args.workers) + order = args.workers[shift:] + args.workers[:shift] + if round_ % 2: + order.reverse() + for workers in order: + run = scan(args.root, workers) + run["round"] = round_ + 1 + # The scan's own share of every core, which background load cannot move. + run["share"] = 100 * run["cpu_s"] / (run["wall"] * cores) + runs.append(run) + print( + f"round {round_ + 1}, {workers} workers: {run['wall']:.1f} s, " + f"{run['cpu_s']:.0f} CPU-s, machine {percent(run['machine'])} " + f"(idle before {percent(run['idle_before'])})", + file=sys.stderr, + ) + if args.out: + args.out.write_text(json.dumps(runs, indent=1)) + + def median(workers, key): + values = [r[key] for r in runs if r["workers"] == workers and r[key] is not None] + return statistics.median(values) if values else None + + b_time, b_cpu, b_share = (median(base, k) for k in ("wall", "cpu_s", "share")) + print(f"\nMedian of {args.rounds}; Δ against {base} workers; {cores} cores.\n") + print("| Preset | Workers | Scan time | Δ time | CPU time | Δ CPU time | Machine used | Δ machine | Whole machine |") + print("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") + for workers in sorted(args.workers, reverse=True): + t, c, s, m = (median(workers, k) for k in ("wall", "cpu_s", "share", "machine")) + first = workers == base + pct = lambda x, y: "baseline" if first else f"{(x - y) / y * 100:+.0f}%" if y else "n/a" + pts = "baseline" if first else f"{s - b_share:+.0f} pts" + print( + f"| {preset(workers, cores)} | {workers} | {t:.0f} s | {pct(t, b_time)} | {c:,.0f} CPU-s " + f"| {pct(c, b_cpu)} | {s:.0f}% | {pts} | {percent(m)} |" + ) + + +if __name__ == "__main__": + main() From ce32b3d05b95c63032f9a3fc4b647839eda8c58c Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 09:30:41 -0400 Subject: [PATCH 5/9] Word the Power Efficiency tradeoff from the 3-round medians scripts/bench-scan-workers.py / --workers 18 8 4 2 --rounds 3 on an 18-CPU M5 Max (20.7M files): 18 workers 234 s / 1,088 CPU-s, 8 workers 267 s / 913, 4 workers 250 s / 661, 2 workers 334 s / 558. Against Balanced, 8 was no faster and 18 at most about 6% faster, for 38% and 65% more CPU time. The scanner's consumer thread, which takes every entry the walkers find, ran at a median 95% of a core during an 18-worker scan, which is why more walkers don't help. The tooltip now says a scan above Balanced finishes little or no sooner for 40-65% more CPU. --- native/diskhound-native-scanner/src/main.rs | 14 ++++++++------ src/renderer/components/PowerEfficiencyControl.tsx | 11 ++++++----- src/shared/powerEfficiency.ts | 10 ++++++---- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/native/diskhound-native-scanner/src/main.rs b/native/diskhound-native-scanner/src/main.rs index c45442e6..2d6c4447 100644 --- a/native/diskhound-native-scanner/src/main.rs +++ b/native/diskhound-native-scanner/src/main.rs @@ -1542,12 +1542,14 @@ fn scan_generic_with_plan( // since reads of separate directories hit different inode blocks. // // The app passes the Power Efficiency choice as --workers (Balanced, - // 4, by default). On an 18-core M5 Max a full `/` scan (20.8M files, - // index and sidecars written) took 258 s and 674 CPU-s at 4 workers, - // 248 s and 849 CPU-s at 8, 264 s and 1,085 CPU-s at 18, and 344 s - // and 572 CPU-s at 2. Past 4, extra workers mostly add kernel time. - // Without --workers (the CLI), at most 8, like the Windows walker. - // DISKHOUND_PARALLEL_THREADS overrides both. + // 4, by default). On an 18-core M5 Max a full `/` scan (20.7M files, + // index and sidecars written; medians of 3 from + // scripts/bench-scan-workers.py) took 250 s and 661 CPU-s at 4 + // workers, 267 s and 913 CPU-s at 8, 234 s and 1,088 CPU-s at 18, and + // 334 s and 558 CPU-s at 2. The loop below takes every entry on one + // thread and runs near a full core, so past 4 more walkers mostly add + // CPU time. Without --workers (the CLI), at most 8, like the Windows + // walker. DISKHOUND_PARALLEL_THREADS overrides both. let thread_count = walk_workers(state.input.workers, { let logical = num_cpus::get().max(1); if logical <= 2 { diff --git a/src/renderer/components/PowerEfficiencyControl.tsx b/src/renderer/components/PowerEfficiencyControl.tsx index 9adb0620..55021280 100644 --- a/src/renderer/components/PowerEfficiencyControl.tsx +++ b/src/renderer/components/PowerEfficiencyControl.tsx @@ -20,14 +20,15 @@ import { toast } from "./Toasts"; /** * What the tooltip says the choice costs, where the gauge alone cannot. - * Measured with the native scanner on an 18-CPU M5 Max, whole `/` (20.8M - * files): against Balanced's 4 workers, 8 finished 4% sooner on 26% more - * CPU time, and 18 finished 2% later on 61% more (DEFAULT_POWER_EFFICIENCY - * has the numbers). Elsewhere the fans are a likely cost, not a measured one. + * Measured with the native scanner on an 18-CPU M5 Max, whole `/` (20.7M + * files, medians of 3): against Balanced's 4 workers, 8 took 7% longer on + * 38% more CPU time and 18 finished 6% sooner on 65% more + * (DEFAULT_POWER_EFFICIENCY has the numbers). Elsewhere the fans are a + * likely cost, not a measured one. */ export function powerTradeoff(platform: string): string { const fans = platform === "darwin" ? "runs the fans at full speed" : "can run the fans at full speed"; - return `Above Balanced, a scan finishes at most about 5% sooner but uses 25–60% more CPU, and ${fans}.`; + return `Above Balanced, a scan finishes little or no sooner but uses 40–65% more CPU, and ${fans}.`; } const TOOLTIP_DELAY_MS = 450; diff --git a/src/shared/powerEfficiency.ts b/src/shared/powerEfficiency.ts index 4b2de595..5ca1d0b6 100644 --- a/src/shared/powerEfficiency.ts +++ b/src/shared/powerEfficiency.ts @@ -16,10 +16,12 @@ export const POWER_EFFICIENCY_PRESETS: readonly PowerEfficiency[] = [ /** * Settings without a saved choice get this. On an 18-CPU M5 Max, a whole - * `/` scan (20.8M files, two warm rounds) took 258 s and 674 CPU-s at 4 - * workers, 248 s and 849 CPU-s at 8 (the scanner's old cap), 264 s and - * 1,085 CPU-s at 18, and 344 s and 572 CPU-s at 2. Past 4 the walk - * barely gets faster; the CPU keeps climbing. + * `/` scan (20.7M files; medians of 3, scripts/bench-scan-workers.py) + * took 250 s and 661 CPU-s at 4 workers, 267 s and 913 CPU-s at 8 (the + * scanner's old cap), 234 s and 1,088 CPU-s at 18, and 334 s and 558 + * CPU-s at 2. The scanner's one consumer thread, which takes every entry + * the walkers find, runs near a full core, so past 4 more walkers barely + * speed the scan up; they only add CPU time. */ export const DEFAULT_POWER_EFFICIENCY: PowerEfficiency = "balanced"; From c09b7d7ed3ccb718311ae879db98dae54a9ef69f Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 09:35:11 -0400 Subject: [PATCH 6/9] Widen the Power Efficiency menu so Drain My Battery fits At 288 px the name lane ellipsized it beside the share bar and the worker count; 320 px matches the 1b design. --- src/renderer/index.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/index.css b/src/renderer/index.css index c00fcc0a..21fd96a7 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1125,7 +1125,8 @@ button, input, select, textarea { font: inherit; } to { opacity: 1; } } .power-menu { - width: 288px; + /* Wide enough for "Drain My Battery" beside the bar and count lanes. */ + width: 320px; padding: 4px; background: var(--bg); outline: none; From 2d8fd0c00cb76351f91324f126f3af321ee18646 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 19:21:43 -0400 Subject: [PATCH 7/9] Honor power efficiency across scan paths --- native/diskhound-native-scanner/src/main.rs | 41 ++++++++++++++----- .../renderer.powerEfficiency.ioBudget.test.ts | 31 +++++++++++++- src/renderer/components/Toasts.tsx | 41 +++++++++++++++++-- 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/native/diskhound-native-scanner/src/main.rs b/native/diskhound-native-scanner/src/main.rs index 2d6c4447..f871e2d7 100644 --- a/native/diskhound-native-scanner/src/main.rs +++ b/native/diskhound-native-scanner/src/main.rs @@ -2022,15 +2022,16 @@ fn emit_mft_records_into_state( // merging at end remains correct because the global top-K is // guaranteed to be contained in the union of all shards' top-Ks. // - // Shard count: env override or num_cpus(), clamped to [1, 8] to - // match the pre-seed parallel walker's tuning. 8 threads on a - // 16-logical-core box gives good speedup without saturating L3. - let shard_count = std::env::var("DISKHOUND_EMIT_THREADS") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|&n| n >= 1) - .unwrap_or_else(|| num_cpus::get().clamp(1, 8)); - let shard_count = shard_count.max(1); + // Shard count: the MFT-specific env override wins for experiments, + // then the app's Power Efficiency choice, then num_cpus() clamped + // to [1, 8]. 8 threads on a 16-logical-core box gives good speedup + // without saturating L3. + let emit_override = std::env::var("DISKHOUND_EMIT_THREADS").ok(); + let shard_count = choose_mft_emit_workers( + emit_override.as_deref(), + state.input.workers, + num_cpus::get().clamp(1, 8), + ); // Slice the Vec into contiguous chunks. Using split_off shuffles // Strings between heaps unnecessarily; Vec::chunks_mut would work @@ -4090,6 +4091,17 @@ fn choose_walk_workers(env: Option<&str>, requested: Option, default: usi from_env.or(requested).unwrap_or(default).max(1) } +/// MFT emission uses its own diagnostic override, but otherwise honors +/// the same Power Efficiency worker count as the directory walkers. +#[cfg(any(windows, test))] +fn choose_mft_emit_workers( + emit_env: Option<&str>, + requested: Option, + default: usize, +) -> usize { + choose_walk_workers(emit_env, requested, default) +} + fn file_extension(file_name: &str) -> String { Path::new(file_name) .extension() @@ -4881,7 +4893,7 @@ fn windows_extended_path(path: &Path) -> String { #[cfg(test)] mod walk_workers_tests { - use super::choose_walk_workers; + use super::{choose_mft_emit_workers, choose_walk_workers}; #[test] fn the_env_override_wins_then_the_app_choice_then_the_walker_default() { @@ -4895,6 +4907,15 @@ mod walk_workers_tests { assert_eq!(choose_walk_workers(Some("lots"), None, 8), 8); assert_eq!(choose_walk_workers(None, None, 0), 1); } + + #[test] + fn mft_emit_honors_the_app_choice_but_keeps_its_explicit_override() { + assert_eq!(choose_mft_emit_workers(None, Some(2), 8), 2); + assert_eq!(choose_mft_emit_workers(None, Some(4), 8), 4); + assert_eq!(choose_mft_emit_workers(Some("6"), Some(2), 8), 6); + assert_eq!(choose_mft_emit_workers(Some("bad"), Some(4), 8), 4); + assert_eq!(choose_mft_emit_workers(None, None, 8), 8); + } } #[cfg(test)] diff --git a/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts b/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts index e5d18ce9..0f7aa621 100644 --- a/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts +++ b/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts @@ -11,7 +11,7 @@ import { PowerEfficiencyControl, PowerEfficiencyMenuButton, } from "../renderer/components/PowerEfficiencyControl"; -import { ToastProvider } from "../renderer/components/Toasts"; +import { toast, ToastProvider } from "../renderer/components/Toasts"; import type { AppSettings } from "../shared/contracts"; import { DEFAULT_POWER_EFFICIENCY, @@ -236,6 +236,35 @@ describe("the Power Efficiency button", () => { }); describe("the saved Power Efficiency control", () => { + it("gives a replacement confirmation a fresh dismissal window", async () => { + const view = ui.mount(h(ToastProvider, null, h("span", null))); + await ui.settle(); + + toast("success", "First choice", undefined, { + id: "power-efficiency", + dismissAfterMs: 120, + action: { label: "Old rescan", run: () => {} }, + }); + await wait(80); + toast("success", "Latest choice", undefined, { + id: "power-efficiency", + dismissAfterMs: 120, + action: { label: "Rescan now", run: () => {} }, + }); + + // The first toast's original deadline has passed, but the replacement + // keeps its complete lifetime and its latest action. + await wait(60); + expect(one(view, ".toast-title").textContent).toBe("Latest choice"); + expect(button(view, "Rescan now")).toBeTruthy(); + expect(one(view, ".toast").classList.contains("exiting")).toBe(false); + + await wait(70); + expect(one(view, ".toast").classList.contains("exiting")).toBe(true); + await wait(230); + expect(view.querySelector(".toast")).toBeNull(); + }); + it("saves a choice through main, says when it applies, and offers a rescan", async () => { const cpus = window.diskhound!.cpuCount; const rescans: string[] = []; diff --git a/src/renderer/components/Toasts.tsx b/src/renderer/components/Toasts.tsx index 4bc3f9b9..0e856958 100644 --- a/src/renderer/components/Toasts.tsx +++ b/src/renderer/components/Toasts.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import type { ToastMessage } from "../../shared/contracts"; import { nativeApi } from "../nativeApi"; @@ -23,6 +23,14 @@ interface ToastAction { run: () => void; } type RendererToast = ToastMessage & { action?: ToastAction }; +type ToastTimer = ReturnType; + +function clearToastTimer(timers: Map, id: string): void { + const timer = timers.get(id); + if (timer === undefined) return; + clearTimeout(timer); + timers.delete(id); +} let externalAddToast: ((toast: RendererToast) => void) | null = null; let externalDismissToast: ((id: string) => void) | null = null; @@ -50,13 +58,25 @@ export function dismissToast(id: string): void { export function ToastProvider({ children }: { children: any }) { const [toasts, setToasts] = useState<(RendererToast & { exiting?: boolean })[]>([]); + const dismissTimers = useRef(new Map()); + const removeTimers = useRef(new Map()); const dismiss = useCallback((id: string) => { + clearToastTimer(dismissTimers.current, id); + clearToastTimer(removeTimers.current, id); setToasts((t) => t.map((x) => (x.id === id ? { ...x, exiting: true } : x))); - setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 220); + const timer = setTimeout(() => { + removeTimers.current.delete(id); + setToasts((t) => t.filter((x) => x.id !== id)); + }, 220); + removeTimers.current.set(id, timer); }, []); const addToast = useCallback((t: RendererToast) => { + // An upsert starts a fresh lifetime. Also cancel a pending exit + // removal in case the replacement arrived during the fade-out. + clearToastTimer(dismissTimers.current, t.id); + clearToastTimer(removeTimers.current, t.id); setToasts((prev) => { // Upsert by id: if the id already exists, replace the entry // in-place. Keeps progress toasts to a single visible card. @@ -71,14 +91,27 @@ export function ToastProvider({ children }: { children: any }) { // Sticky toasts pass 0 — don't auto-dismiss. Non-positive is // treated the same so callers can pass 0, null, or undefined. if (t.dismissAfterMs && t.dismissAfterMs > 0) { - setTimeout(() => dismiss(t.id), t.dismissAfterMs); + const timer = setTimeout(() => { + dismissTimers.current.delete(t.id); + dismiss(t.id); + }, t.dismissAfterMs); + dismissTimers.current.set(t.id, timer); } }, [dismiss]); useEffect(() => { externalAddToast = addToast; externalDismissToast = dismiss; - return nativeApi.onNotification(addToast); + const unsubscribe = nativeApi.onNotification(addToast); + return () => { + unsubscribe(); + if (externalAddToast === addToast) externalAddToast = null; + if (externalDismissToast === dismiss) externalDismissToast = null; + for (const timer of dismissTimers.current.values()) clearTimeout(timer); + for (const timer of removeTimers.current.values()) clearTimeout(timer); + dismissTimers.current.clear(); + removeTimers.current.clear(); + }; }, [addToast, dismiss]); return ( From 887906f7604aef0ba565cd3b182bd77a888e700d Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 28 Sep 2026 21:18:35 -0400 Subject: [PATCH 8/9] Mark the saved Power Efficiency preset with a check in the menu Bold alone lost to the hover fill: the row under the pointer read as the selection. The saved preset now keeps an amber check wherever the highlight goes. --- .../renderer.powerEfficiency.ioBudget.test.ts | 5 +++++ src/renderer/components/PowerEfficiencyControl.tsx | 7 +++++++ src/renderer/index.css | 12 ++++++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts b/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts index 0f7aa621..d53f40b5 100644 --- a/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts +++ b/src/__tests__/renderer.powerEfficiency.ioBudget.test.ts @@ -150,6 +150,11 @@ describe("the Power Efficiency button", () => { const miser = row(view, "Miser"); await pointer(miser, "mouseenter"); expect(highlighted(view)).toBe("Miser"); + // The check stays on the saved preset while the highlight moves. + const checked = () => [...view.querySelectorAll(".power-menu-item")] + .filter((item) => item.querySelector(".power-menu-check svg")) + .map((item) => item.querySelector(".power-menu-name")?.textContent); + expect(checked()).toEqual(["Aggressive"]); await click(miser); expect(chosen).toEqual(["miser"]); expect(menu(view)).toBeNull(); diff --git a/src/renderer/components/PowerEfficiencyControl.tsx b/src/renderer/components/PowerEfficiencyControl.tsx index 55021280..17ef662d 100644 --- a/src/renderer/components/PowerEfficiencyControl.tsx +++ b/src/renderer/components/PowerEfficiencyControl.tsx @@ -277,6 +277,13 @@ export function PowerEfficiencyMenuButton({ preset, cpus, onChoose, platform = n onMouseDown={(event) => event.preventDefault()} onClick={() => choose(p)} > + {powerEfficiencyName(p)}