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({}),