From 76a616a5738bf49b16f1d1ca9804dff4ff532d8b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 17:40:25 +0000 Subject: [PATCH] fix(desktop): a profile restores both panes where you left them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile stored `source` and `destination`, and loading put the source into the LEFT pane always. But with the arrow pointing right-to-left the source *is* the right pane — so a profile saved that way came back **mirrored**, with each pane on the wrong side and the arrow reset to left-to-right. `source` and `destination` still carry the transfer's meaning and always will: that is what `diskpush profile run` acts on, and it must not depend on how a window happened to be arranged. So the arrangement is recorded alongside it — `sourcePane`, one of 'left' or 'right' — and loading puts each pane back where it was and the arrow the way you had it. Migration 005 adds the column defaulting to 'left', which is what every existing row was implicitly assumed to be, so nothing already saved changes behaviour. Worth knowing while reading this: the rail's direction buttons both set the direction AND start the transfer, so `direction` only ever changes as a side effect of running. `sourcePane` therefore records the arrangement the last run used, which is the one on screen. The type checker caught the CLI's `profiles save` building a profile without the new field. It passes 'left': a profile made from a terminal has no panes, and source-on-the-left is how the app will open it. Verified by driving the built renderer end to end: flip the arrow so the source is the right pane, save, disturb the layout, load it back — left pane "This computer", right pane "web-01", arrow still right-to-left. The stored row reads `sourcePane: right`. 517 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4 --- apps/cli/src/commands/profiles.ts | 3 +++ .../electron/main/services/transfers.ts | 2 ++ apps/desktop/electron/shared/contract.ts | 2 ++ apps/desktop/src/app/page.tsx | 25 ++++++++++++++++--- apps/desktop/src/lib/api.ts | 3 +++ docs/profiles.md | 7 ++++++ packages/database/src/migrations.ts | 14 +++++++++++ packages/database/src/store.ts | 7 ++++-- packages/schemas/src/profile.ts | 10 ++++++++ 9 files changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/commands/profiles.ts b/apps/cli/src/commands/profiles.ts index e611deb..3381c4a 100644 --- a/apps/cli/src/commands/profiles.ts +++ b/apps/cli/src/commands/profiles.ts @@ -89,6 +89,9 @@ async function saveProfile(parsed: ParsedArgv, store: DiskPushStore, output: Out destination: parseEndpoint(destination), preset: presetFlag ? PresetNameSchema.parse(presetFlag) : 'fast-sync', options: optionsFromFlags(parsed), + // A profile made here has no panes. 'left' is what the app will use when + // it opens one: source on the left, the way you read it. + sourcePane: 'left', // Never inherited from the command line: unattended mirroring has to be // turned on deliberately, in one place, after the fact. trustDeletes: false, diff --git a/apps/desktop/electron/main/services/transfers.ts b/apps/desktop/electron/main/services/transfers.ts index c662ed1..6c1163d 100644 --- a/apps/desktop/electron/main/services/transfers.ts +++ b/apps/desktop/electron/main/services/transfers.ts @@ -238,6 +238,7 @@ export async function saveProfile(input: { source: EndpointRef destination: EndpointRef options: TransferOptions + sourcePane: 'left' | 'right' }) { const source = await resolveEndpoint(input.source) const destination = await resolveEndpoint(input.destination) @@ -247,6 +248,7 @@ export async function saveProfile(input: { destination: destination.endpoint, preset: 'fast-sync', options: optionsFrom(input.options), + sourcePane: input.sourcePane, // Never set from the app. Unattended mirroring is the one way a delete // list runs without a human looking at it, and it stays a deliberate, // out-of-band choice. diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index a4675d3..1088086 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -145,6 +145,8 @@ export const ProfileSaveSchema = z.object({ source: EndpointRefSchema, destination: EndpointRefSchema, options: TransferOptionsSchema, + /** Which pane the source was on, so loading puts both back where they were. */ + sourcePane: z.enum(['left', 'right']).default('left'), }) export const RemotePathRequestSchema = z.object({ diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index edeac6d..76606e7 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -250,9 +250,23 @@ export default function Workspace() { : { kind: 'ssh', connectionId: endpoint.connectionId } setError(null) - setLeft(blankPane(toPane(profile.source), profile.source.path)) - setRight(blankPane(toPane(profile.destination), profile.destination.path)) - setDirection('ltr') + + /* + * Put each pane back where it was, not where the transfer's direction + * happens to imply. + * + * `source` and `destination` carry the transfer's meaning, so with the + * arrow pointing right-to-left the source IS the right pane. Loading + * source into the left pane unconditionally mirrored the whole window, + * and forcing the arrow back to left-to-right lost the direction too. + */ + const sourceOnLeft = (profile.sourcePane ?? 'left') === 'left' + const forLeft = sourceOnLeft ? profile.source : profile.destination + const forRight = sourceOnLeft ? profile.destination : profile.source + + setLeft(blankPane(toPane(forLeft), forLeft.path)) + setRight(blankPane(toPane(forRight), forRight.path)) + setDirection(sourceOnLeft ? 'ltr' : 'rtl') setMirror(profile.options?.deleteMode !== undefined && profile.options.deleteMode !== 'off') }, [], @@ -268,6 +282,9 @@ export default function Workspace() { source: request.source, destination: request.destination, options: request.options, + // Which pane the source was on. Without it, loading cannot tell a + // right-to-left arrangement from a mirrored left-to-right one. + sourcePane: direction === 'ltr' ? 'left' : 'right', }), ) setProfiles(await unwrap(api()?.profiles.list())) @@ -275,7 +292,7 @@ export default function Workspace() { setError(caught instanceof Error ? caught.message : String(caught)) } }, - [request], + [request, direction], ) const removeProfile = useCallback(async (id: string) => { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 5ec81a2..ba04f42 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -60,6 +60,8 @@ export type SyncProfile = { source: { type: 'local'; path: string } | { type: 'ssh'; connectionId?: string; host: string; path: string } destination: { type: 'local'; path: string } | { type: 'ssh'; connectionId?: string; host: string; path: string } options: { deleteMode: 'off' | 'delay' | 'during' | 'after' | 'before' } + /** Which pane the source was on when saved. Older rows default to 'left'. */ + sourcePane?: 'left' | 'right' } export type StartedJob = { jobId: string; command: string; control: string | null; warnings: string[] } @@ -231,6 +233,7 @@ type Api = { source: unknown destination: unknown options: { deleteMode: 'off' | 'delay' } + sourcePane: 'left' | 'right' }): Promise> remove(id: string): Promise> } diff --git a/docs/profiles.md b/docs/profiles.md index db74d23..0f9244f 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -34,6 +34,13 @@ A profile whose delete mode is on carries a red **mirror** mark, because loading a profile that turns Mirror on is not something to discover from the footer afterwards. +A profile restores **both panes where you left them**, and the arrow with +them. `source` and `destination` carry the transfer's meaning — that is what +`diskpush profile run` acts on, and it must not depend on how a window was +arranged — so the arrangement is recorded separately. Without that, a profile +saved while the arrow pointed right-to-left came back mirrored, because the +source was the *right* pane and loading put it on the left. + Loading a profile sets Mirror to whatever the profile stored, rather than leaving it as it found it — a profile that did something different depending on what you had toggled last would not be a profile. diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts index 85251bc..37d3a09 100644 --- a/packages/database/src/migrations.ts +++ b/packages/database/src/migrations.ts @@ -189,4 +189,18 @@ export const MIGRATIONS: Migration[] = [ `ALTER TABLE fleet_commands ADD COLUMN on_failure TEXT NOT NULL DEFAULT 'continue'`, ], }, + { + name: '005-profile-source-pane', + statements: [ + /* + * Which pane the source was on. + * + * A profile stored source and destination, which is the transfer's + * meaning, and the app restored source into the LEFT pane always. Saved + * with the arrow pointing right-to-left, the panes came back mirrored. + * Defaults to 'left', which is what every existing row was assumed to be. + */ + `ALTER TABLE sync_profiles ADD COLUMN source_pane TEXT NOT NULL DEFAULT 'left'`, + ], + }, ] diff --git a/packages/database/src/store.ts b/packages/database/src/store.ts index 59fbb65..a4a2d1e 100644 --- a/packages/database/src/store.ts +++ b/packages/database/src/store.ts @@ -165,12 +165,13 @@ export class DiskPushStore { await this.client.execute({ sql: `INSERT INTO sync_profiles ( id, name, source_json, destination_json, preset, options_json, trust_deletes, - schedule_json, watch_json, notify_on_success, notify_on_failure, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + source_pane, schedule_json, watch_json, notify_on_success, notify_on_failure, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, source_json=excluded.source_json, destination_json=excluded.destination_json, preset=excluded.preset, options_json=excluded.options_json, trust_deletes=excluded.trust_deletes, + source_pane=excluded.source_pane, schedule_json=excluded.schedule_json, watch_json=excluded.watch_json, notify_on_success=excluded.notify_on_success, notify_on_failure=excluded.notify_on_failure, updated_at=excluded.updated_at`, @@ -182,6 +183,7 @@ export class DiskPushStore { profile.preset, JSON.stringify(profile.options), profile.trustDeletes ? 1 : 0, + profile.sourcePane, JSON.stringify(profile.schedule), JSON.stringify(profile.watch), profile.notifyOnSuccess ? 1 : 0, @@ -572,6 +574,7 @@ function rowToProfile(row: Row): SyncProfile { preset: String(row.preset), options: JSON.parse(String(row.options_json)), trustDeletes: Number(row.trust_deletes) === 1, + sourcePane: String(row.source_pane), schedule: JSON.parse(String(row.schedule_json)), watch: JSON.parse(String(row.watch_json)), notifyOnSuccess: Number(row.notify_on_success) === 1, diff --git a/packages/schemas/src/profile.ts b/packages/schemas/src/profile.ts index 4a452bb..dc82bf4 100644 --- a/packages/schemas/src/profile.ts +++ b/packages/schemas/src/profile.ts @@ -34,6 +34,16 @@ export const SyncProfileSchema = z.object({ * Off by default and deliberately awkward to turn on: it is the only way a * mirror runs without a human looking at the delete list first. */ + /** + * Which pane the source was on when this was saved. + * + * `source` and `destination` carry the transfer's meaning and always will — + * that is what `diskpush profile run` acts on, and it must not depend on how + * a window happened to be arranged. This records the arrangement separately, + * so loading a profile in the app puts each pane back where you left it + * instead of mirroring them whenever the arrow pointed right-to-left. + */ + sourcePane: z.enum(['left', 'right']).default('left'), trustDeletes: z.boolean().default(false), schedule: ScheduleSchema.default({}), watch: WatchSchema.default({}),