feat(snapshot): YES/NO modal on newer auto-save + 24h timestamps (#111)

* feat(snapshot): autosave UX — YES/NO modal when newer + 24h local timestamps

Two snapshot-UX threads in one slice:

1. **24-hour timestamps with relative-recent tier** in the Load dialog
   and snapshot status tooltip. The previous `en-US` 12-hour format
   produced "12:56 AM" for timestamps just past midnight, which read
   visually like noon. Now: `just now` / `12 min ago` / `today 14:32` /
   `yesterday 23:58` / `May 6 14:32`. Centralized in a new
   `format_time.js` helper so future surfaces stay aligned.

2. **YES/NO choice when an auto-save is newer than the saved version.**
   Click any preset row in Snapshot → Load and, if the matching
   `<preset>_autosave/periodic.json` is more recent than the named
   save's mtime, the confirm dialog upgrades from "Load preset" to a
   YES/NO question with both timestamps shown side-by-side. YES loads
   the auto-save, NO loads the older deliberate save (the row clicked),
   Esc cancels. Standard pre-load auto-save protection still runs on
   either path. Server-side `/koolook/presets/list` augments root rows
   with `latestAutosaveMtime` via one extra `stat()` per row — no file
   read, cheap on large libraries. Aligns the system with the user's
   mental model: named saves stay deliberate (only updated on Save /
   Quick Save), and the auto-save is treated as a recovery copy the
   system surfaces at decision time rather than a parallel save the
   user has to remember to check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): restore-from-auto-save baselines as "auto-saved", not "saved"

`doAutosaveRestore` was calling `markStateSaved()` after a successful
restore, which baselined the *named-save* fingerprint to the auto-save's
content. The dot turned green even though `<preset>.json` on disk still
held the OLDER deliberate save — blurring the deliberate-save model the
Path C UX deliberately reinforces (named saves only change on Save /
Quick Save).

New `markStateAutosaved()` export in `snapshot.js` baselines the
*periodic* fingerprint instead, so the dot reads "auto-saved" (blue)
until the user explicitly commits the restored state via Quick Save or
Save. Threaded through `tree.js` → `showLoadSnapshotDialog` via the
existing dependency-injection pattern. Toast message also updated to
nudge: `Restored auto-save of "Foo" — Quick Save to commit to the
named file.`

Fixes the regression flagged on the PR #111 review. Applies to BOTH
restore call sites (the new YES path AND the long-existing Recovery
disclosure) since they share the helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-08 21:20:26 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 27705be037
commit ea4ee071e7
6 changed files with 373 additions and 142 deletions
+47
View File
@@ -7,6 +7,28 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **Load dialog asks YES/NO when an auto-save is newer than the saved
version.** Click any preset row in Snapshot → Load and, if the
matching `<preset>_autosave/periodic.json` is more recent than the
named save's on-disk mtime, the confirm dialog upgrades from a single
"Load preset" button to a YES/NO question: "Auto-save is NEWER than
the saved version. Do you want to load the auto-saved version?". YES
loads the auto-save, NO loads the older deliberate save (the row the
user originally clicked), Esc cancels. Both paths run the standard
pre-load auto-save protection so either choice is reversible.
Replaces the earlier inline "↻ Newer auto-save" subline on the row
itself — putting the question on the row asked the user to compare
two timestamps before they had committed to loading anything; moving
it to the post-click modal puts the choice exactly where the user has
already decided to load and just needs to pick which version. The
list rows render as before (one timestamp, one × button) so the
library list stays scannable. Server-side: `/koolook/presets/list`
augments root rows with `latestAutosaveMtime` via one extra `stat()`
per row — no file read, cheap on large libraries. Aligns the system
with the mental model: named saves stay deliberate (only updated on
Save / Quick Save), and the auto-save is treated as a recovery copy
the system surfaces at decision time rather than a parallel save
destination the user has to remember to check.
- **Bundled visual onboarding guide for the Kforge Labs sidebar.** The
existing Tools-row `H` help shortcut now opens `web/guide/index.html`, an
offline visual guide that frames Snapshots as a reusable project kit for
@@ -55,6 +77,16 @@ The format is inspired by Keep a Changelog and SemVer.
selected folder name visually prominent before the user chooses it.
### Changed
- **Snapshot timestamps now use 24-hour format with relative-recent tier.**
The Load dialog's "saved …" line and the snapshot status-dot tooltip
previously formatted local time as 12-hour `en-US` (`May 8, 12:56 AM`),
which read visually like noon for timestamps just past midnight — the
classic US "12 AM = midnight" trap. Times now render as: `just now`
(<1 min), `12 min ago` (<60 min), `today 14:32` / `yesterday 23:58`
(same / previous calendar day), or `May 6 14:32` (older; year appended
when not current). Centralized in a new `format_time.js` helper used
by both the Load dialog rows and the status tooltip so future surfaces
stay aligned.
- **"Create directory" moved off the Workflows toolbar onto right-click of
the section header.** The 📂 (`pi-folder-open`) button used to live in the
Workflows action bar between the section label and the Save buttons. Tree-
@@ -143,6 +175,21 @@ The format is inspired by Keep a Changelog and SemVer.
`packBadge` parameter; CSS rule `.koolook-pack-badge` removed from
`constants.js`.
### Fixed
- **Snapshot status reads "auto-saved" — not "saved" — after restoring
from an auto-save.** Previously, restoring `<preset>_autosave/
periodic.json` (via the new YES path on the Load dialog OR the
long-existing Recovery section) called `markStateSaved()`, which
baselined the *named-save* fingerprint to the auto-save's content.
The dot turned green even though `<preset>.json` on disk still held
the OLDER deliberate save — blurring the deliberate-save model the
Path C UX deliberately reinforces. New `markStateAutosaved()` export
in `snapshot.js` baselines the *periodic* fingerprint instead, so the
dot reads "auto-saved" (blue) until the user explicitly Quick Saves
or Saves the restored state into the named file. Toast message also
updated to nudge: `Restored auto-save of "Foo" — Quick Save to commit
to the named file.` Fixes the regression flagged on PR #111 review.
### Changed
- **Load dialog header + Recovery group headers now share a layout.** The
library section and each `<preset>_autosave` group header now use the
+24 -7
View File
@@ -519,13 +519,30 @@ def register_routes(routes) -> None:
stat = entry.stat()
except OSError:
continue
out.append(
{
"name": entry.name,
"mtime": stat.st_mtime,
"size": stat.st_size,
}
)
row = {
"name": entry.name,
"mtime": stat.st_mtime,
"size": stat.st_size,
}
# Library-root rows only: stat the matching `<base>_autosave/
# periodic.json` and surface its mtime when strictly newer
# than the named file. Powers the Load dialog's inline
# "Newer auto-save available — restore?" affordance — the
# autosave UX needs the system to proactively offer the
# recovery copy when the user's last manual save is stale,
# rather than burying it inside the Recovery disclosure.
# One extra stat per row, no read — cheap even on large
# libraries.
if not subdir_q:
base_name = entry.name[: -len(".json")]
auto_path = target_dir / f"{base_name}_autosave" / "periodic.json"
try:
a_stat = auto_path.stat()
if a_stat.st_mtime > stat.st_mtime:
row["latestAutosaveMtime"] = a_stat.st_mtime
except OSError:
pass
out.append(row)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not list preset library: {exc}"
+53
View File
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
// =============================================================================
// Local-timezone timestamp formatter — combined relative + 24-hour absolute.
//
// Output cases (in priority order):
// • <1 min ago → "just now"
// • <60 min ago → "12 min ago"
// • same calendar day (local) → "today 14:32"
// • previous day → "yesterday 23:58"
// • else, current year → "May 6 14:32"
// • else → "May 6 2025 14:32"
//
// Why 24-hour: the previous `en-US` 12-hour format produced "12:56 AM" for
// timestamps just past midnight, which reads visually like noon to most
// people (the US "12 AM = midnight" convention is a known UX trap).
// Manually building HH:MM from `getHours()` / `getMinutes()` sidesteps the
// `hour12: false` quirk in some locales (e.g. en-US emitting "24:00").
//
// Why "today" / "yesterday" tier: makes the most-relevant cases (recent
// snapshots, recent autosaves) read at a glance without making the eye
// parse a full date.
// =============================================================================
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function _hhmm(d) {
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
}
export function formatLocalStamp(d, now = new Date()) {
if (!(d instanceof Date) || isNaN(d.getTime())) return "";
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
// Future timestamps (clock skew, browser/server drift): fall through to
// absolute formatting rather than emitting "-3 min ago".
if (diffMin >= 0 && diffMin < 1) return "just now";
if (diffMin >= 1 && diffMin < 60) return diffMin + " min ago";
const time = _hhmm(d);
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfYesterday = new Date(startOfToday);
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
if (d >= startOfToday) return "today " + time;
if (d >= startOfYesterday) return "yesterday " + time;
const datePart = MONTHS[d.getMonth()] + " " + d.getDate();
const yearPart = d.getFullYear() !== now.getFullYear() ? " " + d.getFullYear() : "";
return datePart + yearPart + " " + time;
}
+219 -123
View File
@@ -7,6 +7,7 @@
// listener to the overlay's lifetime so opening many modals doesn't leak.
// =============================================================================
import { compareNames, criticalToast, toast } from "./constants.js";
import { formatLocalStamp } from "./format_time.js";
import { listDirectoryNames, dirOf } from "./workflows_store.js";
import {
detectManager,
@@ -955,28 +956,18 @@ function todayStamp() {
// snapshot's self-reported `exportedAt` because (1) mtime survives
// out-of-band file copies in Finder while exportedAt is frozen at
// gather-time, and (2) "saved <local time>" is what the user actually
// wants to see for "which row is newest". Falls back to `exportedAt` as
// date-only for legacy rows whose listing endpoint didn't carry mtime.
// wants to see for "which row is newest". Falls back to `exportedAt`
// for legacy rows whose listing endpoint didn't carry mtime.
function formatPreviewMeta(p) {
const parts = [];
parts.push(`${p.workflowCount} workflow${p.workflowCount === 1 ? "" : "s"}`);
parts.push(`${p.pickCount} pick${p.pickCount === 1 ? "" : "s"}`);
if (typeof p.mtime === "number" && isFinite(p.mtime)) {
const d = new Date(p.mtime * 1000);
if (!isNaN(d.getTime())) {
const stamp = d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
parts.push(`saved ${stamp}`);
}
const stamp = formatLocalStamp(new Date(p.mtime * 1000));
if (stamp) parts.push(`saved ${stamp}`);
} else if (p.exportedAt) {
try {
const d = new Date(p.exportedAt);
if (!isNaN(d.getTime())) parts.push(`exported ${d.toLocaleDateString()}`);
} catch (e) { /* noop */ }
const stamp = formatLocalStamp(new Date(p.exportedAt));
if (stamp) parts.push(`exported ${stamp}`);
}
return parts.join(" · ");
}
@@ -1212,6 +1203,7 @@ export function showLoadSnapshotDialog({
getLibraryInfo,
writePreLoadAutosave,
markStateSaved,
markStateAutosaved,
listAutosaves,
revealPresetFolder,
onToast,
@@ -1325,7 +1317,147 @@ export function showLoadSnapshotDialog({
listWrap.appendChild(list);
}
// Action helpers. Extracted from the in-line `onConfirm` bodies so
// both the standard load flow AND the autosave-newer YES/NO choice
// modal can dispatch the same pre-load-autosave + applySnapshot +
// tracker-rebase + partial-failure logic without duplicating ~30
// lines per call site. `close()` here refers to the OUTER Load
// Snapshot dialog's overlay — closures over `close` from the
// enclosing `showLoadSnapshotDialog` scope.
async function doNamedLoad(preview) {
try {
// Defensive auto-save BEFORE any destructive write — see
// `writePreLoadAutosave` rationale in snapshot.js. If the
// backup fails, abort the Load entirely; landing in a
// "Load destroyed my state with no backup" world is
// exactly what this whole code path exists to prevent.
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
try {
backupName = await writePreLoadAutosave(preview.displayName);
} catch (e) {
console.error("[Koolook] pre-load auto-save failed:", e);
close();
criticalToast(
`Could not write pre-load auto-save: ${e.message}. ` +
`Load aborted to protect your current state. Fix ` +
`the library access issue (Settings → Library path) ` +
`and retry.`
);
return;
}
}
const snap = await readPreset(preview.fileName);
const { picksOk, workflowsOk } = await applySnapshot(snap);
close();
// Gate the tracker on FULL success only. A partial-apply
// (one of picks / workflows persisted, the other didn't)
// leaves on-disk and in-memory state inconsistent — if
// we tracked the preset name anyway, the next Save would
// default to "Save over '<name>'?" and overwrite the
// saved preset with the corrupted half-state. Clear the
// tracker on partial failure so the next Save forces a
// fresh name prompt.
if (picksOk && workflowsOk) {
setCurrentPresetName(preview.fileName);
// Baseline the saved-state fingerprint — current
// state IS the loaded preset, so the indicator
// should read "saved" until the next mutation.
if (typeof markStateSaved === "function") markStateSaved();
const backupSuffix = backupName ? ` (backup: ${backupName})` : "";
toast(`Loaded preset "${preview.displayName}"${backupSuffix}.`);
} else if (picksOk || workflowsOk) {
setCurrentPresetName(null);
toast(
`Loaded "${preview.displayName}" — PARTIAL: ` +
`picks ${picksOk ? "OK" : "FAIL"}, ` +
`workflows ${workflowsOk ? "OK" : "FAIL"}. ` +
`Reload to recover prior state. Tracker cleared.`
);
} else {
setCurrentPresetName(null);
toast(`Loaded "${preview.displayName}" in memory but persist failed. Reload to recover.`);
}
} catch (e) {
console.error("[Koolook] preset load failed:", e);
toast(`Could not load "${preview.displayName}": ${e.message}`);
}
}
async function doAutosaveRestore(item) {
const tooltipName = `${item.dir}/${item.fileName}`;
try {
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
try {
backupName = await writePreLoadAutosave(`Pre-recovery (${tooltipName})`);
} catch (e) {
console.error("[Koolook] pre-recovery autosave failed:", e);
close();
criticalToast(
`Could not write pre-recovery auto-save: ${e.message}. ` +
`Restore aborted to protect your current state.`
);
return;
}
}
const snap = await readPreset(item.fileName, { dir: item.dir });
const { picksOk, workflowsOk } = await applySnapshot(snap);
close();
// Derive the original named preset from the subfolder so
// the post-restore tracker reads naturally — `Foo_autosave`
// → "Foo", `_unsaved_autosave` → null (no tracked preset).
let restoredName = null;
if (item.dir !== "_unsaved_autosave" && item.dir.endsWith("_autosave")) {
restoredName = item.dir.slice(0, -"_autosave".length);
}
if (picksOk && workflowsOk) {
setCurrentPresetName(restoredName);
// Baseline as "auto-saved", NOT "saved". The named file
// on disk is still the OLDER deliberate save — only
// periodic.json matches what's now in memory. Calling
// `markStateSaved()` here would mis-claim the named save
// is up to date and blur the deliberate-save model
// (named saves only change on Save / Quick Save). The
// dot turns blue, prompting Quick Save when the user
// wants to commit the restored state to the named file.
if (typeof markStateAutosaved === "function") markStateAutosaved();
const backupSuffix = backupName ? ` (backup: ${backupName})` : "";
toast(
`Restored auto-save${restoredName ? ` of "${restoredName}"` : ""}` +
` — Quick Save to commit to the named file${backupSuffix}.`
);
} else if (picksOk || workflowsOk) {
setCurrentPresetName(null);
toast(
`Restored "${tooltipName}" — PARTIAL: ` +
`picks ${picksOk ? "OK" : "FAIL"}, ` +
`workflows ${workflowsOk ? "OK" : "FAIL"}.`
);
} else {
setCurrentPresetName(null);
toast(`Restored "${tooltipName}" in memory but persist failed.`);
}
} catch (e) {
console.error("[Koolook] autosave restore failed:", e);
toast(`Could not restore "${tooltipName}": ${e.message}`);
}
}
function promptApply(preview) {
// When the corresponding `<base>_autosave/periodic.json` is
// strictly newer than the named save, route to the YES/NO
// choice modal so the user can pick which version to load
// without navigating the Recovery disclosure or having to
// mentally reason about which timestamp is newer. The standard
// single-button confirm is reserved for rows where there's no
// newer auto-save (the unambiguous case).
if (typeof preview.latestAutosaveMtime === "number" &&
typeof preview.mtime === "number" &&
preview.latestAutosaveMtime > preview.mtime) {
promptApplyChoice(preview);
return;
}
showConfirmModal({
title: "Replace current state?",
message:
@@ -1335,66 +1467,78 @@ export function showLoadSnapshotDialog({
`current state will be written first as a recovery point.`,
confirmLabel: "Load preset",
danger: true,
onConfirm: async () => {
try {
// Defensive auto-save BEFORE any destructive write — see
// `writePreLoadAutosave` rationale in snapshot.js. If the
// backup fails, abort the Load entirely; landing in a
// "Load destroyed my state with no backup" world is
// exactly what this whole code path exists to prevent.
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
try {
backupName = await writePreLoadAutosave(preview.displayName);
} catch (e) {
console.error("[Koolook] pre-load auto-save failed:", e);
close();
criticalToast(
`Could not write pre-load auto-save: ${e.message}. ` +
`Load aborted to protect your current state. Fix ` +
`the library access issue (Settings → Library path) ` +
`and retry.`
);
return;
}
}
const snap = await readPreset(preview.fileName);
const { picksOk, workflowsOk } = await applySnapshot(snap);
close();
// Gate the tracker on FULL success only. A partial-apply
// (one of picks / workflows persisted, the other didn't)
// leaves on-disk and in-memory state inconsistent — if
// we tracked the preset name anyway, the next Save would
// default to "Save over '<name>'?" and overwrite the
// saved preset with the corrupted half-state. Clear the
// tracker on partial failure so the next Save forces a
// fresh name prompt.
if (picksOk && workflowsOk) {
setCurrentPresetName(preview.fileName);
// Baseline the saved-state fingerprint — current
// state IS the loaded preset, so the indicator
// should read "saved" until the next mutation.
if (typeof markStateSaved === "function") markStateSaved();
const backupSuffix = backupName ? ` (backup: ${backupName})` : "";
toast(`Loaded preset "${preview.displayName}"${backupSuffix}.`);
} else if (picksOk || workflowsOk) {
setCurrentPresetName(null);
toast(
`Loaded "${preview.displayName}" — PARTIAL: ` +
`picks ${picksOk ? "OK" : "FAIL"}, ` +
`workflows ${workflowsOk ? "OK" : "FAIL"}. ` +
`Reload to recover prior state. Tracker cleared.`
);
} else {
setCurrentPresetName(null);
toast(`Loaded "${preview.displayName}" in memory but persist failed. Reload to recover.`);
}
} catch (e) {
console.error("[Koolook] preset load failed:", e);
toast(`Could not load "${preview.displayName}": ${e.message}`);
}
onConfirm: () => doNamedLoad(preview),
});
}
// YES/NO choice modal — appears when the row's auto-save is newer
// than its named save. Built directly via `makeModalShell` (not
// `showConfirmModal`) because we need ESC / overlay click to mean
// "cancel both" rather than "fire one of the actions": showConfirm
// would bind one of the two paths to its cancel handler, which
// also runs on Escape. Two affirmative buttons, one neutral
// dismissal — this layout requires the direct shell.
function promptApplyChoice(preview) {
const namedStamp = formatLocalStamp(new Date(preview.mtime * 1000));
const autoStamp = formatLocalStamp(new Date(preview.latestAutosaveMtime * 1000));
const body = document.createElement("div");
const intro = document.createElement("div");
intro.className = "koolook-modal-message";
intro.textContent =
`Loading "${preview.displayName}" will replace your current ` +
`picks and workflows. A pre-load auto-save of your current ` +
`state will be written first as a recovery point.`;
body.appendChild(intro);
const compareHead = document.createElement("div");
compareHead.className = "koolook-modal-message";
compareHead.style.marginTop = "10px";
compareHead.textContent = "Auto-save is NEWER than the saved version:";
body.appendChild(compareHead);
const compareList = document.createElement("ul");
compareList.style.margin = "4px 0 0 18px";
compareList.style.padding = "0";
compareList.style.fontSize = "12px";
const liNamed = document.createElement("li");
liNamed.textContent = `Saved version — ${namedStamp}`;
const liAuto = document.createElement("li");
liAuto.textContent = `Auto-save — ${autoStamp}`;
compareList.appendChild(liNamed);
compareList.appendChild(liAuto);
body.appendChild(compareList);
const question = document.createElement("div");
question.className = "koolook-modal-message";
question.style.marginTop = "10px";
question.textContent =
"Do you want to load the auto-saved version? " +
"(NO loads the saved version. Press Esc to cancel.)";
body.appendChild(question);
let shell;
const noBtn = makeModalButton({
label: "NO",
onClick: () => { shell.close(); doNamedLoad(preview); },
});
const yesBtn = makeModalButton({
label: "YES — load auto-save",
primary: true,
onClick: () => {
shell.close();
doAutosaveRestore({
dir: `${preview.fileName}_autosave`,
fileName: "periodic",
});
},
});
shell = makeModalShell({
title: "Replace current state?",
body,
actions: [noBtn, yesBtn],
});
}
function promptDelete(preview) {
@@ -1479,55 +1623,7 @@ export function showLoadSnapshotDialog({
`point — this restore is itself reversible.`,
confirmLabel: "Restore",
danger: true,
onConfirm: async () => {
try {
let backupName = null;
if (typeof writePreLoadAutosave === "function") {
try {
backupName = await writePreLoadAutosave(
`Pre-recovery (${tooltipName})`
);
} catch (e) {
console.error("[Koolook] pre-recovery autosave failed:", e);
close();
criticalToast(
`Could not write pre-recovery auto-save: ${e.message}. ` +
`Restore aborted to protect your current state.`
);
return;
}
}
const snap = await readPreset(item.fileName, { dir: item.dir });
const { picksOk, workflowsOk } = await applySnapshot(snap);
close();
// Derive the original named preset from the subfolder so
// the post-restore tracker reads naturally — `Foo_autosave`
// → "Foo", `_unsaved_autosave` → null (no tracked preset).
let restoredName = null;
if (item.dir !== "_unsaved_autosave" && item.dir.endsWith("_autosave")) {
restoredName = item.dir.slice(0, -"_autosave".length);
}
if (picksOk && workflowsOk) {
setCurrentPresetName(restoredName);
if (typeof markStateSaved === "function") markStateSaved();
const backupSuffix = backupName ? ` (backup: ${backupName})` : "";
toast(`Restored auto-save${restoredName ? ` "${restoredName}"` : ""}${backupSuffix}.`);
} else if (picksOk || workflowsOk) {
setCurrentPresetName(null);
toast(
`Restored "${tooltipName}" — PARTIAL: ` +
`picks ${picksOk ? "OK" : "FAIL"}, ` +
`workflows ${workflowsOk ? "OK" : "FAIL"}.`
);
} else {
setCurrentPresetName(null);
toast(`Restored "${tooltipName}" in memory but persist failed.`);
}
} catch (e) {
console.error("[Koolook] autosave restore failed:", e);
toast(`Could not restore "${tooltipName}": ${e.message}`);
}
},
onConfirm: () => doAutosaveRestore(item),
});
}
+26
View File
@@ -276,6 +276,22 @@ export function markStateSaved() {
_emitStatusChanged();
}
// Mark the current in-memory state as matching the latest periodic
// auto-save. Call after restoring from `<preset>_autosave/periodic.json`
// — the in-memory state IS that auto-save's content, but the named file
// on disk is still the OLDER deliberate save. Calling `markStateSaved()`
// here would mis-baseline the dirty indicator as "saved" against the
// named file, blurring the deliberate-save model the snapshot UX relies
// on. Setting `_lastPeriodicFingerprint` instead makes the indicator read
// "auto-saved" until the user explicitly Save / Quick Save's the
// restored state into the named file. Session-scoped (not persisted) —
// matches the rest of the periodic-fingerprint contract.
export function markStateAutosaved() {
_lastPeriodicFingerprint = _computeFingerprint();
_lastPeriodicAt = new Date().toISOString();
_emitStatusChanged();
}
// Returns one of:
// { name: <string|null>, state: "saved" | "autosaved" | "unsaved" | "none",
// lastNamedSaveAt: <ISO|null>, lastAutosaveAt: <ISO|null> }
@@ -668,6 +684,16 @@ async function loadPreview(fullName, dir, rowMeta) {
// across regular snapshot rows and recovery rows.
mtime: rowMeta && typeof rowMeta.mtime === "number" ? rowMeta.mtime : null,
size: rowMeta && typeof rowMeta.size === "number" ? rowMeta.size : null,
// Server augments root rows with the mtime of `<base>_autosave/
// periodic.json` when it exists AND is strictly newer than the
// named file. Powers the Load dialog's inline "Newer auto-save
// available — restore?" affordance. `null` for autosave subdir
// listings (the server only emits this at root) and for rows
// whose autosave is stale or absent.
latestAutosaveMtime:
rowMeta && typeof rowMeta.latestAutosaveMtime === "number"
? rowMeta.latestAutosaveMtime
: null,
workflowCount: countWorkflowsInStore(obj.workflows),
pickCount: Array.isArray(obj.picks) ? obj.picks.length : 0,
};
+4 -12
View File
@@ -28,6 +28,7 @@ import {
toast,
compareNames,
} from "./constants.js";
import { formatLocalStamp } from "./format_time.js";
import {
loadUserPicks,
removeFromMyPicks,
@@ -101,6 +102,7 @@ import {
exportStarterPreset,
writePreLoadAutosave,
markStateSaved,
markStateAutosaved,
getSnapshotStatus,
listAutosaves,
revealPresetFolder,
@@ -2204,6 +2206,7 @@ export function renderPanel(container) {
getLibraryInfo,
writePreLoadAutosave,
markStateSaved,
markStateAutosaved,
listAutosaves,
revealPresetFolder,
onToast: toast,
@@ -2259,18 +2262,7 @@ export function renderPanel(container) {
if (!iso) return "not saved yet";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
// Just the wall-clock time on the user's machine. No `timeZone`
// option (browser uses the system timezone) and no `timeZoneName`
// (the abbreviation read as noisy clutter — `GMT+2` / `CDT` etc.
// didn't help anyone identify "their own time"). If a future
// surface needs the abbreviation back, add it there.
return d.toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return formatLocalStamp(d);
}
function snapshotTooltip(status) {