@floless/app 0.23.3 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43012,7 +43012,7 @@ var require_static = __commonJS({
43012
43012
  "use strict";
43013
43013
  var path = require("node:path");
43014
43014
  var { fileURLToPath: fileURLToPath4 } = require("node:url");
43015
- var { statSync: statSync9 } = require("node:fs");
43015
+ var { statSync: statSync8 } = require("node:fs");
43016
43016
  var { glob } = require_commonjs6();
43017
43017
  var fp = require_plugin2();
43018
43018
  var send = require_send2();
@@ -43375,7 +43375,7 @@ var require_static = __commonJS({
43375
43375
  }
43376
43376
  let pathStat;
43377
43377
  try {
43378
- pathStat = statSync9(rootPath);
43378
+ pathStat = statSync8(rootPath);
43379
43379
  } catch (e) {
43380
43380
  if (e.code === "ENOENT") {
43381
43381
  fastify.log.warn(`"root" path "${rootPath}" must exist`);
@@ -43399,7 +43399,7 @@ var require_static = __commonJS({
43399
43399
  return indexFiles.find((filename) => {
43400
43400
  const p = path.join(root, pathname, filename);
43401
43401
  try {
43402
- const stats = statSync9(p);
43402
+ const stats = statSync8(p);
43403
43403
  return !stats.isDirectory();
43404
43404
  } catch {
43405
43405
  return false;
@@ -52073,14 +52073,11 @@ var import_node_path4 = require("node:path");
52073
52073
  var ROOT = process.env.FLOLESS_HOME ?? (0, import_node_path4.join)((0, import_node_os4.homedir)(), ".floless");
52074
52074
  var uiDir = (0, import_node_path4.join)(ROOT, "ui");
52075
52075
  var extensionsFile = (0, import_node_path4.join)(uiDir, "extensions.json");
52076
- var HISTORY_DIR = (0, import_node_path4.join)(uiDir, "history");
52076
+ var TIMELINE_FILE = (0, import_node_path4.join)(uiDir, "timeline.json");
52077
52077
  var HISTORY_KEEP = 20;
52078
52078
  function ensureUiDir() {
52079
52079
  if (!(0, import_node_fs5.existsSync)(uiDir)) (0, import_node_fs5.mkdirSync)(uiDir, { recursive: true });
52080
52080
  }
52081
- function ensureHistoryDir() {
52082
- if (!(0, import_node_fs5.existsSync)(HISTORY_DIR)) (0, import_node_fs5.mkdirSync)(HISTORY_DIR, { recursive: true });
52083
- }
52084
52081
  function readExtensionsText() {
52085
52082
  try {
52086
52083
  return (0, import_node_fs5.readFileSync)(extensionsFile, "utf8");
@@ -52103,6 +52100,7 @@ function descriptorPanels(d) {
52103
52100
  return d.panels.filter((p) => !!p && typeof p === "object" && !Array.isArray(p));
52104
52101
  }
52105
52102
  function panelsCountOf(text) {
52103
+ if (text === null) return 0;
52106
52104
  try {
52107
52105
  const parsed = JSON.parse(text);
52108
52106
  return parsed && typeof parsed === "object" && Array.isArray(parsed.panels) ? parsed.panels.length : 0;
@@ -52110,69 +52108,35 @@ function panelsCountOf(text) {
52110
52108
  return 0;
52111
52109
  }
52112
52110
  }
52113
- function historyFileName(now) {
52114
- const base = now.toISOString().replace(/[:.]/g, "-");
52115
- let name = `${base}.json`;
52116
- for (let i = 2; (0, import_node_fs5.existsSync)((0, import_node_path4.join)(HISTORY_DIR, name)); i++) name = `${base}-${i}.json`;
52117
- return name;
52118
- }
52119
- function timestampFromName(file) {
52120
- const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z(?:-\d+)?\.json$/.exec(file);
52121
- return m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z` : null;
52122
- }
52123
- function historyFiles() {
52124
- if (!(0, import_node_fs5.existsSync)(HISTORY_DIR)) return [];
52111
+ function loadTimeline() {
52112
+ let text;
52125
52113
  try {
52126
- return (0, import_node_fs5.readdirSync)(HISTORY_DIR).filter((f) => f.endsWith(".json")).sort();
52114
+ text = (0, import_node_fs5.readFileSync)(TIMELINE_FILE, "utf8");
52127
52115
  } catch {
52128
- return [];
52116
+ return { cursor: -1, entries: [] };
52129
52117
  }
52130
- }
52131
- function pruneHistory() {
52132
- const files = historyFiles();
52133
- for (const f of files.slice(0, Math.max(0, files.length - HISTORY_KEEP))) {
52134
- try {
52135
- (0, import_node_fs5.rmSync)((0, import_node_path4.join)(HISTORY_DIR, f));
52136
- } catch {
52137
- }
52138
- }
52139
- }
52140
- function snapshotExtensions(content) {
52141
- const text = content !== void 0 ? content : readExtensionsText();
52142
- if (text === null) return null;
52143
- const files = historyFiles();
52144
- const newest = files[files.length - 1];
52145
- if (newest) {
52146
- try {
52147
- if ((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(HISTORY_DIR, newest), "utf8") === text) return (0, import_node_path4.join)(HISTORY_DIR, newest);
52148
- } catch {
52149
- }
52118
+ try {
52119
+ const parsed = JSON.parse(text);
52120
+ if (!parsed || typeof parsed !== "object") return { cursor: -1, entries: [] };
52121
+ const p = parsed;
52122
+ const rawEntries = Array.isArray(p.entries) ? p.entries : [];
52123
+ const entries = rawEntries.filter((e) => !!e && typeof e === "object").map((e) => ({
52124
+ content: typeof e.content === "string" ? e.content : null,
52125
+ ts: typeof e.ts === "string" ? e.ts : ""
52126
+ }));
52127
+ let cursor = typeof p.cursor === "number" && Number.isInteger(p.cursor) ? p.cursor : entries.length - 1;
52128
+ if (entries.length === 0) cursor = -1;
52129
+ else cursor = Math.max(0, Math.min(cursor, entries.length - 1));
52130
+ return { cursor, entries };
52131
+ } catch {
52132
+ return { cursor: -1, entries: [] };
52150
52133
  }
52151
- ensureHistoryDir();
52152
- const path = (0, import_node_path4.join)(HISTORY_DIR, historyFileName(/* @__PURE__ */ new Date()));
52153
- const tmp = `${path}.${process.pid}.tmp`;
52154
- (0, import_node_fs5.writeFileSync)(tmp, text);
52155
- (0, import_node_fs5.renameSync)(tmp, path);
52156
- pruneHistory();
52157
- return path;
52158
52134
  }
52159
- function listHistory() {
52160
- return historyFiles().reverse().map((f) => {
52161
- let ts = timestampFromName(f);
52162
- if (!ts) {
52163
- try {
52164
- ts = (0, import_node_fs5.statSync)((0, import_node_path4.join)(HISTORY_DIR, f)).mtime.toISOString();
52165
- } catch {
52166
- ts = "";
52167
- }
52168
- }
52169
- let count = 0;
52170
- try {
52171
- count = panelsCountOf((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(HISTORY_DIR, f), "utf8"));
52172
- } catch {
52173
- }
52174
- return { timestamp: ts ?? "", panelsCount: count };
52175
- });
52135
+ function saveTimeline(t) {
52136
+ ensureUiDir();
52137
+ const tmp = `${TIMELINE_FILE}.${process.pid}.tmp`;
52138
+ (0, import_node_fs5.writeFileSync)(tmp, JSON.stringify(t, null, 2));
52139
+ (0, import_node_fs5.renameSync)(tmp, TIMELINE_FILE);
52176
52140
  }
52177
52141
  function writeExtensionsText(text) {
52178
52142
  ensureUiDir();
@@ -52180,34 +52144,96 @@ function writeExtensionsText(text) {
52180
52144
  (0, import_node_fs5.writeFileSync)(tmp, text);
52181
52145
  (0, import_node_fs5.renameSync)(tmp, extensionsFile);
52182
52146
  }
52183
- function undoExtensions() {
52184
- const current = readExtensionsText();
52185
- const files = historyFiles().reverse();
52186
- let target = null;
52187
- for (const f of files) {
52147
+ function applyState(entry2) {
52148
+ if (entry2.content === null) {
52188
52149
  try {
52189
- const text = (0, import_node_fs5.readFileSync)((0, import_node_path4.join)(HISTORY_DIR, f), "utf8");
52190
- if (text !== current) {
52191
- target = text;
52192
- break;
52193
- }
52150
+ (0, import_node_fs5.rmSync)(extensionsFile);
52194
52151
  } catch {
52195
52152
  }
52153
+ } else {
52154
+ writeExtensionsText(entry2.content);
52155
+ }
52156
+ }
52157
+ function currentContent(t) {
52158
+ return t.cursor >= 0 && t.cursor < t.entries.length ? t.entries[t.cursor].content : null;
52159
+ }
52160
+ function seedTimeline() {
52161
+ const t = loadTimeline();
52162
+ if (t.entries.length > 0) return;
52163
+ saveTimeline({ cursor: 0, entries: [{ content: readExtensionsText(), ts: (/* @__PURE__ */ new Date()).toISOString() }] });
52164
+ }
52165
+ function recordChange(text) {
52166
+ const t = loadTimeline();
52167
+ if (t.entries.length === 0) {
52168
+ saveTimeline({ cursor: 0, entries: [{ content: text, ts: (/* @__PURE__ */ new Date()).toISOString() }] });
52169
+ return { recorded: true };
52196
52170
  }
52197
- if (target === null) return { restored: false, descriptor: readExtensions() };
52198
- if (current !== null) snapshotExtensions(current);
52199
- writeExtensionsText(target);
52200
- return { restored: true, descriptor: readExtensions() };
52171
+ if (text === currentContent(t)) return { recorded: false };
52172
+ const entries = t.entries.slice(0, t.cursor + 1);
52173
+ entries.push({ content: text, ts: (/* @__PURE__ */ new Date()).toISOString() });
52174
+ let cursor = entries.length - 1;
52175
+ while (entries.length > HISTORY_KEEP) {
52176
+ entries.shift();
52177
+ cursor--;
52178
+ }
52179
+ saveTimeline({ cursor, entries });
52180
+ return { recorded: true };
52181
+ }
52182
+ function historyState() {
52183
+ const t = loadTimeline();
52184
+ const total = t.entries.length;
52185
+ return {
52186
+ cursor: total ? total - 1 - t.cursor : -1,
52187
+ // express the cursor in newest-first terms
52188
+ total,
52189
+ canUndo: t.cursor > 0,
52190
+ canRedo: t.cursor >= 0 && t.cursor < total - 1
52191
+ };
52192
+ }
52193
+ function listHistory() {
52194
+ const t = loadTimeline();
52195
+ return t.entries.map((e, i) => ({ timestamp: e.ts, panelsCount: panelsCountOf(e.content), current: i === t.cursor })).reverse();
52196
+ }
52197
+ function undoExtensions() {
52198
+ const t = loadTimeline();
52199
+ if (t.cursor <= 0) {
52200
+ return { restored: false, descriptor: readExtensions(), canUndo: false, canRedo: t.cursor >= 0 && t.cursor < t.entries.length - 1 };
52201
+ }
52202
+ t.cursor -= 1;
52203
+ saveTimeline(t);
52204
+ applyState(t.entries[t.cursor]);
52205
+ return { restored: true, descriptor: readExtensions(), canUndo: t.cursor > 0, canRedo: true };
52206
+ }
52207
+ function redoExtensions() {
52208
+ const t = loadTimeline();
52209
+ if (t.cursor < 0 || t.cursor >= t.entries.length - 1) {
52210
+ return { restored: false, descriptor: readExtensions(), canUndo: t.cursor > 0, canRedo: false };
52211
+ }
52212
+ t.cursor += 1;
52213
+ saveTimeline(t);
52214
+ applyState(t.entries[t.cursor]);
52215
+ return { restored: true, descriptor: readExtensions(), canUndo: true, canRedo: t.cursor < t.entries.length - 1 };
52201
52216
  }
52202
52217
  function resetExtensions() {
52203
- const current = readExtensionsText();
52204
- if (current === null) return { reset: false };
52205
- snapshotExtensions(current);
52206
- try {
52207
- (0, import_node_fs5.rmSync)(extensionsFile);
52208
- } catch {
52218
+ if (readExtensionsText() === null) {
52219
+ const st = historyState();
52220
+ return { reset: false, canUndo: st.canUndo, canRedo: st.canRedo };
52221
+ }
52222
+ const t = loadTimeline();
52223
+ if (t.entries.length === 0) {
52224
+ t.entries.push({ content: readExtensionsText(), ts: (/* @__PURE__ */ new Date()).toISOString() });
52225
+ t.cursor = 0;
52226
+ }
52227
+ const entries = t.entries.slice(0, t.cursor + 1);
52228
+ entries.push({ content: null, ts: (/* @__PURE__ */ new Date()).toISOString() });
52229
+ let cursor = entries.length - 1;
52230
+ while (entries.length > HISTORY_KEEP) {
52231
+ entries.shift();
52232
+ cursor--;
52209
52233
  }
52210
- return { reset: true };
52234
+ saveTimeline({ cursor, entries });
52235
+ applyState(entries[cursor]);
52236
+ return { reset: true, canUndo: cursor > 0, canRedo: false };
52211
52237
  }
52212
52238
 
52213
52239
  // ui-validation.ts
@@ -52729,7 +52755,7 @@ function appVersion() {
52729
52755
  return resolveVersion({
52730
52756
  isSea: isSea2(),
52731
52757
  sqVersionXml: readSqVersionXml(),
52732
- define: true ? "0.23.3" : void 0,
52758
+ define: true ? "0.24.1" : void 0,
52733
52759
  pkgVersion: readPkgVersion()
52734
52760
  });
52735
52761
  }
@@ -52739,7 +52765,7 @@ function resolveChannel(s) {
52739
52765
  return "dev";
52740
52766
  }
52741
52767
  function appChannel() {
52742
- return resolveChannel({ isSea: isSea2(), define: true ? "0.23.3" : void 0 });
52768
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.24.1" : void 0 });
52743
52769
  }
52744
52770
 
52745
52771
  // oauth-presets.ts
@@ -53730,6 +53756,16 @@ function cronNextFire(expr, from) {
53730
53756
  function nextFireAt(spec, from) {
53731
53757
  if (!spec || typeof spec !== "object") return null;
53732
53758
  switch (spec.kind) {
53759
+ case "minutes": {
53760
+ const n = Math.max(1, Math.floor(spec.everyMinutes));
53761
+ const start = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), from.getMinutes(), 0, 0);
53762
+ for (let i = 0; i <= 120; i++) {
53763
+ const d = new Date(start.getTime());
53764
+ d.setMinutes(start.getMinutes() + i);
53765
+ if (d.getTime() > from.getTime() && d.getMinutes() % n === 0) return d;
53766
+ }
53767
+ return null;
53768
+ }
53733
53769
  case "hourly": {
53734
53770
  const n = Math.max(1, Math.floor(spec.everyHours));
53735
53771
  const start = new Date(from.getFullYear(), from.getMonth(), from.getDate(), from.getHours(), 0, 0, 0);
@@ -53844,6 +53880,11 @@ function validateSchedule(spec) {
53844
53880
  if (!spec || typeof spec !== "object") return "schedule required";
53845
53881
  const s = spec;
53846
53882
  switch (s.kind) {
53883
+ case "minutes":
53884
+ if (typeof s.everyMinutes !== "number" || !Number.isInteger(s.everyMinutes) || s.everyMinutes < 1 || s.everyMinutes > 30 || 60 % s.everyMinutes !== 0) {
53885
+ return "everyMinutes must evenly divide 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, or 30)";
53886
+ }
53887
+ return null;
53847
53888
  case "hourly":
53848
53889
  if (typeof s.everyHours !== "number" || !Number.isInteger(s.everyHours) || s.everyHours < 1 || s.everyHours > 24 || 24 % s.everyHours !== 0) {
53849
53890
  return "everyHours must evenly divide 24 (1, 2, 3, 4, 6, 8, 12, or 24)";
@@ -57242,18 +57283,17 @@ function startWatcher() {
57242
57283
  ignoreInitial: true,
57243
57284
  awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
57244
57285
  });
57286
+ seedTimeline();
57245
57287
  let lastExtContent = readExtensionsText();
57246
57288
  const handleExtensionsChange = () => {
57247
57289
  const next = readExtensionsText();
57248
57290
  if (next === lastExtContent) return;
57249
- if (lastExtContent !== null) {
57250
- try {
57251
- snapshotExtensions(lastExtContent);
57252
- } catch (err) {
57253
- console.warn("[floless] failed to snapshot the previous extensions.json before a change (undo for this step is lost):", err);
57254
- }
57255
- }
57256
57291
  lastExtContent = next;
57292
+ try {
57293
+ recordChange(next);
57294
+ } catch (err) {
57295
+ console.warn("[floless] failed to record the extensions.json change in the undo timeline (undo for this step is lost):", err);
57296
+ }
57257
57297
  broadcast({ type: "extensions-changed" });
57258
57298
  };
57259
57299
  watcher.on("all", (event, path) => {
@@ -58261,15 +58301,20 @@ async function startServer() {
58261
58301
  });
58262
58302
  app.post("/api/extensions/undo", async (_req, reply) => {
58263
58303
  const result = undoExtensions();
58264
- if (!result.restored) return reply.send({ ok: false, error: "nothing to undo" });
58265
- return { ok: true, descriptor: result.descriptor };
58304
+ if (!result.restored) return reply.send({ ok: false, error: "nothing to undo", canUndo: result.canUndo, canRedo: result.canRedo });
58305
+ return { ok: true, descriptor: result.descriptor, canUndo: result.canUndo, canRedo: result.canRedo };
58306
+ });
58307
+ app.post("/api/extensions/redo", async (_req, reply) => {
58308
+ const result = redoExtensions();
58309
+ if (!result.restored) return reply.send({ ok: false, error: "nothing to redo", canUndo: result.canUndo, canRedo: result.canRedo });
58310
+ return { ok: true, descriptor: result.descriptor, canUndo: result.canUndo, canRedo: result.canRedo };
58266
58311
  });
58267
58312
  app.post("/api/extensions/reset", async (_req, reply) => {
58268
58313
  const result = resetExtensions();
58269
- if (!result.reset) return reply.send({ ok: false, error: "nothing to reset \u2014 the dashboard is already the default" });
58270
- return { ok: true, descriptor: null };
58314
+ if (!result.reset) return reply.send({ ok: false, error: "nothing to reset \u2014 the dashboard is already the default", canUndo: result.canUndo, canRedo: result.canRedo });
58315
+ return { ok: true, descriptor: null, canUndo: result.canUndo, canRedo: result.canRedo };
58271
58316
  });
58272
- app.get("/api/extensions/history", async () => ({ ok: true, history: listHistory() }));
58317
+ app.get("/api/extensions/history", async () => ({ ok: true, history: listHistory(), ...historyState() }));
58273
58318
  app.get("/api/events", async (_req, reply) => {
58274
58319
  reply.hijack();
58275
58320
  reply.raw.writeHead(200, {
@@ -97,10 +97,12 @@ curl -s http://127.0.0.1:4317/api/health
97
97
  ## Schedule presets (structured, NOT cron)
98
98
 
99
99
  Pick exactly one shape. Times are **local machine time**, `HH:MM` 24-hour. The finest granularity
100
- is **hourly** by design (no sub-hour schedules).
100
+ is **every minute** (`minutes`), for near-real-time monitoring (e.g. polling a log folder); the
101
+ 1-minute floor + the serialized run queue keep a routine from flooding.
101
102
 
102
103
  | User intent | ScheduleSpec |
103
104
  |---|---|
105
+ | "every 5 minutes" / "every 15 min" | `{"kind":"minutes","everyMinutes":5}` (must divide 60 and stay sub-hourly: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30) |
104
106
  | "every 2 hours" | `{"kind":"hourly","everyHours":2}` (must divide 24: 1, 2, 3, 4, 6, 8, 12, 24) |
105
107
  | "every day at 7am" | `{"kind":"daily","time":"07:00"}` |
106
108
  | "every weekday at 7am" | `{"kind":"weekdays","time":"07:00"}` (Mon–Fri) |
@@ -123,9 +123,9 @@ app that hasn't run yet is fine.
123
123
 
124
124
  - **Never write raw HTML/markup into descriptor strings** — it renders as literal text
125
125
  by design. Describe; don't mark up.
126
- - Every write is snapshotted to `~/.floless/ui/history/` (newest 20), and the user has
127
- one-click **Undo** and **Reset to default** in the app — so be bold composing, but
128
- never destructive: don't delete panels the user didn't ask to remove, and prefer
129
- editing the existing document over regenerating it.
126
+ - Every write is recorded in the app's undo timeline (`~/.floless/ui/timeline.json`,
127
+ newest 20 states), and the user has multi-level **Undo / Redo** and **Reset to default**
128
+ in the app — so be bold composing, but never destructive: don't delete panels the user
129
+ didn't ask to remove, and prefer editing the existing document over regenerating it.
130
130
  - The UI renders; AWARE runs; you compose. Don't put computed values in `stat` blocks
131
131
  that a `source`-bound block could keep live — a stale hand-written number misleads.
package/dist/web/app.css CHANGED
@@ -2518,7 +2518,7 @@ body {
2518
2518
 
2519
2519
  /* ===== Tweak modal: screenshot attach ===== */
2520
2520
  .fm-file-input { position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
2521
- .fm-drop { position: relative; display: flex; align-items: center; justify-content: center; min-height: 52px; padding: 12px; border: 1px dashed var(--border-strong); border-radius: 4px; background: var(--surface-2); color: var(--text-dim); font-size: 11px; text-align: center; line-height: 1.45; cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s; }
2521
+ .fm-drop { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 52px; padding: 12px; border: 1px dashed var(--border-strong); border-radius: 4px; background: var(--surface-2); color: var(--text-dim); font-size: 11px; text-align: center; line-height: 1.45; cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s; }
2522
2522
  .fm-drop:hover, .fm-drop:focus-visible, .fm-drop.drag-over { border-color: var(--accent-dim); background: var(--accent-soft); color: var(--text-muted); outline: 2px solid var(--accent); outline-offset: 2px; }
2523
2523
  .fm-thumbs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
2524
2524
  .fm-thumbs[hidden] { display: none; }
@@ -2813,8 +2813,14 @@ body {
2813
2813
  display: flex; justify-content: space-between; gap: 10px;
2814
2814
  padding: 4px 8px; border-radius: 4px; font-size: 12px; color: var(--text-dim);
2815
2815
  }
2816
+ .ext-history-item.is-current .ext-history-when { color: var(--accent); }
2816
2817
  .ext-history-when { color: var(--text); }
2817
2818
  .ext-history-count { font-family: var(--mono); font-size: 11px; color: var(--text-muted); }
2819
+ /* "you are here" marker on the cursor's history entry (multi-level undo/redo, #102). */
2820
+ .ext-history-current {
2821
+ font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em;
2822
+ color: var(--accent); margin-right: 6px; vertical-align: 1px;
2823
+ }
2818
2824
  .ext-history-empty { padding: 6px 8px 8px; font-size: 12px; color: var(--text-muted); }
2819
2825
  /* Reset is destructive-looking (though recoverable) — danger on hover, never accent. */
2820
2826
  #ext-reset-confirm:hover { color: var(--err); border-color: var(--err); background: color-mix(in srgb, var(--err) 10%, transparent); }
package/dist/web/aware.js CHANGED
@@ -1628,7 +1628,7 @@
1628
1628
  const has = st.mode !== 'empty';
1629
1629
  drop.classList.toggle('has-file', has);
1630
1630
  if (!has) {
1631
- drop.innerHTML = `Paste (Ctrl+V) or click to attach<span class="fm-drop-hint">${escapeHtml(hint)}</span>`;
1631
+ drop.innerHTML = `<span class="fm-drop-main">Paste (Ctrl+V) or click to attach</span><span class="fm-drop-hint">${escapeHtml(hint)}</span>`;
1632
1632
  return;
1633
1633
  }
1634
1634
  const glyph = st.ext === 'pdf' ? 'pdf' : 'img';
@@ -2778,7 +2778,12 @@
2778
2778
  if (cancelRequested) return;
2779
2779
  if (ev.kind === 'run-start') clearNodeStatus();
2780
2780
  else if (ev.node && ev.kind === 'node-start') setNodeStatus(ev.node, 'running');
2781
- else if (ev.node && /^(node-output|output|write|node-end|node-ok)$/.test(ev.kind)) setNodeStatus(ev.node, 'done');
2781
+ // `would-write` is the simulate-mode counterpart of `write`: a write-mode node,
2782
+ // stubbed during Simulate, emits would-write (with proposed_inputs) instead of
2783
+ // actually writing. Treat it as a completed node so it leaves "running" like its
2784
+ // read-mode peers' node-output does — otherwise write nodes hang on "running"
2785
+ // after a Simulate completes (#104).
2786
+ else if (ev.node && /^(node-output|output|write|would-write|node-end|node-ok)$/.test(ev.kind)) setNodeStatus(ev.node, 'done');
2782
2787
  else if (ev.node && /error/i.test(ev.kind)) setNodeStatus(ev.node, 'error');
2783
2788
  else if (ev.kind === 'run-end' && ev.status && ev.status !== 'ok') {
2784
2789
  // run failed — any node still "running" (started, never produced output) failed
@@ -3902,6 +3907,7 @@
3902
3907
 
3903
3908
  function scheduleText(s) {
3904
3909
  if (!s || !s.kind) return '—';
3910
+ if (s.kind === 'minutes') return s.everyMinutes === 1 ? 'Every minute' : `Every ${s.everyMinutes} minutes`;
3905
3911
  if (s.kind === 'hourly') return s.everyHours === 1 ? 'Every hour' : `Every ${s.everyHours} hours`;
3906
3912
  if (s.kind === 'daily') return `Daily · ${s.time}`;
3907
3913
  if (s.kind === 'weekdays') return `Weekdays · ${s.time}`;
@@ -4481,6 +4487,7 @@
4481
4487
  const sched = (r && r.schedule) ? r.schedule : { kind: 'daily', time: '07:00' };
4482
4488
  fillTimeSelects();
4483
4489
  document.getElementById('rtn-kind').value = sched.kind;
4490
+ document.getElementById('rtn-everyminutes').value = sched.kind === 'minutes' ? String(sched.everyMinutes) : '5';
4484
4491
  document.getElementById('rtn-everyhours').value = sched.kind === 'hourly' ? String(sched.everyHours) : '2';
4485
4492
  setTimeSelects(sched.time || '07:00');
4486
4493
  document.getElementById('rtn-cron').value = sched.kind === 'cron' ? sched.expr : '';
@@ -4503,7 +4510,10 @@
4503
4510
  let schedule;
4504
4511
  if (!asTrigger) {
4505
4512
  const kind = document.getElementById('rtn-kind').value;
4506
- if (kind === 'hourly') {
4513
+ if (kind === 'minutes') {
4514
+ const n = Math.max(1, Math.min(30, parseInt(document.getElementById('rtn-everyminutes').value, 10) || 5));
4515
+ schedule = { kind: 'minutes', everyMinutes: n };
4516
+ } else if (kind === 'hourly') {
4507
4517
  const n = Math.max(1, Math.min(24, parseInt(document.getElementById('rtn-everyhours').value, 10) || 1));
4508
4518
  schedule = { kind: 'hourly', everyHours: n };
4509
4519
  } else if (kind === 'weekly') {
@@ -216,14 +216,18 @@
216
216
  <div class="menu ext-menu" id="ext-menu" role="menu" hidden>
217
217
  <button class="menu-item" id="ext-undo" role="menuitem">
218
218
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/></svg></span>
219
- <span class="menu-label">Undo last change</span>
219
+ <span class="menu-label">Undo</span>
220
+ </button>
221
+ <button class="menu-item" id="ext-redo" role="menuitem">
222
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg></span>
223
+ <span class="menu-label">Redo</span>
220
224
  </button>
221
225
  <button class="menu-item" id="ext-reset" role="menuitem">
222
226
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg></span>
223
227
  <span class="menu-label">Reset to default</span>
224
228
  </button>
225
229
  <div class="menu-divider"></div>
226
- <div class="ext-history-head">History · restored by Undo, newest first</div>
230
+ <div class="ext-history-head">History newest first</div>
227
231
  <div class="ext-history" id="ext-history"></div>
228
232
  </div>
229
233
 
@@ -491,11 +495,23 @@
491
495
  <option value="daily">Every day</option>
492
496
  <option value="weekdays">Every weekday (Mon–Fri)</option>
493
497
  <option value="weekly">Specific days</option>
498
+ <option value="minutes">Every N minutes</option>
494
499
  <option value="hourly">Every N hours</option>
495
500
  <option value="cron">Custom (cron)</option>
496
501
  </select>
497
502
  </div>
498
503
  <div id="rtn-sched-detail">
504
+ <div class="modal-field" data-sched="minutes" hidden>
505
+ <label for="rtn-everyminutes">Run every</label>
506
+ <select id="rtn-everyminutes" class="rtn-kind-select" aria-describedby="rtn-minutes-hint">
507
+ <option value="5" selected>Every 5 minutes</option>
508
+ <option value="10">Every 10 minutes</option>
509
+ <option value="15">Every 15 minutes</option>
510
+ <option value="30">Every 30 minutes</option>
511
+ <option value="1">Every minute</option>
512
+ </select>
513
+ <div class="rtn-cron-hint" id="rtn-minutes-hint">Clock-aligned to the top of the hour (e.g. :00, :15, :30, :45) · actual precision is the ~1-minute scheduler tick.</div>
514
+ </div>
499
515
  <div class="modal-field" data-sched="hourly" hidden>
500
516
  <label for="rtn-everyhours">Run every</label>
501
517
  <select id="rtn-everyhours" class="rtn-kind-select">
@@ -32,6 +32,8 @@
32
32
  const $dashDot = document.getElementById('dash-dot');
33
33
  const $extBadge = document.getElementById('ext-badge');
34
34
  const $extMenu = document.getElementById('ext-menu');
35
+ const $extUndo = document.getElementById('ext-undo');
36
+ const $extRedo = document.getElementById('ext-redo');
35
37
  const $extHistory = document.getElementById('ext-history');
36
38
  const $centerName = document.getElementById('center-panel-name');
37
39
  const $centerRole = document.getElementById('center-panel-role');
@@ -499,42 +501,82 @@
499
501
  if (h < 48) return `${h} h ago`;
500
502
  return `${Math.round(h / 24)} d ago`;
501
503
  }
504
+ // Reflect undo/redo availability on the menu buttons (disabled, not hidden — they're
505
+ // persistent navigation controls; the native :disabled state communicates "you're at
506
+ // the end of the stack"). Called on menu open and after every undo/redo/reset.
507
+ function setUndoRedoEnabled(canUndo, canRedo) {
508
+ if ($extUndo) $extUndo.disabled = !canUndo;
509
+ if ($extRedo) $extRedo.disabled = !canRedo;
510
+ }
502
511
  async function renderHistoryList() {
503
512
  if (!$extHistory) return;
504
513
  $extHistory.innerHTML = '<div class="ext-history-empty">loading…</div>';
505
514
  try {
506
- const { history } = await api('/api/extensions/history');
515
+ const { history, canUndo, canRedo } = await api('/api/extensions/history');
516
+ setUndoRedoEnabled(canUndo, canRedo);
507
517
  if (!history || !history.length) {
508
518
  $extHistory.innerHTML = '<div class="ext-history-empty">No snapshots yet — every change lands here automatically.</div>';
509
519
  return;
510
520
  }
511
521
  $extHistory.innerHTML = history.slice(0, 8).map((h) => {
512
522
  const when = relAgo(h.timestamp) || (h.timestamp ? new Date(h.timestamp).toLocaleString() : '—');
513
- return `<div class="ext-history-item"><span class="ext-history-when" data-tip="${escapeAttr(h.timestamp)}">${escapeHtml(when)}</span>` +
514
- `<span class="ext-history-count">${Number(h.panelsCount) || 0} panel${h.panelsCount === 1 ? '' : 's'}</span></div>`;
523
+ const now = h.current ? '<span class="ext-history-current">now</span>' : '';
524
+ return `<div class="ext-history-item${h.current ? ' is-current' : ''}"><span class="ext-history-when" data-tip="${escapeAttr(h.timestamp)}">${escapeHtml(when)}</span>` +
525
+ `<span class="ext-history-count">${now}${Number(h.panelsCount) || 0} panel${h.panelsCount === 1 ? '' : 's'}</span></div>`;
515
526
  }).join('');
516
527
  } catch {
517
528
  $extHistory.innerHTML = '<div class="ext-history-empty">history unavailable</div>';
518
529
  }
519
530
  }
520
531
 
521
- // The one undo body — shared by the Customized menu's "Undo last change" and
522
- // the empty state's "Restore it". Returns whether the restore happened.
532
+ // The one undo body — shared by the Customized menu's "Undo" and the empty state's
533
+ // "Restore it". Walks one state back per call (multi-level). Returns whether it moved.
523
534
  async function undoNow() {
524
535
  try {
525
- await api('/api/extensions/undo', { method: 'POST' });
536
+ const res = await api('/api/extensions/undo', { method: 'POST' });
537
+ setUndoRedoEnabled(res.canUndo, res.canRedo);
526
538
  showToast('Restored the previous layout', 'ok');
527
539
  fetchExtensions();
528
540
  return true;
529
541
  } catch (err) {
530
- const msg = err && err.body && err.body.error;
531
- if (msg === 'nothing to undo') showToast('Nothing to undo yet', 'info');
542
+ const b = err && err.body;
543
+ if (b) setUndoRedoEnabled(b.canUndo, b.canRedo);
544
+ if (b && b.error === 'nothing to undo') showToast('Nothing to undo', 'info');
532
545
  else showToast('Undo failed: ' + (err && err.message ? err.message : err), 'warn');
533
546
  return false;
534
547
  }
535
548
  }
536
- const $extUndo = document.getElementById('ext-undo');
549
+ // Redo walks one state forward through the redo branch (a new edit truncates it).
550
+ async function redoNow() {
551
+ try {
552
+ const res = await api('/api/extensions/redo', { method: 'POST' });
553
+ setUndoRedoEnabled(res.canUndo, res.canRedo);
554
+ showToast('Restored the next layout', 'ok');
555
+ fetchExtensions();
556
+ return true;
557
+ } catch (err) {
558
+ const b = err && err.body;
559
+ if (b) setUndoRedoEnabled(b.canUndo, b.canRedo);
560
+ if (b && b.error === 'nothing to redo') showToast('Nothing to redo', 'info');
561
+ else showToast('Redo failed: ' + (err && err.message ? err.message : err), 'warn');
562
+ return false;
563
+ }
564
+ }
537
565
  if ($extUndo) $extUndo.onclick = () => { closeExtMenu(); undoNow(); };
566
+ if ($extRedo) $extRedo.onclick = () => { closeExtMenu(); redoNow(); };
567
+
568
+ // Ctrl/Cmd+Z = undo, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z = redo — multi-level, matching the
569
+ // menu. Only while the Dashboard view is active and focus isn't in a text field, so it
570
+ // never steals a real input's (or the terminal's) own undo.
571
+ document.addEventListener('keydown', (e) => {
572
+ if (view !== 'dashboard') return;
573
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
574
+ const t = e.target;
575
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
576
+ const k = e.key.toLowerCase();
577
+ if (k === 'z' && !e.shiftKey) { e.preventDefault(); undoNow(); }
578
+ else if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); redoNow(); }
579
+ });
538
580
 
539
581
  // Reset is recoverable (archived to history first) but still asks — mirrors the
540
582
  // routine-delete confirm: Cancel holds focus; Esc/backdrop cancel.
@@ -562,12 +604,14 @@
562
604
  closeExtMenu();
563
605
  if (!(await confirmReset())) return;
564
606
  try {
565
- await api('/api/extensions/reset', { method: 'POST' });
607
+ const res = await api('/api/extensions/reset', { method: 'POST' });
608
+ setUndoRedoEnabled(res.canUndo, res.canRedo); // symmetric with undo/redo (Undo recovers it)
566
609
  showToast('Dashboard reset — your panels were saved to history', 'ok');
567
610
  fetchExtensions();
568
611
  } catch (err) {
569
- const msg = err && err.body && err.body.error;
570
- if (msg && /nothing to reset/i.test(msg)) showToast('Already the default dashboard', 'info');
612
+ const b = err && err.body;
613
+ if (b) setUndoRedoEnabled(b.canUndo, b.canRedo);
614
+ if (b && b.error && /nothing to reset/i.test(b.error)) showToast('Already the default dashboard', 'info');
571
615
  else showToast('Reset failed: ' + (err && err.message ? err.message : err), 'warn');
572
616
  }
573
617
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.23.3",
3
+ "version": "0.24.1",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {