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
3 changes: 3 additions & 0 deletions apps/cli/src/commands/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/main/services/transfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/shared/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
},
[],
Expand All @@ -268,14 +282,17 @@ 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()))
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
}
},
[request],
[request, direction],
)

const removeProfile = useCallback(async (id: string) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }
Expand Down Expand Up @@ -231,6 +233,7 @@ type Api = {
source: unknown
destination: unknown
options: { deleteMode: 'off' | 'delay' }
sourcePane: 'left' | 'right'
}): Promise<IpcResult<SyncProfile>>
remove(id: string): Promise<IpcResult<boolean>>
}
Expand Down
7 changes: 7 additions & 0 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/database/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'`,
],
},
]
7 changes: 5 additions & 2 deletions packages/database/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/schemas/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
Expand Down
Loading