@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.
- package/dist/floless-server.cjs +145 -100
- package/dist/skills/floless-app-routines/SKILL.md +3 -1
- package/dist/skills/floless-app-ui/SKILL.md +4 -4
- package/dist/web/app.css +7 -1
- package/dist/web/aware.js +13 -3
- package/dist/web/index.html +18 -2
- package/dist/web/panels.js +56 -12
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
52114
|
-
|
|
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
|
-
|
|
52114
|
+
text = (0, import_node_fs5.readFileSync)(TIMELINE_FILE, "utf8");
|
|
52127
52115
|
} catch {
|
|
52128
|
-
return [];
|
|
52116
|
+
return { cursor: -1, entries: [] };
|
|
52129
52117
|
}
|
|
52130
|
-
|
|
52131
|
-
|
|
52132
|
-
|
|
52133
|
-
|
|
52134
|
-
|
|
52135
|
-
|
|
52136
|
-
|
|
52137
|
-
|
|
52138
|
-
|
|
52139
|
-
|
|
52140
|
-
|
|
52141
|
-
|
|
52142
|
-
|
|
52143
|
-
|
|
52144
|
-
|
|
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
|
|
52160
|
-
|
|
52161
|
-
|
|
52162
|
-
|
|
52163
|
-
|
|
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
|
|
52184
|
-
|
|
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
|
-
|
|
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 (
|
|
52198
|
-
|
|
52199
|
-
|
|
52200
|
-
|
|
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
|
-
|
|
52204
|
-
|
|
52205
|
-
|
|
52206
|
-
|
|
52207
|
-
|
|
52208
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 **
|
|
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
|
|
127
|
-
|
|
128
|
-
never destructive: don't delete panels the user
|
|
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 =
|
|
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
|
-
|
|
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 === '
|
|
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') {
|
package/dist/web/index.html
CHANGED
|
@@ -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
|
|
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
|
|
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">
|
package/dist/web/panels.js
CHANGED
|
@@ -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
|
-
|
|
514
|
-
|
|
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
|
|
522
|
-
//
|
|
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
|
|
531
|
-
if (
|
|
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
|
-
|
|
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
|
|
570
|
-
if (
|
|
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
|
};
|