Skip to content

Commit a1a71bf

Browse files
ralyodioclaude
andauthored
fix(desktop): a profile restores both panes where you left them (#28)
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. Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3e7b642 commit a1a71bf

9 files changed

Lines changed: 67 additions & 6 deletions

File tree

‎apps/cli/src/commands/profiles.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ async function saveProfile(parsed: ParsedArgv, store: DiskPushStore, output: Out
8989
destination: parseEndpoint(destination),
9090
preset: presetFlag ? PresetNameSchema.parse(presetFlag) : 'fast-sync',
9191
options: optionsFromFlags(parsed),
92+
// A profile made here has no panes. 'left' is what the app will use when
93+
// it opens one: source on the left, the way you read it.
94+
sourcePane: 'left',
9295
// Never inherited from the command line: unattended mirroring has to be
9396
// turned on deliberately, in one place, after the fact.
9497
trustDeletes: false,

‎apps/desktop/electron/main/services/transfers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export async function saveProfile(input: {
238238
source: EndpointRef
239239
destination: EndpointRef
240240
options: TransferOptions
241+
sourcePane: 'left' | 'right'
241242
}) {
242243
const source = await resolveEndpoint(input.source)
243244
const destination = await resolveEndpoint(input.destination)
@@ -247,6 +248,7 @@ export async function saveProfile(input: {
247248
destination: destination.endpoint,
248249
preset: 'fast-sync',
249250
options: optionsFrom(input.options),
251+
sourcePane: input.sourcePane,
250252
// Never set from the app. Unattended mirroring is the one way a delete
251253
// list runs without a human looking at it, and it stays a deliberate,
252254
// out-of-band choice.

‎apps/desktop/electron/shared/contract.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ export const ProfileSaveSchema = z.object({
145145
source: EndpointRefSchema,
146146
destination: EndpointRefSchema,
147147
options: TransferOptionsSchema,
148+
/** Which pane the source was on, so loading puts both back where they were. */
149+
sourcePane: z.enum(['left', 'right']).default('left'),
148150
})
149151

150152
export const RemotePathRequestSchema = z.object({

‎apps/desktop/src/app/page.tsx‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,23 @@ export default function Workspace() {
252252
: { kind: 'ssh', connectionId: endpoint.connectionId }
253253

254254
setError(null)
255-
setLeft(blankPane(toPane(profile.source), profile.source.path))
256-
setRight(blankPane(toPane(profile.destination), profile.destination.path))
257-
setDirection('ltr')
255+
256+
/*
257+
* Put each pane back where it was, not where the transfer's direction
258+
* happens to imply.
259+
*
260+
* `source` and `destination` carry the transfer's meaning, so with the
261+
* arrow pointing right-to-left the source IS the right pane. Loading
262+
* source into the left pane unconditionally mirrored the whole window,
263+
* and forcing the arrow back to left-to-right lost the direction too.
264+
*/
265+
const sourceOnLeft = (profile.sourcePane ?? 'left') === 'left'
266+
const forLeft = sourceOnLeft ? profile.source : profile.destination
267+
const forRight = sourceOnLeft ? profile.destination : profile.source
268+
269+
setLeft(blankPane(toPane(forLeft), forLeft.path))
270+
setRight(blankPane(toPane(forRight), forRight.path))
271+
setDirection(sourceOnLeft ? 'ltr' : 'rtl')
258272
setMirror(profile.options?.deleteMode !== undefined && profile.options.deleteMode !== 'off')
259273
},
260274
[],
@@ -270,14 +284,17 @@ export default function Workspace() {
270284
source: request.source,
271285
destination: request.destination,
272286
options: request.options,
287+
// Which pane the source was on. Without it, loading cannot tell a
288+
// right-to-left arrangement from a mirrored left-to-right one.
289+
sourcePane: direction === 'ltr' ? 'left' : 'right',
273290
}),
274291
)
275292
setProfiles(await unwrap(api()?.profiles.list()))
276293
} catch (caught) {
277294
setError(caught instanceof Error ? caught.message : String(caught))
278295
}
279296
},
280-
[request],
297+
[request, direction],
281298
)
282299

283300
const removeProfile = useCallback(async (id: string) => {

‎apps/desktop/src/lib/api.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export type SyncProfile = {
7272
source: { type: 'local'; path: string } | { type: 'ssh'; connectionId?: string; host: string; path: string }
7373
destination: { type: 'local'; path: string } | { type: 'ssh'; connectionId?: string; host: string; path: string }
7474
options: { deleteMode: 'off' | 'delay' | 'during' | 'after' | 'before' }
75+
/** Which pane the source was on when saved. Older rows default to 'left'. */
76+
sourcePane?: 'left' | 'right'
7577
}
7678

7779
export type StartedJob = { jobId: string; command: string; control: string | null; warnings: string[] }
@@ -243,6 +245,7 @@ type Api = {
243245
source: unknown
244246
destination: unknown
245247
options: { deleteMode: 'off' | 'delay' }
248+
sourcePane: 'left' | 'right'
246249
}): Promise<IpcResult<SyncProfile>>
247250
remove(id: string): Promise<IpcResult<boolean>>
248251
}

‎docs/profiles.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ A profile whose delete mode is on carries a red **mirror** mark, because
3434
loading a profile that turns Mirror on is not something to discover from the
3535
footer afterwards.
3636

37+
A profile restores **both panes where you left them**, and the arrow with
38+
them. `source` and `destination` carry the transfer's meaning — that is what
39+
`diskpush profile run` acts on, and it must not depend on how a window was
40+
arranged — so the arrangement is recorded separately. Without that, a profile
41+
saved while the arrow pointed right-to-left came back mirrored, because the
42+
source was the *right* pane and loading put it on the left.
43+
3744
Loading a profile sets Mirror to whatever the profile stored, rather than
3845
leaving it as it found it — a profile that did something different depending
3946
on what you had toggled last would not be a profile.

‎packages/database/src/migrations.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,18 @@ export const MIGRATIONS: Migration[] = [
189189
`ALTER TABLE fleet_commands ADD COLUMN on_failure TEXT NOT NULL DEFAULT 'continue'`,
190190
],
191191
},
192+
{
193+
name: '005-profile-source-pane',
194+
statements: [
195+
/*
196+
* Which pane the source was on.
197+
*
198+
* A profile stored source and destination, which is the transfer's
199+
* meaning, and the app restored source into the LEFT pane always. Saved
200+
* with the arrow pointing right-to-left, the panes came back mirrored.
201+
* Defaults to 'left', which is what every existing row was assumed to be.
202+
*/
203+
`ALTER TABLE sync_profiles ADD COLUMN source_pane TEXT NOT NULL DEFAULT 'left'`,
204+
],
205+
},
192206
]

‎packages/database/src/store.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,13 @@ export class DiskPushStore {
165165
await this.client.execute({
166166
sql: `INSERT INTO sync_profiles (
167167
id, name, source_json, destination_json, preset, options_json, trust_deletes,
168-
schedule_json, watch_json, notify_on_success, notify_on_failure, created_at, updated_at
169-
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
168+
source_pane, schedule_json, watch_json, notify_on_success, notify_on_failure, created_at, updated_at
169+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
170170
ON CONFLICT(id) DO UPDATE SET
171171
name=excluded.name, source_json=excluded.source_json,
172172
destination_json=excluded.destination_json, preset=excluded.preset,
173173
options_json=excluded.options_json, trust_deletes=excluded.trust_deletes,
174+
source_pane=excluded.source_pane,
174175
schedule_json=excluded.schedule_json, watch_json=excluded.watch_json,
175176
notify_on_success=excluded.notify_on_success, notify_on_failure=excluded.notify_on_failure,
176177
updated_at=excluded.updated_at`,
@@ -182,6 +183,7 @@ export class DiskPushStore {
182183
profile.preset,
183184
JSON.stringify(profile.options),
184185
profile.trustDeletes ? 1 : 0,
186+
profile.sourcePane,
185187
JSON.stringify(profile.schedule),
186188
JSON.stringify(profile.watch),
187189
profile.notifyOnSuccess ? 1 : 0,
@@ -572,6 +574,7 @@ function rowToProfile(row: Row): SyncProfile {
572574
preset: String(row.preset),
573575
options: JSON.parse(String(row.options_json)),
574576
trustDeletes: Number(row.trust_deletes) === 1,
577+
sourcePane: String(row.source_pane),
575578
schedule: JSON.parse(String(row.schedule_json)),
576579
watch: JSON.parse(String(row.watch_json)),
577580
notifyOnSuccess: Number(row.notify_on_success) === 1,

‎packages/schemas/src/profile.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ export const SyncProfileSchema = z.object({
3434
* Off by default and deliberately awkward to turn on: it is the only way a
3535
* mirror runs without a human looking at the delete list first.
3636
*/
37+
/**
38+
* Which pane the source was on when this was saved.
39+
*
40+
* `source` and `destination` carry the transfer's meaning and always will —
41+
* that is what `diskpush profile run` acts on, and it must not depend on how
42+
* a window happened to be arranged. This records the arrangement separately,
43+
* so loading a profile in the app puts each pane back where you left it
44+
* instead of mirroring them whenever the arrow pointed right-to-left.
45+
*/
46+
sourcePane: z.enum(['left', 'right']).default('left'),
3747
trustDeletes: z.boolean().default(false),
3848
schedule: ScheduleSchema.default({}),
3949
watch: WatchSchema.default({}),

0 commit comments

Comments
 (0)