Merge: recover a damaged catalog, and capture a crash locally

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:45:18 +02:00
co-authored by Claude Opus 5
15 changed files with 1954 additions and 14 deletions
+43
View File
@@ -11,6 +11,7 @@ import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { FocusMarks, FocusPanel } from "peaking.slint";
import { SettingsPage } from "settings.slint";
@@ -365,6 +366,21 @@ export component AppWindow inherits Window {
callback offline-prompt-release();
callback offline-prompt-dismiss();
// The question a damaged catalog asks. Same shape as the prompt above and
// for the same reason: an empty title is what closes it, and every word in
// it is composed in Rust, which is the only side that knows what SQLite
// said and which backups exist.
in property <string> recovery-title: "";
in property <string> recovery-detail: "";
in property <string> recovery-diagnosis: "";
in property <string> recovery-restore-label: "";
in property <bool> recovery-can-restore: false;
in property <string> recovery-rebuild-label: "";
in property <bool> recovery-busy: false;
callback recovery-restore();
callback recovery-rebuild();
callback recovery-dismiss();
in property <string> library-root-label: "";
in-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
@@ -1152,6 +1168,14 @@ in property <bool> panel-visible: true;
// would leave the library from behind an open question — the
// view changing underneath a modal, which reads as the app
// having lost its place.
//
// The recovery question is asked first because it is drawn
// over everything, the offline prompt included: Back must
// reach the thing the user can actually see.
if (root.recovery-title != "") {
root.recovery-dismiss();
return accept;
}
if (root.offline-prompt-title != "") {
root.offline-prompt-dismiss();
return accept;
@@ -2672,5 +2696,24 @@ in property <bool> panel-visible: true;
release() => { root.offline-prompt-release(); }
dismiss() => { root.offline-prompt-dismiss(); }
}
// Last, and therefore over everything including the settings page and
// the offline prompt. Not a preference about layering: this is asked
// before the grid exists, and nothing else in the window is about a
// library that can be read.
RecoveryPrompt {
width: 100%;
height: 100%;
title: root.recovery-title;
detail: root.recovery-detail;
diagnosis: root.recovery-diagnosis;
restore-label: root.recovery-restore-label;
can-restore: root.recovery-can-restore;
rebuild-label: root.recovery-rebuild-label;
busy: root.recovery-busy;
restore() => { root.recovery-restore(); }
rebuild() => { root.recovery-rebuild(); }
dismiss() => { root.recovery-dismiss(); }
}
}
}
+145
View File
@@ -0,0 +1,145 @@
// The question asked when the catalog turns out to be damaged.
//
// # Why this is a modal, when almost nothing else here is
//
// The house rule in this interface is to put the consequence in the button's
// label rather than to raise a dialogue — "Export 40", "Empty trash · 128" —
// and a genuine modal is kept for the two cases where the answer commits
// gigabytes. This is the third case, and it earns it for a different reason:
// there is nothing behind it to interact with. The grid cannot be drawn, the
// scan must not run (it would write into the damage), and every control in the
// window is about a library that cannot be read. A banner over an empty grid
// would be a question the user could scroll away from and then wonder why
// nothing worked.
//
// # Why the backdrop does not dismiss it
//
// Every other overlay here closes on a tap outside, and this one deliberately
// does not. A stray tap that loses the two offers leaves the application in a
// state with no way forward and no obvious way back to the question. There is
// a "Leave it for now" button instead, which says what it does.
//
// # Why the destructive answer is not the primary one
//
// A restore keeps the user's collections; a rebuild cannot, because a manual
// collection is a set of images the user assembled by hand and nothing in the
// filesystem records it (docs/catalog.md §8.1). So the two answers are not
// interchangeable, the difference is stated in the button rather than in a
// second dialogue after it, and the rebuild is the plain button even when it
// is the only one available.
import { Theme } from "theme.slint";
import { Button } from "widgets.slint";
export component RecoveryPrompt inherits Rectangle {
/// What went wrong, in the user's terms. Empty closes the prompt — one
/// source for "is this open", rather than a bool that can disagree with
/// the words beside it.
in property <string> title;
/// What is safe and what is not, which is the part that determines whether
/// the next minute is frightening.
in property <string> detail;
/// What SQLite actually said, kept because a bug report needs it and
/// because a diagnosis the user can read is worth more than a reassurance
/// they cannot check.
in property <string> diagnosis;
/// The restore offer, naming the backup's date. Empty when there is no
/// backup to restore from, which is the case a fresh install is in.
in property <string> restore-label;
in property <bool> can-restore: false;
/// The rebuild offer, naming what it costs — a full rescan, and the
/// collections it cannot bring back.
in property <string> rebuild-label;
/// Set while a restore or rebuild is running, so neither can be started
/// twice against the same file.
in property <bool> busy: false;
callback restore();
callback rebuild();
callback dismiss();
visible: root.title != "";
background: #000000E0;
// Swallows everything that misses the card, and answers nothing. See the
// header: losing this by a stray tap leaves nowhere to go.
TouchArea { }
Rectangle {
width: min(460px, parent.width - 2 * Theme.gap-lg);
height: min(card.preferred-height, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
TouchArea { }
card := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "Recover library";
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
}
Text {
text: root.title;
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
Text {
text: root.detail;
color: Theme.ink-dim;
font-size: Theme.text;
wrap: word-wrap;
}
// Wrapped rather than elided: this is the one line a bug report
// needs verbatim, and a truncated SQLite message is no message.
Text {
text: root.diagnosis;
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
Rectangle { height: 1px; background: Theme.rule; }
// Stacked, not a row: each label carries what its answer costs —
// a date, a count of photographs — and three of those side by side
// elide away exactly the part that lets the user choose.
if root.can-restore: Button {
text: root.busy ? "Working…" : root.restore-label;
primary: true;
enabled: !root.busy;
clicked => { root.restore(); }
}
Button {
text: root.busy ? "Working…" : root.rebuild-label;
// Primary only when it is the only answer there is. A rebuild
// discards collections, so it does not get the emphasis while
// a restore that keeps them is on the table.
primary: !root.can-restore;
enabled: !root.busy;
clicked => { root.rebuild(); }
}
Button {
text: "Leave it for now";
enabled: !root.busy;
clicked => { root.dismiss(); }
}
}
}
}