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
19 changes: 14 additions & 5 deletions apps/desktop/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,16 @@ export default function Workspace() {
footer and status band; Fleet brings its own, pinned so the action
it exists for cannot scroll out of reach.
*/}
{tab === 'transfer' ? (
<>
{/*
Both views stay MOUNTED, and the inactive one is hidden.

This was a ternary, so switching tabs unmounted the other branch and
React threw its state away: a half-written script, the servers you had
ticked, the output of a run still going. Clicking Transfer to check a
path and coming back to a blank Fleet view is not a tab, it is a
reset.
*/}
<div className={`flex min-h-0 flex-1 flex-col ${tab === 'transfer' ? '' : 'hidden'}`}>
<ProfileBar
profiles={profiles}
routeLabel={route}
Expand Down Expand Up @@ -532,10 +540,11 @@ export default function Workspace() {
{rsyncFlags}
</span>
</footer>
</>
) : (
</div>

<div className={`flex min-h-0 flex-1 flex-col ${tab === 'fleet' ? '' : 'hidden'}`}>
<FleetView onAddServer={() => setShowConnection(true)} />
)}
</div>

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

Expand Down
37 changes: 29 additions & 8 deletions apps/desktop/src/components/fleet-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { blankHost, foldHosts, type HostView } from '@/lib/fleet-events'
import { readDraft, writeDraft } from '@/lib/fleet-draft'
import {
api,
unwrap,
Expand Down Expand Up @@ -73,19 +74,25 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) {
const [newListName, setNewListName] = useState('')
const [savingCommand, setSavingCommand] = useState(false)
const [newCommandName, setNewCommandName] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Set<string>>(() => new Set(readDraft().selected))
const [tagFilter, setTagFilter] = useState<string | null>(null)

const [script, setScript] = useState('')
const [label, setLabel] = useState('')
// Read once, synchronously, as the initial state. Restoring in an effect
// would paint an empty editor and then replace what you were looking at.
const [draft] = useState(readDraft)

const [script, setScript] = useState(draft.script)
const [label, setLabel] = useState(draft.label)
const [commandId, setCommandId] = useState<string | null>(null)
const [interpreter, setInterpreter] = useState<'sh' | 'bash' | 'raw'>('raw')
const [sudo, setSudo] = useState(false)
const [interpreter, setInterpreter] = useState<'sh' | 'bash' | 'raw'>(draft.interpreter)
const [sudo, setSudo] = useState(draft.sudo)
// Never restored. A password is held for one run, and writing it anywhere it
// could be read back is exactly what the CLI refuses to do.
const [sudoPassword, setSudoPassword] = useState('')
const [askSudoPassword, setAskSudoPassword] = useState(false)
const [concurrency, setConcurrency] = useState(4)
const [timeoutSeconds, setTimeoutSeconds] = useState(900)
const [stopOnError, setStopOnError] = useState(false)
const [concurrency, setConcurrency] = useState(draft.concurrency)
const [timeoutSeconds, setTimeoutSeconds] = useState(draft.timeoutSeconds)
const [stopOnError, setStopOnError] = useState(draft.stopOnError)

const [hazards, setHazards] = useState<Hazard[]>([])
const [runId, setRunId] = useState<string | null>(null)
Expand All @@ -108,6 +115,14 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) {
setServers(serverList)
setCommands(commandList)
setLists(listList)
// A restored draft can name servers that have since been deleted.
// Dropping them quietly is right here — this is a draft, not a saved
// list, and there is nothing for the run to get wrong yet.
const live = new Set(serverList.map((server) => server.id))
setSelected((current) => {
const kept = [...current].filter((id) => live.has(id))
return kept.length === current.size ? current : new Set(kept)
})
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
}
Expand All @@ -117,6 +132,12 @@ export function FleetView({ onAddServer }: { onAddServer: () => void }) {
void refresh()
}, [refresh])

// Keep the draft current. Cheap, and it is the difference between closing
// the window and losing a fifty-line script.
useEffect(() => {
writeDraft({ script, interpreter, sudo, concurrency, timeoutSeconds, stopOnError, label, selected: [...selected] })
}, [script, interpreter, sudo, concurrency, timeoutSeconds, stopOnError, label, selected])

useEffect(() => {
const bridge = api()
if (!bridge) return
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/src/lib/fleet-draft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearDraft, EMPTY_DRAFT, readDraft, writeDraft } from './fleet-draft'

/** A stand-in for localStorage, since vitest runs this without a DOM. */
function installStorage(impl?: Partial<Storage>) {
const data = new Map<string, string>()
const storage = {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
...impl,
}
vi.stubGlobal('window', { localStorage: storage })
return data
}

beforeEach(() => {
vi.unstubAllGlobals()
})

describe('readDraft', () => {
it('is empty when nothing was ever written', () => {
installStorage()
expect(readDraft()).toEqual(EMPTY_DRAFT)
})

it('round-trips what was written', () => {
installStorage()
const draft = {
script: 'systemctl restart app.service',
interpreter: 'bash' as const,
sudo: true,
concurrency: 7,
timeoutSeconds: 45,
stopOnError: true,
label: 'restart-app',
selected: ['id-web-01', 'id-web-03'],
}
writeDraft(draft)
expect(readDraft()).toEqual(draft)
})

it('returns a blank draft rather than throwing on nonsense', () => {
// Last week's shape after a schema change, or a half-written value.
const data = installStorage()
data.set('diskpush:fleet-draft:v1', '{not json')
expect(readDraft()).toEqual(EMPTY_DRAFT)
})

it('falls back field by field, so one bad value does not lose the script', () => {
const data = installStorage()
data.set(
'diskpush:fleet-draft:v1',
JSON.stringify({ script: 'keep me', interpreter: 'python', concurrency: -4, selected: 'nope' }),
)
const draft = readDraft()
expect(draft.script).toBe('keep me')
expect(draft.interpreter).toBe(EMPTY_DRAFT.interpreter)
expect(draft.concurrency).toBe(EMPTY_DRAFT.concurrency)
expect(draft.selected).toEqual([])
})

it('survives storage that throws outright', () => {
// Private windows and blocked site data throw on access, not on read.
installStorage({
getItem: () => {
throw new Error('access denied')
},
})
expect(readDraft()).toEqual(EMPTY_DRAFT)
})

it('is a blank draft, never a crash, with no window at all', () => {
vi.stubGlobal('window', undefined)
expect(readDraft()).toEqual(EMPTY_DRAFT)
})
})

describe('writeDraft', () => {
it('never throws, because losing a draft must not break a run', () => {
installStorage({
setItem: () => {
throw new Error('quota exceeded')
},
})
expect(() => writeDraft({ ...EMPTY_DRAFT, script: 'x' })).not.toThrow()
})

it('never stores a sudo password, because the draft has no field for one', () => {
const data = installStorage()
writeDraft({ ...EMPTY_DRAFT, script: 'id' })
expect(data.get('diskpush:fleet-draft:v1')).not.toMatch(/password/i)
})
})

describe('clearDraft', () => {
it('removes it', () => {
installStorage()
writeDraft({ ...EMPTY_DRAFT, script: 'x' })
clearDraft()
expect(readDraft()).toEqual(EMPTY_DRAFT)
})
})
96 changes: 96 additions & 0 deletions apps/desktop/src/lib/fleet-draft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client'

/**
* What you had typed, kept across a restart.
*
* Keeping both tabs mounted stops a tab switch from wiping the Fleet view, but
* quitting the app still would, and a fifty-line script is not something to
* lose to a window close. This is the unsaved draft — the thing you have not
* decided to name yet. A *saved* command is the deliberate act; this is the
* safety net under it.
*
* `localStorage`, not the database: it is per-machine UI state, it must be
* readable synchronously during the first render (an async round trip would
* paint an empty editor and then replace what you were looking at), and it
* carries nothing anyone would want in a backup.
*/

const KEY = 'diskpush:fleet-draft:v1'

export type FleetDraft = {
script: string
interpreter: 'sh' | 'bash' | 'raw'
sudo: boolean
concurrency: number
timeoutSeconds: number
stopOnError: boolean
label: string
/** Connection ids. Ones that no longer exist are dropped on read. */
selected: string[]
}

export const EMPTY_DRAFT: FleetDraft = {
script: '',
interpreter: 'raw',
sudo: false,
concurrency: 4,
timeoutSeconds: 900,
stopOnError: false,
label: '',
selected: [],
}

/**
* Reads the draft, and is not allowed to fail.
*
* Storage throws outright in some contexts, and anything in there is last
* week's shape after a schema change. A draft that cannot be restored is a
* blank editor, never a broken window.
*/
export function readDraft(): FleetDraft {
if (typeof window === 'undefined') return EMPTY_DRAFT
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return EMPTY_DRAFT
const parsed = JSON.parse(raw) as Partial<FleetDraft>
return {
script: typeof parsed.script === 'string' ? parsed.script : EMPTY_DRAFT.script,
interpreter:
parsed.interpreter === 'sh' || parsed.interpreter === 'bash' || parsed.interpreter === 'raw'
? parsed.interpreter
: EMPTY_DRAFT.interpreter,
sudo: typeof parsed.sudo === 'boolean' ? parsed.sudo : EMPTY_DRAFT.sudo,
concurrency: positive(parsed.concurrency, EMPTY_DRAFT.concurrency),
timeoutSeconds: positive(parsed.timeoutSeconds, EMPTY_DRAFT.timeoutSeconds),
stopOnError: typeof parsed.stopOnError === 'boolean' ? parsed.stopOnError : EMPTY_DRAFT.stopOnError,
label: typeof parsed.label === 'string' ? parsed.label : EMPTY_DRAFT.label,
selected: Array.isArray(parsed.selected) ? parsed.selected.filter((id) => typeof id === 'string') : [],
}
} catch {
return EMPTY_DRAFT
}
}

/** Writes the draft. Also cannot fail: losing a draft must not break a run. */
export function writeDraft(draft: FleetDraft): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(KEY, JSON.stringify(draft))
} catch {
// A private window, cleared site data, or storage disabled. The editor
// still works; it just will not be there next time.
}
}

export function clearDraft(): void {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(KEY)
} catch {
/* see writeDraft */
}
}

function positive(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
11 changes: 11 additions & 0 deletions docs/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ It names what it matched and how many servers it would reach. That check is
repeated in the main process, so it is not something the window can be talked
out of.

## Switching tabs keeps your work

Both views stay mounted; the inactive one is hidden, not destroyed. Clicking
Transfer to check a path and coming back finds the Fleet view exactly as you
left it — the script, the ticked servers, the output of a run still going.

The Fleet editor's contents also survive quitting the app. That is the
*unsaved draft*: the thing you have not decided to name yet, kept in the
window's own storage as a safety net under **Save these settings**. A sudo
password is never part of it.

## Keyboard

```text
Expand Down
Loading