Skip to content

Commit a36e703

Browse files
committed
Freshness UX: per-drive index status badge + menu in the volume switcher
Every real drive now carries a small index-freshness dot in the volume switcher, in two placements: always-visible next to the dropdown trigger (the ACTIVE drive, beside the SMB light), and per-row inside the dropdown. Four states map from the backend `VolumeIndexStatus`: gray (not indexed), blue (scanning, pulses unless reduce-motion), green (fresh), yellow (stale). Clicking the dot opens a small themed menu — turn on / off, rescan, stop — with a "Last indexed: <date> · took <duration>" footer. - `DriveIndexBadge.svelte`: the dot + popover menu, a focusable `<button>` with an `aria-label` and `aria-haspopup` (no `role="img"` — axe rejects it on a button). Reuses the existing colored-indicator + `use:tooltip` shape. - `drive-index-status.ts`: the pure state→color/menu/duration mapping, unit-tested. - `drive-index-manager.svelte.ts`: a reactive `volumeId → status` map that fetches on demand and stays live by SUBSCRIBING to `index-freshness-changed` / `index-scan-started` / `index-scan-complete` (subscribe, don't poll). Fetch failures degrade to "no badge", never an unhandled rejection. `isDriveRow` is the badge-eligibility predicate (real drives only, not favorites/groups). - `VolumeBreadcrumb.svelte`: renders the badge in both placements and runs the per-drive IPC for a picked action. A refused SMB enable/rescan is classified by TYPED `SmbIndexGateReason` (never message text): `credentials_needed` routes into the direct-connect/login flow, the rest show a friendly toast. - Copy is i18n-ized under `fileExplorer.navigation.driveIndex.*`. - Drive-by: reword a `volume_scanner` debug log to `key=value` form so it stops tripping `pluralize-noun`.
1 parent 556f850 commit a36e703

13 files changed

Lines changed: 1027 additions & 2 deletions

File tree

apps/desktop/src-tauri/src/indexing/volume_scanner.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ pub(crate) async fn scan_volume_via_trait(
211211
.map_err(|e| VolumeScanError::WriterSend(e.to_string()))?;
212212

213213
log::info!(
214-
"volume_scanner: walk complete for {}: {total_entries} entries, {total_dirs} dirs in {}ms",
214+
"volume_scanner: walk complete for {}: entries={total_entries}, dirs={total_dirs} in {}ms",
215215
root.display(),
216216
start.elapsed().as_millis()
217217
);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ Browser-style back/forward history, path resolution, paged keyboard shortcuts, a
3434
make it per-pane.
3535
- **`containingVolumeId` is derived via `resolvePathVolume(currentPath)`, not the `volumeId` prop** (which may be a
3636
favorite's virtual id), so the active checkmark tracks the real containing volume.
37+
- **The drive-index freshness badge (`DriveIndexBadge.svelte`) renders only on real DRIVE rows** (`isDriveRow`: not
38+
favorites, not the synthetic `network` / `search-results` entries), in two placements (active-drive by the trigger,
39+
per-row in the dropdown). State→color/menu mapping is the pure `drive-index-status.ts`; status stays live via the
40+
manager's event subscriptions, not polling. The badge is a `<button>` (no `role="img"` — axe rejects it). Refused
41+
enable/rescan is classified by typed `SmbIndexGateReason`, never message text. Full contract: DETAILS § Drive index
42+
freshness badge.
3743
- **Favorites: mutate ONLY via the `commands.*` wrappers and ALWAYS strip the `fav-` prefix.** The switcher's
3844
`LocationInfo.id` is `fav-<favoriteId>`; `removeFavorite` / `renameFavorite` / `reorderFavorites` take the bare id
3945
(`stripFavoritePrefix`). The favorites group in `volume-grouping.ts` always renders (even empty, for the placeholder),

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ Browser-style back/forward history, path resolution, paged keyboard shortcuts, a
3030
`effectiveVolumes` / `favorites` stay in the component and read `fav.optimisticFavoriteIds`
3131
- **`eject-predicate.ts`**: Pure `isVolumeEjectable(volume)` used by the eject button gate. Returns true when NSURL says
3232
ejectable OR the volume has any SMB connection state
33+
- **`DriveIndexBadge.svelte`**: Per-drive index freshness dot (gray/blue/green/yellow) + its click menu (see § Drive
34+
index freshness badge)
35+
- **`drive-index-status.ts`**: Pure mapping for the badge: `VolumeIndexStatus` → state/color, menu items per state, the
36+
"N min, S s" duration formatter (`drive-index-status.test.ts`)
37+
- **`drive-index-manager.svelte.ts`**: Reactive `volumeId → VolumeIndexStatus` map; fetches on demand and subscribes to
38+
the indexing events to stay live. `isDriveRow(volume)` is the badge-eligibility predicate
3339
- **`navigation-history.test.ts`**: Full unit test coverage of history functions
3440
- **`path-navigation.test.ts`**: Unit tests for path resolution and timeouts
3541
- **`keyboard-shortcuts.test.ts`**: Unit tests for shortcut calculations
@@ -316,6 +322,33 @@ row. Tooltip shows `<label> (Max. <N> MB/s)\nNegotiated for this cable, port, an
316322
`white-space: pre-line`, so `\n` becomes a real line break). The dot is the only visual — no inline text in the chip and
317323
no extra line under the disk-space bar, by design.
318324

325+
### Drive index freshness badge
326+
327+
Each real drive carries a small index-freshness dot (`DriveIndexBadge.svelte`) in TWO placements: always-visible next to
328+
the dropdown trigger (reflecting the ACTIVE drive), and per-row inside the dropdown. Both reuse the same colored-dot +
329+
`use:tooltip` shape as the SMB light and USB-speed ring. The four states map from the backend `VolumeIndexStatus`
330+
(`commands.getVolumeIndexStatusById`): gray = `disabled` (no live index, `enabled: false` or `freshness: null`), blue =
331+
`scanning`, green = `fresh`, yellow = `stale`. The mapping, the menu items per state, and the "N min, S s" duration
332+
formatter are the pure `drive-index-status.ts` (unit-tested). Blue pulses (gated behind `prefers-reduced-motion`).
333+
334+
- **Eligibility is `isDriveRow(volume)`** (in `drive-index-manager.svelte.ts`): every entry except favorites and the
335+
synthetic `network` / `search-results` ids. SMB shares (`category: 'network'`, real id) and the local disk (`root`)
336+
DO get a badge; the synthetic "Network" group entry does not. The badge is gray for any drive without a registered
337+
index, so it's safe to query for every eligible row.
338+
- **Status stays live by SUBSCRIPTION, not polling** (`drive-index-manager.svelte.ts`): it listens to
339+
`index-freshness-changed`, `index-scan-started`, and `index-scan-complete`, refetching the named volume's status on
340+
each (the events alone don't carry the last-scan facts). The active-drive badge also refetches when the active drive
341+
changes; dropdown rows refetch on open.
342+
- **The badge is a focusable `<button>`** with an `aria-label` (state ariaLabel + the tooltip text) and
343+
`aria-haspopup="menu"`; clicking opens a small themed popover menu (NOT a native menu) anchored to the badge. Menu
344+
actions (`enable`/`rescan`/`disable`/`stop`) call back to `VolumeBreadcrumb`'s `handleDriveIndexAction`, which runs
345+
the per-drive IPC. ❌ Don't put `role="img"` on the button (axe rejects it; the button role + label already convey it).
346+
- **Refused enable/rescan is classified by TYPED variant** (`SmbIndexGateReason`), never message text: `credentials_needed`
347+
routes into the existing direct-connect/login flow (`handleSubmenuAction`); the others show a friendly toast.
348+
- **The dropdown-row menu can be clipped by the dropdown's `overflow-y: auto`** (unlike the breadcrumb placement). The
349+
breadcrumb badge is the primary surface (D3) and isn't clipped; the row menu is a convenience. If this becomes a
350+
problem, switch the row menu to `position: fixed` from `getBoundingClientRect()` like the connection submenu.
351+
319352
### Dropdown and submenu UI patterns
320353

321354
These patterns emerged during the volume picker implementation and should be followed in future dropdown/submenu work:
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Tier 3 a11y tests for `DriveIndexBadge.svelte`: the focusable, labeled status
3+
* dot and its open menu must have no axe violations, in each freshness state.
4+
* Mirrors `IndexingStatusIndicator.a11y.test.ts`.
5+
*/
6+
import { describe, it, expect } from 'vitest'
7+
import { mount, flushSync, tick } from 'svelte'
8+
import DriveIndexBadge from './DriveIndexBadge.svelte'
9+
import { expectNoA11yViolations } from '$lib/test-a11y'
10+
import type { Freshness, VolumeIndexStatus } from '$lib/ipc/bindings'
11+
12+
function makeStatus(freshness: Freshness | null, enabled = freshness != null): VolumeIndexStatus {
13+
return {
14+
volumeId: 'smb-test',
15+
enabled,
16+
freshness,
17+
scanCompletedAt: freshness === 'fresh' ? 1_750_000_000 : null,
18+
scanDurationMs: freshness === 'fresh' ? 134_000 : null,
19+
}
20+
}
21+
22+
async function mountBadge(status: VolumeIndexStatus) {
23+
const target = document.createElement('div')
24+
document.body.appendChild(target)
25+
mount(DriveIndexBadge, { target, props: { volumeId: status.volumeId, status, onAction: () => {} } })
26+
await tick()
27+
return target
28+
}
29+
30+
describe('DriveIndexBadge a11y', () => {
31+
it('the gray (disabled) dot has no violations', async () => {
32+
const target = await mountBadge(makeStatus(null, false))
33+
expect(target.querySelector('.drive-index-badge')).not.toBeNull()
34+
await expectNoA11yViolations(target)
35+
})
36+
37+
it('the blue (scanning) dot has no violations', async () => {
38+
const target = await mountBadge(makeStatus('scanning'))
39+
await expectNoA11yViolations(target)
40+
})
41+
42+
it('the green (fresh) dot has no violations', async () => {
43+
const target = await mountBadge(makeStatus('fresh'))
44+
await expectNoA11yViolations(target)
45+
})
46+
47+
it('the yellow (stale) dot has no violations', async () => {
48+
const target = await mountBadge(makeStatus('stale'))
49+
await expectNoA11yViolations(target)
50+
})
51+
52+
it('the open menu has no violations', async () => {
53+
const target = await mountBadge(makeStatus('stale'))
54+
const badge = target.querySelector<HTMLButtonElement>('.drive-index-badge')
55+
expect(badge).not.toBeNull()
56+
badge?.click()
57+
flushSync()
58+
expect(target.querySelector('.drive-index-menu')).not.toBeNull()
59+
await expectNoA11yViolations(target)
60+
})
61+
})

0 commit comments

Comments
 (0)