pinokiod 5.1.35 → 5.1.36
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/kernel/git.js +46 -1
- package/package.json +1 -1
- package/server/index.js +82 -0
- package/server/public/nav.js +30 -8
- package/server/public/style.css +14 -1
- package/server/public/urldropdown.js +272 -84
- package/server/views/app.ejs +1 -1
- package/server/views/checkpoints.ejs +129 -5
package/kernel/git.js
CHANGED
|
@@ -84,6 +84,17 @@ class Git {
|
|
|
84
84
|
const offM = pad2(abs % 60)
|
|
85
85
|
return `${y}-${m}-${day}T${hh}:${mm}:${ss}${sign}${offH}:${offM}`
|
|
86
86
|
}
|
|
87
|
+
normalizeRepoPath(rawPath) {
|
|
88
|
+
if (typeof rawPath !== "string") return "."
|
|
89
|
+
let value = rawPath.trim()
|
|
90
|
+
if (!value) return "."
|
|
91
|
+
value = value.replace(/\\/g, "/").replace(/\/{2,}/g, "/")
|
|
92
|
+
if (value === "." || value === "./") return "."
|
|
93
|
+
if (value.startsWith("./")) {
|
|
94
|
+
value = value.slice(2)
|
|
95
|
+
}
|
|
96
|
+
return value || "."
|
|
97
|
+
}
|
|
87
98
|
upsertCommitMeta(repoUrlNorm, sha, meta) {
|
|
88
99
|
if (!repoUrlNorm || typeof repoUrlNorm !== "string") return false
|
|
89
100
|
if (!this.isCommitSha(sha)) return false
|
|
@@ -231,7 +242,7 @@ class Git {
|
|
|
231
242
|
const repos = []
|
|
232
243
|
for (const repo of rawRepos) {
|
|
233
244
|
if (!repo) continue
|
|
234
|
-
const pathVal =
|
|
245
|
+
const pathVal = this.normalizeRepoPath(repo.path)
|
|
235
246
|
let remote = typeof repo.remote === "string" && repo.remote.length > 0 ? repo.remote : null
|
|
236
247
|
if (!remote) remote = typeof repo.repo === "string" && repo.repo.length > 0 ? repo.repo : null
|
|
237
248
|
const commit = typeof repo.commit === "string" && repo.commit.length > 0 ? repo.commit : null
|
|
@@ -559,6 +570,40 @@ class Git {
|
|
|
559
570
|
await this.saveManifest()
|
|
560
571
|
return true
|
|
561
572
|
}
|
|
573
|
+
async deleteCheckpoint(remoteKey, checkpointId) {
|
|
574
|
+
if (!remoteKey || checkpointId == null) return { ok: false, error: "invalid" }
|
|
575
|
+
const apps = this.apps()
|
|
576
|
+
const entry = apps[remoteKey]
|
|
577
|
+
if (!entry || !Array.isArray(entry.checkpoints)) return { ok: false, error: "not_found" }
|
|
578
|
+
const idStr = String(checkpointId)
|
|
579
|
+
const idx = entry.checkpoints.findIndex((c) => c && String(c.id) === idStr)
|
|
580
|
+
if (idx < 0) return { ok: false, error: "not_found" }
|
|
581
|
+
const record = entry.checkpoints[idx]
|
|
582
|
+
entry.checkpoints.splice(idx, 1)
|
|
583
|
+
await this.saveManifest()
|
|
584
|
+
const hash = record && record.hash ? String(record.hash) : null
|
|
585
|
+
let fileDeleted = false
|
|
586
|
+
if (hash) {
|
|
587
|
+
let stillUsed = false
|
|
588
|
+
for (const entry of Object.values(apps)) {
|
|
589
|
+
if (!entry || !Array.isArray(entry.checkpoints)) continue
|
|
590
|
+
if (entry.checkpoints.some((c) => c && String(c.hash) === hash)) {
|
|
591
|
+
stillUsed = true
|
|
592
|
+
break
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (!stillUsed) {
|
|
596
|
+
const filePath = this.checkpointFilePath(hash)
|
|
597
|
+
if (filePath) {
|
|
598
|
+
try {
|
|
599
|
+
await fs.promises.rm(filePath, { force: true })
|
|
600
|
+
fileDeleted = true
|
|
601
|
+
} catch (_) {}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { ok: true, hash, fileDeleted }
|
|
606
|
+
}
|
|
562
607
|
async logCheckpointRestore(event) {
|
|
563
608
|
const logEntry = {
|
|
564
609
|
ts: Date.now(),
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -5205,6 +5205,38 @@ class Server {
|
|
|
5205
5205
|
}
|
|
5206
5206
|
res.json({ ok: true, redirect: `/p/${encodeURIComponent(folder)}` })
|
|
5207
5207
|
}))
|
|
5208
|
+
this.app.post("/checkpoints/delete", ex(async (req, res) => {
|
|
5209
|
+
const remoteRaw = typeof req.body.remote === 'string'
|
|
5210
|
+
? req.body.remote.trim()
|
|
5211
|
+
: (typeof req.query.remote === 'string' ? req.query.remote.trim() : '')
|
|
5212
|
+
const remoteKeyRaw = typeof req.body.remoteKey === 'string'
|
|
5213
|
+
? req.body.remoteKey.trim()
|
|
5214
|
+
: (typeof req.query.remoteKey === 'string' ? req.query.remoteKey.trim() : '')
|
|
5215
|
+
const snapshotRaw = Object.prototype.hasOwnProperty.call(req.body || {}, "snapshotId")
|
|
5216
|
+
? req.body.snapshotId
|
|
5217
|
+
: (Object.prototype.hasOwnProperty.call(req.query || {}, "snapshotId") ? req.query.snapshotId : "")
|
|
5218
|
+
const snapshotId = snapshotRaw === 'latest'
|
|
5219
|
+
? ''
|
|
5220
|
+
: (snapshotRaw == null ? '' : String(snapshotRaw))
|
|
5221
|
+
const remoteKey = remoteKeyRaw || (remoteRaw ? this.kernel.git.normalizeRemote(remoteRaw) : '')
|
|
5222
|
+
if (!snapshotId || !remoteKey) {
|
|
5223
|
+
res.status(400).json({ ok: false, error: "Missing parameters" })
|
|
5224
|
+
return
|
|
5225
|
+
}
|
|
5226
|
+
const result = await this.kernel.git.deleteCheckpoint(remoteKey, snapshotId)
|
|
5227
|
+
if (!result || !result.ok) {
|
|
5228
|
+
res.status(404).json({ ok: false, error: "Snapshot not found" })
|
|
5229
|
+
return
|
|
5230
|
+
}
|
|
5231
|
+
res.json({
|
|
5232
|
+
ok: true,
|
|
5233
|
+
deleted: {
|
|
5234
|
+
id: snapshotId,
|
|
5235
|
+
hash: result.hash || null,
|
|
5236
|
+
fileDeleted: !!result.fileDeleted
|
|
5237
|
+
}
|
|
5238
|
+
})
|
|
5239
|
+
}))
|
|
5208
5240
|
this.app.get("/checkpoints/restore/:workspace/:snapshotId", ex(async (req, res) => {
|
|
5209
5241
|
const workspace = typeof req.params.workspace === 'string' ? req.params.workspace : ''
|
|
5210
5242
|
const snapshotId = req.params.snapshotId
|
|
@@ -8652,6 +8684,56 @@ class Server {
|
|
|
8652
8684
|
}))
|
|
8653
8685
|
|
|
8654
8686
|
|
|
8687
|
+
this.app.get("/info/apps", ex(async (req, res) => {
|
|
8688
|
+
const apps = []
|
|
8689
|
+
try {
|
|
8690
|
+
const apipath = this.kernel.path("api")
|
|
8691
|
+
const entries = await fs.promises.readdir(apipath, { withFileTypes: true })
|
|
8692
|
+
for (const entry of entries) {
|
|
8693
|
+
let type
|
|
8694
|
+
try {
|
|
8695
|
+
type = await Util.file_type(apipath, entry)
|
|
8696
|
+
} catch (typeErr) {
|
|
8697
|
+
console.warn('Failed to inspect api entry', entry.name, typeErr)
|
|
8698
|
+
continue
|
|
8699
|
+
}
|
|
8700
|
+
if (!type || !type.directory) {
|
|
8701
|
+
continue
|
|
8702
|
+
}
|
|
8703
|
+
try {
|
|
8704
|
+
const meta = await this.kernel.api.meta(entry.name)
|
|
8705
|
+
apps.push({
|
|
8706
|
+
name: entry.name,
|
|
8707
|
+
title: meta && meta.title ? meta.title : entry.name,
|
|
8708
|
+
description: meta && meta.description ? meta.description : '',
|
|
8709
|
+
icon: meta && meta.icon ? meta.icon : "/pinokio-black.png"
|
|
8710
|
+
})
|
|
8711
|
+
} catch (metaError) {
|
|
8712
|
+
console.warn('Failed to load app metadata', entry.name, metaError)
|
|
8713
|
+
apps.push({
|
|
8714
|
+
name: entry.name,
|
|
8715
|
+
title: entry.name,
|
|
8716
|
+
description: '',
|
|
8717
|
+
icon: "/pinokio-black.png"
|
|
8718
|
+
})
|
|
8719
|
+
}
|
|
8720
|
+
}
|
|
8721
|
+
} catch (enumerationError) {
|
|
8722
|
+
console.warn('Failed to enumerate api apps for url dropdown', enumerationError)
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
apps.sort((a, b) => {
|
|
8726
|
+
const at = (a.title || a.name || '').toLowerCase()
|
|
8727
|
+
const bt = (b.title || b.name || '').toLowerCase()
|
|
8728
|
+
if (at < bt) return -1
|
|
8729
|
+
if (at > bt) return 1
|
|
8730
|
+
return (a.name || '').localeCompare(b.name || '')
|
|
8731
|
+
})
|
|
8732
|
+
|
|
8733
|
+
res.json({ apps })
|
|
8734
|
+
}))
|
|
8735
|
+
|
|
8736
|
+
|
|
8655
8737
|
this.app.get("/info/procs", ex(async (req, res) => {
|
|
8656
8738
|
await this.kernel.processes.refresh()
|
|
8657
8739
|
|
package/server/public/nav.js
CHANGED
|
@@ -25,8 +25,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const homeIcon = homeLink ? homeLink.querySelector("img.icon") : null;
|
|
29
|
+
const ensureHomeExpandIcon = () => {
|
|
30
|
+
if (!homeLink || !homeIcon) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
let icon = homeLink.querySelector(".home-expand-icon");
|
|
34
|
+
if (!icon) {
|
|
35
|
+
icon = document.createElement("i");
|
|
36
|
+
icon.className = "fa-solid fa-expand home-expand-icon";
|
|
37
|
+
icon.setAttribute("aria-hidden", "true");
|
|
38
|
+
homeLink.appendChild(icon);
|
|
39
|
+
}
|
|
40
|
+
return icon;
|
|
41
|
+
};
|
|
42
|
+
ensureHomeExpandIcon();
|
|
43
|
+
|
|
28
44
|
// Helper functions used during initial restore must be defined before use
|
|
29
|
-
const MIN_MARGIN =
|
|
45
|
+
const MIN_MARGIN = 0;
|
|
46
|
+
const LEGACY_MARGIN = 8;
|
|
30
47
|
|
|
31
48
|
function clampPosition(left, top, sizeOverride) {
|
|
32
49
|
const rect = header.getBoundingClientRect();
|
|
@@ -168,19 +185,24 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
168
185
|
// Restore persisted or respect DOM state on load (per path, per session)
|
|
169
186
|
const persisted = readPersisted();
|
|
170
187
|
const restoreFromStorage = !!(persisted && persisted.minimized);
|
|
188
|
+
const hasStoredPosition = !!(persisted && Number.isFinite(persisted.left) && Number.isFinite(persisted.top));
|
|
189
|
+
const isLegacyDefault = hasStoredPosition
|
|
190
|
+
&& Math.abs(persisted.left - LEGACY_MARGIN) < 0.5
|
|
191
|
+
&& Math.abs(persisted.top - LEGACY_MARGIN) < 0.5;
|
|
192
|
+
const useStoredPosition = restoreFromStorage && hasStoredPosition && !isLegacyDefault;
|
|
171
193
|
const domIsMinimized = header.classList.contains("minimized");
|
|
172
194
|
if (restoreFromStorage || domIsMinimized) {
|
|
173
195
|
header.classList.add("minimized");
|
|
174
196
|
// Use minimized size for clamping/positioning
|
|
175
197
|
const size = measureRect((clone) => { clone.classList.add("minimized"); });
|
|
176
|
-
const fallbackLeft =
|
|
177
|
-
const fallbackTop =
|
|
178
|
-
const left =
|
|
179
|
-
const top =
|
|
198
|
+
const fallbackLeft = MIN_MARGIN;
|
|
199
|
+
const fallbackTop = MIN_MARGIN;
|
|
200
|
+
const left = useStoredPosition ? persisted.left : fallbackLeft;
|
|
201
|
+
const top = useStoredPosition ? persisted.top : fallbackTop;
|
|
180
202
|
const clamped = clampPosition(left, top, size);
|
|
181
203
|
state.lastLeft = clamped.left;
|
|
182
204
|
state.lastTop = clamped.top;
|
|
183
|
-
state.hasCustomPosition =
|
|
205
|
+
state.hasCustomPosition = useStoredPosition;
|
|
184
206
|
state.minimized = true;
|
|
185
207
|
// Apply immediately and once after layout settles
|
|
186
208
|
applyPosition(clamped.left, clamped.top);
|
|
@@ -234,8 +256,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
234
256
|
clone.classList.add("minimized");
|
|
235
257
|
});
|
|
236
258
|
|
|
237
|
-
const defaultLeft =
|
|
238
|
-
const defaultTop =
|
|
259
|
+
const defaultLeft = MIN_MARGIN;
|
|
260
|
+
const defaultTop = MIN_MARGIN;
|
|
239
261
|
const targetLeft = state.hasCustomPosition ? state.lastLeft : defaultLeft;
|
|
240
262
|
const targetTop = state.hasCustomPosition ? state.lastTop : defaultTop;
|
|
241
263
|
|
package/server/public/style.css
CHANGED
|
@@ -2978,7 +2978,6 @@ header.navheader.minimized {
|
|
|
2978
2978
|
height: auto;
|
|
2979
2979
|
max-height: none;
|
|
2980
2980
|
padding: 4px 8px;
|
|
2981
|
-
border-radius: 12px;
|
|
2982
2981
|
background: var(--light-nav-bg);
|
|
2983
2982
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08);
|
|
2984
2983
|
display: inline-flex;
|
|
@@ -3052,9 +3051,23 @@ header.navheader.minimized .home {
|
|
|
3052
3051
|
padding: 0;
|
|
3053
3052
|
}
|
|
3054
3053
|
header.navheader.minimized .home .icon {
|
|
3054
|
+
display: none;
|
|
3055
3055
|
width: 24px;
|
|
3056
3056
|
height: 24px;
|
|
3057
3057
|
}
|
|
3058
|
+
header.navheader .home .home-expand-icon {
|
|
3059
|
+
display: none;
|
|
3060
|
+
}
|
|
3061
|
+
header.navheader.minimized .home .home-expand-icon {
|
|
3062
|
+
display: inline-flex;
|
|
3063
|
+
width: 24px;
|
|
3064
|
+
height: 24px;
|
|
3065
|
+
align-items: center;
|
|
3066
|
+
justify-content: center;
|
|
3067
|
+
font-size: 16px;
|
|
3068
|
+
line-height: 1;
|
|
3069
|
+
color: inherit;
|
|
3070
|
+
}
|
|
3058
3071
|
|
|
3059
3072
|
|
|
3060
3073
|
header.navheader.transitioning {
|
|
@@ -69,6 +69,7 @@ function initUrlDropdown(config = {}) {
|
|
|
69
69
|
clearBehavior: config.clearBehavior || 'empty', // 'empty' or 'restore'
|
|
70
70
|
defaultValue: config.defaultValue || '',
|
|
71
71
|
apiEndpoint: config.apiEndpoint || '/info/procs',
|
|
72
|
+
appsEndpoint: config.appsEndpoint || '/info/apps',
|
|
72
73
|
...config
|
|
73
74
|
};
|
|
74
75
|
|
|
@@ -218,9 +219,113 @@ function initUrlDropdown(config = {}) {
|
|
|
218
219
|
return `<div class="url-mode-buttons" role="group" aria-label="Project views">${buttonsHtml}</div>`;
|
|
219
220
|
};
|
|
220
221
|
|
|
222
|
+
const getAppBasePath = (app) => {
|
|
223
|
+
if (!app || !app.name) return '';
|
|
224
|
+
return `/p/${encodeURIComponent(app.name)}`;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const getAppDisplayTitle = (app) => {
|
|
228
|
+
if (!app) return 'Untitled app';
|
|
229
|
+
return app.title || app.name || 'Untitled app';
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const buildAppProjectContext = (app, origin) => {
|
|
233
|
+
const basePath = getAppBasePath(app);
|
|
234
|
+
if (!basePath) return null;
|
|
235
|
+
return {
|
|
236
|
+
origin: origin || '',
|
|
237
|
+
project: app.name,
|
|
238
|
+
basePath,
|
|
239
|
+
currentMode: 'run'
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const buildAppsSectionHtml = (apps, {
|
|
244
|
+
includeCurrentTab = true,
|
|
245
|
+
currentUrl = '',
|
|
246
|
+
currentTitle = 'Current tab',
|
|
247
|
+
currentProject = null,
|
|
248
|
+
origin = ''
|
|
249
|
+
} = {}) => {
|
|
250
|
+
const entries = [];
|
|
251
|
+
|
|
252
|
+
if (includeCurrentTab && currentUrl) {
|
|
253
|
+
const schemeLabel = currentUrl.startsWith('https://') ? 'HTTPS' : 'HTTP';
|
|
254
|
+
const currentPathLabel = currentProject ? currentProject.basePath : formatUrlLabel(currentUrl) || currentUrl;
|
|
255
|
+
const projectButtons = currentProject ? buildProjectModeButtons(currentProject) : '';
|
|
256
|
+
entries.push(`
|
|
257
|
+
<div class="url-dropdown-item${currentProject ? ' non-selectable current-project' : ''}" data-url="${escapeAttribute(currentUrl)}" data-host-type="current">
|
|
258
|
+
<div class="url-dropdown-name">
|
|
259
|
+
<span>
|
|
260
|
+
<i class="fa-solid fa-clone"></i>
|
|
261
|
+
${escapeHtml(currentTitle)}
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
${currentProject ? `
|
|
265
|
+
<div class="url-dropdown-url">
|
|
266
|
+
<span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
|
|
267
|
+
<span class="url-address">${escapeHtml(currentPathLabel)}</span>
|
|
268
|
+
</div>
|
|
269
|
+
${projectButtons}
|
|
270
|
+
` : `
|
|
271
|
+
<div class="url-dropdown-url">
|
|
272
|
+
<span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
|
|
273
|
+
<span class="url-address">${escapeHtml(currentPathLabel)}</span>
|
|
274
|
+
</div>
|
|
275
|
+
`}
|
|
276
|
+
</div>
|
|
277
|
+
`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
(Array.isArray(apps) ? apps : []).forEach((app) => {
|
|
281
|
+
if (!app || !app.name) return;
|
|
282
|
+
if (currentProject && app.name === currentProject.project) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const projectCtx = buildAppProjectContext(app, origin);
|
|
286
|
+
if (!projectCtx) return;
|
|
287
|
+
const displayUrl = projectCtx.origin ? `${projectCtx.origin}${projectCtx.basePath}` : projectCtx.basePath;
|
|
288
|
+
const schemeLabel = displayUrl.startsWith('https://') ? 'HTTPS' : 'HTTP';
|
|
289
|
+
const projectButtons = buildProjectModeButtons(projectCtx);
|
|
290
|
+
const displayTitle = getAppDisplayTitle(app);
|
|
291
|
+
entries.push(`
|
|
292
|
+
<div class="url-dropdown-item non-selectable current-project" data-url="${escapeAttribute(displayUrl)}" data-host-type="current">
|
|
293
|
+
<div class="url-dropdown-name">
|
|
294
|
+
<span>
|
|
295
|
+
<i class="fa-solid fa-box"></i>
|
|
296
|
+
${escapeHtml(displayTitle)}
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="url-dropdown-url">
|
|
300
|
+
<span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
|
|
301
|
+
<span class="url-address">${escapeHtml(projectCtx.basePath)}</span>
|
|
302
|
+
</div>
|
|
303
|
+
${projectButtons}
|
|
304
|
+
</div>
|
|
305
|
+
`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (entries.length === 0) {
|
|
309
|
+
return '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return `
|
|
313
|
+
<div class="url-dropdown-host-header current-tab">
|
|
314
|
+
<span class="host-name">Apps</span>
|
|
315
|
+
</div>
|
|
316
|
+
${entries.join('')}
|
|
317
|
+
`;
|
|
318
|
+
};
|
|
319
|
+
|
|
221
320
|
let isDropdownVisible = false;
|
|
222
321
|
let allProcesses = []; // Store all processes for filtering
|
|
223
322
|
let filteredProcesses = []; // Store currently filtered processes
|
|
323
|
+
let allApps = [];
|
|
324
|
+
let filteredApps = [];
|
|
325
|
+
let processesLoaded = false;
|
|
326
|
+
let appsLoaded = false;
|
|
327
|
+
let processesFetchPromise = null;
|
|
328
|
+
let appsFetchPromise = null;
|
|
224
329
|
let createLauncherModal = null;
|
|
225
330
|
let pendingCreateDetail = null;
|
|
226
331
|
let mobileModalKeydownHandler = null;
|
|
@@ -266,7 +371,7 @@ function initUrlDropdown(config = {}) {
|
|
|
266
371
|
if (!el) {
|
|
267
372
|
return;
|
|
268
373
|
}
|
|
269
|
-
const val = el.value
|
|
374
|
+
const val = el.value
|
|
270
375
|
const type = el.getAttribute("data-host-type");
|
|
271
376
|
openUrlWithType(val, type);
|
|
272
377
|
});
|
|
@@ -285,28 +390,21 @@ function initUrlDropdown(config = {}) {
|
|
|
285
390
|
}
|
|
286
391
|
}
|
|
287
392
|
|
|
288
|
-
|
|
289
|
-
if (!
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
393
|
+
const normalizeAppsResponse = (data) => {
|
|
394
|
+
if (!data) return [];
|
|
395
|
+
if (Array.isArray(data.apps)) return data.apps;
|
|
396
|
+
if (Array.isArray(data)) return data;
|
|
397
|
+
return [];
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const fetchProcesses = () => {
|
|
401
|
+
if (processesLoaded) {
|
|
402
|
+
return Promise.resolve(allProcesses);
|
|
294
403
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
dropdown.style.display = 'block';
|
|
298
|
-
|
|
299
|
-
// If we already have processes data, show all initially
|
|
300
|
-
if (allProcesses.length > 0) {
|
|
301
|
-
showAllProcesses();
|
|
302
|
-
return;
|
|
404
|
+
if (processesFetchPromise) {
|
|
405
|
+
return processesFetchPromise;
|
|
303
406
|
}
|
|
304
|
-
|
|
305
|
-
// Otherwise, show loading and fetch data
|
|
306
|
-
dropdown.innerHTML = '<div class="url-dropdown-loading">Loading running processes...</div>';
|
|
307
|
-
|
|
308
|
-
// Fetch processes from API
|
|
309
|
-
fetch(options.apiEndpoint)
|
|
407
|
+
processesFetchPromise = fetch(options.apiEndpoint)
|
|
310
408
|
.then(response => {
|
|
311
409
|
if (!response.ok) {
|
|
312
410
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
@@ -315,18 +413,90 @@ function initUrlDropdown(config = {}) {
|
|
|
315
413
|
})
|
|
316
414
|
.then(data => {
|
|
317
415
|
allProcesses = data.info || [];
|
|
318
|
-
|
|
416
|
+
processesLoaded = true;
|
|
417
|
+
return allProcesses;
|
|
319
418
|
})
|
|
320
419
|
.catch(error => {
|
|
321
420
|
console.error('Failed to fetch processes:', error);
|
|
322
|
-
dropdown.innerHTML = '<div class="url-dropdown-empty">Failed to load processes</div>';
|
|
323
421
|
allProcesses = [];
|
|
422
|
+
processesLoaded = true;
|
|
423
|
+
return allProcesses;
|
|
424
|
+
})
|
|
425
|
+
.finally(() => {
|
|
426
|
+
processesFetchPromise = null;
|
|
427
|
+
});
|
|
428
|
+
return processesFetchPromise;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const fetchApps = () => {
|
|
432
|
+
if (appsLoaded) {
|
|
433
|
+
return Promise.resolve(allApps);
|
|
434
|
+
}
|
|
435
|
+
if (appsFetchPromise) {
|
|
436
|
+
return appsFetchPromise;
|
|
437
|
+
}
|
|
438
|
+
appsFetchPromise = fetch(options.appsEndpoint)
|
|
439
|
+
.then(response => {
|
|
440
|
+
if (!response.ok) {
|
|
441
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
442
|
+
}
|
|
443
|
+
return response.json();
|
|
444
|
+
})
|
|
445
|
+
.then(data => {
|
|
446
|
+
allApps = normalizeAppsResponse(data);
|
|
447
|
+
appsLoaded = true;
|
|
448
|
+
return allApps;
|
|
449
|
+
})
|
|
450
|
+
.catch(error => {
|
|
451
|
+
console.error('Failed to fetch apps:', error);
|
|
452
|
+
allApps = [];
|
|
453
|
+
appsLoaded = true;
|
|
454
|
+
return allApps;
|
|
455
|
+
})
|
|
456
|
+
.finally(() => {
|
|
457
|
+
appsFetchPromise = null;
|
|
458
|
+
});
|
|
459
|
+
return appsFetchPromise;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
function showDropdown() {
|
|
463
|
+
if (!dropdown || !urlInput) return;
|
|
464
|
+
const needsProcessFetch = !processesLoaded;
|
|
465
|
+
const needsAppsFetch = !appsLoaded;
|
|
466
|
+
if (isDropdownVisible && !needsProcessFetch && !needsAppsFetch) {
|
|
467
|
+
// If dropdown is already visible and we have data, show all initially
|
|
468
|
+
showAllProcesses();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
isDropdownVisible = true;
|
|
473
|
+
dropdown.style.display = 'block';
|
|
474
|
+
|
|
475
|
+
const hasAnyData = allProcesses.length > 0 || allApps.length > 0;
|
|
476
|
+
if (hasAnyData) {
|
|
477
|
+
showAllProcesses();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!needsProcessFetch && !needsAppsFetch) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!hasAnyData) {
|
|
485
|
+
dropdown.innerHTML = '<div class="url-dropdown-loading">Loading apps and running processes...</div>';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
Promise.allSettled([fetchApps(), fetchProcesses()])
|
|
489
|
+
.then(() => {
|
|
490
|
+
if (isDropdownVisible) {
|
|
491
|
+
showAllProcesses();
|
|
492
|
+
}
|
|
324
493
|
});
|
|
325
494
|
}
|
|
326
495
|
|
|
327
496
|
function showAllProcesses() {
|
|
328
497
|
filteredProcesses = allProcesses;
|
|
329
|
-
|
|
498
|
+
filteredApps = allApps;
|
|
499
|
+
populateDropdown(filteredProcesses, filteredApps);
|
|
330
500
|
}
|
|
331
501
|
|
|
332
502
|
function handleInputChange() {
|
|
@@ -339,9 +509,11 @@ function initUrlDropdown(config = {}) {
|
|
|
339
509
|
if (urlInput.selectionStart === 0 && urlInput.selectionEnd === urlInput.value.length) {
|
|
340
510
|
// Text is fully selected, show all processes until user starts typing
|
|
341
511
|
filteredProcesses = allProcesses;
|
|
512
|
+
filteredApps = allApps;
|
|
342
513
|
} else if (!query) {
|
|
343
514
|
// No query, show all processes
|
|
344
515
|
filteredProcesses = allProcesses;
|
|
516
|
+
filteredApps = allApps;
|
|
345
517
|
} else {
|
|
346
518
|
// Filter processes based on name and URL
|
|
347
519
|
filteredProcesses = allProcesses.filter(process => {
|
|
@@ -352,9 +524,19 @@ function initUrlDropdown(config = {}) {
|
|
|
352
524
|
const urls = getProcessFilterValues(process);
|
|
353
525
|
return urls.some((value) => (value || '').toLowerCase().includes(query));
|
|
354
526
|
});
|
|
527
|
+
filteredApps = allApps.filter(app => {
|
|
528
|
+
const name = (app && app.name ? app.name : '').toLowerCase();
|
|
529
|
+
const title = (app && app.title ? app.title : '').toLowerCase();
|
|
530
|
+
const description = (app && app.description ? app.description : '').toLowerCase();
|
|
531
|
+
if (name.includes(query) || title.includes(query) || description.includes(query)) {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
const basePath = getAppBasePath(app);
|
|
535
|
+
return (basePath || '').toLowerCase().includes(query);
|
|
536
|
+
});
|
|
355
537
|
}
|
|
356
538
|
|
|
357
|
-
populateDropdown(filteredProcesses);
|
|
539
|
+
populateDropdown(filteredProcesses, filteredApps);
|
|
358
540
|
}
|
|
359
541
|
|
|
360
542
|
function hideDropdown() {
|
|
@@ -526,54 +708,37 @@ function initUrlDropdown(config = {}) {
|
|
|
526
708
|
return html;
|
|
527
709
|
};
|
|
528
710
|
|
|
529
|
-
const buildDropdownHtml = (processes, { includeCurrentTab = true, inputElement } = {}) => {
|
|
711
|
+
const buildDropdownHtml = (processes, { includeCurrentTab = true, apps = [], inputElement } = {}) => {
|
|
530
712
|
const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
|
|
531
713
|
const currentTitle = typeof document !== 'undefined' ? (document.title || 'Current tab') : 'Current tab';
|
|
532
714
|
const currentProject = parseProjectContext(currentUrl);
|
|
715
|
+
const origin = typeof window !== 'undefined' && window.location ? window.location.origin : '';
|
|
533
716
|
|
|
534
717
|
let html = '';
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
<div class="url-dropdown-name">
|
|
545
|
-
<span>
|
|
546
|
-
<i class="fa-solid fa-clone"></i>
|
|
547
|
-
${escapeHtml(currentTitle)}
|
|
548
|
-
</span>
|
|
549
|
-
</div>
|
|
550
|
-
${currentProject ? `
|
|
551
|
-
<div class="url-dropdown-url">
|
|
552
|
-
<span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
|
|
553
|
-
<span class="url-address">${escapeHtml(currentPathLabel)}</span>
|
|
554
|
-
</div>
|
|
555
|
-
${projectButtons}
|
|
556
|
-
` : `
|
|
557
|
-
<div class="url-dropdown-url">
|
|
558
|
-
<span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
|
|
559
|
-
<span class="url-address">${escapeHtml(currentPathLabel)}</span>
|
|
560
|
-
</div>
|
|
561
|
-
`}
|
|
562
|
-
</div>
|
|
563
|
-
`;
|
|
718
|
+
const appsHtml = buildAppsSectionHtml(apps, {
|
|
719
|
+
includeCurrentTab,
|
|
720
|
+
currentUrl,
|
|
721
|
+
currentTitle,
|
|
722
|
+
currentProject,
|
|
723
|
+
origin
|
|
724
|
+
});
|
|
725
|
+
if (appsHtml) {
|
|
726
|
+
html += appsHtml;
|
|
564
727
|
}
|
|
565
728
|
|
|
566
|
-
if (
|
|
567
|
-
html +=
|
|
568
|
-
|
|
729
|
+
if (processes && processes.length > 0) {
|
|
730
|
+
html += buildHostsHtml(processes);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!html) {
|
|
734
|
+
html = createEmptyStateHtml(getEmptyStateMessage(inputElement));
|
|
569
735
|
}
|
|
570
736
|
|
|
571
|
-
html += buildHostsHtml(processes);
|
|
572
737
|
return html;
|
|
573
738
|
};
|
|
574
739
|
|
|
575
|
-
function populateDropdown(processes) {
|
|
576
|
-
dropdown.innerHTML = buildDropdownHtml(processes, { includeCurrentTab: true, inputElement: urlInput });
|
|
740
|
+
function populateDropdown(processes, apps) {
|
|
741
|
+
dropdown.innerHTML = buildDropdownHtml(processes, { includeCurrentTab: true, apps, inputElement: urlInput });
|
|
577
742
|
attachCreateButtonHandler(dropdown, urlInput);
|
|
578
743
|
attachUrlItemHandlers(dropdown);
|
|
579
744
|
}
|
|
@@ -758,7 +923,7 @@ function initUrlDropdown(config = {}) {
|
|
|
758
923
|
const description = document.createElement('p');
|
|
759
924
|
description.className = 'url-modal-description';
|
|
760
925
|
description.id = 'url-modal-description';
|
|
761
|
-
description.textContent = 'Enter a local URL or choose from running processes.';
|
|
926
|
+
description.textContent = 'Enter a local URL or choose from apps and running processes.';
|
|
762
927
|
|
|
763
928
|
content.setAttribute('aria-labelledby', heading.id);
|
|
764
929
|
content.setAttribute('aria-describedby', description.id);
|
|
@@ -980,46 +1145,46 @@ function initUrlDropdown(config = {}) {
|
|
|
980
1145
|
|
|
981
1146
|
const includeCurrent = modalDropdown._includeCurrent !== false;
|
|
982
1147
|
modalDropdown._includeCurrent = includeCurrent;
|
|
1148
|
+
const needsProcessFetch = !processesLoaded;
|
|
1149
|
+
const needsAppsFetch = !appsLoaded;
|
|
983
1150
|
|
|
984
|
-
const render = (processes) => {
|
|
1151
|
+
const render = (processes, apps) => {
|
|
985
1152
|
modalDropdown.innerHTML = buildDropdownHtml(processes, {
|
|
986
1153
|
includeCurrentTab: includeCurrent,
|
|
1154
|
+
apps,
|
|
987
1155
|
inputElement: modalDropdown.parentElement.querySelector('.url-modal-input')
|
|
988
1156
|
});
|
|
989
1157
|
attachCreateButtonHandler(modalDropdown, modalDropdown.parentElement.querySelector('.url-modal-input'));
|
|
990
1158
|
attachUrlItemHandlers(modalDropdown, { onSelect: handleModalSelection });
|
|
991
1159
|
};
|
|
992
1160
|
|
|
993
|
-
|
|
994
|
-
|
|
1161
|
+
const hasAnyData = allProcesses.length > 0 || allApps.length > 0;
|
|
1162
|
+
if (hasAnyData) {
|
|
1163
|
+
render(allProcesses, allApps);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (!needsProcessFetch && !needsAppsFetch) {
|
|
995
1167
|
return;
|
|
996
1168
|
}
|
|
997
1169
|
|
|
998
|
-
|
|
1170
|
+
if (!hasAnyData) {
|
|
1171
|
+
modalDropdown.innerHTML = '<div class="url-dropdown-loading">Loading apps and running processes...</div>';
|
|
1172
|
+
}
|
|
999
1173
|
|
|
1000
|
-
|
|
1001
|
-
.then(
|
|
1002
|
-
|
|
1003
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1004
|
-
}
|
|
1005
|
-
return response.json();
|
|
1006
|
-
})
|
|
1007
|
-
.then(data => {
|
|
1008
|
-
allProcesses = data.info || [];
|
|
1009
|
-
render(allProcesses);
|
|
1010
|
-
})
|
|
1011
|
-
.catch(error => {
|
|
1012
|
-
console.error('Failed to fetch processes:', error);
|
|
1013
|
-
modalDropdown.innerHTML = '<div class="url-dropdown-empty">Failed to load processes</div>';
|
|
1174
|
+
Promise.allSettled([fetchApps(), fetchProcesses()])
|
|
1175
|
+
.then(() => {
|
|
1176
|
+
render(allProcesses, allApps);
|
|
1014
1177
|
});
|
|
1015
1178
|
}
|
|
1016
1179
|
|
|
1017
1180
|
function handleModalInputChange(modalInput, modalDropdown) {
|
|
1018
1181
|
const query = modalInput.value.toLowerCase().trim();
|
|
1019
1182
|
let filtered = allProcesses;
|
|
1183
|
+
let filteredAppList = allApps;
|
|
1020
1184
|
|
|
1021
1185
|
if (modalInput.selectionStart === 0 && modalInput.selectionEnd === modalInput.value.length) {
|
|
1022
1186
|
filtered = allProcesses;
|
|
1187
|
+
filteredAppList = allApps;
|
|
1023
1188
|
} else if (query) {
|
|
1024
1189
|
filtered = allProcesses.filter(process => {
|
|
1025
1190
|
const name = (process.name || '').toLowerCase();
|
|
@@ -1029,18 +1194,29 @@ function initUrlDropdown(config = {}) {
|
|
|
1029
1194
|
const urls = getProcessFilterValues(process);
|
|
1030
1195
|
return urls.some((value) => (value || '').toLowerCase().includes(query));
|
|
1031
1196
|
});
|
|
1197
|
+
filteredAppList = allApps.filter(app => {
|
|
1198
|
+
const name = (app && app.name ? app.name : '').toLowerCase();
|
|
1199
|
+
const title = (app && app.title ? app.title : '').toLowerCase();
|
|
1200
|
+
const description = (app && app.description ? app.description : '').toLowerCase();
|
|
1201
|
+
if (name.includes(query) || title.includes(query) || description.includes(query)) {
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
const basePath = getAppBasePath(app);
|
|
1205
|
+
return (basePath || '').toLowerCase().includes(query);
|
|
1206
|
+
});
|
|
1032
1207
|
}
|
|
1033
1208
|
|
|
1034
|
-
populateModalDropdown(filtered, modalDropdown);
|
|
1209
|
+
populateModalDropdown(filtered, filteredAppList, modalDropdown);
|
|
1035
1210
|
}
|
|
1036
1211
|
|
|
1037
|
-
function populateModalDropdown(processes, modalDropdown) {
|
|
1212
|
+
function populateModalDropdown(processes, apps, modalDropdown) {
|
|
1038
1213
|
const modalInput = modalDropdown.parentElement.querySelector('.url-modal-input');
|
|
1039
1214
|
const overlayRefs = getModalRefs();
|
|
1040
1215
|
const includeCurrent = overlayRefs?.includeCurrent !== false;
|
|
1041
1216
|
|
|
1042
1217
|
modalDropdown.innerHTML = buildDropdownHtml(processes, {
|
|
1043
1218
|
includeCurrentTab: includeCurrent,
|
|
1219
|
+
apps,
|
|
1044
1220
|
inputElement: modalInput
|
|
1045
1221
|
});
|
|
1046
1222
|
|
|
@@ -1061,6 +1237,12 @@ function initUrlDropdown(config = {}) {
|
|
|
1061
1237
|
closeMobileModal,
|
|
1062
1238
|
refresh: function() {
|
|
1063
1239
|
allProcesses = []; // Clear cache to force refetch
|
|
1240
|
+
allApps = [];
|
|
1241
|
+
filteredApps = [];
|
|
1242
|
+
processesLoaded = false;
|
|
1243
|
+
appsLoaded = false;
|
|
1244
|
+
processesFetchPromise = null;
|
|
1245
|
+
appsFetchPromise = null;
|
|
1064
1246
|
if (isDropdownVisible) {
|
|
1065
1247
|
showDropdown();
|
|
1066
1248
|
}
|
|
@@ -1069,7 +1251,7 @@ function initUrlDropdown(config = {}) {
|
|
|
1069
1251
|
openSplitModal: function(modalOptions = {}) {
|
|
1070
1252
|
return showMobileModal({
|
|
1071
1253
|
title: modalOptions.title || 'Split View',
|
|
1072
|
-
description: modalOptions.description || 'Choose a running process or use the current tab URL for the new pane.',
|
|
1254
|
+
description: modalOptions.description || 'Choose an app, a running process, or use the current tab URL for the new pane.',
|
|
1073
1255
|
confirmLabel: modalOptions.confirmLabel || 'Split',
|
|
1074
1256
|
includeCurrent: modalOptions.includeCurrent !== false,
|
|
1075
1257
|
awaitSelection: true,
|
|
@@ -1087,6 +1269,12 @@ function initUrlDropdown(config = {}) {
|
|
|
1087
1269
|
closeMobileModal({ suppressResolve: true });
|
|
1088
1270
|
allProcesses = [];
|
|
1089
1271
|
filteredProcesses = [];
|
|
1272
|
+
allApps = [];
|
|
1273
|
+
filteredApps = [];
|
|
1274
|
+
processesLoaded = false;
|
|
1275
|
+
appsLoaded = false;
|
|
1276
|
+
processesFetchPromise = null;
|
|
1277
|
+
appsFetchPromise = null;
|
|
1090
1278
|
if (fallbackElements.form && fallbackElements.form.parentElement) {
|
|
1091
1279
|
fallbackElements.form.parentElement.removeChild(fallbackElements.form);
|
|
1092
1280
|
}
|
|
@@ -1110,7 +1298,7 @@ function initUrlDropdown(config = {}) {
|
|
|
1110
1298
|
|
|
1111
1299
|
function getEmptyStateMessage(inputElement) {
|
|
1112
1300
|
const rawValue = inputElement.value.trim();
|
|
1113
|
-
return rawValue ? `No processes match "${rawValue}"` : 'No running processes found';
|
|
1301
|
+
return rawValue ? `No apps or processes match "${rawValue}"` : 'No apps or running processes found';
|
|
1114
1302
|
}
|
|
1115
1303
|
|
|
1116
1304
|
function createEmptyStateHtml(message) {
|
package/server/views/app.ejs
CHANGED
|
@@ -229,6 +229,26 @@ body.dark .snapshot-card {
|
|
|
229
229
|
font-size: 14px;
|
|
230
230
|
font-weight: 600;
|
|
231
231
|
}
|
|
232
|
+
.snapshot-actions .snapshot-delete {
|
|
233
|
+
padding: 8px 16px;
|
|
234
|
+
border-radius: 6px;
|
|
235
|
+
font-size: 14px;
|
|
236
|
+
font-weight: 600;
|
|
237
|
+
}
|
|
238
|
+
.url-modal-button.danger {
|
|
239
|
+
background: rgba(239, 68, 68, 0.12);
|
|
240
|
+
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
241
|
+
color: rgba(185, 28, 28, 0.95);
|
|
242
|
+
}
|
|
243
|
+
.url-modal-button.danger:hover {
|
|
244
|
+
background: rgba(239, 68, 68, 0.2);
|
|
245
|
+
box-shadow: 0 12px 28px rgba(239, 68, 68, 0.16);
|
|
246
|
+
}
|
|
247
|
+
body.dark .url-modal-button.danger {
|
|
248
|
+
background: rgba(239, 68, 68, 0.16);
|
|
249
|
+
border-color: rgba(248, 113, 113, 0.5);
|
|
250
|
+
color: rgba(254, 202, 202, 0.95);
|
|
251
|
+
}
|
|
232
252
|
.snapshot-option input[type="radio"] {
|
|
233
253
|
margin-top: 6px;
|
|
234
254
|
}
|
|
@@ -505,6 +525,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
505
525
|
<div class="snapshot-title"><i class="fa-regular fa-clock"></i> ${label}</div>
|
|
506
526
|
<div class="snapshot-actions">
|
|
507
527
|
${publishMarkup}
|
|
528
|
+
<button type="button" class="url-modal-button danger snapshot-delete" data-snapshot-id="${snap.id}">Delete</button>
|
|
508
529
|
<button type="button" class="url-modal-button confirm snapshot-install" data-snapshot-id="${snap.id}">Install</button>
|
|
509
530
|
</div>
|
|
510
531
|
</div>
|
|
@@ -572,15 +593,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
572
593
|
const hint = container.querySelector("#folder-hint")
|
|
573
594
|
const closeBtn = container.querySelector('.url-modal-close')
|
|
574
595
|
const installButtons = Array.from(container.querySelectorAll(".snapshot-install"))
|
|
596
|
+
const deleteButtons = Array.from(container.querySelectorAll(".snapshot-delete"))
|
|
597
|
+
const publishButtons = Array.from(container.querySelectorAll(".snapshot-publish"))
|
|
598
|
+
const actionButtons = installButtons.concat(deleteButtons, publishButtons)
|
|
575
599
|
const loadingOverlay = container.querySelector("#backup-loading")
|
|
600
|
+
const loadingText = loadingOverlay ? loadingOverlay.querySelector(".backup-loading-text") : null
|
|
601
|
+
const defaultLoadingText = loadingText ? loadingText.textContent : ""
|
|
576
602
|
|
|
577
|
-
const setLoading = (isLoading) => {
|
|
603
|
+
const setLoading = (isLoading, message) => {
|
|
578
604
|
if (loadingOverlay) {
|
|
579
605
|
loadingOverlay.classList.toggle("hidden", !isLoading)
|
|
606
|
+
if (loadingText) {
|
|
607
|
+
loadingText.textContent = isLoading ? (message || defaultLoadingText) : defaultLoadingText
|
|
608
|
+
}
|
|
580
609
|
}
|
|
581
|
-
|
|
610
|
+
actionButtons.forEach((btn) => {
|
|
582
611
|
btn.disabled = isLoading
|
|
583
|
-
if (isLoading) btn.textContent = "Installing..."
|
|
584
612
|
})
|
|
585
613
|
if (closeBtn) closeBtn.disabled = isLoading
|
|
586
614
|
}
|
|
@@ -673,7 +701,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
673
701
|
installBtn.disabled = true
|
|
674
702
|
installBtn.textContent = "Installing..."
|
|
675
703
|
}
|
|
676
|
-
setLoading(true)
|
|
704
|
+
setLoading(true, "Installing...")
|
|
677
705
|
try {
|
|
678
706
|
const res = await fetch("/checkpoints/install", {
|
|
679
707
|
method: "POST",
|
|
@@ -712,6 +740,97 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
712
740
|
}
|
|
713
741
|
}
|
|
714
742
|
|
|
743
|
+
const updateItemRow = (currentItem) => {
|
|
744
|
+
if (!currentItem || !currentItem.remoteKey) return
|
|
745
|
+
const row = document.querySelector(`.backup-row[data-remote-key="${currentItem.remoteKey}"]`)
|
|
746
|
+
if (!row) return
|
|
747
|
+
const snapshots = Array.isArray(currentItem.snapshots) ? currentItem.snapshots : []
|
|
748
|
+
const badgeRow = row.querySelector(".description.backup-meta")
|
|
749
|
+
if (badgeRow) {
|
|
750
|
+
const badges = badgeRow.querySelectorAll(".badge")
|
|
751
|
+
if (badges.length > 1) {
|
|
752
|
+
const count = snapshots.length
|
|
753
|
+
badges[1].textContent = count
|
|
754
|
+
? `${count} snapshot${count === 1 ? '' : 's'}`
|
|
755
|
+
: "No snapshots yet"
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const latestRow = Array.from(row.querySelectorAll(".description.backup-meta"))
|
|
759
|
+
.find((el) => el.querySelector(".fa-regular.fa-clock"))
|
|
760
|
+
if (latestRow) {
|
|
761
|
+
if (!snapshots.length) {
|
|
762
|
+
latestRow.remove()
|
|
763
|
+
} else {
|
|
764
|
+
const span = latestRow.querySelector("span")
|
|
765
|
+
if (span) span.textContent = `Latest checkpoint: ${new Date(snapshots[0].id).toLocaleString()}`
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const handleDelete = async (snapshotId, btn) => {
|
|
771
|
+
const idStr = snapshotId != null ? String(snapshotId).trim() : ""
|
|
772
|
+
if (!idStr || idStr === "latest") return
|
|
773
|
+
const installed = installedBySnapshot[idStr] || []
|
|
774
|
+
const snapInfo = Array.isArray(item.snapshots)
|
|
775
|
+
? item.snapshots.find((snap) => snap && String(snap.id) === idStr)
|
|
776
|
+
: null
|
|
777
|
+
const confirmLines = [
|
|
778
|
+
"Delete this checkpoint locally?",
|
|
779
|
+
"This removes the local snapshot file and manifest entry."
|
|
780
|
+
]
|
|
781
|
+
if (installed.length) {
|
|
782
|
+
confirmLines.push(`Installed in: ${installed.join(", ")}`)
|
|
783
|
+
}
|
|
784
|
+
if (snapInfo && snapInfo.sync && snapInfo.sync.status === "published") {
|
|
785
|
+
confirmLines.push("Cloud copy will remain in the registry.")
|
|
786
|
+
}
|
|
787
|
+
const confirmed = confirm(confirmLines.join("\n"))
|
|
788
|
+
if (!confirmed) return
|
|
789
|
+
if (btn) {
|
|
790
|
+
btn.disabled = true
|
|
791
|
+
btn.textContent = "Deleting..."
|
|
792
|
+
}
|
|
793
|
+
setLoading(true, "Deleting...")
|
|
794
|
+
try {
|
|
795
|
+
const res = await fetch("/checkpoints/delete", {
|
|
796
|
+
method: "POST",
|
|
797
|
+
headers: {
|
|
798
|
+
"Content-Type": "application/json",
|
|
799
|
+
"Accept": "application/json"
|
|
800
|
+
},
|
|
801
|
+
body: JSON.stringify({
|
|
802
|
+
remote: item.remoteUrl,
|
|
803
|
+
snapshotId: idStr
|
|
804
|
+
})
|
|
805
|
+
})
|
|
806
|
+
const payload = res && res.ok ? await res.json() : null
|
|
807
|
+
if (payload && payload.ok) {
|
|
808
|
+
const option = btn ? btn.closest(".snapshot-option") : null
|
|
809
|
+
if (option) option.remove()
|
|
810
|
+
if (Array.isArray(item.snapshots)) {
|
|
811
|
+
item.snapshots = item.snapshots.filter((snap) => String(snap.id) !== idStr)
|
|
812
|
+
} else {
|
|
813
|
+
item.snapshots = []
|
|
814
|
+
}
|
|
815
|
+
if (installedBySnapshot && Object.prototype.hasOwnProperty.call(installedBySnapshot, idStr)) {
|
|
816
|
+
delete installedBySnapshot[idStr]
|
|
817
|
+
}
|
|
818
|
+
updateItemRow(item)
|
|
819
|
+
} else {
|
|
820
|
+
const msg = payload && payload.error ? payload.error : "Delete failed"
|
|
821
|
+
Swal.fire({ icon: "error", title: "Error", text: msg })
|
|
822
|
+
}
|
|
823
|
+
} catch (error) {
|
|
824
|
+
Swal.fire({ icon: "error", title: "Error", text: error && error.message ? error.message : "Delete failed" })
|
|
825
|
+
} finally {
|
|
826
|
+
if (btn) {
|
|
827
|
+
btn.disabled = false
|
|
828
|
+
btn.textContent = "Delete"
|
|
829
|
+
}
|
|
830
|
+
setLoading(false)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
715
834
|
installButtons.forEach((btn) => {
|
|
716
835
|
btn.addEventListener("click", () => {
|
|
717
836
|
const snapshotId = btn.getAttribute("data-snapshot-id") || 'latest'
|
|
@@ -719,7 +838,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
719
838
|
})
|
|
720
839
|
})
|
|
721
840
|
|
|
722
|
-
|
|
841
|
+
deleteButtons.forEach((btn) => {
|
|
842
|
+
btn.addEventListener("click", () => {
|
|
843
|
+
const snapshotId = btn.getAttribute("data-snapshot-id") || ''
|
|
844
|
+
handleDelete(snapshotId, btn)
|
|
845
|
+
})
|
|
846
|
+
})
|
|
723
847
|
|
|
724
848
|
const highlightSnapshotId = opts && opts.highlightSnapshotId != null ? String(opts.highlightSnapshotId) : null
|
|
725
849
|
setTimeout(() => {
|