Skip to content

Commit 519a27e

Browse files
committed
Drive indexing: show a per-volume step checklist
Indexing is no longer an opaque bar that restarts under changing titles. Each drive's tooltip now shows a checklist of the actual steps — find files, save the file list, compute folder sizes, catch up on recent changes — each marked waiting (hollow), in progress (spinner), or done (check), with the live detail (count, folder-sizing sub-phase, progress bar, per-step ETA) under the active step. The user can finally see what indexing does, which step it's on, and how many remain. - Steps are composed from the events that actually fire for each drive: a network (SMB/MTP) scan inserts entries inline and shows just find + compute; an event-log roll-on collapses to a single 'Update index' step. The derivation is a pure, unit-tested function driven by the typed pipeline phase + aggregation sub-phase (no string-matching), and stays correct across a mid-scan reload by deriving from the furthest-reached signal. - The corner indicator expands the primary drive's checklist and collapses each other active drive to a one-line summary. The breadcrumb badge shows its own drive's full checklist. All steps render up-front so the tooltip height stays stable as steps tick. - Per-step ETA only; a true overall ETA needs persisted per-phase calibration and is deferred (docs/specs/later/drive-index-overall-eta.md). - Honest folder wording throughout; markers via the shared Icon/Spinner; reduced-motion respected; checklist is screen-reader friendly (per-step status words). Part of the drive-index progress-reporting plan, milestone M4b.
1 parent 138bdfa commit 519a27e

36 files changed

Lines changed: 1717 additions & 366 deletions

apps/desktop/src/lib/file-explorer/navigation/CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ Browser-style back/forward history, path resolution, paged keyboard shortcuts, a
3838
single live-activity source), read per-volume via `getVolumeActivity` — don't reintroduce a manager-side progress map.
3939
The `scanning` badge tooltip switches from a text tooltip to the `contentEl` DOM tooltip and renders the SHARED
4040
`IndexingDriveRow` body (heading off) for its volume, so it matches the corner indicator; it falls back to a static
41-
"Scanning your drive…" text when activity hasn't hydrated yet (the SMB/MTP first-tick window). Non-scanning states keep
42-
the text tooltip. The badge is a `<button>` (axe rejects `role="img"`). Refused enable/rescan is classified by typed
43-
`SmbIndexGateReason`, never text. Full contract: DETAILS § Drive index freshness badge.
41+
"Scanning your drive…" text when activity hasn't hydrated yet (the SMB/MTP first-tick window). Non-scanning states
42+
keep the text tooltip. The badge is a `<button>` (axe rejects `role="img"`). Refused enable/rescan is classified by
43+
typed `SmbIndexGateReason`, never text. Full contract: DETAILS § Drive index freshness badge.
4444
- **Favorites: mutate ONLY via the `commands.*` wrappers, ALWAYS stripping the `fav-` prefix** (the switcher id is
4545
`fav-<favoriteId>`; the commands take the bare id via `stripFavoritePrefix`). The `volume-grouping.ts` favorites group
4646
always renders even when empty (the placeholder row), so don't tidy it into a hide-when-empty branch. "Add to

apps/desktop/src/lib/file-explorer/navigation/DETAILS.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -344,14 +344,14 @@ formatter are the pure `drive-index-status.ts` (unit-tested). Blue pulses (gated
344344
- **A scanning badge shows the SHARED live status body**, not a bespoke string — the same `IndexingDriveRow` body the
345345
corner indicator renders (heading off), so the two surfaces match exactly (count + elapsed for a first scan, or
346346
bar+percent+ETA for a calibrated rescan; see `$lib/indexing` DETAILS § Status indicator tooltip content). The badge
347-
reads ONLY its own volume's live activity from `index-state` via `getVolumeActivity(volumeId)` (+ `getVolumeAggregation`)
348-
`index-state` is the single live-activity source; the manager carries no progress map. For the `scanning` state the
349-
tooltip switches from the text variant to the `contentEl` DOM tooltip: the body lives in a `<div hidden>` host and the
350-
INNER element is handed to the tooltip as `contentEl` (an adopted element keeps its own `hidden`, so the host can't be
351-
passed — mirrors `IndexingStatusIndicator`). Fallback: for a non-root (SMB/MTP) volume, `index-state` only hydrates on
352-
the next ~500 ms progress tick, so in the window between the freshness flip to `scanning` and that tick there's no
353-
activity — the badge then shows a static "Scanning your drive…" text tooltip (`indexing.scan.label`), never an empty
354-
one. Non-scanning states (disabled/fresh/stale) keep their text tooltips.
347+
reads ONLY its own volume's live activity from `index-state` via `getVolumeActivity(volumeId)` (+
348+
`getVolumeAggregation`) `index-state` is the single live-activity source; the manager carries no progress map. For
349+
the `scanning` state the tooltip switches from the text variant to the `contentEl` DOM tooltip: the body lives in a
350+
`<div hidden>` host and the INNER element is handed to the tooltip as `contentEl` (an adopted element keeps its own
351+
`hidden`, so the host can't be passed — mirrors `IndexingStatusIndicator`). Fallback: for a non-root (SMB/MTP) volume,
352+
`index-state` only hydrates on the next ~500 ms progress tick, so in the window between the freshness flip to
353+
`scanning` and that tick there's no activity — the badge then shows a static "Scanning your drive…" text tooltip
354+
(`indexing.scan.label`), never an empty one. Non-scanning states (disabled/fresh/stale) keep their text tooltips.
355355
- **The badge is a focusable `<button>`** with an `aria-label` (state ariaLabel + the tooltip text) and
356356
`aria-haspopup="menu"`; clicking opens a small themed popover menu (NOT a native menu) anchored to the badge. Menu
357357
actions (`enable`/`rescan`/`disable`/`stop`) call back to `VolumeBreadcrumb`'s `handleDriveIndexAction`, which runs

apps/desktop/src/lib/file-explorer/navigation/DriveIndexBadge.a11y.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,27 @@ import { mount, flushSync, tick } from 'svelte'
88
import type { Freshness, VolumeIndexStatus } from '$lib/ipc/bindings'
99
import type { VolumeIndexActivity } from '$lib/indexing'
1010

11-
// The badge reads its own volume's live activity from `index-state`; mock it so
12-
// we can exercise the scanning tooltip's rich DOM body.
11+
// The badge reads its own volume's live activity + phase from `index-state`; mock
12+
// it so we can exercise the scanning tooltip's rich DOM checklist body.
1313
let badgeActivity: VolumeIndexActivity | undefined
1414
vi.mock('$lib/indexing', () => ({
1515
getVolumeActivity: () => badgeActivity,
1616
getVolumeAggregation: () => undefined,
17+
getVolumePhase: () => undefined,
18+
placeholderActivity: (volumeId: string): VolumeIndexActivity => ({
19+
volumeId,
20+
phase: 'scanning',
21+
entriesScanned: 0,
22+
dirsFound: 0,
23+
bytesScanned: 0,
24+
scanStartedAt: 0,
25+
priorTotalEntries: null,
26+
priorScanDurationMs: null,
27+
volumeUsedBytes: null,
28+
replayEventsProcessed: 0,
29+
replayEstimatedTotal: 0,
30+
replayStartedAt: 0,
31+
}),
1732
}))
1833

1934
import DriveIndexBadge from './DriveIndexBadge.svelte'
@@ -32,7 +47,10 @@ function makeStatus(freshness: Freshness | null, enabled = freshness != null): V
3247
async function mountBadge(status: VolumeIndexStatus) {
3348
const target = document.createElement('div')
3449
document.body.appendChild(target)
35-
mount(DriveIndexBadge, { target, props: { volumeId: status.volumeId, status, driveName: 'Backups', onAction: () => {} } })
50+
mount(DriveIndexBadge, {
51+
target,
52+
props: { volumeId: status.volumeId, status, driveName: 'Backups', onAction: () => {} },
53+
})
3654
await tick()
3755
return target
3856
}

apps/desktop/src/lib/file-explorer/navigation/DriveIndexBadge.svelte

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
type DriveIndexMenuAction,
2626
type DriveIndexState,
2727
} from './drive-index-status'
28-
import { getVolumeActivity, getVolumeAggregation } from '$lib/indexing'
28+
import { getVolumeActivity, getVolumeAggregation, getVolumePhase, placeholderActivity } from '$lib/indexing'
2929
import IndexingDriveRow from '$lib/indexing/IndexingDriveRow.svelte'
3030
3131
interface Props {
@@ -69,12 +69,19 @@
6969
// the static text fallback shows instead of an empty tooltip.
7070
const activity = $derived(getVolumeActivity(volumeId))
7171
const aggregation = $derived(getVolumeAggregation(volumeId))
72+
// This volume's top-level phase, so the checklist stays visible through the
73+
// reconcile step (scan + aggregation both done, only the phase event marks it):
74+
// there's no live `activity` then, so fall back to a placeholder the checklist
75+
// reads its state from via the phase. `undefined` until the first signal lands.
76+
const phase = $derived(getVolumePhase(volumeId))
77+
const bodyActivity = $derived(activity ?? (phase != null ? placeholderActivity(volumeId) : undefined))
7278
7379
// The shared body lives in a hidden host; we hand its inner element to the
74-
// tooltip as `contentEl`. Rich only once it's mounted (activity present), else
75-
// the static fallback text. Mirrors the indicator's `contentEl` pattern.
80+
// tooltip as `contentEl`. Rich only once there's something to show (live
81+
// activity, or a phase mid-pipeline), else the static fallback text. Mirrors
82+
// the indicator's `contentEl` pattern.
7683
let scanBodyEl = $state<HTMLDivElement>()
77-
const useRichScanningTooltip = $derived(badgeState === 'scanning' && activity != null && scanBodyEl != null)
84+
const useRichScanningTooltip = $derived(badgeState === 'scanning' && bodyActivity != null && scanBodyEl != null)
7885
7986
// The text tooltip per state. The `scanning` text is the static fallback for
8087
// the no-activity-yet window (the rich body replaces it once activity lands);
@@ -156,14 +163,14 @@
156163
onclick={toggleMenu}
157164
></button>
158165

159-
{#if badgeState === 'scanning' && activity}
166+
{#if badgeState === 'scanning' && bodyActivity}
160167
<!-- The scanning tooltip's rich body. Lives in a hidden host; the tooltip
161168
action adopts the INNER element (`scanBodyEl`) as `contentEl`, not the
162169
hidden host (an adopted element keeps its own `hidden`, so a hidden host
163170
would render an empty tooltip). Mirrors `IndexingStatusIndicator`. -->
164171
<div hidden>
165172
<div bind:this={scanBodyEl} class="scan-tooltip-body">
166-
<IndexingDriveRow {activity} {aggregation} {driveName} showHeading={false} />
173+
<IndexingDriveRow activity={bodyActivity} {aggregation} {driveName} showHeading={false} />
167174
</div>
168175
</div>
169176
{/if}

apps/desktop/src/lib/file-explorer/navigation/DriveIndexBadge.svelte.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,31 @@
88
*/
99
import { describe, it, expect, vi, beforeEach } from 'vitest'
1010
import { mount, flushSync } from 'svelte'
11-
import type { VolumeIndexStatus } from '$lib/ipc/bindings'
11+
import type { ActivityPhase, VolumeIndexStatus } from '$lib/ipc/bindings'
1212
import type { VolumeIndexActivity } from '$lib/indexing'
1313

14-
// The badge reads its own volume's live activity from `index-state` (the single
15-
// live-activity source). Mock it so we can drive the scanning tooltip body.
14+
// The badge reads its own volume's live activity + phase from `index-state` (the
15+
// single live-activity source). Mock it so we can drive the scanning tooltip body.
1616
let badgeActivity: VolumeIndexActivity | undefined
17+
let badgePhase: ActivityPhase | undefined
1718
vi.mock('$lib/indexing', () => ({
1819
getVolumeActivity: () => badgeActivity,
1920
getVolumeAggregation: () => undefined,
21+
getVolumePhase: () => badgePhase,
22+
placeholderActivity: (volumeId: string): VolumeIndexActivity => ({
23+
volumeId,
24+
phase: 'scanning',
25+
entriesScanned: 0,
26+
dirsFound: 0,
27+
bytesScanned: 0,
28+
scanStartedAt: 0,
29+
priorTotalEntries: null,
30+
priorScanDurationMs: null,
31+
volumeUsedBytes: null,
32+
replayEventsProcessed: 0,
33+
replayEstimatedTotal: 0,
34+
replayStartedAt: 0,
35+
}),
2036
}))
2137

2238
import DriveIndexBadge from './DriveIndexBadge.svelte'
@@ -75,6 +91,7 @@ function ariaLabel(target: HTMLElement): string {
7591

7692
beforeEach(() => {
7793
badgeActivity = undefined
94+
badgePhase = undefined
7895
})
7996

8097
describe('DriveIndexBadge color class', () => {
@@ -155,12 +172,13 @@ describe('DriveIndexBadge scanning tooltip', () => {
155172
expect(target.querySelector('.scan-tooltip-body')).toBeNull()
156173
})
157174

158-
it('renders the shared status body (count + elapsed) once live activity is present', () => {
175+
it('renders the shared checklist body (count + elapsed) once live activity is present', () => {
159176
badgeActivity = scanActivity({ volumeUsedBytes: 10_000_000 }) // rough first scan: count + elapsed, no bar
160177
const { target } = render(makeStatus({ freshness: 'scanning' }))
161178
const body = target.querySelector('.scan-tooltip-body')
162179
expect(body).not.toBeNull()
163-
expect(body?.textContent).toContain('Scanning your drive')
180+
// The checklist's first step (a network drive: Find files, then Compute folder sizes).
181+
expect(body?.textContent).toContain('Find files')
164182
expect(body?.textContent).toContain('12,345')
165183
// Rough first scan → no progress bar.
166184
expect(body?.querySelector('[role="progressbar"]')).toBeNull()
@@ -173,6 +191,16 @@ describe('DriveIndexBadge scanning tooltip', () => {
173191
expect(body?.querySelector('[role="progressbar"]')).not.toBeNull()
174192
})
175193

194+
it('renders the checklist from the phase alone when there is no live activity (reconcile / pre-tick)', () => {
195+
badgeActivity = undefined
196+
badgePhase = 'scanning'
197+
const { target } = render(makeStatus({ freshness: 'scanning' }))
198+
// A phase but no activity yet: the body still renders (off a placeholder), so
199+
// the checklist stays visible instead of falling back to the static phrase.
200+
expect(target.querySelector('.scan-tooltip-body')).not.toBeNull()
201+
expect(target.querySelector('.scan-tooltip-body')?.textContent).toContain('Find files')
202+
})
203+
176204
it('does not render the rich body when the badge is not scanning', () => {
177205
badgeActivity = scanActivity()
178206
const { target } = render(makeStatus({ freshness: 'fresh' }))

apps/desktop/src/lib/indexing/CLAUDE.md

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ indicator. Rust counterpart: `apps/desktop/src-tauri/src/indexing/`.
1111
Tauri index events.
1212
- **`index-events.ts`**: listens for `index-dir-updated`, calls back with updated paths.
1313
- **`eta.ts`**: pure ETA helpers + `computeScanProgress` (two-tier scan fraction).
14+
- **`indexing-steps.ts`**: pure, unit-tested step-checklist derivation (`deriveSteps`) + the step/sub-phase label maps.
1415
- **`elapsed.ts`**: pure `formatElapsedClock` (`m:ss`, `null` under 1s) — the first-scan elapsed clock, used by the
1516
shared `IndexingStatusBody` (so both the corner indicator and the badge tooltip show it from one source).
16-
- **`IndexingStatusIndicator.svelte`** + **`IndexingDriveRow.svelte`** + **`IndexingStatusBody.svelte`**: top-right
17-
hourglass shown whenever ANY drive is indexing; tooltip lists one `IndexingDriveRow` per active drive.
18-
`IndexingStatusBody` is the shared, PRESENTATIONAL status body (label / counters+elapsed / detail / bar+percent+ETA);
19-
`IndexingDriveRow` is the thin WRAPPER (heading + body + its own ETA sliding windows + the 1 Hz tick). The breadcrumb
20-
badge's scanning tooltip renders the SAME `IndexingDriveRow` (heading off) for its one volume, so both surfaces show
21-
one representation.
17+
- **The status surface** (`IndexingStatusIndicator` / `IndexingDriveRow` / `IndexingStatusBody` /
18+
`IndexingDriveSummary`): the top-right hourglass shown whenever ANY drive is indexing. `IndexingStatusBody` is the
19+
shared PRESENTATIONAL per-volume step checklist; `IndexingDriveRow` the thin WRAPPER (heading + body + ETA windows +
20+
1 Hz tick). The corner expands the primary drive and collapses each secondary to a one-line `IndexingDriveSummary`;
21+
the breadcrumb badge renders the same `IndexingDriveRow`. One representation everywhere. DETAILS § Step checklist.
22+
- **`indexing-steps.ts`**: pure `deriveSteps` (the checklist's per-volume step states) + the step/sub-phase label maps.
2223
- **`drive-index-prefs.ts`**: FE-OWNED persisted prefs the backend never reads: per-drive "don't ask again" silences
2324
(D6) and the one-time stale-dialog flag (D2), stored as hidden settings.
2425
- **`first-connect-trigger.ts`** + **`FirstConnectIndexToastContent.svelte`**: the first-connect "index this drive?"
@@ -40,12 +41,18 @@ indicator. Rust counterpart: `apps/desktop/src-tauri/src/indexing/`.
4041
- **Scan progress has two tiers** (`computeScanProgress`). Each tier uses a specific counter as both the numerator and
4142
the ETA window sample — don't mix them (swapping counter and denominator ships wrong ETAs). Details and clamping
4243
values: DETAILS.md.
43-
- **The indicator tracks ALL drives** via a per-volume `activity` map keyed by `volumeId`. Aggregation is per-volume
44-
too (its own map). State model, attribution, and the API: [DETAILS.md](DETAILS.md).
45-
- **`index-state` is the SINGLE source of live activity** (scan/replay counters + aggregation), keyed by `volumeId`.
46-
The breadcrumb badge reads its own volume's via `getVolumeActivity(volumeId)` to render the shared body; the badge's
44+
- **The indicator tracks ALL drives** via a per-volume `activity` map keyed by `volumeId`. Aggregation is per-volume too
45+
(its own map). State model, attribution, and the API: [DETAILS.md](DETAILS.md).
46+
- **`index-state` is the SINGLE source of live activity** (scan/replay counters + aggregation), keyed by `volumeId`. The
47+
breadcrumb badge reads its own volume's via `getVolumeActivity(volumeId)` to render the shared body; the badge's
4748
`drive-index-manager` owns ONLY freshness/menu facts (the dot color, last-scan facts), never live progress. Don't
4849
reintroduce a second live-count path.
50+
- **Checklist STEPS are composed from the events that fire for THIS volume** (`deriveSteps`), never a fixed list: a
51+
network (SMB/MTP) scan omits Save and Catch-up; a roll-on collapses to one Update step. Branch on typed discriminants
52+
only. Per-step ETA only; NO overall ETA by design (deferred — `docs/specs/later/drive-index-overall-eta.md`). The
53+
catch-up (reconcile) step has ONLY the `phase` event, so the visibility gate (`isAnyVolumeIndexing`) and the
54+
indicator/badge must include `phase`-only volumes (`getActivePhaseVolumeIds`) or the surface vanishes the moment
55+
aggregation completes and the step never shows. Full step model + composition: DETAILS § Step checklist.
4956
- **A keyed entry is cleared by a TERMINAL event**, never by freshness. Scan → `index-scan-complete`; replay →
5057
`index-replay-complete`; aggregation → `index-aggregation-complete`. A network (SMB/MTP) scan that aborts
5158
(disconnect/cancel/timeout) fires NO completion, so the backend emits `index-scan-aborted { volumeId }` and
@@ -66,5 +73,6 @@ indicator. Rust counterpart: `apps/desktop/src-tauri/src/indexing/`.
6673
`getDirSizeDisplayState` (`views/full-list-utils.ts`) is the single source of truth, consumed in lockstep by
6774
`FullList` / `SelectionInfo` / `measure-column-widths`. Rendering + sort: [DETAILS.md](DETAILS.md).
6875

69-
Full public API, the eight-event table, tooltip content per state, ETA blending, dependencies, and tests:
70-
[DETAILS.md](DETAILS.md). Read it before any non-trivial work here: editing, planning, reorganizing, or advising.
76+
Full public API, the ten-event table, the step model + per-volume composition, tooltip content per state, ETA blending,
77+
dependencies, and tests: [DETAILS.md](DETAILS.md). Read it before any non-trivial work here: editing, planning,
78+
reorganizing, or advising.

0 commit comments

Comments
 (0)