From c7961998aae75faada0c7091f5d8c7239f3cc7bc Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Fri, 15 May 2026 10:35:13 +0200 Subject: [PATCH] feat(snapshot): refine recovery choices and snapshot dialog styling (#146) * Refine snapshot load dialog recovery UI * Make snapshot load recovery choices explicit * Align snapshot picker styling * Respect Comfy theme variables in snapshot dialogs * Document superseded snapshot recovery design * Fix load recovery selection races * Prevent duplicate snapshot dialog actions * Harden snapshot recovery UI fallbacks --- CHANGELOG.md | 21 +- docs/designs/snapshot-dialogs-redesign.md | 60 ++-- docs/designs/snapshot-dialogs.html | 14 +- web/sidebar/constants.js | 100 +++--- web/sidebar/modals.js | 355 +++++++++++++++++----- 5 files changed, 388 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcf986..84c55fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,16 +23,17 @@ The format is inspired by Keep a Changelog and SemVer. a different library via `Save to…` relabels Save to *"Save to new folder"* before the click commits. - The Load dialog's auto-save discovery is now inline: when a preset - has a newer recovery file (server row-augment now considers - `max(periodic.json, pre_load_*.json)` instead of just - `periodic.json`), clicking the preset expands a single scoped - recovery row directly beneath it. The user picks by clicking the - named row (load named) or the scoped row (load the autosave). The - YES/NO modal that used to interrupt the flow is gone. Preset - deletion is now inline too — clicking × outlines the row red and - the bottom Close button transforms to *"Yes — delete ''"*; - Escape cancels. + The Load dialog's auto-save discovery is now handled in one window: + when a preset has a newer recovery file (server row-augment now + considers `max(periodic.json, pre_load_*.json)` instead of just + `periodic.json`), selecting that preset changes the title to + *"Auto-save is newer than the saved version"*, opens the scoped + Recovery auto-saves section, and offers explicit *"NO - load saved"* + / *"YES - load latest"* buttons. Presets without a newer recovery file + simply select the row and change the footer action to *"Load"*. Preset + deletion is now inline too — clicking × outlines the row red and the + bottom Close button transforms to *"Yes"*; Escape cancels. The delete + buttons stay visually neutral until hover. The Settings cog is removed from the Snapshot toolbar (row is now status indicator · Load · Quick Save · Save); library-path diff --git a/docs/designs/snapshot-dialogs-redesign.md b/docs/designs/snapshot-dialogs-redesign.md index fb8b43f..03a0961 100644 --- a/docs/designs/snapshot-dialogs-redesign.md +++ b/docs/designs/snapshot-dialogs-redesign.md @@ -21,38 +21,58 @@ Implementation tracked in [issue #137](https://github.com/malkuthro/ComfyUI-Kool folder ↗" top row, `Load from…` in the bottom command bar opening the same folder picker. 4. **Autosave-newer affordance:** the YES/NO choice modal is **dropped**. - Instead, when a preset has a newer autosave (server row-augment - reports `latestAutosaveMtime > mtime`), clicking that preset row - in the Load dialog expands a single scoped recovery row directly - beneath it. The row carries a kind badge (Pre-load / Periodic) and - the timestamp + meta of the newest entry across `periodic.json` - and every `pre_load_*.json` in `_autosave/`. The user - chooses by clicking: re-click the named row to load named, click - the recovery row to restore the autosave. + The earlier scoped-row click design is also superseded. Final + decision: when a preset has a newer autosave (server row-augment + reports `latestAutosaveMtime > mtime`), selecting that preset keeps + the user in the same Load dialog, changes the header to + *"Auto-save is newer than the saved version"*, opens the bottom + Recovery auto-saves section for that preset only, and shows explicit + footer choices: *"NO - load saved"* and *"YES - load latest"*. The + recovery row carries a kind badge (Pre-load / Periodic) and the + timestamp + meta of the newest entry across `periodic.json` and + every `pre_load_*.json` in `_autosave/`, but row clicks + select only; footer buttons commit. 5. **Inline delete confirm:** the per-preset `×` button no longer opens a confirm modal; instead it outlines the target row red and - transforms the Close button to *"Yes — delete '\'"* (danger - styling). A second click commits; Escape cancels and reverts. + transforms the Close button to *"Yes"* (danger styling). A second + click commits; Escape or clicking another row cancels and reverts. + Delete buttons stay visually neutral until hover. ## Load flow (post-redesign) ```mermaid flowchart TD A[Click preset row] --> B{latestAutosaveMtime > mtime
(server row-augment)?} - B -- No --> C[Direct load
(pre-load auto-save written first)] - B -- Yes --> D[Expand scoped recovery row
under the clicked preset] + B -- No --> C[Select row
footer action becomes Load] + B -- Yes --> D[Change title + open bottom Recovery section
for this preset only] D --> E{User clicks…} - E -- Named preset row again --> C - E -- Scoped recovery row --> F[Restore newest autosave
(periodic.json or pre_load_*.json)] - E -- Elsewhere / Esc --> G[Collapse scoped recovery] - C --> H[markStateSaved · green dot] - F --> I[markStateAutosaved · blue dot] + E -- NO - load saved --> F[Load named snapshot
(pre-load auto-save written first)] + E -- YES - load latest --> G[Restore newest autosave
(periodic.json or pre_load_*.json)] + E -- Another row / Esc --> H[Cancel scoped recovery selection] + C --> I[User clicks Load] + I --> F + F --> J[markStateSaved · green dot] + G --> K[markStateAutosaved · blue dot] ``` Compared to the pre-#137 design (a YES/NO modal that interrupted the -flow), the inline scoped row keeps both options visible alongside the -preset list. The user picks by clicking the row they want — no extra -modal layer, no Esc-to-cancel ambiguity, no double-confirm. +flow), the final one-window design keeps both options visible in the +Load dialog but makes the commit action explicit in the footer. Row +clicks select; footer buttons load. This supersedes the intermediate +"click the named row again / click the scoped row" design because that +proved too easy to miss during review. + +## Superseded notes from the mockup + +- The old section-4 YES/NO modal remains useful as visual history, but + it is no longer the target interaction. +- The intermediate scoped recovery row under the clicked preset is also + superseded. Recovery now appears in the bottom Recovery auto-saves + section. +- The section-5 four-state button cycle remains the pattern for true + committing primary actions. `Save to…` and `Load from…` are utility + navigation buttons that open the folder picker; they do not need the + Save button's write-state cycle. ## Out of scope (deliberately, for this PR) diff --git a/docs/designs/snapshot-dialogs.html b/docs/designs/snapshot-dialogs.html index 4aa4137..0318561 100644 --- a/docs/designs/snapshot-dialogs.html +++ b/docs/designs/snapshot-dialogs.html @@ -967,7 +967,7 @@ -
Recovery section is hidden by default. Clicking a preset row (e.g. Koolook_v03 or starter) opens the recovery dropdown and reveals only that preset's <preset>_autosave/ folder — other presets' autosaves are not shown here. Inside the folder, exactly one row is rendered: whichever of Pre-load or Periodic is the newest (the system picks). Row shows kind badge + timestamp + small picks/workflows meta; the on-disk filename is implementation detail and stays hidden. The group header keeps its Open folder ↗ link for manual inspection/backup.
+
Recovery section is hidden by default. Clicking a preset row (e.g. Koolook_v03 or starter) opens the recovery dropdown and reveals only that preset's <preset>_autosave/ folder — other presets' autosaves are not shown here. Inside the folder, exactly one row is rendered: whichever of Pre-load or Periodic is the newest (the system picks). Row shows kind badge + timestamp + small picks/workflows meta; the on-disk filename is implementation detail and stays hidden. The group header keeps its Open folder ↗ link for manual inspection/backup. Final decision: row clicks select only. When this section opens because the selected snapshot has newer recovery, the Load dialog header changes to Auto-save is newer than the saved version and the footer presents NO - load saved / YES - load latest.
@@ -1017,9 +1017,9 @@ -

4. Autosave-newer YES / NO modal — strip to essentials

+

4. Autosave-newer YES / NO modal — superseded reference

ProposedDefault view shows only what the user needs to decide; details hide behind ⓘ
@@ -1053,7 +1053,7 @@
-
Each option is rendered as a normal preset-row — same shape the user already reads in the Load dialog (bold name + picks · workflows · saved-date meta line), so there's nothing new to parse. NO loads Koolook_v03.json, YES loads the newest file inside Koolook_v03_autosave/. The two rows make the diff obvious without spelling it out: the autosave row carries a later timestamp and (typically) higher picks/workflow counts because it captured WIP added after the morning save. Buttons collapse to NO / YES — load latest so they sit on one row and read as a binary.
+
Superseded final decision: this separate modal is no longer the target UX. Keep the decision inside the Load dialog instead: selecting a snapshot with newer recovery opens the bottom Recovery auto-saves section, changes the header to Auto-save is newer than the saved version, and exposes footer buttons NO - load saved / YES - load latest. The row shapes above remain useful visual references for the named-vs-latest comparison, but the extra modal layer is intentionally dropped.
@@ -1087,7 +1087,7 @@
After a successful write, briefly. Same pattern as the Settings dialog's idle "Saved" state.
-
Same four-state cycle applies to "Load from…", "Save to…", and any future primary action. Doesn't require new infrastructure — just discipline: every place a button is wired, write the four label/state branches up front.
+
Final decision: the four-state cycle applies to true committing primary actions, such as the Save button that writes snapshot data. Save to… and Load from… are utility navigation buttons that open the folder picker; they stay stable rather than cycling through write-state labels.
@@ -1168,10 +1168,10 @@
  • Library row labels: "Saved to" (Save dialog) / "Loaded from" (Load dialog).
  • "Open folder ↗" text link sits in the top-right of the path box, above the path itself.
  • Generic "Open folder" wording (no platform-specific Finder/Explorer split).
  • -
  • Autosave-newer YES/NO modal — dropped. When a preset has a newer autosave (server row-augment reports latestAutosaveMtime > mtime), clicking the preset in the Load dialog expands a single scoped recovery row directly beneath the clicked preset, showing the freshest entry (newest of periodic.json or any pre_load_*.json in the corresponding <preset>_autosave/ subdir). The user chooses by clicking: re-click the named row to load named, click the scoped recovery row to restore the autosave. No extra modal layer; both options are visible alongside the preset list.
  • +
  • Autosave-newer YES/NO modal — dropped. The intermediate scoped-row click design is also superseded. When a preset has newer recovery (server row-augment reports latestAutosaveMtime > mtime), selecting the preset keeps the user in the Load dialog, changes the header to "Auto-save is newer than the saved version", opens the bottom Recovery auto-saves section for that preset only, and shows explicit footer buttons: NO - load saved and YES - load latest. Row clicks select; footer buttons commit.
  • Reset-to-default — dropped. Empty path inside the Save to… / Load from… picker clears the override if needed.
  • Recovery row chrome: kind badge (Pre-load / Periodic) + timestamp + meta. No filenames exposed in rows.
  • -
  • Delete confirmation is inline in the command bar, not a separate modal. Target row gets a red outline; Close transforms to "Yes — delete '\'". Escape cancels.
  • +
  • Delete confirmation is inline in the command bar, not a separate modal. Target row gets a red outline; Close transforms to "Yes". Escape or clicking another row cancels. Delete buttons stay visually neutral until hover.
  • Folder picker: title only, no descriptive subtitle. Any explanatory gloss goes on the title's hover tooltip — same convention as every other dialog (conventions.md § Modal headers).
  • Visual harness in docs/designs/_harness/ mounts each redesigned dialog against the live web/sidebar/ modules so agents and maintainers can diff implementations against this mockup without dev-syncing. Workflow: visual-harness.md.
  • diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 84df260..88864b3 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -175,24 +175,24 @@ export function ensureStyle() { .koolook-toast-btn { padding: 4px 10px; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.25); border-radius: 3px; color: #fff; cursor: pointer; font-size: 11px; font-family: inherit; } .koolook-toast-btn:hover { background: rgba(0,0,0,0.4); } .koolook-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 9998; display: flex; align-items: center; justify-content: center; } -.koolook-modal { background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid var(--border-color, rgba(255,255,255,0.15)); border-radius: 6px; padding: 16px 18px; min-width: 320px; max-width: 520px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, inherit); } -.koolook-modal-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; } +.koolook-modal { background: var(--comfy-menu-bg, #151515); border: 1px solid var(--border-color, #4f4f54); border-radius: 8px; padding: 16px 18px; min-width: 320px; max-width: 520px; box-shadow: 0 10px 40px rgba(0,0,0,0.6); color: var(--input-text, #f9fafb); overflow: hidden; } +.koolook-modal-title { font-size: 15px; font-weight: 700; margin: -16px -18px 14px; padding: 14px 18px; border-bottom: 1px solid var(--border-color, #302f2f); background: var(--comfy-menu-bg, #181818); } .koolook-modal-pathline { font-size: 11px; opacity: 0.55; margin: -6px 0 12px; white-space: normal; overflow-wrap: anywhere; cursor: default; } .koolook-modal-message { font-size: 12px; opacity: 0.85; margin-bottom: 14px; line-height: 1.45; } .koolook-modal-label { font-size: 11px; opacity: 0.7; margin: 6px 0 4px; display: block; text-transform: uppercase; letter-spacing: 0.04em; } -.koolook-modal-input, .koolook-modal-select { width: 100%; padding: 6px 8px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: inherit; font-size: 13px; box-sizing: border-box; outline: none; } +.koolook-modal-input, .koolook-modal-select { width: 100%; padding: 6px 8px; background: var(--comfy-input-bg, #111111); border: 1px solid var(--border-color, #302f2f); border-radius: 4px; color: var(--input-text, #e6e8ec); font-size: 13px; box-sizing: border-box; outline: none; } .koolook-modal-input:focus, .koolook-modal-select:focus { border-color: var(--p-primary-color, rgba(100,150,255,0.5)); } /* Library-folder name + path pair used by the redesigned Save and Load dialogs to render the inline 'Saved to' / 'Loaded from' info row. Naming kept from the (deleted) Settings dialog for git-blame continuity; the two classes have always rendered the same shape (leaf folder name on the first line, full absolute path on the second). */ -.koolook-settings-folder-name { opacity: 0.95; font-size: 12px; font-weight: 600; color: var(--input-text, inherit); } -.koolook-settings-folder-path { opacity: 0.68; font-size: 11px; margin-top: 2px; overflow-wrap: anywhere; line-height: 1.35; } -.koolook-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } +.koolook-settings-folder-name { opacity: 1; font-size: 13px; font-weight: 700; color: var(--input-text, #e6e8ec); margin-top: 2px; } +.koolook-settings-folder-path { color: var(--input-text, #8f959c); opacity: 0.68; font: 11px/1.35 ui-monospace, "Cascadia Mono", Menlo, monospace; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.koolook-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin: 16px -18px -16px; padding: 12px 18px; border-top: 1px solid var(--border-color, #302f2f); background: var(--comfy-menu-bg, #131313); } .koolook-modal-checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 12px; opacity: 0.85; cursor: pointer; user-select: none; } .koolook-modal-checkbox-row input[type="checkbox"] { cursor: pointer; } -.koolook-modal-btn { padding: 6px 14px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 4px; color: inherit; cursor: pointer; font-size: 12px; } +.koolook-modal-btn { padding: 6px 14px; background: var(--comfy-input-bg, #1a1a1f); border: 1px solid var(--border-color, #4f4f54); border-radius: 4px; color: var(--input-text, #e6e8ec); cursor: pointer; font: inherit; font-size: 12px; font-weight: 600; } .koolook-modal-btn:hover { background: rgba(255,255,255,0.1); } .koolook-modal-btn-primary { background: rgba(80,140,235,0.35); border-color: rgba(80,140,235,0.55); } .koolook-modal-btn-primary:hover { background: rgba(80,140,235,0.5); } @@ -222,13 +222,13 @@ export function ensureStyle() { .koolook-install-unresolved-summary { cursor: pointer; font-size: 11px; opacity: 0.75; margin-top: 4px; } .koolook-install-progress { width: 100%; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; margin: 10px 0 4px; overflow: hidden; } .koolook-install-progress-bar { height: 100%; background: rgba(80,140,235,0.6); width: 0%; transition: width 200ms ease; } -.koolook-snapshot-list { max-height: 280px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; } -/* Recovery auto-saves section in the Load dialog. Collapsible details - element; lazy-loads the list on first expand. Each subdir gets its own - group header so users can scan visually for the preset they want to - recover from. */ -.koolook-recovery-section { margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.06); padding-top: 8px; } -.koolook-recovery-summary { cursor: pointer; font-size: 12px; opacity: 0.78; padding: 4px 2px; user-select: none; outline: none; } +.koolook-snapshot-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border-color, #302f2f); border-radius: 4px; background: var(--comfy-input-bg, #111111); overflow: hidden auto; } +/* Recovery auto-saves section in the Load dialog. The collapsed summary is + always visible at the bottom of the body; clicking a preset with a newer + autosave opens one scoped group inside this container. */ +.koolook-recovery-section { margin-top: 14px; border: 1px solid var(--border-color, #302f2f); border-radius: 4px; background: rgba(255,255,255,0.02); background: color-mix(in srgb, var(--comfy-menu-bg, #151515) 92%, var(--comfy-input-bg, #111111)); overflow: hidden; } +.koolook-recovery-summary { cursor: pointer; font-size: 12px; color: var(--input-text, #d8dadd); padding: 10px 12px; user-select: none; outline: none; } +.koolook-recovery-summary-passive { cursor: default; } .koolook-recovery-summary:hover { opacity: 1; } /* No outer border on the recovery list — each group carries its own bordered rows-list, mirroring the library section's structure. We @@ -239,27 +239,31 @@ export function ensureStyle() { here — they would prematurely close the template literal in ES module mode and break the entire constants.js module load (and with it the whole sidebar). Use plain quotes or no quotes at all. */ -.koolook-recovery-list { margin-top: 6px; max-height: 240px; overflow-y: auto; } -.koolook-recovery-group { border-bottom: 1px solid rgba(255,255,255,0.04); } -.koolook-recovery-group:last-child { border-bottom: none; } -/* Group header is a flex wrapper hosting the same folder-name plus - folder-path pair the library section uses, so it carries no - typography of its own — the inner CSS drives appearance. */ -.koolook-recovery-group-header { padding: 4px 0 6px; } +.koolook-recovery-list { margin-top: 0; max-height: 240px; overflow-y: auto; border-top: 1px solid rgba(255,255,255,0.04); } +.koolook-recovery-group { padding: 10px 12px; } +.koolook-recovery-group-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 6px; } +.koolook-recovery-group-title { color: var(--input-text, #e6e8ec); font-size: 12px; font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.koolook-recovery-group-path { color: var(--input-text, #8f959c); opacity: 0.68; font: 11px/1.35 ui-monospace, "Cascadia Mono", Menlo, monospace; margin-bottom: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .koolook-recovery-group + .koolook-recovery-group { margin-top: 10px; } -.koolook-recovery-kind { font-size: 10px; opacity: 0.65; margin-right: 5px; padding: 1px 6px; border-radius: 8px; background: rgba(255,255,255,0.06); text-transform: uppercase; letter-spacing: 0.04em; } -.koolook-recovery-kind-pre_load { color: #ffb74d; } -.koolook-recovery-kind-periodic { color: #6db4ff; } -.koolook-snapshot-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); } +.koolook-recovery-kind { display: inline-flex; align-items: center; width: fit-content; font-size: 10px; line-height: 1.2; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 6px; border-radius: 3px; font-weight: 700; margin-bottom: 6px; } +.koolook-recovery-kind-pre_load { background: rgba(255,184,77,0.16); color: #f5d3a0; border: 1px solid rgba(255,184,77,0.35); } +.koolook-recovery-kind-periodic { background: rgba(109,180,255,0.16); color: #b3d4f5; border: 1px solid rgba(109,180,255,0.35); } +.koolook-recovery-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid var(--border-color, #302f2f); border-radius: 3px; background: var(--comfy-input-bg, #111111); } +.koolook-recovery-row-selected { background: rgba(80,140,235,0.16); background: color-mix(in srgb, var(--comfy-input-bg, #111111) 88%, var(--p-primary-color, #6db4ff)); } +.koolook-recovery-row-info { flex: 1; min-width: 0; cursor: pointer; font-size: 12px; } +.koolook-recovery-row-info:hover .koolook-recovery-row-meta { color: var(--p-primary-color, rgba(120,170,255,1)); } +.koolook-recovery-row-meta { color: var(--input-text, #8f959c); opacity: 0.68; font-size: 11px; line-height: 1.35; } +.koolook-snapshot-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border-color, #302f2f); background: var(--comfy-input-bg, #111111); } +.koolook-snapshot-row-selected { background: rgba(80,140,235,0.16); background: color-mix(in srgb, var(--comfy-input-bg, #111111) 88%, var(--p-primary-color, #6db4ff)); } .koolook-snapshot-row:last-child { border-bottom: none; } .koolook-snapshot-row-info { flex: 1; min-width: 0; cursor: pointer; } .koolook-snapshot-row-info:hover .koolook-snapshot-row-name { color: var(--p-primary-color, rgba(120,170,255,1)); } -.koolook-snapshot-row-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.koolook-snapshot-row-meta { font-size: 11px; opacity: 0.55; margin-top: 2px; } +.koolook-snapshot-row-name { color: var(--input-text, #e6e8ec); font-size: 13px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.koolook-snapshot-row-meta { color: var(--input-text, #8f959c); opacity: 0.68; font-size: 11px; margin-top: 2px; } .koolook-snapshot-row-actions { display: flex; gap: 4px; flex-shrink: 0; } -.koolook-snapshot-row-btn { padding: 3px 8px; background: var(--comfy-input-bg, rgba(0,0,0,0.3)); border: 1px solid rgba(255,255,255,0.08); border-radius: 3px; cursor: pointer; color: inherit; font-size: 11px; } +.koolook-snapshot-row-btn { min-width: 26px; height: 24px; padding: 0 8px; background: var(--comfy-input-bg, #1a1a1f); border: 1px solid var(--border-color, #4f4f54); border-radius: 4px; cursor: pointer; color: var(--input-text, #e6e8ec); font-size: 16px; font-weight: 800; line-height: 1; } .koolook-snapshot-row-btn:hover { background: rgba(255,255,255,0.08); } -.koolook-snapshot-row-btn-danger:hover { background: rgba(220,80,80,0.25); border-color: rgba(220,80,80,0.4); } +.koolook-snapshot-row-btn-danger:hover { background: rgba(74,5,5,0.45); border-color: rgba(255,138,138,0.45); color: #ffd9d9; } .koolook-snapshot-empty { padding: 20px; opacity: 0.5; font-size: 12px; text-align: center; } .koolook-mode-toggle { display: inline-flex; gap: 0; margin-right: 3px; border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 3px; overflow: hidden; } .koolook-mode-toggle-btn { padding: 0; width: 28px; height: 17px; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; background: transparent; border: none; cursor: pointer; color: rgba(218,221,226,0.78); font-size: 11px; opacity: 1; line-height: 1; } @@ -303,37 +307,38 @@ export function ensureStyle() { backticks in comments here — they prematurely close the template literal and break the entire sidebar at load. */ .koolook-folder-picker { min-width: 480px; max-width: 600px; } -.koolook-folder-picker-toolbar { display: flex; gap: 8px; align-items: center; margin: 4px 0 8px; } +.koolook-folder-picker-toolbar { display: flex; gap: 8px; align-items: center; margin: 0 0 10px; } .koolook-folder-picker-up { flex-shrink: 0; } /* End-visible path overflow: 'direction: rtl' makes the input's overflow-axis right-to-left, 'unicode-bidi: plaintext' keeps the Latin path characters in natural left-to-right order. Net effect: long paths clip the LEFT (leading /Users/...) and always show the trailing folder name on the right. Per mockup section 6. */ -.koolook-folder-picker-path { flex: 1; min-width: 0; direction: rtl; text-align: left; unicode-bidi: plaintext; font-family: var(--font-family-mono, ui-monospace, monospace); font-size: 12px; } -.koolook-folder-picker-list { max-height: 320px; min-height: 120px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; background: rgba(0,0,0,0.18); } -.koolook-folder-picker-row { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 7px 12px; border: 0; border-bottom: 1px solid rgba(255,255,255,0.05); background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 12px; } +.koolook-folder-picker-path { flex: 1; min-width: 0; direction: rtl; text-align: left; unicode-bidi: plaintext; font-family: ui-monospace, "Cascadia Mono", Menlo, monospace; font-size: 12px; } +.koolook-folder-picker-list { max-height: 320px; min-height: 120px; overflow-y: auto; border: 1px solid var(--border-color, #302f2f); border-radius: 4px; background: var(--comfy-input-bg, #111111); overflow: hidden auto; } +.koolook-folder-picker-row { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 10px 12px; border: 0; border-bottom: 1px solid var(--border-color, #302f2f); background: var(--comfy-input-bg, #111111); color: var(--input-text, #e6e8ec); cursor: pointer; font: inherit; font-size: 12px; } .koolook-folder-picker-row:last-child { border-bottom: none; } -.koolook-folder-picker-row:hover { background: rgba(255,255,255,0.08); } -.koolook-folder-picker-row-file { cursor: default; opacity: 0.45; } -.koolook-folder-picker-row-file:hover { background: transparent; } +.koolook-folder-picker-row:hover { background: rgba(80,140,235,0.16); background: color-mix(in srgb, var(--comfy-input-bg, #111111) 88%, var(--p-primary-color, #6db4ff)); } +.koolook-folder-picker-row-file { cursor: default; color: var(--input-text, #8f959c); opacity: 0.68; } +.koolook-folder-picker-row-file:hover { background: var(--comfy-input-bg, #111111); } .koolook-folder-picker-icon { font-size: 13px; line-height: 1; flex-shrink: 0; } .koolook-folder-picker-row:not(.koolook-folder-picker-row-file) .koolook-folder-picker-icon { filter: hue-rotate(-30deg) saturate(1.4); } .koolook-folder-picker-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.koolook-folder-picker-empty { padding: 18px 14px; opacity: 0.6; font-size: 12px; font-style: italic; text-align: center; } +.koolook-folder-picker-empty { padding: 20px; color: var(--input-text, #8f959c); opacity: 0.68; font-size: 12px; font-style: italic; text-align: center; } .koolook-folder-picker-error { color: #ffae9a; font-style: normal; opacity: 0.9; } .koolook-folder-picker-spacer { flex: 1; } -.koolook-folder-picker-newfolder-label { font-size: 11px; opacity: 0.6; flex-shrink: 0; } +.koolook-folder-picker-newfolder-label { color: var(--input-text, #8f959c); opacity: 0.68; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; flex-shrink: 0; } .koolook-folder-picker-newfolder-input { flex: 1; min-width: 0; } /* Save dialog redesign (issue #137, mockup section 2). The library row at the top is purely informational — label + Open folder link sit on the top edge so a long absolute path can't overlap the link. Every action lives in the bottom command bar (Save to... | Cancel | Save as new... | Save). */ -.koolook-snap-lib-row { padding: 8px 10px; border: 1px solid rgba(255,255,255,0.06); border-radius: 4px; background: rgba(0,0,0,0.18); margin-bottom: 12px; } -.koolook-snap-lib-row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 4px; } -.koolook-snap-lib-label { font-size: 10px; opacity: 0.55; text-transform: uppercase; letter-spacing: 0.05em; } -.koolook-snap-open-folder-link { font-size: 11px; opacity: 0.7; color: var(--p-primary-color, rgba(120,170,255,1)); text-decoration: none; } +.koolook-snap-lib-row { padding: 10px 12px; border: 1px solid var(--border-color, #302f2f); border-radius: 4px; background: var(--comfy-input-bg, #181818); margin-bottom: 14px; } +.koolook-snap-lib-row-info { min-width: 0; } +.koolook-snap-lib-row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 2px; } +.koolook-snap-lib-label { color: var(--input-text, #8f959c); opacity: 0.68; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; } +.koolook-snap-open-folder-link { font-size: 11px; opacity: 1; color: #6db4ff; text-decoration: none; white-space: nowrap; } .koolook-snap-open-folder-link:hover { opacity: 1; text-decoration: underline; } .koolook-snap-save-message { margin: 6px 0 0; } /* Four-state primary button (mockup section 5). The in-progress and @@ -345,14 +350,9 @@ export function ensureStyle() { short-circuits naturally. */ .koolook-snap-save-in-progress { opacity: 0.7; cursor: progress; } .koolook-snap-save-done { background: rgba(120, 200, 120, 0.18); border-color: rgba(120, 200, 120, 0.5); color: #b3e3b3; opacity: 0.95; cursor: default; } -/* Load dialog redesign (issue #137, mockup section 3). The scoped - recovery row sits as a sibling immediately after the preset row that - triggered it, so the kind badge plus timestamp meta line up below - the named row with no nested chrome. The inline delete state outlines - the row in red and the bottom Close button transforms to Yes (delete). - Reminder: no backticks in comments inside this CSS template literal. */ -.koolook-snapshot-scoped-recovery { margin: -1px 0 4px; padding-left: 14px; border-left: 2px solid rgba(120, 170, 255, 0.35); } -.koolook-snapshot-scoped-recovery-row { background: rgba(80, 140, 235, 0.06); } +.koolook-delete-confirm-text { align-self: center; color: #ffb1b1; font-size: 12px; font-weight: 600; } +/* Inline delete state outlines the target row in red. Reminder: no + backticks in comments inside this CSS template literal. */ .koolook-snapshot-row-pending-delete { outline: 1.5px solid rgba(220, 80, 80, 0.75); outline-offset: -1px; background: rgba(220, 80, 80, 0.06); } `; document.head.appendChild(s); diff --git a/web/sidebar/modals.js b/web/sidebar/modals.js index e73486b..4af970f 100644 --- a/web/sidebar/modals.js +++ b/web/sidebar/modals.js @@ -6,7 +6,7 @@ // click / Cancel / OK dismissal paths and ties the document-level keydown // listener to the overlay's lifetime so opening many modals doesn't leak. // ============================================================================= -import { compareNames, criticalToast, toast } from "./constants.js"; +import { compareNames, criticalToast, formatLibraryPathBreadcrumb, toast } from "./constants.js"; import { formatLocalStamp } from "./format_time.js"; import { listDirectoryNames, dirOf } from "./workflows_store.js"; import { @@ -86,7 +86,7 @@ function makeModalShell({ title, titleTooltip, body, actions }) { escHandler = (e) => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", escHandler); document.body.appendChild(overlay); - return { overlay, modal, close }; + return { overlay, modal, titleEl, close }; } function makeModalButton({ label, primary, danger, onClick }) { @@ -990,14 +990,14 @@ function todayStamp() { // 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"}`); + parts.push(`${p.workflowCount} workflow${p.workflowCount === 1 ? "" : "s"}`); if (typeof p.mtime === "number" && isFinite(p.mtime)) { const stamp = formatLocalStamp(new Date(p.mtime * 1000)); - if (stamp) parts.push(`saved ${stamp}`); + if (stamp) parts.push(p.kind ? stamp : `saved ${stamp}`); } else if (p.exportedAt) { const stamp = formatLocalStamp(new Date(p.exportedAt)); - if (stamp) parts.push(`exported ${stamp}`); + if (stamp) parts.push(p.kind ? stamp : `exported ${stamp}`); } return parts.join(" · "); } @@ -1055,6 +1055,7 @@ export function showSaveSnapshotDialog({ // → Dirty state → primary label becomes "Save to new folder". let originalLibraryPath = ""; let currentLibraryPath = ""; + let libraryRevealInFlight = false; const body = document.createElement("div"); @@ -1067,6 +1068,9 @@ export function showSaveSnapshotDialog({ const libRow = document.createElement("div"); libRow.className = "koolook-snap-lib-row"; + const libRowInfo = document.createElement("div"); + libRowInfo.className = "koolook-snap-lib-row-info"; + const libRowTop = document.createElement("div"); libRowTop.className = "koolook-snap-lib-row-top"; const libLabel = document.createElement("span"); @@ -1082,25 +1086,30 @@ export function showSaveSnapshotDialog({ openFolderLink.addEventListener("click", async (e) => { e.preventDefault(); if (typeof revealPresetFolder !== "function") return; + if (libraryRevealInFlight) return; + libraryRevealInFlight = true; try { const r = await revealPresetFolder(); toast(`Opened: ${r.path}`); } catch (err) { console.error("[Koolook] reveal failed:", err); toast(`Could not open library folder: ${err.message}`); + } finally { + libraryRevealInFlight = false; } }); libRowTop.appendChild(openFolderLink); - libRow.appendChild(libRowTop); + libRowInfo.appendChild(libRowTop); const libName = document.createElement("div"); libName.className = "koolook-settings-folder-name"; libName.textContent = "Library folder: (loading…)"; - libRow.appendChild(libName); + libRowInfo.appendChild(libName); const libPath = document.createElement("div"); libPath.className = "koolook-settings-folder-path"; - libRow.appendChild(libPath); + libRowInfo.appendChild(libPath); + libRow.appendChild(libRowInfo); body.appendChild(libRow); @@ -1262,6 +1271,9 @@ export function showSaveSnapshotDialog({ toast("Folder picker unavailable in this session."); return; } + if (saveToBtn.disabled) return; + saveToBtn.disabled = true; + setTimeout(() => { saveToBtn.disabled = false; }, 0); showFolderPicker({ title: "Save snapshots to", titleTooltip: "Pick the library folder this Save will write into.", @@ -1448,6 +1460,9 @@ export function showLoadSnapshotDialog({ const libRow = document.createElement("div"); libRow.className = "koolook-snap-lib-row"; + const libRowInfo = document.createElement("div"); + libRowInfo.className = "koolook-snap-lib-row-info"; + const libRowTop = document.createElement("div"); libRowTop.className = "koolook-snap-lib-row-top"; const libLabel = document.createElement("span"); @@ -1463,42 +1478,71 @@ export function showLoadSnapshotDialog({ openFolderLink.addEventListener("click", async (e) => { e.preventDefault(); if (typeof revealPresetFolder !== "function") return; + if (libraryRevealInFlight) return; + libraryRevealInFlight = true; try { const r = await revealPresetFolder(); toast(`Opened: ${r.path}`); } catch (err) { console.error("[Koolook] reveal failed:", err); toast(`Could not open library folder: ${err.message}`); + } finally { + libraryRevealInFlight = false; } }); libRowTop.appendChild(openFolderLink); - libRow.appendChild(libRowTop); + libRowInfo.appendChild(libRowTop); const libName = document.createElement("div"); libName.className = "koolook-settings-folder-name"; libName.textContent = "Library folder: (loading…)"; - libRow.appendChild(libName); + libRowInfo.appendChild(libName); const libPath = document.createElement("div"); libPath.className = "koolook-settings-folder-path"; - libRow.appendChild(libPath); + libRowInfo.appendChild(libPath); + libRow.appendChild(libRowInfo); body.appendChild(libRow); const listWrap = document.createElement("div"); body.appendChild(listWrap); + const recoverySection = document.createElement("div"); + recoverySection.className = "koolook-recovery-section"; + const recoverySummary = document.createElement("div"); + recoverySummary.className = "koolook-recovery-summary"; + recoverySummary.textContent = "▸ Recovery auto-saves"; + recoverySummary.classList.add("koolook-recovery-summary-passive"); + recoverySection.appendChild(recoverySummary); + const recoveryContent = document.createElement("div"); + recoveryContent.className = "koolook-recovery-list"; + recoveryContent.hidden = true; + recoverySection.appendChild(recoveryContent); + recoverySummary.addEventListener("click", () => { + if (scopedRecovery) { + clearScopedRecovery(); + setDialogTitle("Load snapshot"); + applyCloseButtonState(); + } + }); + body.appendChild(recoverySection); + let overlay; + let titleEl; const close = () => overlay.remove(); - // Per-dialog state. ``pendingDelete`` holds the preview whose × was - // clicked and the row element currently outlined red; the Close - // button's label flips to "Yes (delete)" until the user confirms or - // Escape cancels. ``scopedRecoveryRow`` holds the inline single-row - // recovery widget that opens beneath a preset row that has a newer - // autosave — exactly one is shown at a time (mockup section 3). + // Per-dialog state. ``pendingDelete`` holds the target whose × was + // clicked and the row element currently outlined red; the command + // bar swaps to a "Confirm delete?" prompt until the user confirms or + // Escape cancels. ``scopedRecovery`` holds the bottom recovery + // section state for one preset at a time (mockup section 3). let pendingDelete = null; - let scopedRecoveryRow = null; + let scopedRecovery = null; + let selectedNamed = null; + let selectedRecovery = null; + let recoveryRequestId = 0; + let libraryRevealInFlight = false; function renderEmpty(text) { const el = document.createElement("div"); @@ -1508,6 +1552,7 @@ export function showLoadSnapshotDialog({ } async function refresh() { + cancelDelete(); listWrap.innerHTML = ""; listWrap.appendChild(renderEmpty("Loading…")); let previews; @@ -1515,6 +1560,7 @@ export function showLoadSnapshotDialog({ catch (e) { previews = []; } listWrap.innerHTML = ""; clearScopedRecovery(); + clearLoadSelection(); if (previews.length === 0) { listWrap.appendChild(renderEmpty("No presets in this library yet. Use Save to create one.")); return; @@ -1680,51 +1726,54 @@ export function showLoadSnapshotDialog({ } // ---- Click routing ---- - // Mockup section 3: clicking a preset that has a newer autosave does - // NOT load it; it expands a single scoped recovery row beneath the - // clicked preset. The user then re-clicks the named row (to load - // named) or clicks the recovery row (to load the autosave). Presets - // with no newer autosave skip the choice entirely — direct load. + // Row clicks select; footer buttons commit. A newer recovery opens + // the bottom section and changes the dialog title/button pair, but + // there is no double-click-to-load convention. function onPresetClick(preview, rowEl) { // Cancel any pending delete-confirm on a different row. if (pendingDelete && pendingDelete.preview.fileName !== preview.fileName) { cancelDelete(); } - // A second click on the SAME preset that already has its scoped - // recovery row open commits the named load — the user has seen - // both options and is choosing the named version. - if (scopedRecoveryRow && scopedRecoveryRow.parentPreview.fileName === preview.fileName) { - doNamedLoad(preview); - return; - } + if (selectedNamed?.preview.fileName === preview.fileName && !pendingDelete) return; + clearLoadSelection(); + selectedNamed = { preview, rowEl }; + rowEl.classList.add("koolook-snapshot-row-selected"); + clearScopedRecovery(); if (typeof preview.latestAutosaveMtime === "number" && typeof preview.mtime === "number" && preview.latestAutosaveMtime > preview.mtime) { - insertScopedRecovery(preview, rowEl); + openScopedRecovery(preview); + setDialogTitle("Auto-save is newer than the saved version"); + applyCloseButtonState(); return; } - // No newer autosave — direct load. The dialog already says - // "Load snapshot" at the title; a second "Replace current state?" - // confirm modal is the kind of extra friction the redesign drops. - doNamedLoad(preview); + setDialogTitle("Load snapshot"); + applyCloseButtonState(); } - // Build a single inline row representing the newest recovery file - // for the clicked preset, slotted in the DOM directly after the - // preset row. Loading state shows briefly while listAutosaves() - // resolves; error/empty states render in place so the user knows - // the affordance opened but yielded nothing. - async function insertScopedRecovery(preview, anchorRow) { - const slot = document.createElement("div"); - slot.className = "koolook-snapshot-scoped-recovery"; - slot.appendChild(renderEmpty("Loading recovery…")); - anchorRow.insertAdjacentElement("afterend", slot); - scopedRecoveryRow = { el: slot, parentPreview: preview }; + async function openScopedRecovery(preview) { + const requestId = ++recoveryRequestId; + const expectedFileName = preview.fileName; + scopedRecovery = { parentPreview: preview, item: null, rowEl: null, loading: true }; + recoverySummary.textContent = "▾ Recovery auto-saves"; + recoverySummary.classList.remove("koolook-recovery-summary-passive"); + recoveryContent.hidden = false; + recoveryContent.innerHTML = ""; + recoveryContent.appendChild(renderEmpty("Loading recovery…")); let items = []; if (typeof listAutosaves === "function") { - try { items = await listAutosaves(); } catch (e) { items = []; } + try { + items = await listAutosaves(); + } catch (e) { + items = []; + toast("Could not load recovery list; saved snapshot is still available."); + } + } + if (requestId !== recoveryRequestId || + selectedNamed?.preview.fileName !== expectedFileName) { + return; } // Filter to this preset's autosave subdir AND pick the single // freshest entry by mtime. ``periodic.json`` and the rotated @@ -1735,59 +1784,142 @@ export function showLoadSnapshotDialog({ const scoped = items .filter((item) => item.dir === subdir) .sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); - slot.innerHTML = ""; + recoveryContent.innerHTML = ""; if (scoped.length === 0) { - slot.appendChild(renderEmpty("No recovery auto-save for this preset.")); + clearScopedRecovery(); + setDialogTitle("Load snapshot"); + applyCloseButtonState(); return; } const newest = scoped[0]; - const list = document.createElement("div"); - list.className = "koolook-snapshot-list"; + + const group = document.createElement("div"); + group.className = "koolook-recovery-group"; + + const groupHead = document.createElement("div"); + groupHead.className = "koolook-recovery-group-head"; + const groupTitle = document.createElement("div"); + groupTitle.className = "koolook-recovery-group-title"; + groupTitle.textContent = subdir; + groupHead.appendChild(groupTitle); + + const groupOpen = document.createElement("a"); + groupOpen.className = "koolook-snap-open-folder-link"; + groupOpen.href = "#"; + groupOpen.textContent = "Open folder ↗"; + groupOpen.title = "Open this recovery folder in your file manager"; + let groupRevealInFlight = false; + groupOpen.addEventListener("click", async (e) => { + e.preventDefault(); + if (typeof revealPresetFolder !== "function") return; + if (groupRevealInFlight) return; + groupRevealInFlight = true; + try { + const r = await revealPresetFolder({ dir: subdir }); + toast(`Opened: ${r.path}`); + } catch (err) { + console.error("[Koolook] reveal recovery folder failed:", err); + toast(`Could not open recovery folder: ${err.message}`); + } finally { + groupRevealInFlight = false; + } + }); + groupHead.appendChild(groupOpen); + group.appendChild(groupHead); + + const groupPath = document.createElement("div"); + groupPath.className = "koolook-recovery-group-path"; + const fullGroupPath = libraryPath ? `${libraryPath}/${subdir}/` : subdir; + groupPath.textContent = formatLibraryPathBreadcrumb(fullGroupPath, 56); + groupPath.title = fullGroupPath; + group.appendChild(groupPath); + const row = document.createElement("div"); - row.className = "koolook-snapshot-row koolook-snapshot-scoped-recovery-row"; + row.className = "koolook-recovery-row"; const info = document.createElement("div"); - info.className = "koolook-snapshot-row-info"; + info.className = "koolook-recovery-row-info"; info.title = "Click to load this auto-save instead of the named preset."; - const name = document.createElement("div"); - name.className = "koolook-snapshot-row-name"; const kindBadge = document.createElement("span"); kindBadge.className = "koolook-recovery-kind koolook-recovery-kind-" + newest.kind; kindBadge.textContent = newest.kind === "pre_load" ? "Pre-load" : newest.kind === "periodic" ? "Periodic" : "Other"; - name.appendChild(kindBadge); - // Per locked-in decision: recovery rows show kind + timestamp + - // meta, no filenames. Timestamp comes from formatPreviewMeta - // (which already prefers mtime → "saved "). - info.appendChild(name); + info.appendChild(kindBadge); const meta = document.createElement("div"); - meta.className = "koolook-snapshot-row-meta"; + meta.className = "koolook-recovery-row-meta"; meta.textContent = formatPreviewMeta(newest); info.appendChild(meta); - info.addEventListener("click", () => doAutosaveRestore(newest)); + info.addEventListener("click", () => { + if (pendingDelete) { + cancelDelete(); + } + if (selectedNamed) { + selectedNamed.rowEl.classList.remove("koolook-snapshot-row-selected"); + selectedNamed = null; + } + if (selectedRecovery) selectedRecovery.rowEl.classList.remove("koolook-recovery-row-selected"); + selectedRecovery = { item: newest, rowEl: row }; + row.classList.add("koolook-recovery-row-selected"); + applyCloseButtonState(); + }); row.appendChild(info); - list.appendChild(row); - slot.appendChild(list); + + const actions = document.createElement("div"); + actions.className = "koolook-snapshot-row-actions"; + const delBtn = document.createElement("button"); + delBtn.className = "koolook-snapshot-row-btn koolook-snapshot-row-btn-danger"; + delBtn.textContent = "×"; + delBtn.title = "Delete this recovery auto-save"; + delBtn.addEventListener("click", (e) => { + e.stopPropagation(); + armDelete(newest, row, { dir: newest.dir }); + }); + actions.appendChild(delBtn); + row.appendChild(actions); + + group.appendChild(row); + recoveryContent.appendChild(group); + scopedRecovery = { parentPreview: preview, item: newest, rowEl: row }; + applyCloseButtonState(); } function clearScopedRecovery() { - if (scopedRecoveryRow && scopedRecoveryRow.el && scopedRecoveryRow.el.parentNode) { - scopedRecoveryRow.el.remove(); + recoveryRequestId += 1; + recoverySummary.textContent = "▸ Recovery auto-saves"; + recoverySummary.classList.add("koolook-recovery-summary-passive"); + recoveryContent.hidden = true; + recoveryContent.innerHTML = ""; + scopedRecovery = null; + if (selectedRecovery) { + selectedRecovery.rowEl.classList.remove("koolook-recovery-row-selected"); + selectedRecovery = null; } - scopedRecoveryRow = null; + } + + function clearLoadSelection() { + if (selectedNamed) selectedNamed.rowEl.classList.remove("koolook-snapshot-row-selected"); + if (selectedRecovery) selectedRecovery.rowEl.classList.remove("koolook-recovery-row-selected"); + selectedNamed = null; + selectedRecovery = null; + } + + function setDialogTitle(text) { + if (titleEl) titleEl.textContent = text; } // ---- Inline delete confirm ---- // Mockup-locked: × outlines the row red, Close transforms to // "Yes (delete)". A second confirm click commits; Escape cancels. - function armDelete(preview, rowEl) { + function armDelete(preview, rowEl, { dir = "" } = {}) { if (pendingDelete) cancelDelete(); - clearScopedRecovery(); + clearLoadSelection(); + if (!dir) clearScopedRecovery(); rowEl.classList.add("koolook-snapshot-row-pending-delete"); - pendingDelete = { preview, rowEl }; + pendingDelete = { preview, rowEl, dir }; + setDialogTitle("Load snapshot"); applyCloseButtonState(); } @@ -1802,8 +1934,9 @@ export function showLoadSnapshotDialog({ const target = pendingDelete; if (!target) return; try { - await deletePreset(target.preview.fileName); + await deletePreset(target.preview.fileName, { dir: target.dir }); if (typeof getCurrentPresetName === "function" && + !target.dir && getCurrentPresetName() === target.preview.fileName) { setCurrentPresetName(null); } @@ -1824,6 +1957,29 @@ export function showLoadSnapshotDialog({ onClick: () => openLoadFrom(), }); + const confirmText = document.createElement("span"); + confirmText.className = "koolook-delete-confirm-text"; + confirmText.hidden = true; + + const loadSavedBtn = makeModalButton({ + label: "NO - load saved", + onClick: () => { + if (!scopedRecovery) return; + doNamedLoad(scopedRecovery.parentPreview); + }, + }); + loadSavedBtn.hidden = true; + + const loadLatestBtn = makeModalButton({ + label: "YES - load latest", + primary: true, + onClick: () => { + if (!scopedRecovery || !scopedRecovery.item) return; + doAutosaveRestore(selectedRecovery?.item || scopedRecovery.item); + }, + }); + loadLatestBtn.hidden = true; + const closeBtn = makeModalButton({ label: "Close", onClick: () => { @@ -1831,18 +1987,64 @@ export function showLoadSnapshotDialog({ commitDelete(); return; } + if (selectedNamed) { + doNamedLoad(selectedNamed.preview); + return; + } close(); }, }); function applyCloseButtonState() { if (pendingDelete) { - closeBtn.textContent = `Yes — delete "${pendingDelete.preview.displayName}"`; + loadFromBtn.hidden = true; + loadSavedBtn.hidden = true; + loadLatestBtn.hidden = true; + confirmText.hidden = false; + confirmText.textContent = `Confirm delete "${pendingDelete.preview.displayName}"?`; + closeBtn.hidden = false; + closeBtn.textContent = "Yes"; closeBtn.classList.add("koolook-modal-btn-danger"); + closeBtn.classList.remove("koolook-modal-btn-primary"); closeBtn.title = "Confirm deletion. Press Esc to cancel."; - } else { + } else if (scopedRecovery && scopedRecovery.item) { + loadFromBtn.hidden = true; + confirmText.hidden = true; + confirmText.textContent = ""; + loadSavedBtn.hidden = false; + loadLatestBtn.hidden = false; + closeBtn.hidden = true; + closeBtn.classList.remove("koolook-modal-btn-danger", "koolook-modal-btn-primary"); + } else if (scopedRecovery && scopedRecovery.loading) { + loadFromBtn.hidden = true; + confirmText.hidden = true; + confirmText.textContent = ""; + loadSavedBtn.hidden = true; + loadLatestBtn.hidden = true; + closeBtn.hidden = false; closeBtn.textContent = "Close"; + closeBtn.classList.remove("koolook-modal-btn-danger", "koolook-modal-btn-primary"); + closeBtn.title = ""; + } else if (selectedNamed) { + loadFromBtn.hidden = false; + confirmText.hidden = true; + confirmText.textContent = ""; + loadSavedBtn.hidden = true; + loadLatestBtn.hidden = true; + closeBtn.hidden = false; + closeBtn.textContent = "Load"; + closeBtn.classList.add("koolook-modal-btn-primary"); closeBtn.classList.remove("koolook-modal-btn-danger"); + closeBtn.title = `Load "${selectedNamed.preview.displayName}".`; + } else { + loadFromBtn.hidden = false; + confirmText.hidden = true; + confirmText.textContent = ""; + loadSavedBtn.hidden = true; + loadLatestBtn.hidden = true; + closeBtn.hidden = false; + closeBtn.textContent = "Close"; + closeBtn.classList.remove("koolook-modal-btn-danger", "koolook-modal-btn-primary"); closeBtn.title = ""; } } @@ -1852,6 +2054,9 @@ export function showLoadSnapshotDialog({ toast("Folder picker unavailable in this session."); return; } + if (loadFromBtn.disabled) return; + loadFromBtn.disabled = true; + setTimeout(() => { loadFromBtn.disabled = false; }, 0); showFolderPicker({ title: "Load snapshots from", titleTooltip: "Switch the snapshot library this Load is reading from.", @@ -1886,11 +2091,11 @@ export function showLoadSnapshotDialog({ const spacer = document.createElement("span"); spacer.className = "koolook-folder-picker-spacer"; - ({ overlay } = makeModalShell({ + ({ overlay, titleEl } = makeModalShell({ title: "Load snapshot", titleTooltip: "Restore the sidebar state from a preset file.", body, - actions: [loadFromBtn, spacer, closeBtn], + actions: [loadFromBtn, confirmText, spacer, loadSavedBtn, loadLatestBtn, closeBtn], })); // Escape-to-cancel for inline delete. The modal shell already wires