Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -452,11 +452,16 @@ component HeaderActions inherits HorizontalLayout {
|
||||
in property <bool> scanning: false;
|
||||
in property <bool> syncing: false;
|
||||
in property <bool> scope-pinned: false;
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Whether settings have been copied, and what pasting them would apply.
|
||||
in property <bool> settings-armed: false;
|
||||
in property <string> settings-summary;
|
||||
/// Centres each button in a 44px header. Off in the disclosure row, which
|
||||
/// is sized to its content.
|
||||
in property <bool> centred: true;
|
||||
in property <length> row-height: 44px;
|
||||
|
||||
callback paste-settings-to-selection();
|
||||
callback remove-from-collection();
|
||||
callback change-library();
|
||||
callback toggle-pin-scope();
|
||||
@@ -466,6 +471,21 @@ component HeaderActions inherits HorizontalLayout {
|
||||
|
||||
spacing: Theme.gap;
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Batch-apply the copied settings. Shown only with both a selection and a
|
||||
// clipboard, because it is meaningless without either — and because a
|
||||
// permanently visible button that is usually disabled teaches the user to
|
||||
// stop reading this row.
|
||||
//
|
||||
// The count is in the label rather than in a confirmation: this writes to
|
||||
// every selected image, and "Paste to 40" said before the click is worth
|
||||
// more than a dialogue asking the same question after it.
|
||||
if root.selected-count > 0 && root.settings-armed: Button {
|
||||
text: "Paste to " + root.selected-count;
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => { root.paste-settings-to-selection(); }
|
||||
}
|
||||
|
||||
// Removing from a collection is only meaningful while the grid is scoped
|
||||
// to one. Offering it unscoped would invite the reading "remove from the
|
||||
// library", which nothing here does.
|
||||
@@ -725,6 +745,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// How many images are selected, for the header's count.
|
||||
in property <int> selected-count: 0;
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Batch-applying copied develop settings to the selection. The clipboard
|
||||
// itself belongs to the window — a copy is taken in the develop view and
|
||||
// pasted here — so the grid only reports what it has and asks.
|
||||
in property <bool> settings-armed: false;
|
||||
in property <string> settings-summary;
|
||||
callback paste-settings-to-selection();
|
||||
|
||||
// --- the keyboard cursor ------------------------------------------------
|
||||
//
|
||||
// Where the keyboard is in the library, as an **image ordinal** — not a
|
||||
@@ -861,6 +889,9 @@ export component LibraryGrid inherits Rectangle {
|
||||
scanning: root.scanning;
|
||||
syncing: root.syncing;
|
||||
scope-pinned: root.scope-pinned;
|
||||
settings-armed: root.settings-armed;
|
||||
settings-summary: root.settings-summary;
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
@@ -905,6 +936,9 @@ export component LibraryGrid inherits Rectangle {
|
||||
scanning: root.scanning;
|
||||
syncing: root.syncing;
|
||||
scope-pinned: root.scope-pinned;
|
||||
settings-armed: root.settings-armed;
|
||||
settings-summary: root.settings-summary;
|
||||
paste-settings-to-selection => { root.paste-settings-to-selection(); }
|
||||
remove-from-collection => { root.remove-from-collection(); }
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
|
||||
Reference in New Issue
Block a user