From c8f846cbb028d73ba2444ad47f04713f123a5a32 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 15:58:14 +0800 Subject: [PATCH 1/5] fix(search): isolate search failures from operation errors --- docs/reference/features/launcher.md | 6 +- src/App.tsx | 19 ++- src/launcherController.ts | 13 +- tests/launcher-controller.spec.ts | 99 +++++++++++- tests/mock-backend.ts | 9 ++ tests/search-errors.spec.ts | 232 ++++++++++++++++++++++++++++ 6 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 tests/search-errors.spec.ts diff --git a/docs/reference/features/launcher.md b/docs/reference/features/launcher.md index 05e1aca..2d59189 100644 --- a/docs/reference/features/launcher.md +++ b/docs/reference/features/launcher.md @@ -8,6 +8,8 @@ All searches applications, files, clipboard text, emoji, calculations, system co Search keeps one expensive request in flight and replaces waiting input with the latest query. New input, hiding, or disposal sends a separate lightweight cancellation for the active request. Stale replies are still ignored. A cancellation failure does not dispatch overlapping searches; the latest waiting query follows the old response. Cancellation acknowledgements finish before the next dispatch. +A successful current search clears a previous search failure, including a background retry of the same query after a data-change event. It does not dismiss an action or startup failure. Those errors take precedence while present and keep the existing explicit dismissal paths, such as editing the query or reopening the launcher. Obsolete successes and failures cannot change the current search error. + A request has one 250 ms cooperative budget, starting in the backend command before blocking-worker queueing and including search-lock wait, providers, and pins. App/file matching checks every 64 entries, clipboard matching every 16 entries, and calculator evaluation uses its interrupt callback. The backend also checks between providers and pins and before returning a response. Expired requests report a notice/error rather than a partially ranked list. This is not a hard input-to-paint guarantee: runtime scheduling, individual matcher calls, sorting, fixed-size providers, serialization, IPC, and WebView rendering can overrun the cooperative checkpoint interval. Tab and Shift + Tab change categories while preserving the query and search focus. Arrow keys select results. Enter runs the selected action. Escape closes a dialog or menu before hiding the launcher. [Keyboard reference](../keyboard-shortcuts.md) lists the remaining controls. @@ -22,10 +24,10 @@ Run this browser recipe from the repository root. It retains successful traces i ```sh bun run verify:browser tests/welcome.spec.ts tests/categories.spec.ts tests/launcher.spec.ts -bun run verify:browser tests/launcher-controller.spec.ts tests/native-subscriptions.spec.ts tests/launcher-warnings.spec.ts tests/ipc-contract.spec.ts tests/ipc-drift.spec.ts +bun run verify:browser tests/launcher-controller.spec.ts tests/search-errors.spec.ts tests/native-subscriptions.spec.ts tests/launcher-warnings.spec.ts tests/ipc-contract.spec.ts tests/ipc-drift.spec.ts ``` -The standalone controller checks cover result reconciliation, refresh selection, hidden/disposed replies, reopening before an old reply settles (the original single-flight queue must ignore the old result and keep the new search pending), failure state, and browser-preview gating. Subscription checks cover late registrations and teardown. Warning tests use changed display text to prove that settings repair follows structured codes rather than English prefixes; unrelated platform warnings remain visible. A storage warning must leave search results usable. +The standalone controller checks cover result reconciliation, refresh selection, hidden/disposed replies, reopening before an old reply settles (the original single-flight queue must ignore the old result and keep the new search pending), failure state, same-query error recovery, superseded-success isolation, and browser-preview gating. Mocked-IPC browser regressions drive input and actions, then emit background events to check search-error recovery without an input edit, action/startup error survival, and stale success isolation while the latest retry is pending. These tests prove frontend ownership, not native event delivery or OS action outcomes. Subscription checks cover late registrations and teardown. Warning tests use changed display text to prove that settings repair follows structured codes rather than English prefixes; unrelated platform warnings remain visible. A storage warning must leave search results usable. For affected backend behavior: diff --git a/src/App.tsx b/src/App.tsx index 230126b..060120c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,16 +103,29 @@ export default function App( files, setFiles, currency, - error, - setError, + searchError, + clearSearchError, indexError, storageError, notice, setNotice, search, - changeQuery, + changeQuery: changeSearchQuery, markSelectionChanged, } = controller; + // Actions and startup own their failures independently of search refreshes. + const [operationError, setOperationError] = createSignal(); + const error = () => operationError() ?? searchError(); + function setError(reason: string | undefined) { + setOperationError(reason); + // Existing explicit dismissals (input, actions, reopen) clear both owners. + // Search delivery never uses this setter. + if (reason === undefined) clearSearchError(); + } + function changeQuery(value: string) { + setOperationError(undefined); + changeSearchQuery(value); + } const [pinBusy, setPinBusy] = createSignal(false); const [info, setInfo] = createSignal(); const [busy, setBusy] = createSignal(false); diff --git a/src/launcherController.ts b/src/launcherController.ts index e454643..1cef301 100644 --- a/src/launcherController.ts +++ b/src/launcherController.ts @@ -66,7 +66,7 @@ export function createLauncherController(options: { refreshing: false, warning: null, }); - const [error, setError] = createSignal(); + const [searchError, setSearchError] = createSignal(); const [indexError, setIndexError] = createSignal(); const [storageError, setStorageError] = createSignal(); const [notice, setNotice] = createSignal(); @@ -89,6 +89,9 @@ export function createLauncherController(options: { }); displayedQuery = { value: request.value, mode: request.mode }; batch(() => { + // The queue delivers only current replies. A background retry can + // repair search without dismissing App-owned action/startup errors. + setSearchError(undefined); setResults(response.results); setSelected(index); setTotal(response.total); @@ -102,7 +105,7 @@ export function createLauncherController(options: { }, fail(_request, reason) { setResults([]); - setError(String(reason)); + setSearchError(String(reason)); }, settled: () => setPending(false), }); @@ -123,7 +126,7 @@ export function createLauncherController(options: { function changeQuery(value: string) { setQuery(value); - setError(undefined); + setSearchError(undefined); void search(value); } @@ -145,8 +148,8 @@ export function createLauncherController(options: { files, setFiles, currency, - error, - setError, + searchError, + clearSearchError: () => setSearchError(undefined), indexError, storageError, notice, diff --git a/tests/launcher-controller.spec.ts b/tests/launcher-controller.spec.ts index 0e75d5c..70b6991 100644 --- a/tests/launcher-controller.spec.ts +++ b/tests/launcher-controller.spec.ts @@ -197,6 +197,103 @@ test("hide then reopen before the old reply settles keeps the new search pending }); }); +test("controller clears only a current search failure on a same-query refresh", async ({ + page, +}) => { + await page.goto("/"); + const result = await page.evaluate(async (response: SearchResponse) => { + const path = "/src/launcherController.ts"; + const { createLauncherController } = (await import( + path + )) as typeof import("../src/launcherController"); + let fail = true; + const controller = createLauncherController({ + desktop: true, + send: async () => { + if (fail) throw new Error("Search failed"); + return response; + }, + }); + controller.setQuery("fixture"); + await controller.search(); + const failure = controller.searchError(); + fail = false; + await controller.search(controller.query(), true); + const recovered = { + query: controller.query(), + error: controller.searchError() ?? null, + count: controller.results().length, + pending: controller.pending(), + }; + controller.dispose(); + return { failure, recovered }; + }, contracts.response); + expect(result).toEqual({ + failure: "Error: Search failed", + recovered: { query: "fixture", error: null, count: 11, pending: false }, + }); +}); + +test("a superseded controller success leaves the failure intact until the current retry settles", async ({ + page, +}) => { + await page.goto("/"); + const result = await page.evaluate(async (response: SearchResponse) => { + const path = "/src/launcherController.ts"; + const { createLauncherController } = (await import( + path + )) as typeof import("../src/launcherController"); + const replies: { + resolve: (response: SearchResponse) => void; + reject: (reason: Error) => void; + }[] = []; + let nextStarted!: () => void; + const latestStarted = new Promise((resolve) => { + nextStarted = resolve; + }); + const controller = createLauncherController({ + desktop: true, + send: () => + new Promise((resolve, reject) => { + replies.push({ resolve, reject }); + if (replies.length === 3) nextStarted(); + }), + }); + controller.setQuery("fixture"); + const initial = controller.search(); + replies[0].reject(new Error("First failure")); + await initial; + const old = controller.search(controller.query(), true); + const latest = controller.search(controller.query(), true); + const beforeOld = replies.length; + replies[1].resolve(response); + await latestStarted; + const afterOld = { + error: controller.searchError(), + pending: controller.pending(), + count: controller.results().length, + }; + replies[2].reject(new Error("Latest failure")); + await Promise.all([old, latest]); + const afterLatest = { + error: controller.searchError(), + pending: controller.pending(), + }; + const recovery = controller.search(controller.query(), true); + replies[3].resolve(response); + await recovery; + const recovered = controller.searchError() ?? null; + controller.dispose(); + return { beforeOld, afterOld, afterLatest, recovered }; + }, contracts.response); + expect(result).toEqual({ + beforeOld: 2, + afterOld: { error: "Error: First failure", pending: true, count: 0 }, + afterLatest: { error: "Error: Latest failure", pending: false }, + recovered: null, + }); +}); + test("controller clears stale welcome rows, reports failures, and gates non-desktop search", async ({ page, }) => { @@ -220,7 +317,7 @@ test("controller clears stale welcome rows, reports failures, and gates non-desk const clearedImmediately = controller.results().length; await pending; const failed = { - message: controller.error(), + message: controller.searchError(), pending: controller.pending(), }; const preview = createLauncherController({ diff --git a/tests/mock-backend.ts b/tests/mock-backend.ts index ad86996..feb8ba5 100644 --- a/tests/mock-backend.ts +++ b/tests/mock-backend.ts @@ -24,6 +24,8 @@ declare global { rejectNativeGlass: boolean; holdNativeGlass: boolean; releaseNativeGlass?: () => void; + rejectReady: string | null; + rejectSearch: string | null; rejectSettings: string | null; rejectResetPosition: string | null; pins: Partial>; @@ -311,6 +313,8 @@ window.__launcherTest = { (localStorage.getItem("tinydash.test.platform") as "macos" | "windows" | "linux") ?? "macos", settings: { ...defaultSettings, ...savedSettings }, + rejectReady: localStorage.getItem("tinydash.test.rejectReady"), + rejectSearch: null, rejectSettings: null, rejectResetPosition: null, pins: Array.isArray(savedPins) @@ -462,6 +466,7 @@ mockIPC( return; } if (command === "launcher_ready") { + if (state.rejectReady) throw new Error(state.rejectReady); return { platform: state.platform, settings: state.settings, @@ -567,6 +572,9 @@ mockIPC( } if (command === "search") { const { query, mode } = payload as { query: string; mode: SearchMode }; + // Capture the outcome at dispatch so a held old success can race a + // newer failure without changing the old request's result. + const rejection = state.rejectSearch; if (state.holdNextSearch) { state.holdNextSearch = false; await new Promise((resolve) => { @@ -580,6 +588,7 @@ mockIPC( }); else await new Promise((resolve) => setTimeout(resolve, 250)); } + if (rejection) throw new Error(rejection); if (query === "error") throw new Error( "The application index is unavailable. Restart TinyDash.", diff --git a/tests/search-errors.spec.ts b/tests/search-errors.spec.ts new file mode 100644 index 0000000..e0039af --- /dev/null +++ b/tests/search-errors.spec.ts @@ -0,0 +1,232 @@ +import { expect, test, type Page } from "@playwright/test"; +import type {} from "./mock-backend"; + +async function openLauncher(page: Page) { + await page.route( + (url) => url.pathname === "/src/index.tsx", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + body: `import "/tests/mock-backend.ts";\n${await response.text()}`, + }); + }, + ); + await page.goto("/"); + await expect( + page.getByRole("combobox", { name: "Search TinyDash" }), + ).toBeFocused(); +} + +async function expectSettled(page: Page) { + await expect( + page.getByRole("listbox", { includeHidden: true }), + ).toHaveAttribute("aria-busy", "false"); +} + +test("a current background retry clears a search failure without editing the query", async ({ + page, +}) => { + await openLauncher(page); + await expect(page.locator(".list-count")).toHaveText("0 results"); + await page.evaluate(() => { + window.__launcherTest.rejectSearch = "Search temporarily unavailable"; + }); + const input = page.getByRole("combobox", { name: "Search TinyDash" }); + await input.fill("sa"); + await expect(page.getByRole("alert")).toContainText( + "Search temporarily unavailable", + ); + await expect(page.getByRole("option")).toHaveCount(0); + await expectSettled(page); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = null; + await window.__launcherTest.emit("apps-changed"); + }); + await expect(page.getByRole("option", { selected: true })).toContainText( + "Safari", + ); + await expectSettled(page); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(input).toHaveValue("sa"); + await expect(input).toBeFocused(); + const searches = await page.evaluate(() => + window.__launcherTest.calls + .filter((call) => call.command === "search") + .map((call) => call.payload), + ); + expect(searches).toMatchObject([ + { query: "", mode: "all" }, + { query: "sa", mode: "all" }, + { query: "sa", mode: "all" }, + ]); +}); + +test("background success clears only search errors and preserves action failures", async ({ + page, +}) => { + await openLauncher(page); + const input = page.getByRole("combobox", { name: "Search TinyDash" }); + await input.fill("sa"); + await expect(page.getByRole("option", { selected: true })).toContainText( + "Safari", + ); + await expectSettled(page); + await page.evaluate(() => { + window.__launcherTest.rejectActions = "Application launch failed"; + }); + await input.press("Enter"); + await expect(page.getByRole("alert")).toHaveText("Application launch failed"); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = "Background search failed"; + await window.__launcherTest.emit("apps-changed"); + }); + await expect(page.getByRole("option")).toHaveCount(0); + await expectSettled(page); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = null; + await window.__launcherTest.emit("apps-changed"); + }); + await expect(page.getByRole("option", { selected: true })).toContainText( + "Safari", + ); + await expectSettled(page); + await expect(page.getByRole("alert")).toHaveText("Application launch failed"); + await expect(input).toHaveValue("sa"); + // An explicit input edit still dismisses the action failure. + await input.fill("saf"); + await expect(page.getByRole("alert")).toHaveCount(0); +}); + +test("a successful background search does not dismiss a startup failure", async ({ + page, +}) => { + await page.addInitScript(() => + localStorage.setItem( + "tinydash.test.rejectReady", + "Startup fixture failure", + ), + ); + await openLauncher(page); + await expect(page.getByRole("alert")).toContainText( + "Could not connect to TinyDash. Error: Startup fixture failure", + ); + await page.evaluate(async () => { + await window.__launcherTest.emit("apps-changed"); + }); + await expect + .poll(() => + page.evaluate( + () => + window.__launcherTest.calls.filter( + (call) => call.command === "search", + ).length, + ), + ) + .toBe(1); + await expectSettled(page); + await expect(page.getByRole("alert")).toContainText( + "Startup fixture failure", + ); +}); + +test("an obsolete background success leaves search errors intact while the latest retry is pending", async ({ + page, +}) => { + await openLauncher(page); + await expect(page.locator(".list-count")).toHaveText("0 results"); + await page.evaluate(() => { + window.__launcherTest.rejectSearch = "Initial search failure"; + }); + const input = page.getByRole("combobox", { name: "Search TinyDash" }); + await input.fill("sa"); + await expect(page.getByRole("alert")).toContainText("Initial search failure"); + await expectSettled(page); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = null; + window.__launcherTest.holdNextSearch = true; + await window.__launcherTest.emit("apps-changed"); + }); + await page.waitForFunction(() => !!window.__launcherTest.releaseSearch); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = "Latest search failure"; + window.__launcherTest.holdNextSearch = true; + await window.__launcherTest.emit("apps-changed"); + const release = window.__launcherTest.releaseSearch!; + window.__launcherTest.releaseSearch = undefined; + release(); + }); + await page.waitForFunction(() => !!window.__launcherTest.releaseSearch); + await expect(page.getByRole("listbox")).toHaveAttribute("aria-busy", "true"); + await expect(page.getByRole("alert")).toContainText("Initial search failure"); + await expect(page.getByRole("option")).toHaveCount(0); + await page.evaluate(() => window.__launcherTest.releaseSearch!()); + await expectSettled(page); + await expect(page.getByRole("alert")).toContainText("Latest search failure"); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = null; + await window.__launcherTest.emit("apps-changed"); + }); + await expect(page.getByRole("option", { selected: true })).toContainText( + "Safari", + ); + await expectSettled(page); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(input).toHaveValue("sa"); +}); + +test("an obsolete success cannot dismiss a newer action failure or replace the latest search failure", async ({ + page, +}) => { + await openLauncher(page); + const input = page.getByRole("combobox", { name: "Search TinyDash" }); + await input.fill("sa"); + await expect(page.getByRole("option")).toContainText("Safari"); + await expectSettled(page); + await page.evaluate(async () => { + window.__launcherTest.holdNextSearch = true; + await window.__launcherTest.emit("apps-changed"); + }); + await page.waitForFunction(() => !!window.__launcherTest.releaseSearch); + // This action is available during a pending search, unlike result actions. + await page.evaluate(() => { + window.__launcherTest.rejectResetPosition = "Newer reset failure"; + }); + await page.keyboard.press("Meta+k"); + await page.getByRole("menuitem", { name: "Reset window position" }).click(); + await expect(page.getByRole("alert")).toContainText("Newer reset failure"); + await page.evaluate(async () => { + window.__launcherTest.rejectSearch = "Latest search failed"; + window.__launcherTest.holdNextSearch = true; + await window.__launcherTest.emit("apps-changed"); + const release = window.__launcherTest.releaseSearch!; + window.__launcherTest.releaseSearch = undefined; + release(); + }); + await page.waitForFunction(() => !!window.__launcherTest.releaseSearch); + await expect(page.getByRole("listbox")).toHaveAttribute("aria-busy", "true"); + await expect(page.getByRole("alert")).toContainText("Newer reset failure"); + await page.evaluate(() => window.__launcherTest.releaseSearch!()); + await expectSettled(page); + await expect(page.getByRole("option")).toHaveCount(0); + await expect(page.getByRole("alert")).toContainText("Newer reset failure"); + // Reopening keeps the query and dismisses the action error. Its new search + // still fails, so the latest search failure becomes visible. + await input.press("Escape"); + await page.evaluate(async () => { + await window.__launcherTest.emit("launcher-opened", false); + }); + await expect(page.getByRole("alert")).toContainText("Latest search failed"); + await expectSettled(page); + await expect(input).toHaveValue("sa"); + const calls = await page.evaluate(() => window.__launcherTest.calls); + const held = calls.filter((call) => call.command === "search")[2].payload as { + requestId: number; + }; + expect( + calls.filter((call) => call.command === "cancel_search"), + ).toContainEqual({ + command: "cancel_search", + payload: { requestId: held.requestId }, + }); +}); From a56063a212f0771a2703b293dee0ccd121fb5b44 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 16:10:23 +0800 Subject: [PATCH 2/5] fix(files): distinguish queued refresh from active scans --- docs/reference/features/files.md | 6 +- scripts/perf/file-index/src/main.rs | 2 +- src-tauri/src/launcher/contract_tests.rs | 26 +- src-tauri/src/launcher/file_watch.rs | 57 ++- src-tauri/src/launcher/files.rs | 497 ++++++++++++++++++--- src-tauri/src/launcher/mod.rs | 5 +- src-tauri/src/launcher/wire_types_tests.rs | 1 + src/App.tsx | 75 ++-- src/bridge.ts | 4 +- src/launcherController.ts | 2 +- tests/fixtures/ipc-contract.ts | 31 +- tests/fixtures/ipc-wire.ts | 3 +- tests/ipc-contract.spec.ts | 5 + tests/ipc-contract.ts | 2 + tests/launcher.spec.ts | 154 ++++++- tests/mock-backend.ts | 45 +- 16 files changed, 790 insertions(+), 125 deletions(-) diff --git a/docs/reference/features/files.md b/docs/reference/features/files.md index 967d8ec..2682fcc 100644 --- a/docs/reference/features/files.md +++ b/docs/reference/features/files.md @@ -4,7 +4,7 @@ TinyDash scans Desktop, Documents, and Downloads after the launcher opens. It us The index contains regular files and folders under the roots. The roots themselves are not results. It excludes dot files, hidden files, Windows system files, symbolic links, and the `node_modules` and `target` folders. Hidden and excluded folders are not traversed. Configured roots must be folders and cannot be symbolic links. Overlapping roots are scanned once. Paths that cannot be represented as UTF-8 are skipped. Access errors appear as a scan warning; other folders remain searchable. TinyDash does not read file contents. -The default limit is 50,000 files and folders together. A scan also stops after visiting 500,000 entries, including folders. The UI reports a limit when the index is incomplete. Use smaller roots if a large directory reaches a limit. Scans and index preparation run on a background worker. The previous index remains searchable during a scan with the same settings. Changing file settings immediately clears the old snapshot so removed roots cannot remain searchable. Each settings change advances a generation, including changing roots back to an earlier value. Root lookup, alias resolution, traversal, and watch registration check that generation and shutdown state between operations; obsolete partial results and partially registered watches are discarded and cannot replace the current index. A cancelled cycle notifies the UI that scanning has stopped before waiting for its next budget. Cancellation cannot interrupt an OS filesystem call that is already blocked. Search uses memory, returns at most 30 results, and applies usage scores before limiting file results. +The default limit is 50,000 files and folders together. A scan also stops after visiting 500,000 entries, including folders. The UI reports a limit when the index is incomplete. Use smaller roots if a large directory reaches a limit. Scans and index preparation run on a background worker. The previous index remains searchable during a scan with the same settings. Changing file settings immediately clears the old snapshot so removed roots cannot remain searchable. Each settings change advances a generation, including changing roots back to an earlier value. Root lookup, alias resolution, traversal, and watch registration check that generation and shutdown state between operations; obsolete partial results and partially registered watches are discarded and cannot replace the current index. A cancelled cycle notifies the UI before waiting for its next budget. For the current settings, the status is queued when replacement work is pending, or disabled when no search folders are selected; an obsolete cycle cannot restore its old status or warning. Cancellation cannot interrupt an OS filesystem call that is already blocked. Search uses memory, returns at most 30 results, and applies usage scores before limiting file results. File actions and missing pins use an expected O(1) hash-table ID lookup rather than scanning the whole index. This deliberately adds one owned ID and one table entry per indexed file. On a 64-bit host, ID bytes plus 24 bytes per table capacity slot are a lower bound; control bytes, spare buckets, and allocator overhead add more. The table is replaced and released with its index. It caches neither file contents nor complete result objects. The [synthetic search benchmark](launcher.md#synthetic-search-measurements) reports this estimate for each fixture size. @@ -12,6 +12,8 @@ File matching uses Unicode NFC so composed and decomposed accents match. The sma Operating system notifications update the file index after creation, renaming, moving, and deletion. A single worker combines a burst of changes, waiting for 300 ms of quiet or at most two seconds of continuous changes. Separately, after every worker cycle (including cancelled work), it rests for at least one second or three times that cycle's duration, whichever is longer. The measured work includes root lookup, alias resolution, watcher creation, traversal, index preparation, and watch registration. Rest starts only after that work completes; registration cannot consume the cooldown. Disposing obsolete watches during a wait also counts as work and extends the rest. This bounds sustained filesystem-work duty to 25% of work-plus-rest time; long cycles therefore delay automatic and manual refreshes beyond the settle window. Refresh requests and settings changes cannot bypass the workload budget. The worker retains only one pending request, reads the latest settings after waiting, and drops obsolete watches during the wait. There is no folder polling timer. Content-write and access events do not require filename indexing. +The launcher distinguishes **Waiting to refresh files...** from **Scanning files...**. Waiting includes burst settling and budget cooldown, even when the worker has already consumed and combined the queued signals. Existing same-settings results remain usable while waiting. A root change clears the old results but shows waiting, not a finished empty index. With no configured folders it shows **File search is off**. Only an idle, completed empty index shows **No files in the index**. A scanner startup failure shows **File scan unavailable**, preserves its warning, and can be retried with Refresh files. The internal file status uses one `phase` (`disabled`, `idle`, `queued`, `scanning`, or `failed`), rather than an additional independent indexing flag. Events announce transitions, not a countdown or repeated cooldown ticks. + Aggregate `File scan workload` logs report worker cycles as `scans`, cancellations, visited entries, total work milliseconds as `scan_ms`, and elapsed worker-window milliseconds (for cycle frequency). Preparation cancelled before traversal and cancelled registration contribute to these totals, not just completed scans. Metrics contain no full paths, filenames, queries, or error strings. The [notify](https://docs.rs/notify/8.2.0/notify/) watcher uses FSEvents on macOS, ReadDirectoryChangesW on Windows, and inotify on Linux. Linux watches only folders accepted by the scanner, up to 8192 folders, so excluded trees do not consume recursive watches. Parent watches detect a removed or recreated search root. Registration limits or failures produce a warning. Network filesystems and restricted folders can omit events. **Refresh files** in the actions or tray menu remains available; Command/Ctrl + R refreshes files in Files mode. Set `fileWatchEnabled` to `false` for manual updates. File contents and file metadata are not stored in SQLite. @@ -32,7 +34,7 @@ bun run test:rust -- launcher::files bun run test:rust -- launcher::file_watch ``` -Rust regressions pause a 4096-entry traversal during rapid root changes, reject an obsolete completed scan even after roots change back, and accept only the newest generation. They also cover stop/disable cancellation, same-settings snapshot retention, immediate removed-root invalidation, stale-warning rejection, aggregate counters, and workload limits under refresh floods. Worker-loop regressions delay preparation and registration to check complete-cycle accounting, post-work rest, and cancellation status notifications. Controlled watcher operations cancel at batch start, removal, addition, and commit to prove that the remaining obsolete operations are skipped; root-resolution checks cover cancellation between metadata and ancestor lookups. These are backend proofs, not integrated desktop checks. +Rust regressions pause a 4096-entry traversal during rapid root changes, reject an obsolete completed scan even after roots change back, and accept only the newest generation. They also cover stop/disable cancellation, same-settings snapshot retention, immediate removed-root invalidation, stale-warning rejection, aggregate counters, and workload limits under refresh floods. Worker-loop regressions use an injected clock at preparation, registration, rest, and settling boundaries to check complete-cycle accounting and cancellation status notifications without timing sleeps. They check queued follow-ups during scans, refresh floods consumed during cooldown, newest-root selection, disabling, stale phase/warning rejection, and retry after scanner startup failure. Browser regressions distinguish queued, scanning, finished-empty, disabled, and failed states, including Settings-window root-change notifications; they do not simulate real filesystem timing. Controlled watcher operations cancel at batch start, removal, addition, and commit to prove that the remaining obsolete operations are skipped; root-resolution checks cover cancellation between metadata and ancestor lookups. These are backend proofs, not integrated desktop checks. Use Files and All with an isolated root. Create, rename, and delete a file without manual refresh. Confirm the visible result changes. Open a selected file and check the handler marker. Exercise Refresh files and Ctrl/Command + R when manual refresh changes. diff --git a/scripts/perf/file-index/src/main.rs b/scripts/perf/file-index/src/main.rs index 68e53a9..ddca422 100644 --- a/scripts/perf/file-index/src/main.rs +++ b/scripts/perf/file-index/src/main.rs @@ -18,7 +18,7 @@ mod launcher { #[derive(Debug, serde::Serialize)] pub struct FileStatus { pub total: usize, - pub indexing: bool, + pub phase: &'static str, pub warning: Option, } } diff --git a/src-tauri/src/launcher/contract_tests.rs b/src-tauri/src/launcher/contract_tests.rs index a008873..c4b67dc 100644 --- a/src-tauri/src/launcher/contract_tests.rs +++ b/src-tauri/src/launcher/contract_tests.rs @@ -1,7 +1,12 @@ //! Canonical wire examples are produced by serde, then type-checked by TypeScript. use super::{Action, ActionConfirmation, ResultKind, SearchResponse, SearchResult, ToolDetail}; use crate::{ - launcher::{currency::CurrencyStatus, files::FileStatus, pins::ResultPin, query::SearchMode}, + launcher::{ + currency::CurrencyStatus, + files::{FilePhase, FileStatus}, + pins::ResultPin, + query::SearchMode, + }, settings::{AppPreference, CategoryShortcut, Settings, WebSearch}, }; use serde_json::json; @@ -155,7 +160,7 @@ fn serialized_ipc_contracts_match_frontend_fixture() { storage_error: None, files: FileStatus { total: 3, - indexing: false, + phase: FilePhase::Idle, warning: None, }, currency: CurrencyStatus { @@ -178,7 +183,7 @@ fn serialized_ipc_contracts_match_frontend_fixture() { )), files: FileStatus { total: 1, - indexing: true, + phase: FilePhase::Scanning, warning: Some("Scan incomplete".into()), }, currency: CurrencyStatus { @@ -187,7 +192,22 @@ fn serialized_ipc_contracts_match_frontend_fixture() { warning: Some("Rates are old".into()), }, }; + let file_statuses: Vec<_> = [ + FilePhase::Disabled, + FilePhase::Idle, + FilePhase::Queued, + FilePhase::Scanning, + FilePhase::Failed, + ] + .into_iter() + .map(|phase| FileStatus { + total: 0, + phase, + warning: (phase == FilePhase::Failed).then(|| "Cannot start the file scanner".into()), + }) + .collect(); let value = json!({ + "fileStatuses": file_statuses, "modes": modes, "actions": actions, "response": response, "warningResponse": warning_response, "fullResult": full_result, "details": details, "settings": settings, "defaults": Settings::default(), diff --git a/src-tauri/src/launcher/file_watch.rs b/src-tauri/src/launcher/file_watch.rs index d042dc6..fa20609 100644 --- a/src-tauri/src/launcher/file_watch.rs +++ b/src-tauri/src/launcher/file_watch.rs @@ -29,10 +29,38 @@ pub fn scan_rest(elapsed: Duration) -> Duration { Duration::from_secs(1).max(elapsed.saturating_mul(3)) } +// One clock covers work, rest, and settling. Tests advance it at phase +// boundaries without sleeps or assumptions about filesystem speed. +pub(super) trait WorkerClock { + fn now(&self) -> Instant; + fn recv_timeout( + &self, + receiver: &Receiver, + timeout: Duration, + ) -> Result; +} + +pub(super) struct RealClock; + +impl WorkerClock for RealClock { + fn now(&self) -> Instant { + Instant::now() + } + + fn recv_timeout( + &self, + receiver: &Receiver, + timeout: Duration, + ) -> Result { + receiver.recv_timeout(timeout) + } +} + /// Coalesce requests without letting manual refresh or settings churn bypass /// the work budget. The callback also drops obsolete watches during long rests. /// Polling here checks only in-memory control state, never the filesystem. -pub fn wait_for_budget( +pub(super) fn wait_for_budget( + clock: &impl WorkerClock, receiver: &Receiver, deadline: &mut Instant, mut keep_running: impl FnMut(&mut Instant) -> bool, @@ -41,11 +69,11 @@ pub fn wait_for_budget( if !keep_running(deadline) { return false; } - let remaining = deadline.saturating_duration_since(Instant::now()); + let remaining = deadline.saturating_duration_since(clock.now()); if remaining.is_zero() { return true; } - match receiver.recv_timeout(remaining.min(Duration::from_millis(50))) { + match clock.recv_timeout(receiver, remaining.min(Duration::from_millis(50))) { Ok(Request::Changed | Request::Refresh) | Err(RecvTimeoutError::Timeout) => {} Ok(Request::Stop) | Err(RecvTimeoutError::Disconnected) => return false, } @@ -56,29 +84,30 @@ pub fn wait_for_budget( // thousands of filesystem paths. Settling combines bursts; scan_rest separately // bounds the scan workload under sustained events. pub fn settle(receiver: &Receiver, quiet: Duration, maximum: Duration) -> bool { - settle_cancellable(receiver, quiet, maximum, || true) + settle_cancellable(&RealClock, receiver, quiet, maximum, || true) } -pub fn settle_cancellable( +pub(super) fn settle_cancellable( + clock: &impl WorkerClock, receiver: &Receiver, quiet: Duration, maximum: Duration, mut keep_running: impl FnMut() -> bool, ) -> bool { - let deadline = Instant::now() + maximum; - let mut quiet_until = Instant::now() + quiet; + let deadline = clock.now() + maximum; + let mut quiet_until = clock.now() + quiet; loop { if !keep_running() { return false; } let remaining = deadline .min(quiet_until) - .saturating_duration_since(Instant::now()); + .saturating_duration_since(clock.now()); if remaining.is_zero() { return true; } - match receiver.recv_timeout(remaining.min(Duration::from_millis(50))) { - Ok(Request::Changed) => quiet_until = Instant::now() + quiet, + match clock.recv_timeout(receiver, remaining.min(Duration::from_millis(50))) { + Ok(Request::Changed) => quiet_until = clock.now() + quiet, Ok(Request::Refresh) => return true, Err(RecvTimeoutError::Timeout) => {} Ok(Request::Stop) | Err(RecvTimeoutError::Disconnected) => return false, @@ -423,11 +452,17 @@ mod tests { std::thread::sleep(Duration::from_millis(1)); } }); - assert!(wait_for_budget(&receiver, &mut { deadline }, |_| true)); + assert!(wait_for_budget( + &RealClock, + &receiver, + &mut { deadline }, + |_| true + )); }); assert!(started.elapsed() >= Duration::from_millis(80)); let _ = sender.try_send(Request::Changed); assert!(!wait_for_budget( + &RealClock, &receiver, &mut (Instant::now() + Duration::from_secs(60)), |_| false diff --git a/src-tauri/src/launcher/files.rs b/src-tauri/src/launcher/files.rs index 117cc25..afff0c2 100644 --- a/src-tauri/src/launcher/files.rs +++ b/src-tauri/src/launcher/files.rs @@ -22,26 +22,68 @@ use crate::{ #[derive(Default)] pub struct FileScan { - running: AtomicBool, generation: AtomicU64, - warning: Mutex>, + lifecycle: Mutex, sender: Mutex>>, stopped: Arc, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum FilePhase { + Disabled, + #[default] + Idle, + Queued, + Scanning, + Failed, +} + +#[derive(Default)] +struct Lifecycle { + phase: FilePhase, + warning: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] pub struct FileStatus { pub total: usize, - pub indexing: bool, + pub phase: FilePhase, pub warning: Option, } impl FileScan { - pub(super) fn invalidate(&self) { + pub(super) fn new(settings: &Settings) -> Self { + let scan = Self::default(); + scan.lifecycle.lock().unwrap().phase = if Self::enabled(settings) { + FilePhase::Queued + } else { + FilePhase::Disabled + }; + scan + } + + fn enabled(settings: &Settings) -> bool { + settings + .file_search_roots + .as_ref() + .is_none_or(|roots| !roots.is_empty()) + } + + pub(super) fn invalidate(&self, settings: &Settings) { + // Generation and status publication share this lock. A late old cycle + // cannot restore its warning or phase after settings invalidation. + let mut lifecycle = self.lifecycle.lock().unwrap(); self.generation.fetch_add(1, Ordering::AcqRel); - self.warning(None); + lifecycle.warning = None; + lifecycle.phase = if Self::enabled(settings) { + FilePhase::Queued + } else { + FilePhase::Disabled + }; } fn generation(&self) -> u64 { @@ -53,10 +95,11 @@ impl FileScan { } pub fn status(&self, total: usize) -> FileStatus { + let lifecycle = self.lifecycle.lock().unwrap(); FileStatus { total, - indexing: self.running.load(Ordering::Acquire), - warning: self.warning.lock().ok().and_then(|warning| warning.clone()), + phase: lifecycle.phase, + warning: lifecycle.warning.clone(), } } @@ -69,34 +112,84 @@ impl FileScan { } } - fn warning(&self, warning: Option) { - if let Ok(mut stored) = self.warning.lock() { - *stored = warning; + fn warning_for(&self, generation: u64, warning: Option) { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if !self.cancelled(generation) { + lifecycle.warning = warning; } } - fn warning_for(&self, generation: u64, warning: Option) { - if let Ok(mut stored) = self.warning.lock() - && !self.cancelled(generation) - { - *stored = warning; + fn queued(&self) -> bool { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if matches!(lifecycle.phase, FilePhase::Idle | FilePhase::Failed) { + lifecycle.phase = FilePhase::Queued; + true + } else { + false } } + fn begin(&self, generation: u64, rx: &Receiver) -> bool { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if self.cancelled(generation) || lifecycle.phase == FilePhase::Disabled { + return false; + } + // Any signal already waiting is covered by the scan about to start. + // Manual enqueue uses this lock, so later refreshes remain pending. + if matches!(rx.try_recv(), Ok(Request::Stop)) { + self.stopped.store(true, Ordering::Release); + return false; + } + lifecycle.phase = FilePhase::Scanning; + true + } + + fn finish_stopped(&self) -> bool { + let mut lifecycle = self.lifecycle.lock().unwrap(); + if matches!(lifecycle.phase, FilePhase::Queued | FilePhase::Scanning) { + lifecycle.phase = FilePhase::Idle; + true + } else { + false + } + } + + fn finish(&self, generation: u64, rx: &Receiver, retry: bool) -> Option { + let mut lifecycle = self.lifecycle.lock().unwrap(); + // Own the pending request across cooldown even though the bounded + // channel is now empty. Further signals coalesce into this same cycle. + let pending = rx.try_recv().ok().or(retry.then_some(Request::Refresh)); + if self.generation() == generation && lifecycle.phase != FilePhase::Disabled { + lifecycle.phase = if pending.is_some() && !self.stopped.load(Ordering::Acquire) { + FilePhase::Queued + } else { + FilePhase::Idle + }; + } + pending + } + fn request( &self, spawn: impl FnOnce(Receiver, SyncSender) -> std::io::Result<()>, - ) -> std::io::Result<()> { + ) -> std::io::Result { let mut sender = self .sender .lock() .map_err(|_| std::io::Error::other("File scanner is unavailable"))?; if self.stopped.load(Ordering::Acquire) { - return Ok(()); + return Ok(false); + } + // Serialize manual enqueue with completion's pending-request check. + let mut lifecycle = self.lifecycle.lock().unwrap(); + let generation = self.generation(); + let changed = matches!(lifecycle.phase, FilePhase::Idle | FilePhase::Failed); + if changed { + lifecycle.phase = FilePhase::Queued; } if let Some(active) = sender.as_ref() { match active.try_send(Request::Refresh) { - Ok(()) | Err(TrySendError::Full(_)) => return Ok(()), + Ok(()) | Err(TrySendError::Full(_)) => return Ok(changed), // An exited worker drops its receiver. Discard the dead sender // so settings changes and manual refresh can start a new worker. Err(TrySendError::Disconnected(_)) => *sender = None, @@ -104,9 +197,11 @@ impl FileScan { } if sender.is_none() { let (tx, rx) = sync_channel(1); - self.running.store(true, Ordering::Release); if let Err(error) = spawn(rx, tx.clone()) { - self.running.store(false, Ordering::Release); + if self.generation() == generation && lifecycle.phase != FilePhase::Disabled { + lifecycle.phase = FilePhase::Failed; + lifecycle.warning = Some(format!("Cannot start the file scanner: {error}")); + } return Err(error); } *sender = Some(tx); @@ -114,7 +209,7 @@ impl FileScan { if let Some(sender) = sender.as_ref() { let _ = sender.try_send(Request::Refresh); } - Ok(()) + Ok(changed) } } @@ -125,16 +220,14 @@ pub fn refresh_files(app: AppHandle) { pub fn scan_files(app: &AppHandle) { let state = app.state::(); - if let Err(error) = state.files.request(|rx, callback_sender| { + let transition = state.files.request(|rx, callback_sender| { let worker = app.clone(); std::thread::Builder::new() .name("file-index".into()) .spawn(move || run_worker(worker, rx, callback_sender)) .map(|_| ()) - }) { - state - .files - .warning(Some(format!("Cannot start the file scanner: {error}"))); + }); + if !matches!(transition, Ok(false)) { let _ = app.emit("files-changed", ()); } } @@ -231,6 +324,7 @@ fn extend_rest_for_work(deadline: &mut Instant, started: Instant, finished: Inst fn run_worker(worker: AppHandle, rx: Receiver, callback_sender: SyncSender) { let state = worker.state::(); run_worker_loop( + &file_watch::RealClock, &state, rx, callback_sender, @@ -245,6 +339,7 @@ fn run_worker(worker: AppHandle, rx: Receiver, callback_sender: SyncSen // Keep the real worker loop independent of Tauri so phase boundaries, status // events and workload accounting can be tested with controlled slow OS calls. fn run_worker_loop( + clock: &impl file_watch::WorkerClock, state: &LauncherState, rx: Receiver, callback_sender: SyncSender, @@ -264,20 +359,24 @@ fn run_worker_loop( let mut watcher: Option = None; let mut active_settings: Option = None; let mut active_generation = state.files.generation(); - let mut not_before = Instant::now(); + let mut not_before = clock.now(); let mut metrics = ScanMetrics::new(); - while let Ok(request) = rx.recv() { + let mut pending = None; + while let Some(request) = pending.take().or_else(|| rx.recv().ok()) { if stop.load(Ordering::Acquire) || matches!(request, Request::Stop) { break; } + if state.files.queued() { + notify(); + } let mut keep_running = |deadline: &mut Instant| { if state.files.generation() != active_generation { // Settings/disable must remove old watches even during a long // cooldown, not just when the replacement scan finally starts. if let Some(obsolete) = watcher.take() { - let started = Instant::now(); + let started = clock.now(); drop(obsolete); - let finished = Instant::now(); + let finished = clock.now(); let elapsed = finished.duration_since(started); metrics.duration += elapsed; // Disposal is work too; it cannot consume the remaining @@ -289,11 +388,12 @@ fn run_worker_loop( } !stop.load(Ordering::Acquire) }; - if !file_watch::wait_for_budget(&rx, &mut not_before, &mut keep_running) { + if !file_watch::wait_for_budget(clock, &rx, &mut not_before, &mut keep_running) { break; } if matches!(request, Request::Changed) && !file_watch::settle_cancellable( + clock, &rx, Duration::from_millis(300), Duration::from_secs(2), @@ -304,7 +404,7 @@ fn run_worker_loop( } // A settings change during settling may have disposed watches and // extended the deadline. Do not start new work on that cleanup's rest. - if !file_watch::wait_for_budget(&rx, &mut not_before, &mut keep_running) { + if !file_watch::wait_for_budget(clock, &rx, &mut not_before, &mut keep_running) { break; } // Read the generation first: a concurrent settings write will either @@ -312,11 +412,16 @@ fn run_worker_loop( let generation = state.files.generation(); let settings = state.settings(); active_generation = generation; - state.files.running.store(true, Ordering::Release); + if !state.files.begin(generation, &rx) { + if stop.load(Ordering::Acquire) { + break; + } + continue; + } notify(); // Account for the entire cycle, not only traversal: root lookup, // watcher creation/registration and obsolete-watcher disposal can block. - let started = Instant::now(); + let started = clock.now(); let mut report = ScanReport::default(); let cancelled = || state.files.cancelled(generation); let completed = (|| { @@ -413,20 +518,19 @@ fn run_worker_loop( active_settings = None; watch_warning = None; } - let finished = Instant::now(); + let finished = clock.now(); let elapsed = finished.duration_since(started); not_before = finished + file_watch::scan_rest(elapsed); metrics.record(elapsed, report.visited, obsolete); - state.files.running.store(false, Ordering::Release); - notify(); // Also notify on cancellation, before entering cooldown. + pending = state.files.finish(generation, &rx, obsolete); + notify(); // Includes queued replacement work, never a false idle gap. if stop.load(Ordering::Acquire) { break; } - if obsolete { - let _ = callback_sender.try_send(Request::Refresh); - } } - state.files.running.store(false, Ordering::Release); + if state.files.finish_stopped() { + notify(); + } metrics } @@ -434,6 +538,48 @@ fn run_worker_loop( mod tests { use super::*; use crate::launcher::query::SearchMode; + use file_watch::WorkerClock; + use std::{ + cell::{Cell, RefCell}, + sync::mpsc::RecvTimeoutError, + }; + + struct TestClock<'a> { + now: Cell, + waiting: RefCell>, + } + + impl TestClock<'_> { + fn new() -> Self { + Self { + now: Cell::new(Instant::now()), + waiting: RefCell::new(Box::new(|| {})), + } + } + + fn advance(&self, elapsed: Duration) { + self.now.set(self.now.get() + elapsed); + } + } + + impl WorkerClock for TestClock<'_> { + fn now(&self) -> Instant { + self.now.get() + } + + fn recv_timeout( + &self, + rx: &Receiver, + timeout: Duration, + ) -> Result { + (self.waiting.borrow_mut())(); + self.advance(timeout); + rx.try_recv().map_err(|error| match error { + std::sync::mpsc::TryRecvError::Empty => RecvTimeoutError::Timeout, + std::sync::mpsc::TryRecvError::Disconnected => RecvTimeoutError::Disconnected, + }) + } + } #[test] fn changing_roots_removes_old_results_and_rejects_an_unfinished_old_scan() { @@ -693,7 +839,7 @@ mod tests { let files = FileScan::default(); let old = files.generation(); files.warning_for(old, Some("old warning".into())); - files.invalidate(); + files.invalidate(&Settings::default()); files.warning_for(old, Some("late old warning".into())); assert!(files.status(0).warning.is_none()); files.warning_for(files.generation(), Some("current warning".into())); @@ -702,9 +848,8 @@ mod tests { #[test] fn worker_accounts_preparation_and_registration_before_rest_and_notifies_cancellation() { - // Exercise the production loop, including real scans and registration. - // A slow OS call is represented by a delayed phase callback, not by - // feeding synthetic durations directly into the budget formula. + // The production loop uses a controlled clock at OS phase boundaries. + // No sleep or assertion depends on real filesystem speed. for phase in [ "cancelled preparation", "registration", @@ -718,10 +863,21 @@ mod tests { }; let state = LauncherState::new(settings, vec![]); let (sender, receiver) = sync_channel(1); + *state.files.sender.lock().unwrap() = Some(sender.clone()); sender.try_send(Request::Refresh).unwrap(); let mut events = Vec::new(); let delay = Duration::from_millis(400); + let clock = TestClock::new(); + let started = clock.now(); + *clock.waiting.borrow_mut() = Box::new(|| { + assert_eq!(state.files.status(0).phase, FilePhase::Queued); + // Flood both request kinds throughout rest; none may bypass it + // or cause an idle status after wait_for_budget consumes them. + assert!(!state.files.request(|_, _| panic!("second worker")).unwrap()); + let _ = sender.try_send(Request::Changed); + }); let metrics = run_worker_loop( + &clock, &state, receiver, sender.clone(), @@ -730,43 +886,51 @@ mod tests { return None; } if phase == "cancelled preparation" { - std::thread::sleep(delay); - state.files.invalidate(); + clock.advance(delay); + state.files.invalidate(&state.settings()); return None; } Some((settings.file_search_roots.clone().unwrap(), None)) }, || { - events.push((state.files.status(0).indexing, Instant::now())); - if events.len() == 2 { - // Refresh must not bypass the post-work rest. - let _ = sender.try_send(Request::Refresh); - } else if events.len() == 3 { + events.push((state.files.status(0).phase, clock.now())); + if events.len() == 3 { state.files.stop(); } }, - |watcher, report, cancelled| { + |_, _, cancelled| { assert_ne!(phase, "cancelled preparation"); - std::thread::sleep(delay); + clock.advance(delay); if phase == "cancelled registration" { - state.files.invalidate(); + state.files.invalidate(&state.settings()); } - watcher.update(report, cancelled) + // Manual refresh during registration coalesces with the + // new watch's follow-up. It still owes the entire rest. + let _ = sender.try_send(Request::Refresh); + (!cancelled()).then_some((true, None)) }, ); assert_eq!( events.iter().map(|event| event.0).collect::>(), - [true, false, true, false], + [ + FilePhase::Scanning, + FilePhase::Queued, + FilePhase::Scanning, + FilePhase::Idle + ], "{phase}" ); - assert!(metrics.duration >= delay, "Unaccounted {phase}"); + assert_eq!(metrics.duration, delay, "Unaccounted {phase}"); + assert_eq!(events[0].1, started); + assert_eq!(events[1].1, started + delay); assert_eq!(metrics.scans, 2); assert_eq!( metrics.cancelled, if phase == "registration" { 1 } else { 2 } ); - assert!( - events[2].1.duration_since(events[1].1) >= delay * 3, + assert_eq!( + events[2].1.duration_since(events[1].1), + delay * 3, "Rest was consumed by {phase}" ); if phase == "cancelled preparation" { @@ -784,24 +948,229 @@ mod tests { sender.try_send(Request::Refresh).unwrap(); let mut events = Vec::new(); let delay = Duration::from_millis(20); + let clock = TestClock::new(); let metrics = run_worker_loop( + &clock, &state, receiver, sender, |_, _| { - std::thread::sleep(delay); + clock.advance(delay); state.files.stop(); // Even a resolver returning stale roots must not start watches. Some((vec![PathBuf::from("/obsolete")], None)) }, - || events.push(state.files.status(0).indexing), + || events.push(state.files.status(0).phase), |_, _, _| panic!("registration after shutdown"), ); - assert_eq!(events, [true, false]); + assert_eq!(events, [FilePhase::Scanning, FilePhase::Idle]); assert_eq!(metrics.scans, 1); assert_eq!(metrics.cancelled, 1); assert_eq!(metrics.visited, 0); - assert!(metrics.duration >= delay); + assert_eq!(metrics.duration, delay); + } + + #[test] + fn lifecycle_coalesces_requests_and_rejects_stale_publication() { + let settings = Settings::default(); + let files = FileScan::new(&settings); + let mut receiver = None; + files + .request(|rx, _| { + receiver = Some(rx); + Ok(()) + }) + .unwrap(); + let rx = receiver.unwrap(); + let generation = files.generation(); + assert_eq!(files.status(0).phase, FilePhase::Queued); + assert!(files.begin(generation, &rx)); + assert!( + rx.try_recv().is_err(), + "pre-scan signal is covered by this scan" + ); + for _ in 0..100 { + assert!(!files.request(|_, _| panic!("second worker")).unwrap()); + } + assert_eq!(files.status(0).phase, FilePhase::Scanning); + let pending = files.finish(generation, &rx, false); + assert!(matches!(pending, Some(Request::Refresh))); + assert_eq!(files.status(0).phase, FilePhase::Queued); + assert!( + rx.try_recv().is_err(), + "queued status must outlive consumed signals" + ); + assert!(files.begin(generation, &rx)); + assert!(files.finish(generation, &rx, false).is_none()); + assert_eq!(files.status(0).phase, FilePhase::Idle); + assert!(files.request(|_, _| panic!("second worker")).unwrap()); + assert_eq!(files.status(0).phase, FilePhase::Queued); + assert!(files.begin(generation, &rx)); + files.invalidate(&settings); + files.warning_for(generation, Some("obsolete".into())); + files.finish(generation, &rx, false); + assert_eq!(files.status(0).phase, FilePhase::Queued); + assert!(files.status(0).warning.is_none()); + let disabled = Settings { + file_search_roots: Some(vec![]), + ..settings + }; + files.invalidate(&disabled); + files.finish(generation, &rx, true); + assert!(!files.begin(files.generation(), &rx)); + assert_eq!(files.status(0).phase, FilePhase::Disabled); + } + + #[test] + fn cooldown_root_changes_use_latest_settings_and_disable_stays_disabled() { + let state = LauncherState::new( + Settings { + file_watch_enabled: false, + ..Settings::default() + }, + vec![], + ); + let (sender, receiver) = sync_channel(1); + sender.try_send(Request::Refresh).unwrap(); + let cycle = Cell::new(0); + let changed = Cell::new(false); + let clock = TestClock::new(); + let base = clock.now(); + let mut events = Vec::new(); + *clock.waiting.borrow_mut() = Box::new(|| { + assert_eq!(state.files.status(0).phase, FilePhase::Queued); + if cycle.get() == 1 && !changed.replace(true) { + for root in ["/old", "/newest"] { + state.replace_settings(Settings { + file_search_roots: Some(vec![root.into()]), + file_watch_enabled: false, + ..Settings::default() + }); + let _ = sender.try_send(Request::Refresh); + } + } else if cycle.get() == 2 { + state.replace_settings(Settings { + file_search_roots: Some(vec![]), + ..state.settings() + }); + assert_eq!(state.files.status(0).phase, FilePhase::Disabled); + state.files.stop(); + } + }); + let metrics = run_worker_loop( + &clock, + &state, + receiver, + sender.clone(), + |settings, _| { + cycle.set(cycle.get() + 1); + if cycle.get() == 2 { + assert_eq!( + settings.file_search_roots, + Some(vec![PathBuf::from("/newest")]) + ); + assert_eq!(clock.now(), base + Duration::from_secs(4)); + } + clock.advance(Duration::from_secs(1)); + let _ = sender.try_send(Request::Refresh); + Some((vec![], None)) + }, + || events.push(state.files.status(0).phase), + |_, _, _| panic!("watching is off"), + ); + assert_eq!( + events, + [ + FilePhase::Scanning, + FilePhase::Queued, + FilePhase::Scanning, + FilePhase::Queued + ] + ); + assert_eq!(metrics.scans, 2); + assert_eq!(metrics.duration, Duration::from_secs(2)); + assert_eq!(state.files.status(0).phase, FilePhase::Disabled); + assert_eq!(state.search.lock().unwrap().file_count(), 0); + } + + #[test] + fn automatic_followup_remains_queued_through_rest_and_settling() { + let state = LauncherState::new( + Settings { + file_watch_enabled: false, + ..Settings::default() + }, + vec![], + ); + let (sender, receiver) = sync_channel(1); + sender.try_send(Request::Refresh).unwrap(); + let clock = TestClock::new(); + let base = clock.now(); + let mut events = Vec::new(); + *clock.waiting.borrow_mut() = Box::new(|| { + assert_eq!(state.files.status(0).phase, FilePhase::Queued); + }); + let metrics = run_worker_loop( + &clock, + &state, + receiver, + sender.clone(), + |_, cancelled| { + if cancelled() { + return None; + } + clock.advance(Duration::from_secs(1)); + sender.try_send(Request::Changed).unwrap(); + Some((vec![], None)) + }, + || { + events.push((state.files.status(0).phase, clock.now())); + if events.len() == 3 { + state.files.stop(); + } + }, + |_, _, _| panic!("watching is off"), + ); + assert_eq!( + events.iter().map(|event| event.0).collect::>(), + [ + FilePhase::Scanning, + FilePhase::Queued, + FilePhase::Scanning, + FilePhase::Idle + ] + ); + assert_eq!(events[2].1, base + Duration::from_millis(4300)); + assert_eq!(metrics.duration, Duration::from_secs(1)); + assert_eq!(metrics.scans, 2); + } + + #[test] + fn scanner_spawn_failure_is_retryable() { + let files = FileScan::default(); + assert!( + files + .request(|_, _| Err(std::io::Error::other("test failure"))) + .is_err() + ); + assert_eq!(files.status(0).phase, FilePhase::Failed); + assert!(files.status(0).warning.unwrap().contains("test failure")); + let mut receiver = None; + assert!( + files + .request(|rx, _| { + receiver = Some(rx); + Ok(()) + }) + .unwrap() + ); + assert_eq!(files.status(0).phase, FilePhase::Queued); + let rx = receiver.unwrap(); + assert!(files.begin(files.generation(), &rx)); + files.warning_for(files.generation(), None); + files.finish(files.generation(), &rx, false); + assert_eq!(files.status(0).phase, FilePhase::Idle); + assert!(files.status(0).warning.is_none()); } #[test] diff --git a/src-tauri/src/launcher/mod.rs b/src-tauri/src/launcher/mod.rs index 46c7db2..814acfe 100644 --- a/src-tauri/src/launcher/mod.rs +++ b/src-tauri/src/launcher/mod.rs @@ -63,6 +63,7 @@ impl LauncherState { pub fn new(settings: Settings, warnings: Vec) -> Self { let mut search = SearchManager::default(); search.apply_settings(&settings); + let files = files::FileScan::new(&settings); Self { search: Mutex::new(search), cancelled_search: Arc::default(), @@ -77,7 +78,7 @@ impl LauncherState { index_error: Mutex::new(None), storage: storage::Storage::default(), clipboard: clipboard::Monitor::default(), - files: files::FileScan::default(), + files, currency: currency::Currency::default(), } } @@ -104,7 +105,7 @@ impl LauncherState { let files_changed = !current.same_file_settings(&settings); if files_changed { // Advance under the publication lock, even for A -> B -> A changes. - self.files.invalidate(); + self.files.invalidate(&settings); } let previous_files = search.as_mut().and_then(|search| { files_changed.then(|| search.replace_files(FileProvider::default())) diff --git a/src-tauri/src/launcher/wire_types_tests.rs b/src-tauri/src/launcher/wire_types_tests.rs index 02dfaeb..bddd9fb 100644 --- a/src-tauri/src/launcher/wire_types_tests.rs +++ b/src-tauri/src/launcher/wire_types_tests.rs @@ -412,6 +412,7 @@ fn generated_ipc_wire_types_match_frontend() { warning::LauncherWarning, warning::WarningCode, files::FileStatus, + files::FilePhase, currency::CurrencyStatus, window::LauncherAppearance, Settings, diff --git a/src/App.tsx b/src/App.tsx index 230126b..58af39c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -101,7 +101,6 @@ export default function App( indexing, setIndexing, files, - setFiles, currency, error, setError, @@ -167,6 +166,12 @@ export default function App( !clearOpen() && !clipboardTool() && !pendingAction(); + const filePending = () => + files().phase === "queued" || files().phase === "scanning"; + const fileActivity = () => + files().phase === "queued" + ? "Waiting to refresh files..." + : "Scanning files..."; const message = () => error() ?? notice() ?? @@ -358,7 +363,7 @@ export default function App( label: "Refresh files", icon: "refresh" as const, run: () => void refresh("files"), - disabled: !desktop || files().indexing, + disabled: !desktop || filePending() || files().phase === "disabled", key: mode() === "files" ? `${modifier()} R` : undefined, }, { @@ -655,8 +660,7 @@ export default function App( ) { setMenuOpen(false); setError(undefined); - if (target === "files") setFiles((state) => ({ ...state, indexing: true })); - else if (target === "apps") setIndexing(true); + if (target === "apps") setIndexing(true); focusInput(); try { if (target === "files") await backend.refreshFiles(); @@ -664,9 +668,7 @@ export default function App( else await backend.refresh(); await search(); } catch (reason) { - if (target === "files") - setFiles((state) => ({ ...state, indexing: false })); - else if (target === "apps") setIndexing(false); + if (target === "apps") setIndexing(false); setError(String(reason)); } } @@ -1150,11 +1152,11 @@ export default function App(
- Scanning files... You can search apps now. + {fileActivity()} You can search apps now.
@@ -1311,13 +1313,19 @@ export default function App( ? "No clipboard entries found" : "No saved clipboard text" : mode() === "files" - ? files().indexing - ? "Finding your files" - : query() - ? "No files found" - : "No files in the index" + ? files().phase === "queued" + ? "Waiting to refresh files" + : files().phase === "scanning" + ? "Finding your files" + : files().phase === "disabled" + ? "File search is off" + : files().phase === "failed" + ? "File scan unavailable" + : query() + ? "No files found" + : "No files in the index" : mode() === "all" && - files().indexing && + filePending() && query() ? "No results yet" : indexing() @@ -1351,18 +1359,21 @@ export default function App( ? "Try a word from the text you copied." : "Copy text in any application. It will appear here." : mode() === "files" - ? files().indexing - ? "You can search applications while the scan runs." - : query() - ? "Try a filename or part of a path." - : info()?.settings.fileSearchRoots - ?.length === 0 - ? "File search is off. Choose folders in Settings, File search." - : "Check your folders in Settings, File search, then refresh the file list." + ? filePending() + ? files().phase === "queued" + ? "The refresh is queued. You can search applications while waiting." + : "You can search applications while the scan runs." + : files().phase === "disabled" + ? "File search is off. Choose folders in Settings, File search." + : files().phase === "failed" + ? "Use Refresh files to try again." + : query() + ? "Try a filename or part of a path." + : "Check your folders in Settings, File search, then refresh the file list." : mode() === "all" && - files().indexing && + filePending() && query() - ? "The file scan is still running. You can search applications now." + ? `${fileActivity()} You can search applications now.` : indexing() ? "You can start typing while the list loads." : query() @@ -1372,7 +1383,9 @@ export default function App( ({ total: 0, - indexing: options.desktop, + phase: options.desktop ? "queued" : "idle", warning: null, }); const [currency, setCurrency] = createSignal({ diff --git a/tests/fixtures/ipc-contract.ts b/tests/fixtures/ipc-contract.ts index c9b374b..6b8f316 100644 --- a/tests/fixtures/ipc-contract.ts +++ b/tests/fixtures/ipc-contract.ts @@ -90,6 +90,33 @@ export const contracts = { "url": "https://example.com/?q=hello%20world" } ], + "fileStatuses": [ + { + "phase": "disabled", + "total": 0, + "warning": null + }, + { + "phase": "idle", + "total": 0, + "warning": null + }, + { + "phase": "queued", + "total": 0, + "warning": null + }, + { + "phase": "scanning", + "total": 0, + "warning": null + }, + { + "phase": "failed", + "total": 0, + "warning": "Cannot start the file scanner" + } + ], "fullResult": { "confirmation": { "confirmLabel": "Restart", @@ -317,7 +344,7 @@ export const contracts = { "warning": null }, "files": { - "indexing": false, + "phase": "idle", "total": 3, "warning": null }, @@ -586,7 +613,7 @@ export const contracts = { "warning": "Rates are old" }, "files": { - "indexing": true, + "phase": "scanning", "total": 1, "warning": "Scan incomplete" }, diff --git a/tests/fixtures/ipc-wire.ts b/tests/fixtures/ipc-wire.ts index 2c6d2c4..3a233e8 100644 --- a/tests/fixtures/ipc-wire.ts +++ b/tests/fixtures/ipc-wire.ts @@ -6,7 +6,8 @@ export type AppearanceChange = { "kind": "appearance", "value": LauncherAppearan export type CategoryShortcut = { mode: SearchMode, shortcut: string, }; export type ClipboardEntry = { id: number, content: string, createdAt: number, lastUsedAt: number | null, }; export type CurrencyStatus = { asOf: string | null, refreshing: boolean, warning: string | null, }; -export type FileStatus = { total: number, indexing: boolean, warning: string | null, }; +export type FilePhase = "disabled" | "idle" | "queued" | "scanning" | "failed"; +export type FileStatus = { total: number, phase: FilePhase, warning: string | null, }; export type LauncherAppearance = "light" | "dark" | "sage" | "rose" | "ink"; export type LauncherInfo = { settings: Settings, platform: string, warnings: Array, visible: boolean, initialMode: SearchMode | null, }; export type LauncherWarning = { code: WarningCode, message: string, diff --git a/tests/ipc-contract.spec.ts b/tests/ipc-contract.spec.ts index cd5cd52..4ec2f7f 100644 --- a/tests/ipc-contract.spec.ts +++ b/tests/ipc-contract.spec.ts @@ -4,6 +4,7 @@ import { commandArguments } from "./fixtures/ipc-wire"; import { resolve } from "node:path"; import type { Action, + FilePhase, LauncherWarning, SearchMode, SearchResult, @@ -14,6 +15,7 @@ import { commandWrappers } from "./ipc-types"; // A new TS variant also needs a serialized Rust example, not just a widened union. type MissingExamples = | Exclude + | Exclude | Exclude | Exclude< SearchResult["kind"], @@ -44,6 +46,9 @@ function variants(path: string, name: string) { test("canonical serde fixtures cover variants and representative omitted/nullable values", () => { expect(complete).toBe(true); + expect(contracts.fileStatuses.map((status) => status.phase).sort()).toEqual( + variants("launcher/files.rs", "FilePhase"), + ); expect([...contracts.actions].sort()).toEqual( variants("launcher/result.rs", "Action"), ); diff --git a/tests/ipc-contract.ts b/tests/ipc-contract.ts index b0fc782..c040e56 100644 --- a/tests/ipc-contract.ts +++ b/tests/ipc-contract.ts @@ -1,5 +1,6 @@ import type { Action, + FileStatus, ClipboardEntry, LauncherInfo, SearchMode, @@ -14,6 +15,7 @@ import type { /** Compile-time consumer of the Rust-serialized examples; no runtime validator/SDK. */ export interface ContractFixture { actions: Action[]; + fileStatuses: FileStatus[]; modes: SearchMode[]; response: SearchResponse; warningResponse: SearchResponse; diff --git a/tests/launcher.spec.ts b/tests/launcher.spec.ts index b99c1e0..9c3761c 100644 --- a/tests/launcher.spec.ts +++ b/tests/launcher.spec.ts @@ -1330,7 +1330,7 @@ test("keeps results usable during a file scan and shows scan warnings and empty await openLauncher(page); await page.getByRole("combobox", { name: "Search TinyDash" }).fill("any"); await page.evaluate(() => { - window.__launcherTest.fileIndexing = true; + window.__launcherTest.filePhase = "scanning"; return window.__launcherTest.emit("files-changed", null); }); await expect(page.locator(".query-hint")).toContainText("Scanning files..."); @@ -1345,7 +1345,7 @@ test("keeps results usable during a file scan and shows scan warnings and empty page.getByRole("button", { name: "Open", exact: true }), ).toBeEnabled(); await page.evaluate(() => { - window.__launcherTest.fileIndexing = false; + window.__launcherTest.filePhase = "idle"; window.__launcherTest.fileWarning = "File scan skipped 1 item. Permission denied."; return window.__launcherTest.emit("files-changed", null); @@ -1362,6 +1362,156 @@ test("keeps results usable during a file scan and shows scan warnings and empty await expect(page.getByRole("alert")).toHaveCount(0); }); +test("file refresh distinguishes queued cooldown, active scan, and finished empty results", async ({ + page, +}) => { + await openLauncher(page); + await selectCategory(page, "Files"); + const input = page.getByRole("combobox", { name: "Search TinyDash" }); + await expect(page.locator(".result-title")).toHaveText("Launch notes.md"); + await input.press("Meta+r"); + await expect(page.locator(".list-count")).toHaveText( + "Waiting to refresh files...", + ); + await expect( + page.getByRole("button", { name: "Open", exact: true }), + ).toBeEnabled(); + await input.fill("missing"); + await expect( + page.getByRole("heading", { name: "Waiting to refresh files" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "No files found" }), + ).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("files-queued.png") }); + await selectCategory(page, "All"); + await expect(page.locator(".query-hint")).toContainText( + "Waiting to refresh files...", + ); + await expect( + page.getByRole("heading", { name: "No results yet" }), + ).toBeVisible(); + await selectCategory(page, "Files"); + await page.evaluate(() => { + window.__launcherTest.filePhase = "scanning"; + return window.__launcherTest.emit("files-changed"); + }); + await expect(page.locator(".list-count")).toHaveText("Scanning files..."); + await expect( + page.getByRole("heading", { name: "Finding your files" }), + ).toBeVisible(); + await page.evaluate(() => { + window.__launcherTest.filePhase = "idle"; + window.__launcherTest.fileTotal = 0; + return window.__launcherTest.emit("files-changed"); + }); + await expect( + page.getByRole("heading", { name: "No files found" }), + ).toBeVisible(); + await input.fill(""); + await expect( + page.getByRole("heading", { name: "No files in the index" }), + ).toBeVisible(); + await expect(page.locator(".list-count")).toHaveText("0 items indexed"); +}); + +test("file status follows root-change and disable notifications without showing a finished empty scan", async ({ + page, +}) => { + await openLauncher(page); + await selectCategory(page, "Files"); + await expect(page.locator(".result-title")).toHaveText("Launch notes.md"); + // Model notifications from the separate Settings window. Settings form/save + // journeys are covered in settings.spec.ts; this proves their launcher UI. + for (const root of ["/Downloads", "/Documents"]) { + await page.evaluate(async (root) => { + const state = window.__launcherTest; + state.settings = { ...state.settings, fileSearchRoots: [root] }; + state.fileTotal = 0; + state.filePhase = "queued"; + await state.emit("settings-changed", state.settings); + }, root); + await expect(page.locator(".result-title")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Waiting to refresh files" }), + ).toBeVisible(); + await expect(page.locator(".list-count")).toHaveText( + "Waiting to refresh files...", + ); + } + await page.evaluate(async () => { + const state = window.__launcherTest; + state.settings = { ...state.settings, fileSearchRoots: [] }; + state.filePhase = "disabled"; + await state.emit("settings-changed", state.settings); + // A queued files notification carries no stale status payload. + await state.emit("files-changed"); + }); + await expect( + page.getByRole("heading", { name: "File search is off" }), + ).toBeVisible(); + await expect(page.locator(".list-count")).toHaveText("File search is off"); + await expect( + page.getByText( + "File search is off. Choose folders in Settings, File search.", + ), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "No files in the index" }), + ).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("files-disabled.png") }); + await page.getByRole("combobox", { name: "Search TinyDash" }).fill("missing"); + await expect( + page.getByRole("heading", { name: "File search is off" }), + ).toBeVisible(); + await expect( + page.getByText( + "File search is off. Choose folders in Settings, File search.", + ), + ).toBeVisible(); + await page.evaluate(async () => { + const state = window.__launcherTest; + state.settings = { ...state.settings, fileSearchRoots: null }; + state.filePhase = "queued"; + await state.emit("settings-changed", state.settings); + }); + await expect( + page.getByRole("heading", { name: "Waiting to refresh files" }), + ).toBeVisible(); + await page.evaluate(() => { + window.__launcherTest.filePhase = "scanning"; + return window.__launcherTest.emit("files-changed"); + }); + await expect( + page.getByRole("heading", { name: "Finding your files" }), + ).toBeVisible(); +}); + +test("file scanner startup failure is distinct from a finished empty scan and can retry", async ({ + page, +}) => { + await openLauncher(page); + await selectCategory(page, "Files"); + await page.evaluate(() => { + window.__launcherTest.fileTotal = 0; + window.__launcherTest.filePhase = "failed"; + window.__launcherTest.fileWarning = + "Cannot start the file scanner: unavailable"; + return window.__launcherTest.emit("files-changed"); + }); + await expect( + page.getByRole("heading", { name: "File scan unavailable" }), + ).toBeVisible(); + await expect(page.getByRole("alert")).toContainText( + "Cannot start the file scanner", + ); + await expect(page.getByText("Use Refresh files to try again.")).toBeVisible(); + await page.getByRole("combobox", { name: "Search TinyDash" }).press("Meta+r"); + await expect( + page.getByRole("heading", { name: "Waiting to refresh files" }), + ).toBeVisible(); +}); + test("previews plain text, copies by ID, and deletes without hiding the launcher", async ({ page, }) => { diff --git a/tests/mock-backend.ts b/tests/mock-backend.ts index ad86996..d25d385 100644 --- a/tests/mock-backend.ts +++ b/tests/mock-backend.ts @@ -3,6 +3,7 @@ import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { emit, emitTo, type EventTarget } from "@tauri-apps/api/event"; import type { AppearanceChange, + FilePhase, SearchResult, SearchMode, LauncherWarning, @@ -38,7 +39,8 @@ declare global { clipboardCleared: boolean; rejectClear: boolean; slowPreview: boolean; - fileIndexing: boolean; + filePhase: FilePhase; + fileTotal: number; fileWarning: string | null; currencyDate: string | null; currencyRefreshing: boolean; @@ -327,7 +329,8 @@ window.__launcherTest = { clipboardCleared: false, rejectClear: false, slowPreview: false, - fileIndexing: false, + filePhase: "idle", + fileTotal: 1, fileWarning: null, currencyDate: null, currencyRefreshing: false, @@ -493,7 +496,25 @@ mockIPC( } if (command === "save_settings") { if (state.rejectSettings) throw new Error(state.rejectSettings); - state.settings = (payload as { settings: SettingsValues }).settings; + const next = (payload as { settings: SettingsValues }).settings; + if ( + [ + "fileSearchRoots", + "fileSearchExcludedDirs", + "fileSearchLimit", + "fileWatchEnabled", + ].some( + (key) => + JSON.stringify(state.settings[key as keyof SettingsValues]) !== + JSON.stringify(next[key as keyof SettingsValues]), + ) + ) { + state.filePhase = + next.fileSearchRoots?.length === 0 ? "disabled" : "queued"; + state.fileTotal = 0; + state.fileWarning = null; + } + state.settings = next; localStorage.setItem( "tinydash.test.settings", JSON.stringify(state.settings), @@ -514,6 +535,12 @@ mockIPC( ); return state.settings; } + if (command === "refresh_files") { + if (state.filePhase !== "disabled" && state.filePhase !== "scanning") + state.filePhase = "queued"; + await emit("files-changed"); + return; + } if (command === "app_catalog") return apps; if (command === "set_app_preference") { const { id, aliases, hidden } = payload as { @@ -602,7 +629,9 @@ mockIPC( ? [...systemCommands].reverse() : systemCommands : mode === "files" || query === "Launch notes.md" - ? query === "missing" + ? query === "missing" || + state.fileTotal === 0 || + state.settings.fileSearchRoots?.length === 0 ? [] : query === "Projects" ? [folder] @@ -719,8 +748,12 @@ mockIPC( warning: state.currencyWarning, }, files: { - total: 1, - indexing: state.fileIndexing, + total: + state.settings.fileSearchRoots?.length === 0 ? 0 : state.fileTotal, + phase: + state.settings.fileSearchRoots?.length === 0 + ? "disabled" + : state.filePhase, warning: state.fileWarning, }, }; From 5a07dccf580cd1d48609349003885909b41a80f2 Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 16:18:34 +0800 Subject: [PATCH 3/5] test(files): retain real worker watch registration coverage --- src-tauri/src/launcher/files.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/launcher/files.rs b/src-tauri/src/launcher/files.rs index afff0c2..90f91f7 100644 --- a/src-tauri/src/launcher/files.rs +++ b/src-tauri/src/launcher/files.rs @@ -898,16 +898,17 @@ mod tests { state.files.stop(); } }, - |_, _, cancelled| { + |watcher, report, cancelled| { assert_ne!(phase, "cancelled preparation"); clock.advance(delay); if phase == "cancelled registration" { state.files.invalidate(&state.settings()); } - // Manual refresh during registration coalesces with the - // new watch's follow-up. It still owes the entire rest. + // Manual refresh coalesces with any new watch follow-up. + // Keep real watcher registration in this worker regression; + // only its elapsed time is controlled by the test clock. let _ = sender.try_send(Request::Refresh); - (!cancelled()).then_some((true, None)) + watcher.update(report, cancelled) }, ); assert_eq!( From 8c4e472e39f9e012d60a49eacc9bf8eb8b001dba Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 16:46:10 +0800 Subject: [PATCH 4/5] fix(files): dispose obsolete watches after late begin rejection --- docs/reference/features/files.md | 2 +- src-tauri/src/launcher/file_watch.rs | 16 ++++ src-tauri/src/launcher/files.rs | 132 ++++++++++++++++++++++++++- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/docs/reference/features/files.md b/docs/reference/features/files.md index 2682fcc..1281210 100644 --- a/docs/reference/features/files.md +++ b/docs/reference/features/files.md @@ -34,7 +34,7 @@ bun run test:rust -- launcher::files bun run test:rust -- launcher::file_watch ``` -Rust regressions pause a 4096-entry traversal during rapid root changes, reject an obsolete completed scan even after roots change back, and accept only the newest generation. They also cover stop/disable cancellation, same-settings snapshot retention, immediate removed-root invalidation, stale-warning rejection, aggregate counters, and workload limits under refresh floods. Worker-loop regressions use an injected clock at preparation, registration, rest, and settling boundaries to check complete-cycle accounting and cancellation status notifications without timing sleeps. They check queued follow-ups during scans, refresh floods consumed during cooldown, newest-root selection, disabling, stale phase/warning rejection, and retry after scanner startup failure. Browser regressions distinguish queued, scanning, finished-empty, disabled, and failed states, including Settings-window root-change notifications; they do not simulate real filesystem timing. Controlled watcher operations cancel at batch start, removal, addition, and commit to prove that the remaining obsolete operations are skipped; root-resolution checks cover cancellation between metadata and ancestor lookups. These are backend proofs, not integrated desktop checks. +Rust regressions pause a 4096-entry traversal during rapid root changes, reject an obsolete completed scan even after roots change back, and accept only the newest generation. They also cover stop/disable cancellation, same-settings snapshot retention, immediate removed-root invalidation, stale-warning rejection, aggregate counters, and workload limits under refresh floods. Worker-loop regressions use an injected clock at preparation, registration, rest, and settling boundaries to check complete-cycle accounting and cancellation status notifications without timing sleeps. They check queued follow-ups during scans, refresh floods consumed during cooldown, newest-root selection, disabling, stale phase/warning rejection, and retry after scanner startup failure. A final-budget-to-begin regression registers real watches, disables in that gap, observes obsolete-watch disposal before re-enabling, and checks that re-enable honors the disposal's accounted rest. Browser regressions distinguish queued, scanning, finished-empty, disabled, and failed states, including Settings-window root-change notifications; they do not simulate real filesystem timing. Controlled watcher operations cancel at batch start, removal, addition, and commit to prove that the remaining obsolete operations are skipped; root-resolution checks cover cancellation between metadata and ancestor lookups. These are backend proofs, not integrated desktop checks. Use Files and All with an isolated root. Create, rename, and delete a file without manual refresh. Confirm the visible result changes. Open a selected file and check the handler marker. Exercise Refresh files and Ctrl/Command + R when manual refresh changes. diff --git a/src-tauri/src/launcher/file_watch.rs b/src-tauri/src/launcher/file_watch.rs index fa20609..04f66e2 100644 --- a/src-tauri/src/launcher/file_watch.rs +++ b/src-tauri/src/launcher/file_watch.rs @@ -126,6 +126,19 @@ pub struct FileWatcher { watched: BTreeMap, roots: Vec, changes: Arc, + // Last field: observe only after the real OS watcher has been dropped. + #[cfg(test)] + pub(super) drop_observer: Option, +} + +#[cfg(test)] +pub(super) struct WatchDropObserver(pub(super) Arc); + +#[cfg(test)] +impl Drop for WatchDropObserver { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } } impl FileWatcher { @@ -169,6 +182,8 @@ impl FileWatcher { watched: BTreeMap::new(), roots, changes, + #[cfg(test)] + drop_observer: None, }) } } @@ -546,6 +561,7 @@ mod tests { .into(), roots: roots.clone(), changes: Arc::default(), + drop_observer: None, }; assert!( watcher diff --git a/src-tauri/src/launcher/files.rs b/src-tauri/src/launcher/files.rs index 90f91f7..a5c39c3 100644 --- a/src-tauri/src/launcher/files.rs +++ b/src-tauri/src/launcher/files.rs @@ -411,13 +411,16 @@ fn run_worker_loop( // be included by settings() or invalidate this scan before publication. let generation = state.files.generation(); let settings = state.settings(); - active_generation = generation; if !state.files.begin(generation, &rx) { - if stop.load(Ordering::Acquire) { + // Invalidation can land after the final budget callback. Reconcile + // old watch ownership (and its rest) before rejecting this cycle; + // a disabled worker may receive no further requests. + if !keep_running(&mut not_before) { break; } continue; } + active_generation = generation; notify(); // Account for the entire cycle, not only traversal: root lookup, // watcher creation/registration and obsolete-watcher disposal can block. @@ -546,6 +549,7 @@ mod tests { struct TestClock<'a> { now: Cell, + reading: RefCell) + 'a>>, waiting: RefCell>, } @@ -553,6 +557,7 @@ mod tests { fn new() -> Self { Self { now: Cell::new(Instant::now()), + reading: RefCell::new(Box::new(|_| {})), waiting: RefCell::new(Box::new(|| {})), } } @@ -564,6 +569,7 @@ mod tests { impl WorkerClock for TestClock<'_> { fn now(&self) -> Instant { + (self.reading.borrow_mut())(&self.now); self.now.get() } @@ -1094,6 +1100,128 @@ mod tests { assert_eq!(state.search.lock().unwrap().file_count(), 0); } + #[test] + fn disable_in_final_budget_gap_drops_old_watch_and_reenable_rests() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("example.txt"), "").unwrap(); + let settings = Settings { + file_search_roots: Some(vec![directory.path().into()]), + ..Settings::default() + }; + let state = LauncherState::new(settings.clone(), vec![]); + let (sender, receiver) = sync_channel(1); + *state.files.sender.lock().unwrap() = Some(sender.clone()); + sender.try_send(Request::Refresh).unwrap(); + let registered = Cell::new(0); + let budget_exits = Cell::new(0); + let disabled = Cell::new(false); + let disposal_started = Cell::new(false); + let disposal_finished = Cell::new(false); + let reenabled = Cell::new(false); + let dropped = Arc::new(AtomicBool::new(false)); + let base = Instant::now(); + let work = Duration::from_secs(1); + let disposal = Duration::from_secs(2); + let clock = TestClock::new(); + clock.now.set(base); + *clock.reading.borrow_mut() = Box::new(|now| { + if !disabled.get() { + if registered.get() == 1 && now.get() == base + work * 4 { + budget_exits.set(budget_exits.get() + 1); + if budget_exits.get() == 2 { + // wait_for_budget calls now AFTER keep_running. This + // is the final wait's successful exit, before the + // worker reads generation/settings or calls begin. + assert!(!dropped.load(Ordering::Acquire)); + state.replace_settings(Settings { + file_search_roots: Some(vec![]), + ..settings.clone() + }); + // Only the disable's own settings refresh is queued. + // No later request/change rescues obsolete ownership. + sender.try_send(Request::Refresh).unwrap(); + disabled.set(true); + } + } + } else if !disposal_finished.get() { + if !disposal_started.replace(true) { + assert_eq!(state.files.status(0).phase, FilePhase::Disabled); + assert_eq!(state.files.generation(), 1); + assert_eq!(state.search.lock().unwrap().file_count(), 0); + } else { + // Original code reaches this on the next final budget + // check with the old watcher still alive. Fail BEFORE it + // can block on recv: the negative regression cannot hang. + assert!( + dropped.load(Ordering::Acquire), + "obsolete watcher retained after late begin rejection" + ); + assert!(!reenabled.get()); + assert_eq!(now.get(), base + work * 4); + now.set(now.get() + disposal); + disposal_finished.set(true); + } + } + }); + *clock.waiting.borrow_mut() = Box::new(|| { + if disabled.get() && !reenabled.replace(true) { + assert!(disposal_finished.get()); + assert!(dropped.load(Ordering::Acquire)); + assert_eq!(state.files.status(0).phase, FilePhase::Disabled); + state.replace_settings(settings.clone()); + sender.try_send(Request::Refresh).unwrap(); + } + }); + let metrics = run_worker_loop( + &clock, + &state, + receiver, + sender.clone(), + |settings, _| { + if reenabled.get() { + assert_eq!(registered.get(), 1); + assert_eq!( + clock.now(), + base + work * 4 + disposal * 4, + "re-enable bypassed disposal rest" + ); + } + Some((settings.file_search_roots.clone().unwrap(), None)) + }, + || { + if registered.get() == 2 { + state.files.stop(); + } + }, + |watcher, report, cancelled| { + if registered.get() == 0 { + // Queue Refresh before registration can emit Changed, so + // the next cycle deterministically skips burst settling. + sender.try_send(Request::Refresh).unwrap(); + } + // Real OS registration is exercised, not replaced by a stub. + let result = watcher.update(report, cancelled); + assert!(matches!(result, Some((true, None))), "{result:?}"); + registered.set(registered.get() + 1); + if registered.get() == 1 { + watcher.drop_observer = + Some(file_watch::WatchDropObserver(Arc::clone(&dropped))); + clock.advance(work); + } + result + }, + ); + assert!(disabled.get() && disposal_finished.get() && reenabled.get()); + assert_eq!(registered.get(), 2); + assert_eq!(metrics.scans, 2); + assert_eq!(metrics.cancelled, 0); + assert_eq!( + metrics.duration, + work + disposal, + "disposal was unaccounted" + ); + } + #[test] fn automatic_followup_remains_queued_through_rest_and_settling() { let state = LauncherState::new( From d82007c394ebd825867c18fba5b01b9e2fb71c3d Mon Sep 17 00:00:00 2001 From: Jewei Mak Date: Mon, 28 Sep 2026 16:50:13 +0800 Subject: [PATCH 5/5] test(files): name the controlled clock read hook --- src-tauri/src/launcher/files.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/launcher/files.rs b/src-tauri/src/launcher/files.rs index a5c39c3..6fb58ad 100644 --- a/src-tauri/src/launcher/files.rs +++ b/src-tauri/src/launcher/files.rs @@ -547,9 +547,11 @@ mod tests { sync::mpsc::RecvTimeoutError, }; + type ClockReadHook<'a> = Box) + 'a>; + struct TestClock<'a> { now: Cell, - reading: RefCell) + 'a>>, + reading: RefCell>, waiting: RefCell>, }