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:
co-authored by
Claude Opus 4.7
parent
27705be037
commit
ea4ee071e7
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user