Skip to content

Commit 3d73410

Browse files
ralyodioclaude
andauthored
fix(desktop): Fleet is a tab, not a modal, and Run cannot scroll away (#18)
Fleet shipped in v0.2.8 as a dialog. It is the wrong container and it broke the thing the view exists for: pick the `upgrade` recipe and its fifty-seven lines of shell push the Run button off the bottom of the modal, where nothing can scroll it back. The script itself was five textarea rows, so most of what was about to run on every selected server was hidden too. A modal is for a question you must answer before anything continues. This is somewhere you sit for minutes with a long script in front of you while a dozen servers report — the opposite. So Fleet is now one of two tabs in the header, beside Transfer, and the layout has exactly three scrolling regions: the server list, the script editor, and the results. **The action bar sits outside all three**, pinned to the bottom of the window. Run is on screen at the 960x600 minimum size just as it is maximised. The editor is `clamp(120px, 26vh, 340px)`, resizable, with its own scrollbar and a `57 lines - runs under sh -e` line under it, so the size of what you are about to run is stated rather than discovered. Modals are kept for modality: the destructive-command confirmation is now a small dialog with Cancel / Run it anyway, which is a real blocking yes/no. Also fixes a clipped field found in the screenshots: Timeout was 70px, and the upgrade recipe's default of 3600 rendered as "360C". Verified by screenshotting the built renderer rather than by reading it, per the harness in [[diskpush-desktop-ui-preview]] — mock preload bridge served as an external script, the app's real hashed CSP on the response, playwright-core in headless Chromium. Eight shots: the tab empty, the upgrade recipe at 1360x860 and at the 960x600 minimum, a run in flight with a failure and an unreachable host, the check sweep, the hazard dialog, light theme, and the Transfer tab intact. No CSP violations. One trap worth recording: `page.waitForFunction` compiles its predicate with `eval`, which the real policy refuses, so hydration is polled with `page.evaluate` instead. Bypassing CSP would have hidden exactly what the harness is there to catch. Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b2b6e7a commit 3d73410

6 files changed

Lines changed: 835 additions & 731 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ diskpush ./data/ prod:/data/ -- --checksum # your own rsync flags
2020
- **Never deletes** destination-only files unless you explicitly enable Mirror,
2121
and Mirror always shows you the delete list first.
2222
- **One command, many servers.** Package upgrades, a health sweep, or a script
23-
you already have — run across a whole tagged fleet, each server reported
24-
separately.
23+
you already have — run across a whole tagged fleet from the Fleet tab or the
24+
CLI, each server reported separately.
2525
- **No cloud account, no relay.** For a server-to-server job the payload moves
2626
directly between the two servers; DiskPush only orchestrates.
2727

apps/desktop/src/app/page.tsx

Lines changed: 124 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useMemo, useState } from 'react'
44
import Image from 'next/image'
55
import {
6+
ArrowLeftRight,
67
CircleAlert,
78
CircleCheck,
89
ExternalLink,
@@ -15,7 +16,7 @@ import {
1516
X,
1617
} from 'lucide-react'
1718
import { ConnectionDialog } from '@/components/connection-dialog'
18-
import { FleetDialog } from '@/components/fleet-dialog'
19+
import { FleetView } from '@/components/fleet-view'
1920
import { endpointLabel, loadPane, Pane, type PaneEndpoint, type PaneState } from '@/components/pane'
2021
import { TransferRail } from '@/components/transfer-rail'
2122
import { MirrorPreviewDialog, TransferBand, type ActiveJob } from '@/components/transfer-panel'
@@ -38,6 +39,32 @@ function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: stri
3839
)
3940
}
4041

42+
/** One of the two top-level views. A segmented control, not a link. */
43+
function TabButton({
44+
active,
45+
onClick,
46+
children,
47+
}: {
48+
active: boolean
49+
onClick: () => void
50+
children: React.ReactNode
51+
}) {
52+
return (
53+
<button
54+
type="button"
55+
onClick={onClick}
56+
aria-current={active ? 'page' : undefined}
57+
className={`focus-ring flex items-center gap-1.5 rounded-[7px] px-2.5 py-1 text-[12px] transition-colors ${
58+
active
59+
? 'bg-background text-foreground shadow-sm'
60+
: 'text-muted-foreground hover:text-foreground'
61+
}`}
62+
>
63+
{children}
64+
</button>
65+
)
66+
}
67+
4168
const blankPane = (endpoint: PaneEndpoint, path: string): PaneState => ({
4269
endpoint,
4370
path,
@@ -62,7 +89,7 @@ export default function Workspace() {
6289
const [job, setJob] = useState<ActiveJob | null>(null)
6390
const [error, setError] = useState<string | null>(null)
6491
const [showConnection, setShowConnection] = useState(false)
65-
const [showFleet, setShowFleet] = useState(false)
92+
const [tab, setTab] = useState<'transfer' | 'fleet'>('transfer')
6693
const [outsideShell, setOutsideShell] = useState(false)
6794

6895
const refreshConnections = useCallback(async () => {
@@ -278,20 +305,23 @@ export default function Workspace() {
278305
<span>No servers yet</span>
279306
)}
280307
</div>
281-
<div className="ml-auto flex items-center gap-2">
282-
{/*
283-
Fleet sits beside "New server" rather than inside the menu: it is
284-
the other half of what this app does with a list of servers, and
285-
a feature nobody can find is a feature nobody has.
286-
*/}
287-
<Button
288-
variant="outline"
289-
onClick={() => setShowFleet(true)}
290-
className="h-[var(--control)] gap-2 border-line-strong text-[12px]"
291-
>
308+
{/*
309+
Two views, not a dialog. Fleet is somewhere you work for minutes with
310+
a long script in front of you, which is the opposite of what a modal
311+
is for -- and as a modal its Run button ended up below the fold.
312+
*/}
313+
<nav className="flex items-center gap-0.5 rounded-lg bg-secondary p-0.5">
314+
<TabButton active={tab === 'transfer'} onClick={() => setTab('transfer')}>
315+
<ArrowLeftRight className="size-3.5" />
316+
Transfer
317+
</TabButton>
318+
<TabButton active={tab === 'fleet'} onClick={() => setTab('fleet')}>
292319
<Server className="size-3.5" />
293320
Fleet
294-
</Button>
321+
</TabButton>
322+
</nav>
323+
324+
<div className="ml-auto flex items-center gap-2">
295325
<Button
296326
variant="outline"
297327
onClick={() => setShowConnection(true)}
@@ -356,83 +386,92 @@ export default function Workspace() {
356386
</div>
357387
) : null}
358388

359-
<div className="flex min-h-0 flex-1 gap-0 p-3.5">
360-
<Pane
361-
role="Source"
362-
state={left}
363-
saved={saved}
364-
sshConfig={sshConfig}
365-
onRefreshHosts={refreshConnections}
366-
active={active === 'left'}
367-
onFocus={() => setActive('left')}
368-
onChange={(patch) => setLeft((current) => ({ ...current, ...patch }))}
369-
onNavigate={(path) => void navigate('left', left.endpoint, path)}
370-
onEndpointChange={(endpoint) => setLeft(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))}
371-
onAddServer={() => setShowConnection(true)}
372-
/>
389+
{/*
390+
One view or the other, never both. The transfer side keeps its own
391+
footer and status band; Fleet brings its own, pinned so the action
392+
it exists for cannot scroll out of reach.
393+
*/}
394+
{tab === 'transfer' ? (
395+
<>
396+
<div className="flex min-h-0 flex-1 gap-0 p-3.5">
397+
<Pane
398+
role="Source"
399+
state={left}
400+
saved={saved}
401+
sshConfig={sshConfig}
402+
onRefreshHosts={refreshConnections}
403+
active={active === 'left'}
404+
onFocus={() => setActive('left')}
405+
onChange={(patch) => setLeft((current) => ({ ...current, ...patch }))}
406+
onNavigate={(path) => void navigate('left', left.endpoint, path)}
407+
onEndpointChange={(endpoint) => setLeft(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))}
408+
onAddServer={() => setShowConnection(true)}
409+
/>
410+
411+
<TransferRail
412+
direction={direction}
413+
mirror={mirror}
414+
busy={job !== null && !job.finished}
415+
leftLabel={railLabel(left.endpoint, allConnections)}
416+
rightLabel={railLabel(right.endpoint, allConnections)}
417+
onDirection={setDirection}
418+
onToggleMirror={() => setMirror((value) => !value)}
419+
onPreview={runPreview}
420+
onRun={run}
421+
/>
422+
423+
<Pane
424+
role="Destination"
425+
state={right}
426+
saved={saved}
427+
sshConfig={sshConfig}
428+
onRefreshHosts={refreshConnections}
429+
active={active === 'right'}
430+
onFocus={() => setActive('right')}
431+
onChange={(patch) => setRight((current) => ({ ...current, ...patch }))}
432+
onNavigate={(path) => void navigate('right', right.endpoint, path)}
433+
onEndpointChange={(endpoint) => setRight(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))}
434+
onAddServer={() => setShowConnection(true)}
435+
/>
436+
</div>
373437

374-
<TransferRail
375-
direction={direction}
438+
<TransferBand
439+
job={job}
440+
route={route}
376441
mirror={mirror}
377-
busy={job !== null && !job.finished}
378-
leftLabel={railLabel(left.endpoint, allConnections)}
379-
rightLabel={railLabel(right.endpoint, allConnections)}
380-
onDirection={setDirection}
381-
onToggleMirror={() => setMirror((value) => !value)}
382-
onPreview={runPreview}
383-
onRun={run}
384-
/>
385-
386-
<Pane
387-
role="Destination"
388-
state={right}
389-
saved={saved}
390-
sshConfig={sshConfig}
391-
onRefreshHosts={refreshConnections}
392-
active={active === 'right'}
393-
onFocus={() => setActive('right')}
394-
onChange={(patch) => setRight((current) => ({ ...current, ...patch }))}
395-
onNavigate={(path) => void navigate('right', right.endpoint, path)}
396-
onEndpointChange={(endpoint) => setRight(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))}
397-
onAddServer={() => setShowConnection(true)}
442+
onCancel={() => {
443+
if (job) void api()?.transfers.cancel(job.jobId)
444+
}}
398445
/>
399-
</div>
400446

401-
<TransferBand
402-
job={job}
403-
route={route}
404-
mirror={mirror}
405-
onCancel={() => {
406-
if (job) void api()?.transfers.cancel(job.jobId)
407-
}}
408-
/>
409-
410-
{/*
411-
This line used to be a fixed string that read like the command being
412-
run but could not change -- turn Mirror on and it still claimed no
413-
deletes. A command line nobody can trust is worse than none, so it is
414-
built from the same state the transfer is.
415-
*/}
416-
<footer className="flex h-[28px] shrink-0 items-center gap-2.5 border-t border-line bg-background px-4 text-[11px] text-faint">
417-
<span>Incremental</span>
418-
<span className="text-line-strong">·</span>
419-
<span>Archive metadata</span>
420-
<span className="text-line-strong">·</span>
421-
<span>Resume</span>
422-
<span className="text-line-strong">·</span>
423-
<span className={mirror ? 'font-medium text-destructive' : 'text-ok'}>Deletes {mirror ? 'ON' : 'off'}</span>
424447
{/*
425-
The command used to run flush to the window edge and get sliced
426-
mid-token by the truncation, so the last thing in the footer was
427-
always half a word. It keeps a gutter now, and the full string is in
428-
the tooltip.
448+
This line used to be a fixed string that read like the command being
449+
run but could not change -- turn Mirror on and it still claimed no
450+
deletes. A command line nobody can trust is worse than none, so it is
451+
built from the same state the transfer is.
429452
*/}
430-
<span className="selectable numeric ml-auto min-w-0 max-w-[54%] truncate pl-4 text-[10.5px]" title={rsyncFlags}>
431-
{rsyncFlags}
432-
</span>
433-
</footer>
434-
435-
<FleetDialog open={showFleet} onClose={() => setShowFleet(false)} />
453+
<footer className="flex h-[28px] shrink-0 items-center gap-2.5 border-t border-line bg-background px-4 text-[11px] text-faint">
454+
<span>Incremental</span>
455+
<span className="text-line-strong">·</span>
456+
<span>Archive metadata</span>
457+
<span className="text-line-strong">·</span>
458+
<span>Resume</span>
459+
<span className="text-line-strong">·</span>
460+
<span className={mirror ? 'font-medium text-destructive' : 'text-ok'}>Deletes {mirror ? 'ON' : 'off'}</span>
461+
{/*
462+
The command used to run flush to the window edge and get sliced
463+
mid-token by the truncation, so the last thing in the footer was
464+
always half a word. It keeps a gutter now, and the full string is in
465+
the tooltip.
466+
*/}
467+
<span className="selectable numeric ml-auto min-w-0 max-w-[54%] truncate pl-4 text-[10.5px]" title={rsyncFlags}>
468+
{rsyncFlags}
469+
</span>
470+
</footer>
471+
</>
472+
) : (
473+
<FleetView onAddServer={() => setShowConnection(true)} />
474+
)}
436475

437476
<ConnectionDialog open={showConnection} onClose={() => setShowConnection(false)} onSaved={() => void refreshConnections()} />
438477

0 commit comments

Comments
 (0)