Skip to content

Commit ae62960

Browse files
committed
Transfer toast: split files and folders by selection
The transfer-complete toast now reports what the user SELECTED at the top level, split by type: "Moved 1 file and 3 folders". Interior counts never surface — moving one folder with thousands of files inside still reads as one folder. Proper pluralization, zero parts omitted (never "0 files and 3 folders"), and the same split applies to Copy ("Copied …"). Skips are file-level (folders always merge now), so a skip count unambiguously means files and rides as a suffix: "Moved 1 file and 1 folder, skipped 1 file (already at the target)." The clipboard-paste path has no per-type selection split, so it falls back to the existing file-count wording. Trash and delete are unchanged (no skip/merge concept). Threading: `composeTransferCompleteToast` gains optional `fileCount`/`folderCount`; the dialog opener already computes these at dialog-open time, so they're stashed on `TransferProgressPropsData` at confirm time and read in `handleTransferComplete` — no change to the transfer dialogs' external props interface. Composer unit tests written red-first.
1 parent f069e37 commit ae62960

4 files changed

Lines changed: 292 additions & 34 deletions

File tree

apps/desktop/src/lib/file-explorer/pane/dialog-state.svelte.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export interface TransferProgressPropsData {
4343
/** Source filenames known to conflict at dest (from pre-flight scan).
4444
* Forwarded to the BE so it can bulk-skip them upfront under `Skip all`. */
4545
preKnownConflicts?: string[]
46+
/** Top-level files the user selected (for the completion toast's per-type split).
47+
* Absent on the clipboard-paste path, where the per-type split isn't known. */
48+
fileCount?: number
49+
/** Top-level folders the user selected (for the completion toast's per-type split). */
50+
folderCount?: number
4651
}
4752

4853
export interface NewFolderDialogPropsData {
@@ -97,6 +102,11 @@ export interface DialogStateDeps {
97102
onOpenInEditor: (path: string) => void
98103
}
99104

105+
/** Human-readable label for a transfer op, used in log lines. */
106+
function transferOpLabel(op: TransferOperationType): string {
107+
return op === 'copy' ? 'Copy' : op === 'move' ? 'Move' : op === 'trash' ? 'Trash' : 'Delete'
108+
}
109+
100110
/** Force a backend re-read on a pane's listing so file diffs are emitted promptly. */
101111
function refreshPaneListing(paneRef: FilePaneAPI | undefined): void {
102112
const listingId = paneRef?.getListingId()
@@ -292,6 +302,8 @@ export function createDialogState(deps: DialogStateDeps) {
292302
conflictResolution,
293303
scanInProgress,
294304
preKnownConflicts,
305+
fileCount: transferDialogProps.fileCount,
306+
folderCount: transferDialogProps.folderCount,
295307
}
296308
snapshotSourcePaneSelection()
297309

@@ -346,8 +358,9 @@ export function createDialogState(deps: DialogStateDeps) {
346358
},
347359

348360
handleTransferComplete(filesProcessed: number, filesSkipped: number, bytesProcessed: number) {
349-
const op = transferProgressProps?.operationType ?? 'copy'
350-
const opLabel = op === 'copy' ? 'Copy' : op === 'move' ? 'Move' : op === 'trash' ? 'Trash' : 'Delete'
361+
const props = transferProgressProps
362+
const op = props?.operationType ?? 'copy'
363+
const opLabel = transferOpLabel(op)
351364

352365
// Cross-snapshot delete sync (M8c, plan §3.7): when files are removed from
353366
// disk via Delete or Trash (or moved away via Move — the source path no
@@ -357,15 +370,23 @@ export function createDialogState(deps: DialogStateDeps) {
357370
// containing it" rule. The snapshot store bumps its mutation tick so
358371
// `SearchResultsView`'s `$derived` re-evaluates and the row vanishes
359372
// without a manual refresh. No-op when no snapshot contains the path.
360-
if ((op === 'delete' || op === 'trash' || op === 'move') && transferProgressProps?.sourcePaths) {
361-
for (const sourcePath of transferProgressProps.sourcePaths) {
373+
if ((op === 'delete' || op === 'trash' || op === 'move') && props?.sourcePaths) {
374+
for (const sourcePath of props.sourcePaths) {
362375
removeEntryFromAllSnapshots(sourcePath)
363376
}
364377
}
365378
log.info(
366379
`${opLabel} complete: ${String(filesProcessed)} files (${String(filesSkipped)} skipped, ${formatBytes(bytesProcessed)})`,
367380
)
368-
const toastMessage = composeTransferCompleteToast({ operationType: op, filesProcessed, filesSkipped })
381+
// Top-level selection counts for the per-type split ("Moved 1 file and 3
382+
// folders"). Absent on the clipboard-paste path → composer falls back.
383+
const toastMessage = composeTransferCompleteToast({
384+
operationType: op,
385+
filesProcessed,
386+
filesSkipped,
387+
fileCount: props?.fileCount,
388+
folderCount: props?.folderCount,
389+
})
369390
// `info` for the all-skipped case (nothing actually moved/copied — neutral
370391
// outcome, not a success). `success` everywhere else, including mixed: the
371392
// user's intent landed at the target.
@@ -386,7 +407,7 @@ export function createDialogState(deps: DialogStateDeps) {
386407

387408
handleTransferCancelled(filesProcessed: number) {
388409
const op = transferProgressProps?.operationType ?? 'copy'
389-
const opLabel = op === 'copy' ? 'Copy' : op === 'move' ? 'Move' : op === 'trash' ? 'Trash' : 'Delete'
410+
const opLabel = transferOpLabel(op)
390411
log.info(`${opLabel} cancelled after ${String(filesProcessed)} files`)
391412

392413
refreshPanesAfterTransfer()
@@ -399,7 +420,7 @@ export function createDialogState(deps: DialogStateDeps) {
399420

400421
handleTransferError(error: WriteOperationError, friendly?: FriendlyError) {
401422
const op = transferProgressProps?.operationType ?? 'copy'
402-
const opLabel = op === 'copy' ? 'Copy' : op === 'move' ? 'Move' : op === 'trash' ? 'Trash' : 'Delete'
423+
const opLabel = transferOpLabel(op)
403424
log.error('{op} failed: {errorType}', {
404425
op: opLabel,
405426
errorType: error.type,

apps/desktop/src/lib/file-operations/transfer/CLAUDE.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@ for the shared state machine, ETA/throughput, and settle contract.
1212

1313
## File map
1414

15-
| File | Responsibility |
16-
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
17-
| `TransferDialog.svelte` | Destination picker, segmented Copy/Move toggle, pre-flight dry-run scan, upfront conflict-policy radios |
18-
| `TransferProgressDialog.svelte` | Execution: dual progress bars, cancel/rollback, conflict dialog, scan-phase body, terminal-event handling |
19-
| `TransferErrorDialog.svelte` | Modal that renders backend `FriendlyError` or FE fallback, category-colored container, optional Retry button |
20-
| `FriendlyErrorContent.svelte` | Renders `friendly.explanation` + `friendly.suggestion` markdown; click delegate for `x-apple.systempreferences:` / http(s) URLs |
21-
| `FallbackErrorContent.svelte` | Renders the FE-derived message when no backend `FriendlyError` is attached to the `WriteErrorEvent` |
22-
| `ScanPhaseBody.svelte` | Scan-phase tallies (files/dirs/bytes), throughput readout, current directory, spinner. Shared by both scan-phase code paths |
23-
| `DirectionIndicator.svelte` | Arrow graphic for source → destination (operation-agnostic, reused by `DeleteDialog`) |
24-
| `transfer-dialog-utils.ts` | `generateTitle()`, `toBackendIndices()` / `toBackendCursorIndex()` ".." offset helpers, `toVolumeRelativePath()` |
25-
| `transfer-error-messages.ts` | Operation-specific error strings used by `FallbackErrorContent` |
26-
| `transfer-complete-toast.ts` | Pure `composeTransferCompleteToast({...})`: picks the right "Copy/Move/Trash complete" wording, branches on op/skip/single case |
27-
| `*.test.ts` / `*.a11y.test.ts` | Vitest unit tests (utility + component) and a11y assertions |
15+
| File | Responsibility |
16+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17+
| `TransferDialog.svelte` | Destination picker, segmented Copy/Move toggle, pre-flight dry-run scan, upfront conflict-policy radios |
18+
| `TransferProgressDialog.svelte` | Execution: dual progress bars, cancel/rollback, conflict dialog, scan-phase body, terminal-event handling |
19+
| `TransferErrorDialog.svelte` | Modal that renders backend `FriendlyError` or FE fallback, category-colored container, optional Retry button |
20+
| `FriendlyErrorContent.svelte` | Renders `friendly.explanation` + `friendly.suggestion` markdown; click delegate for `x-apple.systempreferences:` / http(s) URLs |
21+
| `FallbackErrorContent.svelte` | Renders the FE-derived message when no backend `FriendlyError` is attached to the `WriteErrorEvent` |
22+
| `ScanPhaseBody.svelte` | Scan-phase tallies (files/dirs/bytes), throughput readout, current directory, spinner. Shared by both scan-phase code paths |
23+
| `DirectionIndicator.svelte` | Arrow graphic for source → destination (operation-agnostic, reused by `DeleteDialog`) |
24+
| `transfer-dialog-utils.ts` | `generateTitle()`, `toBackendIndices()` / `toBackendCursorIndex()` ".." offset helpers, `toVolumeRelativePath()` |
25+
| `transfer-error-messages.ts` | Operation-specific error strings used by `FallbackErrorContent` |
26+
| `transfer-complete-toast.ts` | Pure `composeTransferCompleteToast({...})`: "Moved 1 file and 3 folders" — splits the top-level SELECTION by type (never interior counts); omits zero parts; skip suffix is file-only (folders always merge); falls back to file-count wording on clipboard paste |
27+
| `*.test.ts` / `*.a11y.test.ts` | Vitest unit tests (utility + component) and a11y assertions |
2828

2929
## How transfer flows
3030

apps/desktop/src/lib/file-operations/transfer/transfer-complete-toast.test.ts

Lines changed: 192 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,184 @@ import { describe, it, expect } from 'vitest'
22
import { composeTransferCompleteToast } from './transfer-complete-toast'
33

44
describe('composeTransferCompleteToast', () => {
5-
describe('copy', () => {
5+
describe('copy with selection split (fileCount / folderCount available)', () => {
6+
it('files only, all copied', () => {
7+
expect(
8+
composeTransferCompleteToast({
9+
operationType: 'copy',
10+
filesProcessed: 2,
11+
filesSkipped: 0,
12+
fileCount: 2,
13+
folderCount: 0,
14+
}),
15+
).toBe('Copied 2 files.')
16+
})
17+
18+
it('single file, all copied', () => {
19+
expect(
20+
composeTransferCompleteToast({
21+
operationType: 'copy',
22+
filesProcessed: 1,
23+
filesSkipped: 0,
24+
fileCount: 1,
25+
folderCount: 0,
26+
}),
27+
).toBe('Copied 1 file.')
28+
})
29+
30+
it('folders only, all copied', () => {
31+
expect(
32+
composeTransferCompleteToast({
33+
operationType: 'copy',
34+
filesProcessed: 10,
35+
filesSkipped: 0,
36+
fileCount: 0,
37+
folderCount: 3,
38+
}),
39+
).toBe('Copied 3 folders.')
40+
})
41+
42+
it('single folder, all copied', () => {
43+
expect(
44+
composeTransferCompleteToast({
45+
operationType: 'copy',
46+
filesProcessed: 4,
47+
filesSkipped: 0,
48+
fileCount: 0,
49+
folderCount: 1,
50+
}),
51+
).toBe('Copied 1 folder.')
52+
})
53+
54+
it('mixed files and folders, all copied', () => {
55+
expect(
56+
composeTransferCompleteToast({
57+
operationType: 'copy',
58+
filesProcessed: 7,
59+
filesSkipped: 0,
60+
fileCount: 2,
61+
folderCount: 1,
62+
}),
63+
).toBe('Copied 2 files and 1 folder.')
64+
})
65+
66+
it('the canonical "1 file and 3 folders" example', () => {
67+
expect(
68+
composeTransferCompleteToast({
69+
operationType: 'copy',
70+
filesProcessed: 99,
71+
filesSkipped: 0,
72+
fileCount: 1,
73+
folderCount: 3,
74+
}),
75+
).toBe('Copied 1 file and 3 folders.')
76+
})
77+
78+
it('omits the zero file part', () => {
79+
// Never "0 files and 3 folders".
80+
expect(
81+
composeTransferCompleteToast({
82+
operationType: 'copy',
83+
filesProcessed: 5,
84+
filesSkipped: 0,
85+
fileCount: 0,
86+
folderCount: 3,
87+
}),
88+
).toBe('Copied 3 folders.')
89+
})
90+
})
91+
92+
describe('move with selection split', () => {
93+
it('the canonical "Moved 1 file and 3 folders" example', () => {
94+
expect(
95+
composeTransferCompleteToast({
96+
operationType: 'move',
97+
filesProcessed: 50,
98+
filesSkipped: 0,
99+
fileCount: 1,
100+
folderCount: 3,
101+
}),
102+
).toBe('Moved 1 file and 3 folders.')
103+
})
104+
105+
it('mixed files and folders, all moved', () => {
106+
expect(
107+
composeTransferCompleteToast({
108+
operationType: 'move',
109+
filesProcessed: 9,
110+
filesSkipped: 0,
111+
fileCount: 2,
112+
folderCount: 1,
113+
}),
114+
).toBe('Moved 2 files and 1 folder.')
115+
})
116+
})
117+
118+
describe('selection split with skipped files (skips are file-level; folders always merge)', () => {
119+
it('mixed: some files skipped, folder merged', () => {
120+
// Selected 2 files + 1 folder; 1 file skipped. The folder always merges.
121+
expect(
122+
composeTransferCompleteToast({
123+
operationType: 'move',
124+
filesProcessed: 8,
125+
filesSkipped: 1,
126+
fileCount: 2,
127+
folderCount: 1,
128+
}),
129+
).toBe('Moved 1 file and 1 folder, skipped 1 file (already at the target).')
130+
})
131+
132+
it('all selected files skipped, folder still merged', () => {
133+
// Selected 2 files + 1 folder; both files skipped. The folder merges.
134+
expect(
135+
composeTransferCompleteToast({
136+
operationType: 'move',
137+
filesProcessed: 6,
138+
filesSkipped: 2,
139+
fileCount: 2,
140+
folderCount: 1,
141+
}),
142+
).toBe('Moved 1 folder, skipped 2 files (already at the target).')
143+
})
144+
145+
it('files-only selection, all skipped', () => {
146+
expect(
147+
composeTransferCompleteToast({
148+
operationType: 'copy',
149+
filesProcessed: 3,
150+
filesSkipped: 3,
151+
fileCount: 3,
152+
folderCount: 0,
153+
}),
154+
).toBe('Copy complete: skipped all 3 files (already at the target), nothing was copied.')
155+
})
156+
157+
it('files-only selection, single file skipped', () => {
158+
expect(
159+
composeTransferCompleteToast({
160+
operationType: 'copy',
161+
filesProcessed: 1,
162+
filesSkipped: 1,
163+
fileCount: 1,
164+
folderCount: 0,
165+
}),
166+
).toBe('Copy complete: file already at the target, not copied.')
167+
})
168+
169+
it('mixed copy with a single skipped file uses singular noun', () => {
170+
expect(
171+
composeTransferCompleteToast({
172+
operationType: 'copy',
173+
filesProcessed: 5,
174+
filesSkipped: 1,
175+
fileCount: 2,
176+
folderCount: 1,
177+
}),
178+
).toBe('Copied 1 file and 1 folder, skipped 1 file (already at the target).')
179+
})
180+
})
181+
182+
describe('copy fallback (no selection counts — clipboard paste)', () => {
6183
it('all copied, multi-file', () => {
7184
expect(composeTransferCompleteToast({ operationType: 'copy', filesProcessed: 5, filesSkipped: 0 })).toBe(
8185
'Copy complete: copied 5 files.',
@@ -34,7 +211,7 @@ describe('composeTransferCompleteToast', () => {
34211
})
35212
})
36213

37-
describe('move', () => {
214+
describe('move fallback (no selection counts — clipboard paste)', () => {
38215
it('all moved, multi-file', () => {
39216
expect(composeTransferCompleteToast({ operationType: 'move', filesProcessed: 5, filesSkipped: 0 })).toBe(
40217
'Move complete: moved 5 files.',
@@ -60,9 +237,6 @@ describe('composeTransferCompleteToast', () => {
60237
})
61238

62239
it('mixed: phrased as "already at target", not "now at target"', () => {
63-
// Move skip semantics differ: the source file stays at source, the target file
64-
// (which won the skip) was already there. Don't claim "now at target" the way
65-
// copy does — that would lie about the moved subset and ignore the source-still-has-it.
66240
expect(composeTransferCompleteToast({ operationType: 'move', filesProcessed: 5, filesSkipped: 2 })).toBe(
67241
'Move complete: moved 3, skipped 2. 2 files were already at the target.',
68242
)
@@ -75,7 +249,7 @@ describe('composeTransferCompleteToast', () => {
75249
})
76250
})
77251

78-
describe('trash and delete (no skip concept)', () => {
252+
describe('trash and delete (no skip concept, no split)', () => {
79253
it('trash uses historic short wording', () => {
80254
expect(composeTransferCompleteToast({ operationType: 'trash', filesProcessed: 3, filesSkipped: 0 })).toBe(
81255
'Moved 3 files to trash',
@@ -88,6 +262,18 @@ describe('composeTransferCompleteToast', () => {
88262
)
89263
})
90264

265+
it('trash ignores selection counts (stays file-level, honest)', () => {
266+
expect(
267+
composeTransferCompleteToast({
268+
operationType: 'trash',
269+
filesProcessed: 3,
270+
filesSkipped: 0,
271+
fileCount: 1,
272+
folderCount: 1,
273+
}),
274+
).toBe('Moved 3 files to trash')
275+
})
276+
91277
it('delete uses historic short wording', () => {
92278
expect(composeTransferCompleteToast({ operationType: 'delete', filesProcessed: 3, filesSkipped: 0 })).toBe(
93279
'Delete complete: 3 files',

0 commit comments

Comments
 (0)