Put the found backups under the field they fill in

The results were rendered above the whole form, which made them read as a
separate step rather than as an answer about the Folder input. They now sit
under that field, inside its box, alongside the verdict.

What is shown depends on how many were found, because those are different
situations:

  one       it is the answer, not a choice — filled in, with its verdict. No
            card, because the card and the verdict said the same thing twice
  several   listed as buttons, most snapshots first; the field stays empty,
            since this is genuinely ambiguous and guessing would be worse
  none      says so — "we looked and there is nothing here" is information

And a bug: the single result filled in only the PLACEHOLDER, so the field was
still empty and pressing Check replied "give a full path, starting with /"
about the very backup shown directly above it. It fills the value in now, and
shows the verdict straight from the scan rather than making the user press
Check to be told what is already on screen. Never overwrites something already
typed — the user's own answer outranks anything we guessed.

_adoptSingleResult moved into renderFoundBackups: "show what we found, and if
there is exactly one, take it" is one behaviour, and splitting it meant the
test could only reach half of it.

Not done, deliberately: listing individual snapshots before the password. The
count is a directory listing, but each snapshot's identity — host, tags,
contents, when they are from — is in the encrypted object. Unlocked, all that
could be shown is a column of hex IDs and file timestamps, which is not
something anyone can choose between. After unlocking, the Contents step already
lists the settings snapshot and one entry per app, which is how people think
about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-29 07:27:16 +01:00
parent cf8a4b2c69
commit 7fef102369
3 changed files with 143 additions and 32 deletions

View File

@ -448,9 +448,7 @@ class SetupWizard {
Where is it? Backups live in a repository \u2014 a folder on a
disk, or a remote server. Not a single file.
</p>
<div id="sw-rs-found"></div>
<div id="sw-rs-fields"></div>
<div id="sw-rs-verify-result"></div>
<div class="setup-field">
<label for="sw-rs-pass">
@ -1114,35 +1112,40 @@ class SetupWizard {
this.foundBackups = [];
}
this.renderFoundBackups();
// The fields are rendered before the scan (and before the storage feed)
// land, so the placeholder starts as the generic fallback. Now that a real
// path is known, use it — the placeholder's whole job is to show the SHAPE
// of the answer, and a shape from this machine is the only useful one.
const inp = this.container.querySelector('#sw-rs-path');
if (inp && !inp.value) inp.placeholder = this._backupPathPlaceholder();
}
renderFoundBackups() {
const box = this.container.querySelector('#sw-rs-found');
if (!box) return;
const found = this.foundBackups || [];
if (!found.length) {
// Said out loud rather than left blank: "we looked and there is nothing
// here" is information, and an empty area is not.
// here" is information, and empty space is not.
box.innerHTML = `<p class="setup-section-hint">
No backups found on this machine \u2014 point us at yours below.</p>`;
Nothing found on this machine \u2014 type the path to your backup above.</p>`;
this._adoptSingleResult();
return;
}
if (found.length === 1) {
// The card and the verdict would say the same thing twice. The verdict
// wins: it sits under the field it describes and names the next action.
box.innerHTML = '';
this._adoptSingleResult();
return;
}
box.innerHTML =
'<div class="setup-storage-divider"><span>Found on this machine</span></div>' +
`<p class="setup-section-hint">Backups found on this machine \u2014 pick one:</p>` +
found.map((f, i) => `
<button type="button" class="setup-app-card setup-found-backup" data-found="${i}">
<span class="setup-app-name">${this.escapeHtml(f.path)}</span>
<span class="setup-storage-badge setup-storage-badge-ok">${f.snapshots} snapshot${f.snapshots === 1 ? '' : 's'}</span>
<span class="setup-app-desc">${f.newest ? 'newest ' + this._restoreWhen(f.newest) : ''}</span>
</button>`).join('') +
`<p class="setup-section-hint">
Pick one to fill it in below. You will still need its password.</p>`;
</button>`).join('');
this._adoptSingleResult();
box.querySelectorAll('.setup-found-backup').forEach((b) => {
b.addEventListener('click', () => {
@ -1151,7 +1154,7 @@ class SetupWizard {
const sel = this.container.querySelector('#sw-rs-type');
if (sel) { sel.value = 'local'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
const path = this.container.querySelector('#sw-rs-path');
if (path) { path.value = f.path; path.dispatchEvent(new Event('input', { bubbles: true })); }
if (path) path.value = f.path;
this.renderVerifyResult({ repo: true, path: f.path, snapshots: f.snapshots, newest: f.newest });
const pass = this.container.querySelector('#sw-rs-pass');
if (pass) pass.focus();
@ -1159,6 +1162,38 @@ class SetupWizard {
});
}
// One repository found is not an ambiguous choice — it is the answer.
//
// Leaving it as a placeholder left the field empty, so pressing Check
// answered "give a full path, starting with /" about the very backup shown
// on screen directly above it.
//
// Never overwrites something already typed: the user's own answer outranks
// anything we guessed for them.
_adoptSingleResult() {
const inp = this.container.querySelector('#sw-rs-path');
if (!inp || inp.value) return;
const found = this.foundBackups || [];
if (found.length !== 1) {
// Several, or none: the placeholder's job is to show the SHAPE of the
// answer, and a shape from this machine is the only useful one.
inp.placeholder = this._backupPathPlaceholder();
return;
}
const sel = this.container.querySelector('#sw-rs-type');
if (sel && sel.value !== 'local') {
sel.value = 'local';
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
inp.value = found[0].path;
// The scan already counted the snapshots, so say so rather than making the
// user press Check to be told what is on screen above.
this.renderVerifyResult({ repo: true, path: found[0].path,
snapshots: found[0].snapshots, newest: found[0].newest });
}
// Check a typed path without unlocking anything.
async verifyBackupPath() {
const inp = this.container.querySelector('#sw-rs-path');
@ -1276,6 +1311,8 @@ class SetupWizard {
<input type="text" id="sw-rs-path" class="setup-input-with-icon" placeholder="${this.escapeHtml(this._backupPathPlaceholder())}" autocomplete="off">
<button type="button" class="setup-storage-details" id="sw-rs-verify">Check</button>
</div>
<div id="sw-rs-found"></div>
<div id="sw-rs-verify-result"></div>
</div>`) +
group('sftp', 'SFTP server',
'Reached over SSH, with the key or password this server already uses.',

View File

@ -574,6 +574,31 @@ The placeholder now comes from this machine — the first repository found, or
the install's own backups root — because a placeholder's whole job is to show
the shape of the answer, and only a real one does that.
Both the results and the verdict live **under the Folder field**, inside its
box: they are answers about that input, and floating them above the whole form
made them look like a separate step. What is shown depends on how many were
found, because those are genuinely different situations:
| Found | What happens |
|---|---|
| one | it is the answer, not a choice — filled in, with its verdict. No card: the card and the verdict said the same thing twice |
| several | listed as buttons, most snapshots first. The field stays empty — this is genuinely ambiguous and guessing would be worse |
| none | says so. "We looked and there is nothing here" is information; empty space is not |
The first version filled in only the *placeholder*, which left the field empty —
so pressing **Check** replied "give a full path, starting with /" about the very
backup displayed directly above it.
**Not done: listing individual snapshots before the password.** The count is a
directory listing, but the *identity* of each snapshot — its host, its tags,
what it holds, when its contents are from — lives in the encrypted object. All
that could be shown unlocked is a column of hex IDs and file timestamps, which
is not a thing anyone can choose between. Once the password is in, the Contents
step already lists what is there, grouped the way people think about it: the
settings snapshot, and one entry per app. Choosing a specific *older* snapshot
to restore from is a real feature, but it belongs there, after unlocking, per
app — not here.
### The index that moved
Inserting `Start` shifted every step index by one, and `validateStep` was a

View File

@ -129,16 +129,50 @@ read -r -d '' DRIVE <<'JS'
}
out.scanFoundSomething = (w.foundBackups || []).length > 0;
out.foundCarrySnapshotCounts = (w.foundBackups || []).every(f => typeof f.snapshots === 'number');
const card = $('.setup-found-backup');
out.foundRenderedAsButton = !!card && card.tagName === 'BUTTON';
if (card) {
card.click();
await new Promise(r => setTimeout(r, 200));
out.clickFillsThePath = ($('#sw-rs-path') || {}).value === (w.foundBackups[0] || {}).path;
out.clickReportsWithoutPassword = /snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
}
// The placeholder must be a path from THIS machine, never an invented
// example: /mnt/usb/... sends someone looking for a folder that is not there.
// Both the list and the verdict belong to the Folder field, under it, rather
// than floating above the whole form: they are answers about that input.
const localGroup = $('.setup-subgroup[data-rs-group="local"]');
out.foundInsideTheGroup = !!(localGroup && $('#sw-rs-found') && localGroup.contains($('#sw-rs-found')));
out.verdictInsideTheGroup = !!(localGroup && $('#sw-rs-verify-result')
&& localGroup.contains($('#sw-rs-verify-result')));
// --- exactly one found: it is the answer, not a choice ---
$('#sw-rs-path').value = '';
w.foundBackups = [{ path: '/only/one', snapshots: 4, newest: '2026-08-29T05:51:00+01:00' }];
w.renderFoundBackups();
out.singlePrefillsThePath = ($('#sw-rs-path') || {}).value === '/only/one';
out.singleShowsItsVerdict = /4 snapshots/.test(($('#sw-rs-verify-result') || {}).textContent || '');
// The card and the verdict would otherwise say the same thing twice.
out.singleDrawsNoDuplicateCard = $('#sw-rs-found .setup-found-backup') === null;
// Pressing Check must confirm, not reply "give a full path" about the backup
// shown directly above it — which is what an empty field did.
await w.verifyBackupPath();
out.singleCheckDoesNotScold = !/full path/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
// --- several found: a real choice, so ask ---
$('#sw-rs-path').value = '';
w.foundBackups = [
{ path: '/repo/a', snapshots: 3, newest: '2026-08-01T10:00:00+01:00' },
{ path: '/repo/b', snapshots: 9, newest: '2026-08-20T10:00:00+01:00' }
];
w.renderFoundBackups();
out.multipleAreListed = document.querySelectorAll('#sw-rs-found .setup-found-backup').length;
out.multipleRenderedAsButtons =
Array.from(document.querySelectorAll('#sw-rs-found .setup-found-backup')).every(b => b.tagName === 'BUTTON');
// Never guessed when it is genuinely ambiguous.
out.multipleLeaveThePathEmpty = ($('#sw-rs-path') || {}).value === '';
$('#sw-rs-found .setup-found-backup').click();
out.pickingOneFillsThePath = ($('#sw-rs-path') || {}).value === '/repo/a';
out.pickingOneReportsWithoutPassword =
/snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
// --- none found ---
$('#sw-rs-path').value = '';
w.foundBackups = [];
w.renderFoundBackups();
// "We looked and there is nothing here" is information; empty space is not.
out.noneSaysSo = /nothing found/i.test(($('#sw-rs-found') || {}).textContent || '');
out.placeholderIsReal = !/mnt\/usb/.test(($('#sw-rs-path') || {}).placeholder || '');
// Pointing at the folder that HOLDS the repositories is the common near-miss,
@ -264,14 +298,29 @@ chk "missing password refused" "$(g .missingPasswordRefused)" true
chk "a complete source accepted" "$(g .completeAccepted)" true
echo "finding backups without a password"
chk "the scan found one" "$(g .scanFoundSomething)" true
chk "with a snapshot count" "$(g .foundCarrySnapshotCounts)" true
chk "rendered as a button" "$(g .foundRenderedAsButton)" true
chk "clicking fills the path" "$(g .clickFillsThePath)" true
chk "and reports before any password" "$(g .clickReportsWithoutPassword)" true
chk "placeholder is a real path" "$(g .placeholderIsReal)" true
chk "the scan found one" "$(g .scanFoundSomething)" true
chk "with a snapshot count" "$(g .foundCarrySnapshotCounts)" true
chk "the list sits under the field" "$(g .foundInsideTheGroup)" true
chk "so does the verdict" "$(g .verdictInsideTheGroup)" true
echo " one result is the answer, not a choice"
chk "it fills the path in" "$(g .singlePrefillsThePath)" true
chk "and shows its verdict" "$(g .singleShowsItsVerdict)" true
chk "without a duplicate card" "$(g .singleDrawsNoDuplicateCard)" true
chk "Check confirms, not scolds" "$(g .singleCheckDoesNotScold)" true
echo " several is a real choice, so ask"
chk "all are listed" "$(g .multipleAreListed)" 2
chk "as buttons" "$(g .multipleRenderedAsButtons)" true
chk "and nothing is guessed" "$(g .multipleLeaveThePathEmpty)" true
chk "picking one fills the path" "$(g .pickingOneFillsThePath)" true
chk "and reports before any password" "$(g .pickingOneReportsWithoutPassword)" true
echo " none found"
chk "says so rather than nothing" "$(g .noneSaysSo)" true
chk "placeholder is a real path" "$(g .placeholderIsReal)" true
chk "the parent-folder mistake is explained" "$(g .parentFolderExplained)" true
chk "and the real path is offered" "$(g .offersTheRealPath)" true
chk "and the real path is offered" "$(g .offersTheRealPath)" true
echo "the password"
chk "goes through the secret channel" "$(g .passwordWasStashed)" true