Skip to content

Commit 0dddb45

Browse files
committed
Freshness UX: first-connect index prompt (D6) and one-time stale dialog (D2)
Two pieces of the freshness UX that teach the user about external-drive indexing: - First-connect prompt (D6): the first time a NEW external drive is selected, Cmdr asks whether to index it, with [Don't ask again for any drives] [Don't ask again for this drive] [Enable indexing]. Self-gated in `first-connect-trigger.ts` on `indexing.enabled` + `indexing.askForEachDrive` + not-silenced + not-already-indexed + not-`root` + not-already-prompted-this-session. - One-time stale dialog (D2): the FIRST time any external drive's index goes Stale, `StaleDriveDialog.svelte` (mounted once in `+page.svelte`) explains the drive may have changed while disconnected, with [Never show again] [Close]. "Never show again" flips `indexing.staleNotify` off; the yellow badge shows regardless. It fires on the exact Fresh→Stale edge by subscribing to the new `index-freshness-changed` event, and only once via the persisted one-shot. `drive-index-prefs.ts` owns the persisted FE-only state (per-drive silences as a JSON array, the stale-dialog one-shot) the backend doesn't read. New `drive-index-stale` soft-dialog id for MCP. Copy under `indexing.firstConnect.*` and `indexing.staleDialog.*`.
1 parent bcd433a commit 0dddb45

12 files changed

Lines changed: 755 additions & 0 deletions

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ indicator. Rust counterpart: `apps/desktop/src-tauri/src/indexing/`.
1010
- **`index-events.ts`**: listens for `index-dir-updated`, calls back with updated paths.
1111
- **`eta.ts`**: pure ETA helpers + `computeScanProgress` (two-tier scan fraction).
1212
- **`IndexingStatusIndicator.svelte`**: top-right hourglass icon with a rich tooltip (scan / aggregation / replay).
13+
- **`drive-index-prefs.ts`**: FE-OWNED persisted prefs the backend never reads: per-drive "don't ask again" silences
14+
(D6) and the one-time stale-dialog flag (D2), stored as hidden settings.
15+
- **`first-connect-trigger.ts`** + **`FirstConnectIndexToastContent.svelte`**: the first-connect "index this drive?"
16+
prompt (D6), shown once per session per new external drive, self-gated on settings + silence + already-indexed.
17+
- **`StaleDriveDialog.svelte`**: the one-time "your drive went stale" dialog (D2), mounted once in `+page.svelte`,
18+
subscribes to `index-freshness-changed`, fires on the first external Fresh→Stale edge (gated on `indexing.staleNotify`).
1319

1420
## Must-knows
1521

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<script lang="ts">
2+
/**
3+
* First-connect indexing prompt (D6): shown the first time the user opens a
4+
* NEW external drive, asking whether to index it. Three actions: enable
5+
* indexing, silence this drive, silence all drives. The caller (the toast
6+
* trigger) gates whether this even shows; this component just renders the
7+
* choice and runs the picked action, then self-dismisses.
8+
*/
9+
import Button from '$lib/ui/Button.svelte'
10+
import { dismissToast } from '$lib/ui/toast'
11+
import { tString } from '$lib/intl/messages.svelte'
12+
13+
interface Props {
14+
/** Dedup id of this toast; lets the component self-dismiss on a choice. */
15+
toastId: string
16+
/** The drive being prompted about. */
17+
volumeId: string
18+
/** The drive's display name (for the heading). */
19+
volumeName: string
20+
/** Turn on indexing for this drive (kicks off the scan). */
21+
onEnable: (volumeId: string) => void
22+
/** Remember "don't ask again for this drive". */
23+
onSilenceDrive: (volumeId: string) => void
24+
/** Turn the per-drive prompt off for every drive. */
25+
onSilenceAll: () => void
26+
}
27+
28+
const { toastId, volumeId, volumeName, onEnable, onSilenceDrive, onSilenceAll }: Props = $props()
29+
30+
function enable() {
31+
onEnable(volumeId)
32+
dismissToast(toastId)
33+
}
34+
function silenceDrive() {
35+
onSilenceDrive(volumeId)
36+
dismissToast(toastId)
37+
}
38+
function silenceAll() {
39+
onSilenceAll()
40+
dismissToast(toastId)
41+
}
42+
</script>
43+
44+
<div class="first-connect-toast">
45+
<span class="title">{tString('indexing.firstConnect.title', { name: volumeName })}</span>
46+
<p class="body">{tString('indexing.firstConnect.body')}</p>
47+
<div class="actions">
48+
<Button variant="primary" size="mini" onclick={enable}>
49+
{tString('indexing.firstConnect.enable')}
50+
</Button>
51+
<Button variant="secondary" size="mini" onclick={silenceDrive}>
52+
{tString('indexing.firstConnect.silenceDrive')}
53+
</Button>
54+
<Button variant="secondary" size="mini" onclick={silenceAll}>
55+
{tString('indexing.firstConnect.silenceAll')}
56+
</Button>
57+
</div>
58+
</div>
59+
60+
<style>
61+
.first-connect-toast {
62+
display: flex;
63+
flex-direction: column;
64+
gap: var(--spacing-xs);
65+
}
66+
67+
.title {
68+
font-weight: 600;
69+
color: var(--color-text-primary);
70+
}
71+
72+
.body {
73+
margin: 0;
74+
color: var(--color-text-secondary);
75+
font-size: var(--font-size-sm);
76+
line-height: 1.4;
77+
}
78+
79+
.actions {
80+
display: flex;
81+
flex-wrap: wrap;
82+
gap: var(--spacing-xs);
83+
margin-top: var(--spacing-xxs);
84+
}
85+
</style>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* Tests for the first-connect indexing prompt toast: each of the three buttons
3+
* runs its callback with the volume id and self-dismisses the toast.
4+
*/
5+
import { describe, it, expect, vi, beforeEach } from 'vitest'
6+
import { mount, flushSync } from 'svelte'
7+
import FirstConnectIndexToastContent from './FirstConnectIndexToastContent.svelte'
8+
9+
const dismissToast = vi.fn()
10+
vi.mock('$lib/ui/toast', () => ({
11+
dismissToast: (id: string) => {
12+
dismissToast(id)
13+
},
14+
}))
15+
16+
function must(root: ParentNode, label: string): HTMLButtonElement {
17+
const btn = [...root.querySelectorAll('button')].find((b) => b.textContent.trim() === label)
18+
if (!btn) throw new Error(`no button labeled "${label}"`)
19+
return btn
20+
}
21+
22+
function render() {
23+
const onEnable = vi.fn()
24+
const onSilenceDrive = vi.fn()
25+
const onSilenceAll = vi.fn()
26+
const target = document.createElement('div')
27+
document.body.appendChild(target)
28+
mount(FirstConnectIndexToastContent, {
29+
target,
30+
props: {
31+
toastId: 'toast-1',
32+
volumeId: 'smb-backups',
33+
volumeName: 'Backups',
34+
onEnable,
35+
onSilenceDrive,
36+
onSilenceAll,
37+
},
38+
})
39+
flushSync()
40+
return { target, onEnable, onSilenceDrive, onSilenceAll }
41+
}
42+
43+
beforeEach(() => {
44+
dismissToast.mockClear()
45+
})
46+
47+
describe('FirstConnectIndexToastContent', () => {
48+
it('shows the drive name in the heading', () => {
49+
const { target } = render()
50+
expect(target.textContent).toContain('Backups')
51+
})
52+
53+
it('"Enable indexing" enables the drive and dismisses', () => {
54+
const { target, onEnable } = render()
55+
must(target, 'Enable indexing').click()
56+
flushSync()
57+
expect(onEnable).toHaveBeenCalledWith('smb-backups')
58+
expect(dismissToast).toHaveBeenCalledWith('toast-1')
59+
})
60+
61+
it('"Don\'t ask again for this drive" silences the drive and dismisses', () => {
62+
const { target, onSilenceDrive } = render()
63+
must(target, "Don't ask again for this drive").click()
64+
flushSync()
65+
expect(onSilenceDrive).toHaveBeenCalledWith('smb-backups')
66+
expect(dismissToast).toHaveBeenCalledWith('toast-1')
67+
})
68+
69+
it('"Don\'t ask again for any drives" silences all and dismisses', () => {
70+
const { target, onSilenceAll } = render()
71+
must(target, "Don't ask again for any drives").click()
72+
flushSync()
73+
expect(onSilenceAll).toHaveBeenCalledTimes(1)
74+
expect(dismissToast).toHaveBeenCalledWith('toast-1')
75+
})
76+
})
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<script lang="ts">
2+
/**
3+
* One-time "your drive's index may be stale" dialog (D2). Mounted once
4+
* app-wide. Subscribes to the `index-freshness-changed` event (emitted only
5+
* on a real value change), and the FIRST time any external drive flips to
6+
* Stale — gated on the `indexing.staleNotify` setting and a persisted
7+
* one-shot flag — shows this explainer so the user learns the concept. The
8+
* yellow badge keeps showing regardless of this dialog.
9+
*/
10+
import ModalDialog from '$lib/ui/ModalDialog.svelte'
11+
import Button from '$lib/ui/Button.svelte'
12+
import { onDestroy, onMount } from 'svelte'
13+
import type { UnlistenFn } from '@tauri-apps/api/event'
14+
import { onIndexFreshnessChanged } from '$lib/tauri-commands/indexing'
15+
import { getSetting, setSetting } from '$lib/settings'
16+
import { getVolumes } from '$lib/stores/volume-store.svelte'
17+
import { t, tString } from '$lib/intl/messages.svelte'
18+
import { hasShownFirstStaleDialog, markFirstStaleDialogShown } from './drive-index-prefs'
19+
20+
let open = $state(false)
21+
let staleVolumeName = $state('')
22+
let unlisten: UnlistenFn | undefined
23+
24+
function volumeName(volumeId: string): string {
25+
// `root` is the local disk, which is journaled and never goes stale — but
26+
// fall back to the id for any volume not currently in the store.
27+
return getVolumes().find((v) => v.id === volumeId)?.name ?? volumeId
28+
}
29+
30+
onMount(() => {
31+
void onIndexFreshnessChanged((payload) => {
32+
// Only the exact Fresh→Stale edge for an EXTERNAL drive matters (the
33+
// event fires only on a change). Local `root` is journaled and never
34+
// stale, so it can't trip this even if a future path emitted it.
35+
if (payload.freshness !== 'stale' || payload.volumeId === 'root') return
36+
if (!getSetting('indexing.staleNotify')) return
37+
if (hasShownFirstStaleDialog()) return
38+
39+
markFirstStaleDialogShown()
40+
staleVolumeName = volumeName(payload.volumeId)
41+
open = true
42+
}).then((u) => {
43+
unlisten = u
44+
})
45+
})
46+
47+
onDestroy(() => {
48+
unlisten?.()
49+
})
50+
51+
function close() {
52+
open = false
53+
}
54+
55+
function neverShowAgain() {
56+
setSetting('indexing.staleNotify', false)
57+
open = false
58+
}
59+
</script>
60+
61+
{#if open}
62+
<ModalDialog
63+
titleId="drive-index-stale-dialog-title"
64+
dialogId="drive-index-stale"
65+
role="dialog"
66+
onclose={close}
67+
ariaDescribedby="drive-index-stale-body"
68+
containerStyle="width: 440px"
69+
>
70+
{#snippet title()}{tString('indexing.staleDialog.title')}{/snippet}
71+
72+
<div class="body">
73+
<p id="drive-index-stale-body" class="description">
74+
{t('indexing.staleDialog.body', { name: staleVolumeName })}
75+
</p>
76+
77+
<div class="button-row">
78+
<Button variant="secondary" onclick={neverShowAgain}>
79+
{tString('indexing.staleDialog.neverShowAgain')}
80+
</Button>
81+
<Button variant="primary" autoFocus onclick={close}>
82+
{tString('indexing.staleDialog.close')}
83+
</Button>
84+
</div>
85+
</div>
86+
</ModalDialog>
87+
{/if}
88+
89+
<style>
90+
.body {
91+
padding: var(--spacing-md);
92+
}
93+
94+
.description {
95+
margin: 0 0 var(--spacing-md);
96+
color: var(--color-text-primary);
97+
font-size: var(--font-size-sm);
98+
line-height: 1.5;
99+
}
100+
101+
.button-row {
102+
display: flex;
103+
justify-content: flex-end;
104+
gap: var(--spacing-sm);
105+
}
106+
</style>

0 commit comments

Comments
 (0)