From ea4ee071e71882fdcb3967fecc3fd573968a86cc Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Fri, 8 May 2026 21:20:26 +0200 Subject: [PATCH] feat(snapshot): YES/NO modal on newer auto-save + 24h timestamps (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `_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) * 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 `.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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 47 +++++ koolook_routes.py | 31 +++- web/sidebar/format_time.js | 53 ++++++ web/sidebar/modals.js | 342 ++++++++++++++++++++++++------------- web/sidebar/snapshot.js | 26 +++ web/sidebar/tree.js | 16 +- 6 files changed, 373 insertions(+), 142 deletions(-) create mode 100644 web/sidebar/format_time.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2967fd8..59de18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `_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 `_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 `.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 `_autosave` group header now use the diff --git a/koolook_routes.py b/koolook_routes.py index d0a4a71..eaaeb1c 100644 --- a/koolook_routes.py +++ b/koolook_routes.py @@ -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 `_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}" diff --git a/web/sidebar/format_time.js b/web/sidebar/format_time.js new file mode 100644 index 0000000..f959681 --- /dev/null +++ b/web/sidebar/format_time.js @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2025-2026 Kforge Labs +// 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; +} diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index 58fecf4..e4d6ed4 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -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 " 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 ''?" 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 `_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 ''?" 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), }); } diff --git a/web/sidebar/snapshot.js b/web/sidebar/snapshot.js index 498338a..c007d05 100644 --- a/web/sidebar/snapshot.js +++ b/web/sidebar/snapshot.js @@ -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 `_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: , state: "saved" | "autosaved" | "unsaved" | "none", // lastNamedSaveAt: , lastAutosaveAt: } @@ -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 `_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, }; diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index 632684d..40e3c9d 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -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) {