diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index f4a6611..79e5daa 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -29,9 +29,12 @@ import { blankPane, clampIndex, listLocal, + nothingToDo, parentPath, pushChange, resetTree, + scannedFrom, + scopeTransfer, selectedRow, visibleRows, } from './model.js' @@ -306,6 +309,35 @@ export class Tui { await this.load('right') } + /** + * Lists the pane again without folding anything: the root and every + * unfolded directory are re-read in place, so a sync landing in the other + * pane shows up under the rows that were already open. + */ + async refresh(side: Side): Promise { + const pane = this.panes[side] + pane.error = null + try { + pane.entries = pane.connection ? await this.listRemote(pane, pane.path) : listLocal(pane.path) + for (const rel of [...pane.unfolded]) { + try { + pane.children.set(rel, await this.listBelow(pane, rel)) + } catch { + // Gone, or unreadable now: fold it rather than show a stale listing. + pane.unfolded.delete(rel) + pane.children.delete(rel) + } + } + clampIndex(pane) + } catch (error) { + pane.entries = [] + resetTree(pane) + pane.error = error instanceof Error ? error.message : String(error) + } finally { + this.invalidate() + } + } + private async listRemote(pane: Pane, path: string): Promise { const browser = await SftpBrowser.open(await this.session(pane.connection!)) try { @@ -390,7 +422,7 @@ export class Tui { this.openPicker() break case key.name === 'r': - await this.load(this.active) + await this.refresh(this.active) break case key.name === '/': this.filtering = this.active @@ -627,13 +659,18 @@ export class Tui { const source = this.current const destination = this.other const controller = new AbortController() + const scope = scopeTransfer(source, destination) const transfer: Transfer = { mode: previewOnly ? 'preview' : 'sync', - from: endpointString(source), - to: endpointString(destination), + from: scope.from, + to: scope.to, + what: scope.what, running: true, + startedAt: Date.now(), + endedAt: null, progress: null, + scanned: null, recent: [], summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, outcome: null, @@ -642,38 +679,57 @@ export class Tui { this.transfer = transfer this.busy = true this.invalidate() + // rsync can be silent for a long time while it walks a tree; the clock in + // the panel must not be. + const clock = setInterval(() => this.invalidate(), 1000) try { const remote = source.connection ?? destination.connection const plan = planTransfer({ source: parseEndpoint(transfer.from), destination: parseEndpoint(transfer.to), - options: defaultRsyncOptions({ dryRun: previewOnly, stats: true }), + // mkpath: the destination of a nested directory may not exist yet. + options: defaultRsyncOptions({ dryRun: previewOnly, stats: true, mkpath: true }), ...(remote ? { remoteShell: { keyPath: remote.keyPath, port: remote.port } } : {}), }) const result = await runToCompletion(plan, { signal: controller.signal }, (event) => { if (event.type === 'change') pushChange(transfer, event.change as Change) - else if (event.type === 'progress') transfer.progress = event.progress + else if (event.type === 'progress') { + transfer.progress = event.progress + transfer.scanned = scannedFrom(event.progress) ?? transfer.scanned + } else if (event.type === 'stats' && event.stats.filesTotal !== null) { + transfer.scanned = { checked: event.stats.filesTotal, total: event.stats.filesTotal } + } this.invalidate() }) transfer.running = false const moved = transfer.summary.add + transfer.summary.update + const kind = previewOnly ? 'Preview' : 'Sync' - if (!result.ok) { + if (controller.signal.aborted) { + // Esc, not a fault: rsync was asked to stop and did. + transfer.outcome = { ok: false, cancelled: true, message: `${kind} cancelled.` } + this.say(`${kind} cancelled`, 'warn') + } else if (!result.ok) { transfer.outcome = { ok: false, message: result.message } this.say(result.message, 'error') } else if (previewOnly) { transfer.outcome = { ok: true, message: 'Preview complete' } - this.say( - `Preview: ${transfer.summary.add} to add, ${transfer.summary.update} to update, ${transfer.summary.unchanged} unchanged`, - 'ok', - ) + if (nothingToDo(transfer)) { + const total = transfer.scanned?.total + this.say(`Already in sync${total != null ? `: ${total} files checked` : ''}`, 'ok') + } else { + this.say( + `Preview: ${transfer.summary.add} to add, ${transfer.summary.update} to update, ${transfer.summary.unchanged} unchanged`, + 'ok', + ) + } } else { transfer.outcome = { ok: true, message: 'Sync complete' } - this.say(`Synced ${moved} file${moved === 1 ? '' : 's'}`, 'ok') - await this.load(this.active === 'left' ? 'right' : 'left') + this.say(`Synced ${moved} file${moved === 1 ? '' : 's'} to ${transfer.to}`, 'ok') + await this.refresh(this.active === 'left' ? 'right' : 'left') } } catch (error) { transfer.running = false @@ -681,7 +737,9 @@ export class Tui { transfer.outcome = { ok: false, message } this.say(message, 'error') } finally { + clearInterval(clock) transfer.running = false + transfer.endedAt = Date.now() this.busy = false this.invalidate() } @@ -692,9 +750,3 @@ export class Tui { for (const session of this.sessions.values()) session.close() } } - -function endpointString(pane: Pane): string { - const path = pane.path.endsWith('/') ? pane.path : `${pane.path}/` - if (!pane.connection) return path - return `${pane.connection.username}@${pane.connection.host}:${path}` -} diff --git a/apps/cli/src/tui/interaction.test.ts b/apps/cli/src/tui/interaction.test.ts index 77fb0ce..97e461d 100644 --- a/apps/cli/src/tui/interaction.test.ts +++ b/apps/cli/src/tui/interaction.test.ts @@ -377,6 +377,93 @@ describe('the mouse', () => { }) }) +describe('preview', () => { + /** Two real directories, so `p` runs the real rsync as a dry run. */ + function twoTrees() { + const { app, root } = realTree() + const other = mkdtempSync(join(tmpdir(), 'diskpush-tui-dst-')) + const right = pane(app, 'right') + right.path = other + right.entries = [] + return { app, root, other } + } + const finished = async (app: Tui) => { + for (let i = 0; i < 200 && state(app).transfer?.running !== false; i += 1) await new Promise((r) => setTimeout(r, 25)) + return state(app).transfer! + } + + it('covers the selected directory, mirrored to the same place in the other pane', async () => { + const { app, root, other } = twoTrees() + await press(app, 'p') + const transfer = await finished(app) + expect(transfer.mode).toBe('preview') + expect(transfer.from).toBe(`${root}/src/`) + expect(transfer.to).toBe(`${other}/src/`) + expect(transfer.what).toBe('src') + expect(transfer.outcome?.ok).toBe(true) + // Two files and a directory would be created over there. + expect(transfer.summary.add).toBeGreaterThanOrEqual(3) + expect(transfer.scanned?.total).toBeGreaterThanOrEqual(3) + expect(state(app).status?.text).toMatch(/^Preview: \d+ to add/) + }) + + it('says so when there is nothing to do, with the count of files it checked', async () => { + const { app, root, other } = twoTrees() + mkdirSync(join(other, 'src', 'lib'), { recursive: true }) + for (const rel of ['src/index.ts', 'src/lib/deep.ts']) writeFileSync(join(other, rel), 'export {}\n') + await press(app, 'p') + const transfer = await finished(app) + expect(transfer.outcome?.ok).toBe(true) + expect(transfer.summary.add + transfer.summary.update).toBe(0) + expect(state(app).status?.text).toMatch(/^Already in sync: \d+ files checked/) + const screen = frame(app) + expect(screen.contains('Already in sync')).toBe(true) + expect(screen.contains('Nothing to do')).toBe(true) + void root + }) + + it('syncs the selected file into the directory that holds it over there, and refreshes that pane in place', async () => { + const { app, root, other } = twoTrees() + // Unfold src and select src/index.ts (rows: src, lib, index.ts). + await press(app, 'right', 'down', 'down') + expect(pane(app, 'left').index).toBe(2) + await press(app, 's') + const transfer = await finished(app) + expect(transfer.mode).toBe('sync') + expect(transfer.from).toBe(`${root}/src/index.ts`) + expect(transfer.to).toBe(`${other}/src/`) + expect(transfer.outcome?.ok).toBe(true) + expect(listLocal(join(other, 'src')).map((e) => e.name)).toEqual(['index.ts']) + // The destination pane was re-read, not reset. + expect(pane(app, 'right').entries.map((e) => e.name)).toEqual(['src']) + }) + + it('reports esc as a cancel, not a failure', async () => { + const { app } = twoTrees() + // Enough files that the dry run is still walking when esc lands. + const wide = join(pane(app, 'left').path, 'wide') + mkdirSync(wide) + for (let i = 0; i < 4000; i += 1) writeFileSync(join(wide, `f${i}.txt`), String(i)) + pane(app, 'left').entries = listLocal(pane(app, 'left').path) + await press(app, 'end') + // Not awaited: `p` resolves only when rsync has finished, and the point + // is to press esc while it is still walking. + const running = app.onKey(key('p')) + await settle() + await press(app, 'escape') + await running + const transfer = await finished(app) + if (transfer.outcome?.cancelled) { + expect(transfer.outcome.message).toBe('Preview cancelled.') + expect(state(app).status).toEqual({ text: 'Preview cancelled', tone: 'warn' }) + expect(frame(app).contains('signal')).toBe(false) + } else { + // rsync beat the escape on this machine; the only other honest outcome is a finished preview. + expect(transfer.outcome?.ok).toBe(true) + } + }) +}) + describe('the last message', () => { it('is cleared by the next keystroke, so it never answers the wrong question', async () => { const app = tui() diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts index f0b8a03..b6fc8d2 100644 --- a/apps/cli/src/tui/model.ts +++ b/apps/cli/src/tui/model.ts @@ -7,7 +7,7 @@ */ import { readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' -import { join, posix } from 'node:path' +import { dirname, join, posix } from 'node:path' import type { Change, ChangeSummary, Connection, RsyncProgress } from '@diskpush/schemas' export type Entry = { @@ -287,15 +287,69 @@ export type Transfer = { mode: 'preview' | 'sync' from: string to: string + /** What is being synced, for a person: `src/lib`, `README.md`, or `everything`. */ + what: string running: boolean + /** `Date.now()` when rsync was started, so the clock ticks without rsync saying anything. */ + startedAt: number + /** `Date.now()` when it stopped, however it stopped; null while running. */ + endedAt: number | null progress: RsyncProgress | null + /** + * Files rsync has checked against the other side, out of the total it + * found. This is the number that moves during a preview, where the byte + * percentage is zero by definition: a dry run transfers nothing. + */ + scanned: { checked: number; total: number } | null /** Most recent paths rsync reported, newest last. Capped by `pushChange`. */ recent: Change[] summary: ChangeSummary - outcome: { ok: boolean; message: string } | null + outcome: { ok: boolean; message: string; cancelled?: boolean } | null cancel: () => void } +/** True when a finished preview found nothing that a sync would change. */ +export function nothingToDo(transfer: Transfer): boolean { + const { add, update, metadata, delete: removed, error } = transfer.summary + return transfer.outcome?.ok === true && add + update + metadata + removed + error === 0 +} + +/** Files checked from rsync's `to-chk=remaining/total`, once it has said. */ +export function scannedFrom(progress: RsyncProgress): { checked: number; total: number } | null { + if (progress.filesTotal === null || progress.filesRemaining === null) return null + return { checked: Math.max(0, progress.filesTotal - progress.filesRemaining), total: progress.filesTotal } +} + +/** An rsync endpoint for a path under the pane: `user@host:/srv/app/src/` or `/home/me/src/`. */ +export function endpointString(pane: Pane, rel = '', isDirectory = true): string { + const joined = pane.connection ? posix.join(pane.path, rel) : join(pane.path, rel) + const path = isDirectory && !joined.endsWith('/') ? `${joined}/` : joined + if (!pane.connection) return path + return `${pane.connection.username}@${pane.connection.host}:${path}` +} + +export type TransferScope = { from: string; to: string; what: string } + +/** + * What a preview or sync covers: the row under the cursor, mirrored to the + * same relative path in the other pane, so the two trees stay aligned. A + * directory goes to the directory of the same name; a file goes into the + * directory that holds it. With nothing under the cursor, the whole pane. + */ +export function scopeTransfer(source: Pane, destination: Pane): TransferScope { + const row = selectedRow(source) + if (!row) return { from: endpointString(source), to: endpointString(destination), what: 'everything' } + if (row.entry.isDirectory) { + return { from: endpointString(source, row.rel), to: endpointString(destination, row.rel), what: row.rel } + } + const parent = dirname(row.rel) + return { + from: endpointString(source, row.rel, false), + to: endpointString(destination, parent === '.' ? '' : parent), + what: row.rel, + } +} + export const TRANSFER_LOG_LIMIT = 200 export function pushChange(transfer: Transfer, change: Change): void { diff --git a/apps/cli/src/tui/scope.test.ts b/apps/cli/src/tui/scope.test.ts new file mode 100644 index 0000000..bcdef37 --- /dev/null +++ b/apps/cli/src/tui/scope.test.ts @@ -0,0 +1,79 @@ +/** + * What a preview or sync covers, as a pure function of the two panes. + * + * Before this, both keys always synced the whole of one pane into the whole + * of the other, whatever was under the cursor, which is not what a person + * who has just unfolded a directory and pressed `p` meant. + */ +import { describe, expect, it } from 'vitest' +import type { Connection } from '@diskpush/schemas' +import { blankPane, scopeTransfer, type Entry } from './model.js' + +const entry = (name: string, over: Partial = {}): Entry => ({ name, isDirectory: false, size: 0, modifiedAt: null, ...over }) + +const prod = { id: 'c1', name: 'prod', host: 'prod.example', username: 'deploy', port: 22 } as unknown as Connection + +function panes() { + const local = blankPane('Local', '/home/me/project') + local.entries = [entry('src', { isDirectory: true }), entry('README.md')] + local.children.set('src', [entry('lib', { isDirectory: true }), entry('index.ts')]) + local.unfolded.add('src') + const remote = blankPane('prod', '/srv/app', prod) + return { local, remote } +} + +describe('scopeTransfer', () => { + it('mirrors the selected directory to the same relative path in the other pane', () => { + const { local, remote } = panes() + local.index = 0 + expect(scopeTransfer(local, remote)).toEqual({ + from: '/home/me/project/src/', + to: 'deploy@prod.example:/srv/app/src/', + what: 'src', + }) + // Nested rows keep their whole relative path, so the trees stay aligned. + local.index = 1 + expect(scopeTransfer(local, remote)).toEqual({ + from: '/home/me/project/src/lib/', + to: 'deploy@prod.example:/srv/app/src/lib/', + what: 'src/lib', + }) + }) + + it('sends a selected file into the directory that holds it over there', () => { + const { local, remote } = panes() + local.index = 2 // src/index.ts + expect(scopeTransfer(local, remote)).toEqual({ + from: '/home/me/project/src/index.ts', + to: 'deploy@prod.example:/srv/app/src/', + what: 'src/index.ts', + }) + local.index = 3 // README.md at the root + expect(scopeTransfer(local, remote)).toEqual({ + from: '/home/me/project/README.md', + to: 'deploy@prod.example:/srv/app/', + what: 'README.md', + }) + }) + + it('covers the whole pane when there is nothing under the cursor', () => { + const { local, remote } = panes() + local.entries = [] + local.unfolded.clear() + expect(scopeTransfer(local, remote)).toEqual({ + from: '/home/me/project/', + to: 'deploy@prod.example:/srv/app/', + what: 'everything', + }) + }) + + it('works in either direction, remote to local included', () => { + const { local, remote } = panes() + remote.entries = [entry('logs', { isDirectory: true })] + expect(scopeTransfer(remote, local)).toEqual({ + from: 'deploy@prod.example:/srv/app/logs/', + to: '/home/me/project/logs/', + what: 'logs', + }) + }) +}) diff --git a/apps/cli/src/tui/view.test.ts b/apps/cli/src/tui/view.test.ts index 4b5fc24..6c04840 100644 --- a/apps/cli/src/tui/view.test.ts +++ b/apps/cli/src/tui/view.test.ts @@ -142,8 +142,12 @@ describe('the mouse', () => { mode: 'sync', from: '/home/me/project/', to: 'deploy@prod:/srv/app/', + what: 'everything', running, + startedAt: 0, + endedAt: null, progress: null, + scanned: null, recent: [], summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, outcome: running ? null : { ok: true, message: 'Sync complete' }, @@ -369,7 +373,11 @@ describe('the transfer panel', () => { mode: 'sync', from: '/home/me/project/', to: 'deploy@prod:/srv/app/', + what: 'everything', running: true, + startedAt: 0, + endedAt: null, + scanned: null, progress: { bytesTransferred: 12_000_000, percent: 42, @@ -395,6 +403,70 @@ describe('the transfer panel', () => { expect(text).toContain('+7') }) + it('counts files checked during a preview, since a dry run moves no bytes', () => { + const scanning = transfer({ + mode: 'preview', + what: 'src', + progress: null, + scanned: { checked: 120, total: 500 }, + recent: [], + summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 0, error: 0 }, + }) + const text = screen(state({ transfer: scanning })) + expect(text).toContain('Scanning src') + expect(text).toContain('120/500 files') + expect(text).toContain('0 changes found so far') + expect(text).not.toContain('0%') + + const blank = transfer({ mode: 'preview', progress: null, scanned: null, recent: [] }) + expect(screen(state({ transfer: blank }))).toContain('scanning…') + }) + + it('keeps its clock running off the wall while rsync says nothing', () => { + const quiet = transfer({ mode: 'preview', progress: null, scanned: null, startedAt: Date.parse('2026-09-08T11:58:35.000Z') }) + // state().now is 12:00:00, so 85 seconds have gone by. + expect(screen(state({ transfer: quiet }))).toContain('1:25') + // Once it has stopped, the clock stops with it rather than following `now`. + const done = transfer({ + mode: 'preview', + running: false, + progress: null, + scanned: null, + startedAt: Date.parse('2026-09-08T11:58:35.000Z'), + endedAt: Date.parse('2026-09-08T11:58:47.000Z'), + outcome: { ok: true, message: 'Preview complete' }, + }) + expect(screen(state({ transfer: done }))).toContain('0:12') + }) + + it('says a preview found nothing to do instead of drawing a full bar over an empty list', () => { + const idle = transfer({ + mode: 'preview', + running: false, + progress: null, + scanned: { checked: 5300, total: 5300 }, + recent: [], + summary: { add: 0, update: 0, metadata: 0, delete: 0, unchanged: 5300, error: 0 }, + outcome: { ok: true, message: 'Preview complete' }, + }) + const text = screen(state({ transfer: idle })) + expect(text).toContain('Already in sync') + expect(text).toContain('5300 files checked') + }) + + it('reports a cancel as a cancel, not as a failure', () => { + const stopped = transfer({ + mode: 'preview', + running: false, + outcome: { ok: false, cancelled: true, message: 'Preview cancelled.' }, + }) + const text = screen(state({ transfer: stopped })) + expect(text).toContain('Cancelled') + expect(text).toContain('stopped by esc') + expect(text).not.toContain('Failed') + expect(text).not.toContain('signal') + }) + it('offers cancel while it runs and dismiss once it is done', () => { expect(screen(state({ transfer: transfer() }))).toContain('cancel') const done = transfer({ running: false, outcome: { ok: true, message: 'Sync complete' } }) diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index 764c280..1e082ae 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -19,6 +19,7 @@ import { formatDuration, formatSize, formatWhen, + nothingToDo, parentPath, rowTree, visibleEntries, @@ -160,7 +161,7 @@ export function draw( // A short terminal gives its rows to the panes; the transfer is still // readable from the status line, and half a panel is worse than none. - if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state.transfer, handlers) + if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state, state.transfer, handlers) drawFooter(ui, theme, state, width, handlers) @@ -312,20 +313,30 @@ function drawPane( ) } -function drawTransfer(ui: Container, theme: Theme, transfer: Transfer, handlers: ViewHandlers): void { +function drawTransfer(ui: Container, theme: Theme, state: ViewState, transfer: Transfer, handlers: ViewHandlers): void { const progress = transfer.progress + const preview = transfer.mode === 'preview' + const cancelled = transfer.outcome?.cancelled === true + const failed = transfer.outcome !== null && !transfer.outcome.ok && !cancelled + const idle = nothingToDo(transfer) // rsync's last progress line is whatever it happened to print before it - // exited — 80% on a preview that finished. A completed transfer is 100%. + // exited — 80% on a sync that finished. A completed transfer is 100%. const percent = transfer.outcome?.ok ? 100 : (progress?.percent ?? 0) - const remaining = estimateRemaining(progress) - const preview = transfer.mode === 'preview' + const remaining = preview ? null : estimateRemaining(progress) + // The clock runs off the wall, not off rsync: a dry run over a big tree can + // say nothing for a minute, and a panel that stops counting looks dead. + const elapsed = Math.max(0, ((transfer.endedAt ?? state.now.getTime()) - transfer.startedAt) / 1000) const title = transfer.running - ? ` ${preview ? 'Previewing' : 'Syncing'} ` - : transfer.outcome?.ok - ? ` ${preview ? 'Preview' : 'Sync'} complete ` - : ' Failed ' - const titleColor = transfer.running ? theme.warning : transfer.outcome?.ok ? theme.success : theme.danger + ? ` ${preview ? 'Scanning' : 'Syncing'} ${transfer.what} ` + : cancelled + ? ' Cancelled ' + : transfer.outcome?.ok + ? idle && preview + ? ' Already in sync ' + : ` ${preview ? 'Preview' : 'Sync'} complete ` + : ' Failed ' + const titleColor = transfer.running || cancelled ? theme.warning : transfer.outcome?.ok ? theme.success : theme.danger ui.panel( { @@ -343,22 +354,47 @@ function drawTransfer(ui: Container, theme: Theme, transfer: Transfer, handlers: ...(transfer.running ? {} : { onClick: () => handlers.onAction?.('dismissTransfer') }), }, (panel) => { - panel.meter({ - height: 1, - value: Math.max(0, Math.min(1, percent / 100)), - label: preview ? 'scan' : 'copy', - text: `${percent.toFixed(0)}%`, - heat: false, - color: transfer.outcome?.ok === false ? theme.danger : theme.primary, - }) + if (preview) { + // A dry run moves no bytes, so its percentage is 0 until the end by + // definition. What moves is the count of files checked. + const scanned = transfer.scanned + const value = transfer.outcome?.ok ? 1 : scanned && scanned.total > 0 ? scanned.checked / scanned.total : 0 + const text = scanned + ? `${transfer.outcome?.ok ? scanned.total : scanned.checked}/${scanned.total} files` + : transfer.running + ? 'scanning…' + : '' + panel.meter({ + height: 1, + value: Math.max(0, Math.min(1, value)), + label: 'checked', + text, + heat: false, + color: failed ? theme.danger : cancelled ? theme.warning : theme.primary, + }) + } else { + panel.meter({ + height: 1, + value: Math.max(0, Math.min(1, percent / 100)), + label: 'copy', + text: `${percent.toFixed(0)}%`, + heat: false, + color: failed ? theme.danger : cancelled ? theme.warning : theme.primary, + }) + } panel.row({ height: 1, gap: 1 }, (row) => { - const rate = progress && progress.bytesPerSecond > 0 ? `${formatSize(progress.bytesPerSecond)}/s` : '—' - const moved = progress ? formatSize(progress.bytesTransferred) : '—' - const files = progress?.filesTransferred != null ? String(progress.filesTransferred) : '—' - row.text(` ${moved} · ${rate} · ${files} files`, { fg: theme.muted }) + if (preview) { + const found = transfer.summary.add + transfer.summary.update + transfer.summary.delete + row.text(` ${found} change${found === 1 ? '' : 's'} found so far`, { fg: theme.muted }) + } else { + const rate = progress && progress.bytesPerSecond > 0 ? `${formatSize(progress.bytesPerSecond)}/s` : '—' + const moved = progress ? formatSize(progress.bytesTransferred) : '—' + const files = progress?.filesTransferred != null ? String(progress.filesTransferred) : '—' + row.text(` ${moved} · ${rate} · ${files} files`, { fg: theme.muted }) + } row.text( - remaining != null ? `${formatDuration(remaining)} left ` : progress ? `${formatDuration(progress.elapsedSeconds)} ` : '', + remaining != null ? `${formatDuration(remaining)} left ` : `${formatDuration(elapsed)} `, { fg: theme.muted, align: 'right' }, ) }) @@ -379,8 +415,23 @@ function drawTransfer(ui: Container, theme: Theme, transfer: Transfer, handlers: row.spacer('fill') }) - if (transfer.outcome && !transfer.outcome.ok) { - panel.text(transfer.outcome.message, { fg: theme.danger, wrap: true }) + if (cancelled) { + panel.text(`${transfer.outcome!.message} It was stopped by esc; nothing on either side was changed by stopping it.`, { + fg: theme.warning, + wrap: true, + }) + return + } + if (failed) { + panel.text(transfer.outcome!.message, { fg: theme.danger, wrap: true }) + return + } + if (idle) { + const total = transfer.scanned?.total + panel.text( + `Nothing to do: ${total != null ? `${total} files checked, ` : ''}the other side already matches.`, + { fg: theme.muted, wrap: true }, + ) return } @@ -549,8 +600,8 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { label: 'o / O', value: 'cycle sort / reverse it' }, { label: '.', value: 'show hidden files' }, { label: 'r', value: 'reload' }, - { label: 'p', value: 'preview a sync to the other pane' }, - { label: 's', value: 'sync to the other pane' }, + { label: 'p', value: 'preview syncing this to the other pane' }, + { label: 's', value: 'sync it, into the same place over there' }, { label: 'esc', value: 'cancel a transfer, or close this' }, { label: 'q', value: 'quit' }, ],