Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 70 additions & 18 deletions apps/cli/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ import {
blankPane,
clampIndex,
listLocal,
nothingToDo,
parentPath,
pushChange,
resetTree,
scannedFrom,
scopeTransfer,
selectedRow,
visibleRows,
} from './model.js'
Expand Down Expand Up @@ -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<void> {
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<Entry[]> {
const browser = await SftpBrowser.open(await this.session(pane.connection!))
try {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -642,46 +679,67 @@ 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
const message = error instanceof Error ? error.message : String(error)
transfer.outcome = { ok: false, message }
this.say(message, 'error')
} finally {
clearInterval(clock)
transfer.running = false
transfer.endedAt = Date.now()
this.busy = false
this.invalidate()
}
Expand All @@ -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}`
}
87 changes: 87 additions & 0 deletions apps/cli/src/tui/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
58 changes: 56 additions & 2 deletions apps/cli/src/tui/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading