pinokiod 5.1.10 → 5.1.17

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.
Files changed (56) hide show
  1. package/kernel/api/fs/download_worker.js +158 -0
  2. package/kernel/api/fs/index.js +95 -91
  3. package/kernel/api/index.js +3 -0
  4. package/kernel/bin/index.js +5 -2
  5. package/kernel/environment.js +19 -2
  6. package/kernel/git.js +972 -1
  7. package/kernel/index.js +65 -30
  8. package/kernel/peer.js +1 -2
  9. package/kernel/plugin.js +0 -8
  10. package/kernel/procs.js +92 -36
  11. package/kernel/prototype.js +45 -22
  12. package/kernel/shells.js +30 -6
  13. package/kernel/sysinfo.js +33 -13
  14. package/kernel/util.js +61 -24
  15. package/kernel/workspace_status.js +131 -7
  16. package/package.json +1 -1
  17. package/pipe/index.js +1 -1
  18. package/server/index.js +1169 -350
  19. package/server/public/create-launcher.js +157 -2
  20. package/server/public/install.js +135 -41
  21. package/server/public/style.css +32 -1
  22. package/server/public/tab-link-popover.js +45 -14
  23. package/server/public/terminal-settings.js +51 -35
  24. package/server/public/urldropdown.css +89 -3
  25. package/server/socket.js +12 -7
  26. package/server/views/agents.ejs +4 -3
  27. package/server/views/app.ejs +798 -30
  28. package/server/views/bootstrap.ejs +2 -1
  29. package/server/views/checkpoints.ejs +1014 -0
  30. package/server/views/checkpoints_registry_beta.ejs +260 -0
  31. package/server/views/columns.ejs +4 -4
  32. package/server/views/connect.ejs +1 -0
  33. package/server/views/d.ejs +74 -4
  34. package/server/views/download.ejs +28 -28
  35. package/server/views/editor.ejs +4 -5
  36. package/server/views/env_editor.ejs +1 -1
  37. package/server/views/file_explorer.ejs +1 -1
  38. package/server/views/index.ejs +3 -1
  39. package/server/views/init/index.ejs +2 -1
  40. package/server/views/install.ejs +2 -1
  41. package/server/views/net.ejs +9 -7
  42. package/server/views/network.ejs +15 -14
  43. package/server/views/pro.ejs +5 -2
  44. package/server/views/prototype/index.ejs +2 -1
  45. package/server/views/registry_link.ejs +76 -0
  46. package/server/views/rows.ejs +4 -4
  47. package/server/views/screenshots.ejs +1 -0
  48. package/server/views/settings.ejs +1 -0
  49. package/server/views/shell.ejs +4 -6
  50. package/server/views/terminal.ejs +528 -38
  51. package/server/views/tools.ejs +1 -0
  52. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764297248545 +0 -45
  53. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764335557118 +0 -45
  54. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764335834126 +0 -45
  55. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/events +0 -12
  56. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/latest +0 -45
@@ -0,0 +1,1014 @@
1
+ <html>
2
+ <head>
3
+ <meta charset="UTF-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
5
+ <link href="/xterm.min.css" rel="stylesheet" />
6
+ <link href="/css/fontawesome.min.css" rel="stylesheet">
7
+ <link href="/css/solid.min.css" rel="stylesheet">
8
+ <link href="/css/regular.min.css" rel="stylesheet">
9
+ <link href="/css/brands.min.css" rel="stylesheet">
10
+ <link href="/markdown.css" rel="stylesheet"/>
11
+ <link href="/noty.css" rel="stylesheet"/>
12
+ <link href="/style.css" rel="stylesheet"/>
13
+ <link href="/urldropdown.css" rel="stylesheet" />
14
+ <% if (agent === "electron") { %>
15
+ <link href="/electron.css" rel="stylesheet"/>
16
+ <% } %>
17
+ <style>
18
+ main {
19
+ display: flex;
20
+ }
21
+ aside {
22
+ width: 200px;
23
+ display: block;
24
+ flex-shrink: 0;
25
+ }
26
+ aside .tab i {
27
+ width: 20px;
28
+ text-align: center;
29
+ }
30
+ body.dark aside .tab {
31
+ color: white;
32
+ }
33
+ body.dark aside .tab:hover, aside .tab:hover {
34
+ color: rgba(127, 91, 243, 0.9) !important;
35
+ opacity: 1;
36
+ }
37
+ aside .tab {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 5px;
41
+ color: black;
42
+ text-decoration: none;
43
+ padding: 10px;
44
+ font-size: 12px;
45
+ opacity: 0.5;
46
+ border-left: 10px solid transparent;
47
+ }
48
+ body.dark aside .tab.selected {
49
+ color: white;
50
+ }
51
+ aside .tab.selected {
52
+ font-weight: bold;
53
+ opacity: 1;
54
+ }
55
+ @media only screen and (max-width: 600px) {
56
+ aside {
57
+ width: unset;
58
+ flex-shrink: unset;
59
+ }
60
+ aside {
61
+ padding: 0 10px;
62
+ }
63
+ aside .tab.submenu {
64
+ padding: 10px;
65
+ }
66
+ aside .tab i {
67
+ width: 100%;
68
+ }
69
+ aside .tab .caption {
70
+ display: none;
71
+ }
72
+ aside .tab {
73
+ margin: 0;
74
+ padding: 10px;
75
+ border-left: none;
76
+ }
77
+ aside .btn-tab {
78
+ flex-direction: column;
79
+ padding: 10px 0;
80
+ }
81
+ aside .btn-tab .btn {
82
+ display: flex;
83
+ justify-content: center;
84
+ }
85
+ aside .btn-tab .btn .caption {
86
+ display: none;
87
+ }
88
+ header .flexible {
89
+ min-width: unset;
90
+ }
91
+ }
92
+ @media only screen and (max-width: 480px) {
93
+ .btn2 {
94
+ padding: 5px;
95
+ font-size: 11px;
96
+ }
97
+ .nav-btns {
98
+ flex-grow: 1;
99
+ justify-content: center;
100
+ padding: 0;
101
+ }
102
+ }
103
+ .backups-container {
104
+ padding: 20px;
105
+ }
106
+ .backups-container h2 {
107
+ font-size: 24px;
108
+ margin-bottom: 15px;
109
+ }
110
+ .backup-meta {
111
+ font-size: 12px;
112
+ opacity: 0.75;
113
+ }
114
+ body.dark .swal2-popup.backup-modal-shell, .swal2-popup.backup-modal-shell {
115
+ border: none;
116
+ background: transparent;
117
+ box-shadow: none;
118
+ padding: 0;
119
+ width: auto;
120
+ }
121
+ .backup-modal-wrapper {
122
+ display: flex;
123
+ justify-content: center;
124
+ }
125
+ .backup-modal-content {
126
+ text-align: left;
127
+ width: min(640px, calc(100vw - 48px));
128
+ gap: 14px;
129
+ position: relative;
130
+ max-height: calc(100vh - 160px);
131
+ max-height: calc(100dvh - 160px);
132
+ min-height: 0;
133
+ }
134
+ .backup-modal-header h3 {
135
+ margin: 0 0 4px 0;
136
+ font-size: 22px;
137
+ font-weight: 700;
138
+ }
139
+ .backup-modal-description {
140
+ margin: 0 0 6px 0;
141
+ font-size: 14px;
142
+ color: rgba(71, 85, 105, 0.78);
143
+ }
144
+ body.dark .backup-modal-description {
145
+ color: rgba(203, 213, 225, 0.88);
146
+ }
147
+ .backup-modal.snapshot-options {
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: 10px;
151
+ flex: 1 1 auto;
152
+ min-height: 0;
153
+ overflow-y: auto;
154
+ padding: 2px 6px 6px 2px;
155
+ overscroll-behavior: contain;
156
+ }
157
+ .line.backup-row {
158
+ cursor: pointer;
159
+ transition: background 0.15s ease, box-shadow 0.15s ease;
160
+ }
161
+ .line.backup-row:hover {
162
+ background: rgba(127, 91, 243, 0.05);
163
+ box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
164
+ }
165
+ .snapshot-option {
166
+ display: flex;
167
+ align-items: flex-start;
168
+ gap: 12px;
169
+ font-size: 14px;
170
+ cursor: pointer;
171
+ margin: 0;
172
+ }
173
+ .snapshot-card {
174
+ flex: 1;
175
+ border: 1px solid rgba(15, 23, 42, 0.12);
176
+ border-radius: 6px;
177
+ padding: 12px;
178
+ background: rgba(248, 250, 255, 0.9);
179
+ /*
180
+ box-shadow: 0 5px 12px rgba(15, 23, 42, 0.1);
181
+ */
182
+ }
183
+ .snapshot-card.snapshot-card--with-repos {
184
+ align-items: flex-start;
185
+ }
186
+ body.dark .snapshot-card {
187
+ background: rgba(15, 23, 42, 0.86);
188
+ border-color: rgba(148, 163, 184, 0.2);
189
+ }
190
+ .snapshot-header {
191
+ display: flex;
192
+ align-items: center;
193
+ justify-content: space-between;
194
+ gap: 8px;
195
+ margin-bottom: 6px;
196
+ }
197
+ .snapshot-card.snapshot-card--with-repos .snapshot-header {
198
+ grid-column: 1 / 3;
199
+ }
200
+ .snapshot-title {
201
+ font-weight: 700;
202
+ font-size: 15px;
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 6px;
206
+ }
207
+ .snapshot-meta {
208
+ font-size: 12px;
209
+ opacity: 0.75;
210
+ }
211
+ .snapshot-card.snapshot-card--with-repos .snapshot-meta {
212
+ grid-column: 1 / 3;
213
+ margin-bottom: 4px;
214
+ }
215
+ .snapshot-actions {
216
+ display: flex;
217
+ justify-content: flex-end;
218
+ margin-top: 10px;
219
+ gap: 10px;
220
+ flex-wrap: wrap;
221
+ align-items: center;
222
+ }
223
+ .snapshot-header .snapshot-actions {
224
+ margin-top: 0;
225
+ }
226
+ .snapshot-actions .snapshot-install {
227
+ padding: 8px 16px;
228
+ border-radius: 6px;
229
+ font-size: 14px;
230
+ font-weight: 600;
231
+ }
232
+ .snapshot-option input[type="radio"] {
233
+ margin-top: 6px;
234
+ }
235
+ .repo-list {
236
+ margin-top: 10px;
237
+ }
238
+ .snapshot-card.snapshot-card--with-repos .repo-list {
239
+ border-top: 1px solid rgba(0,0,0,0.06);
240
+ }
241
+ body.dark .repo-line {
242
+ border-bottom: 1px solid rgba(255,255,255,0.05);
243
+ }
244
+ .repo-line {
245
+ display: flex;
246
+ flex-direction: column;
247
+ gap: 2px;
248
+ align-items: flex-start;
249
+ font-size: 14px;
250
+ padding: 8px 0;
251
+ border-bottom: 1px solid rgba(0,0,0,0.05);
252
+ }
253
+ .repo-line:last-child {
254
+ border-bottom: none;
255
+ }
256
+ .repo-line-main {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: 8px;
260
+ }
261
+ .repo-path {
262
+ font-weight: 700;
263
+ }
264
+ .repo-commit code {
265
+ background: rgba(0,0,0,0.05);
266
+ padding: 3px 6px;
267
+ border-radius: 4px;
268
+ font-size: 12px;
269
+ }
270
+ body.dark .repo-message {
271
+ color: silver;
272
+ }
273
+ .repo-message {
274
+ color: #333;
275
+ }
276
+ .repo-person {
277
+ font-style: italic;
278
+ opacity: 0.75;
279
+ }
280
+ .installed-list {
281
+ margin-top: 8px;
282
+ font-size: 12px;
283
+ display: flex;
284
+ flex-wrap: wrap;
285
+ gap: 6px;
286
+ align-items: center;
287
+ }
288
+ .snapshot-card.snapshot-card--with-repos .installed-list {
289
+ grid-column: 2 / 3;
290
+ }
291
+ .installed-list span {
292
+ font-weight: 700;
293
+ }
294
+ .installed-link {
295
+ color: rgba(127, 91, 243, 0.95);
296
+ text-decoration: none;
297
+ }
298
+ .installed-link:hover {
299
+ text-decoration: underline;
300
+ }
301
+ .backup-modal.folder-row {
302
+ margin-top: 10px;
303
+ }
304
+ .backup-modal label {
305
+ display: block;
306
+ font-weight: 600;
307
+ margin-bottom: 5px;
308
+ }
309
+ .backup-modal input[type="text"] {
310
+ width: 100%;
311
+ padding: 12px 14px;
312
+ border: 1px solid rgba(15, 23, 42, 0.12);
313
+ border-radius: 12px;
314
+ box-sizing: border-box;
315
+ }
316
+ body.dark .backup-modal input[type="text"] {
317
+ border-color: rgba(148, 163, 184, 0.3);
318
+ background: rgba(148, 163, 184, 0.14);
319
+ color: rgba(226, 232, 240, 0.95);
320
+ }
321
+ .backup-modal.hidden,
322
+ .backup-loading.hidden {
323
+ display: none;
324
+ }
325
+ .folder-hint {
326
+ font-size: 12px;
327
+ color: #c0392b;
328
+ margin-top: 6px;
329
+ }
330
+ .backup-loading {
331
+ position: absolute;
332
+ inset: 0;
333
+ display: flex;
334
+ align-items: center;
335
+ justify-content: center;
336
+ gap: 10px;
337
+ background: rgba(15, 23, 42, 0.06);
338
+ border-radius: 18px;
339
+ backdrop-filter: blur(2px);
340
+ z-index: 10;
341
+ }
342
+ body.dark .backup-loading {
343
+ background: rgba(15, 23, 42, 0.2);
344
+ }
345
+ .backup-loading.hidden {
346
+ display: none;
347
+ }
348
+ .backup-loading .spinner {
349
+ width: 18px;
350
+ height: 18px;
351
+ border: 3px solid rgba(15, 23, 42, 0.15);
352
+ border-top-color: rgba(127, 91, 243, 0.95);
353
+ border-radius: 50%;
354
+ animation: backup-spin 0.8s linear infinite;
355
+ }
356
+ @keyframes backup-spin {
357
+ to { transform: rotate(360deg); }
358
+ }
359
+ .backup-loading .backup-loading-text {
360
+ font-weight: 600;
361
+ color: rgba(15, 23, 42, 0.9);
362
+ }
363
+ body.dark .backup-loading .backup-loading-text {
364
+ color: rgba(226, 232, 240, 0.95);
365
+ }
366
+ </style>
367
+ <script src="/window_storage.js"></script>
368
+ <script src="/hotkeys.min.js"></script>
369
+ <script src="/sweetalert2.js"></script>
370
+ <script src="/noty.js"></script>
371
+ <script src="/notyq.js"></script>
372
+ <script src="/xterm.js"></script>
373
+ <script src="/xterm-addon-fit.js"></script>
374
+ <script src="/xterm-addon-web-links.js"></script>
375
+ <script src="/xterm-theme.js"></script>
376
+ <script src="/Socket.js"></script>
377
+ <script src="/common.js"></script>
378
+ <script src="/opener.js"></script>
379
+ <script src="/nav.js"></script>
380
+ <script src="/urldropdown.js"></script>
381
+ <script src="/report.js"></script>
382
+ <script>
383
+ window.backupItems = <%- JSON.stringify(items || []) %>;
384
+ window.backupAutoInstall = <%- JSON.stringify(autoInstall || null) %>;
385
+ window.backupImportError = <%- JSON.stringify(importError || null) %>;
386
+ window.registryBetaEnabled = <%- JSON.stringify(!!registryBetaEnabled) %>;
387
+ document.addEventListener("DOMContentLoaded", () => {
388
+ const items = Array.isArray(window.backupItems) ? window.backupItems : []
389
+ const autoInstall = window.backupAutoInstall && typeof window.backupAutoInstall === "object" ? window.backupAutoInstall : null
390
+ const importError = typeof window.backupImportError === "string" && window.backupImportError.trim() ? window.backupImportError.trim() : null
391
+ const registryBetaEnabled = window.registryBetaEnabled === true
392
+
393
+ const escapeHtml = (value) => {
394
+ const str = value == null ? '' : String(value)
395
+ return str.replace(/[&<>"']/g, (ch) => {
396
+ switch (ch) {
397
+ case '&': return '&amp;'
398
+ case '<': return '&lt;'
399
+ case '>': return '&gt;'
400
+ case '"': return '&quot;'
401
+ case "'": return '&#39;'
402
+ default: return ch
403
+ }
404
+ })
405
+ }
406
+ const escapeAttr = (value) => escapeHtml(value)
407
+
408
+ const guessFolderName = (remoteUrl) => {
409
+ if (!remoteUrl || typeof remoteUrl !== "string") return "project"
410
+ let str = remoteUrl.replace(/\.git$/i, '')
411
+ const atIdx = str.lastIndexOf('@')
412
+ if (atIdx !== -1) {
413
+ str = str.slice(atIdx + 1)
414
+ }
415
+ const parts = str.split(/[/:\\]/).filter(Boolean)
416
+ const last = parts.length ? parts[parts.length - 1] : "project"
417
+ const safe = last.replace(/[^a-zA-Z0-9._-]/g, '-')
418
+ return safe || "project"
419
+ }
420
+
421
+ const formatPerson = (person) => {
422
+ if (!person) return null
423
+ const name = person.name || ""
424
+ let date = null
425
+ if (Number.isFinite(person.timestamp)) {
426
+ try {
427
+ date = new Date(person.timestamp * 1000).toLocaleString()
428
+ } catch (_) {}
429
+ }
430
+ const label = name.trim()
431
+ if (label && date) return `${label} · ${date}`
432
+ if (label) return label
433
+ if (date) return date
434
+ return null
435
+ }
436
+
437
+ const renderSnapshotOption = (snap, isLatest, installedNames) => {
438
+ if (isLatest) {
439
+ return `
440
+ <div class="snapshot-option">
441
+ <div class="snapshot-card snapshot-card--latest" data-snapshot-id="latest">
442
+ <div class="snapshot-header">
443
+ <div class="snapshot-title"><i class="fa-solid fa-clock-rotate-left"></i> Latest</div>
444
+ <div class="snapshot-actions">
445
+ <button type="button" class="url-modal-button confirm snapshot-install" data-snapshot-id="latest">Install</button>
446
+ </div>
447
+ </div>
448
+ <div class="snapshot-meta">Clone the current default branch HEAD.</div>
449
+ </div>
450
+ </div>
451
+ `
452
+ }
453
+ const repos = Array.isArray(snap.repos) ? snap.repos : []
454
+ const repoList = repos.map((repo) => {
455
+ const hash = escapeHtml(repo.commit ? repo.commit.slice(0, 8) : "unknown")
456
+ const message = escapeHtml(repo.message ? repo.message.split('\n')[0].trim() : "(no message recorded)")
457
+ const person = formatPerson(repo.author || repo.committer)
458
+ const personLabel = person ? escapeHtml(person) : null
459
+ const pathLabel = escapeHtml(repo.path || '.')
460
+ return `
461
+ <div class="repo-line">
462
+ <div class="repo-line-main">
463
+ <div class="repo-path">${pathLabel}</div>
464
+ <div class="repo-commit"><code>${hash}</code></div>
465
+ </div>
466
+ <div class="repo-message">${message}</div>
467
+ ${personLabel ? `<div class="repo-person">${personLabel}</div>` : ''}
468
+ </div>
469
+ `
470
+ }).join("")
471
+ const label = "Checkpoint"
472
+ const snapTimestamp = snap && snap.id != null ? Number(snap.id) : Number.NaN
473
+ const whenRaw = Number.isFinite(snapTimestamp) && snapTimestamp > 0 ? new Date(snapTimestamp).toLocaleString() : ""
474
+ const when = whenRaw ? escapeHtml(whenRaw) : ""
475
+ const plat = snap && snap.platform ? escapeHtml(String(snap.platform)) : ""
476
+ const arch = snap && snap.arch ? escapeHtml(String(snap.arch)) : ""
477
+ const gpu = snap && snap.gpu ? escapeHtml(String(snap.gpu)) : ""
478
+ const ramVal = snap && typeof snap.ram === "number" ? snap.ram : null
479
+ const vramVal = snap && typeof snap.vram === "number" ? snap.vram : null
480
+ const envParts = []
481
+ if (plat) envParts.push(plat)
482
+ if (arch) envParts.push(arch)
483
+ if (gpu) envParts.push(gpu)
484
+ if (ramVal != null && ramVal > 0) envParts.push(`${ramVal} GB RAM`)
485
+ if (vramVal != null && vramVal > 0) envParts.push(`${vramVal} GB VRAM`)
486
+ const envText = envParts.join(" · ")
487
+ const metaParts = []
488
+ metaParts.push(`${repos.length} repo${repos.length === 1 ? '' : 's'}`)
489
+ if (when) metaParts.push(when)
490
+ const metaText = metaParts.join(" · ")
491
+ const syncStatus = snap && snap.sync && snap.sync.status ? String(snap.sync.status) : null
492
+ const publishMarkup = registryBetaEnabled
493
+ ? (syncStatus === "published"
494
+ ? `<span class="badge">Cloud saved</span>`
495
+ : `<button type="button" class="url-modal-button confirm snapshot-publish" data-snapshot-id="${snap.id}">Save to Cloud</button>`)
496
+ : ""
497
+ const installed = Array.isArray(installedNames) ? installedNames : []
498
+ const installedMarkup = installed.length
499
+ ? `<div class="installed-list"><span>Installed:</span> ${installed.map((name) => `<a href="/p/${encodeURIComponent(name)}" class="installed-link">/p/${escapeHtml(name)}</a>`).join(", ")}</div>`
500
+ : ""
501
+ return `
502
+ <div class="snapshot-option">
503
+ <div class="snapshot-card snapshot-card--with-repos" data-snapshot-id="${snap.id}">
504
+ <div class="snapshot-header">
505
+ <div class="snapshot-title"><i class="fa-regular fa-clock"></i> ${label}</div>
506
+ <div class="snapshot-actions">
507
+ ${publishMarkup}
508
+ <button type="button" class="url-modal-button confirm snapshot-install" data-snapshot-id="${snap.id}">Install</button>
509
+ </div>
510
+ </div>
511
+ <div class="snapshot-meta">
512
+ ${envText ? `${envText} · ` : ""}${metaText}
513
+ </div>
514
+ <div class="repo-list">${repoList}</div>
515
+ ${installedMarkup}
516
+ </div>
517
+ </div>
518
+ `
519
+ }
520
+
521
+ const openInstallModal = (item, opts = {}) => {
522
+ const conflictNames = new Set((item.folders || []).map((f) => f.name))
523
+ const installedBySnapshot = item.installedBySnapshot || {}
524
+ const defaultFolder = (item.folders && item.folders[0] && item.folders[0].name) || guessFolderName(item.remoteUrl)
525
+ const suggestName = (base) => {
526
+ const clean = base && base.trim() ? base.trim() : defaultFolder
527
+ if (!conflictNames.has(clean)) return clean
528
+ let n = 2
529
+ let candidate = `${clean}-${n}`
530
+ while (conflictNames.has(candidate) && n < 100) {
531
+ n += 1
532
+ candidate = `${clean}-${n}`
533
+ }
534
+ return candidate
535
+ }
536
+ const snapshots = [renderSnapshotOption(null, true, (item.folders || []).map((f) => f.name))].concat(
537
+ (item.snapshots || []).map((snap) => renderSnapshotOption(snap, false, installedBySnapshot[snap.id]))
538
+ )
539
+ const html = `
540
+ <div class="backup-modal-wrapper">
541
+ <div class="url-modal-content backup-modal-content" role="dialog" aria-modal="true" aria-labelledby="backup-modal-title" aria-describedby="backup-modal-description">
542
+ <div class="backup-loading hidden" id="backup-loading">
543
+ <div class="spinner"></div>
544
+ <div class="backup-loading-text">Installing…</div>
545
+ </div>
546
+ <button type="button" class="url-modal-close" aria-label="Close">&times;</button>
547
+ <div class="backup-modal-header">
548
+ <h3 id="backup-modal-title">Install</h3>
549
+ <p class="backup-modal-description" id="backup-modal-description">Pick a version and, if needed, a new folder name.</p>
550
+ </div>
551
+ <div class="backup-modal snapshot-options">${snapshots.join("")}</div>
552
+ <div class="backup-modal folder-row hidden" id="folder-row">
553
+ <label for="folder-name">New folder name</label>
554
+ <input id="folder-name" type="text" value="${escapeAttr(defaultFolder)}" autocomplete="off" />
555
+ <div class="folder-hint" id="folder-hint"></div>
556
+ </div>
557
+ </div>
558
+ </div>
559
+ `
560
+ Swal.fire({
561
+ html,
562
+ showConfirmButton: false,
563
+ showCancelButton: false,
564
+ customClass: {
565
+ popup: 'backup-modal-shell'
566
+ },
567
+ didOpen: () => {
568
+ const container = Swal.getHtmlContainer()
569
+ if (!container) return
570
+ const folderRow = container.querySelector("#folder-row")
571
+ const folderInput = container.querySelector("#folder-name")
572
+ const hint = container.querySelector("#folder-hint")
573
+ const closeBtn = container.querySelector('.url-modal-close')
574
+ const installButtons = Array.from(container.querySelectorAll(".snapshot-install"))
575
+ const loadingOverlay = container.querySelector("#backup-loading")
576
+
577
+ const setLoading = (isLoading) => {
578
+ if (loadingOverlay) {
579
+ loadingOverlay.classList.toggle("hidden", !isLoading)
580
+ }
581
+ installButtons.forEach((btn) => {
582
+ btn.disabled = isLoading
583
+ if (isLoading) btn.textContent = "Installing..."
584
+ })
585
+ if (closeBtn) closeBtn.disabled = isLoading
586
+ }
587
+ const openRenameModal = (baseName, message) => {
588
+ const suggested = escapeAttr(suggestName(baseName))
589
+ return new Promise((resolve) => {
590
+ const overlay = document.createElement("div")
591
+ overlay.className = "modal-overlay url-modal-overlay backup-rename-overlay is-visible"
592
+ overlay.innerHTML = `
593
+ <div class="url-modal-content backup-modal-content" role="dialog" aria-modal="true" aria-labelledby="backup-rename-title">
594
+ <button type="button" class="url-modal-close" aria-label="Close">&times;</button>
595
+ <div class="backup-modal-header">
596
+ <h3 id="backup-rename-title">Choose a new folder</h3>
597
+ <p class="backup-modal-description">${escapeHtml(message || "Pick a different folder name for this install.")}</p>
598
+ </div>
599
+ <div class="backup-modal folder-row" style="margin-top: 0;">
600
+ <label for="rename-folder-name">Folder name</label>
601
+ <input id="rename-folder-name" type="text" value="${suggested}" autocomplete="off" />
602
+ <div class="folder-hint" id="rename-folder-hint"></div>
603
+ </div>
604
+ <div class="url-modal-actions">
605
+ <button type="button" class="url-modal-button cancel" data-action="cancel">Cancel</button>
606
+ <button type="button" class="url-modal-button confirm" data-action="confirm">Use name</button>
607
+ </div>
608
+ </div>
609
+ `
610
+ document.body.appendChild(overlay)
611
+ const input = overlay.querySelector("#rename-folder-name")
612
+ const hintEl = overlay.querySelector("#rename-folder-hint")
613
+ const cancelBtn = overlay.querySelector('[data-action="cancel"]')
614
+ const confirmBtn = overlay.querySelector('[data-action="confirm"]')
615
+ const closeBtn = overlay.querySelector('.url-modal-close')
616
+ const cleanup = (value) => {
617
+ overlay.remove()
618
+ resolve(value)
619
+ }
620
+ const validate = () => {
621
+ const val = input.value.trim()
622
+ if (!val) {
623
+ hintEl.textContent = "Folder name required."
624
+ return null
625
+ }
626
+ if (val.includes('/') || val.includes('\\')) {
627
+ hintEl.textContent = "Folder name cannot contain slashes."
628
+ return null
629
+ }
630
+ hintEl.textContent = ""
631
+ return val
632
+ }
633
+ confirmBtn.addEventListener("click", () => {
634
+ const val = validate()
635
+ if (val) cleanup(val)
636
+ })
637
+ cancelBtn.addEventListener("click", () => cleanup(null))
638
+ closeBtn.addEventListener("click", () => cleanup(null))
639
+ input.addEventListener("keydown", (e) => {
640
+ if (e.key === "Enter") {
641
+ e.preventDefault()
642
+ confirmBtn.click()
643
+ }
644
+ if (e.key === "Escape") {
645
+ e.preventDefault()
646
+ cleanup(null)
647
+ }
648
+ })
649
+ setTimeout(() => {
650
+ input.focus()
651
+ input.select()
652
+ }, 0)
653
+ })
654
+ }
655
+
656
+ const handleInstall = async (snapshotId, forcedName) => {
657
+ let name = forcedName || (folderInput ? folderInput.value.trim() : '') || defaultFolder
658
+ const alreadyInstalled = snapshotId !== 'latest' ? (installedBySnapshot[snapshotId] || []) : []
659
+ const nameConflict = conflictNames.has(name)
660
+ const sameFolder = snapshotId !== 'latest' && alreadyInstalled.includes(name)
661
+ if (!name || name.includes('/') || name.includes('\\') || nameConflict || sameFolder) {
662
+ const reason = nameConflict
663
+ ? "Folder exists. Pick a different name."
664
+ : sameFolder
665
+ ? `Snapshot already installed in: ${alreadyInstalled.join(", ")}. Choose a different folder.`
666
+ : "Folder name required."
667
+ const rename = await openRenameModal(name || defaultFolder, reason)
668
+ if (!rename) return
669
+ name = rename
670
+ }
671
+ const installBtn = installButtons.find((b) => b.dataset.snapshotId === String(snapshotId))
672
+ if (installBtn) {
673
+ installBtn.disabled = true
674
+ installBtn.textContent = "Installing..."
675
+ }
676
+ setLoading(true)
677
+ try {
678
+ const res = await fetch("/checkpoints/install", {
679
+ method: "POST",
680
+ headers: {
681
+ "Content-Type": "application/json",
682
+ "Accept": "application/json"
683
+ },
684
+ body: JSON.stringify({
685
+ remote: item.remoteUrl,
686
+ snapshotId,
687
+ folder: name
688
+ })
689
+ })
690
+ const payload = res && res.ok ? await res.json() : null
691
+ if (payload && payload.ok && payload.redirect) {
692
+ window.location = payload.redirect
693
+ } else {
694
+ if (payload && payload.code === "exists") {
695
+ const rename = await openRenameModal(name, "Folder exists. Pick a different name.")
696
+ if (rename) {
697
+ await handleInstall(snapshotId, rename)
698
+ }
699
+ return
700
+ }
701
+ const msg = payload && payload.error ? payload.error : "Install failed"
702
+ Swal.fire({ icon: "error", title: "Error", text: msg })
703
+ }
704
+ } catch (error) {
705
+ Swal.fire({ icon: "error", title: "Error", text: error && error.message ? error.message : "Install failed" })
706
+ } finally {
707
+ if (installBtn) {
708
+ installBtn.disabled = false
709
+ installBtn.textContent = "Install"
710
+ }
711
+ setLoading(false)
712
+ }
713
+ }
714
+
715
+ installButtons.forEach((btn) => {
716
+ btn.addEventListener("click", () => {
717
+ const snapshotId = btn.getAttribute("data-snapshot-id") || 'latest'
718
+ handleInstall(snapshotId)
719
+ })
720
+ })
721
+
722
+ const publishButtons = Array.from(container.querySelectorAll(".snapshot-publish"))
723
+
724
+ const highlightSnapshotId = opts && opts.highlightSnapshotId != null ? String(opts.highlightSnapshotId) : null
725
+ setTimeout(() => {
726
+ let focusBtn = null
727
+ if (highlightSnapshotId) {
728
+ const card = container.querySelector(`.snapshot-card[data-snapshot-id="${highlightSnapshotId}"]`)
729
+ if (card) {
730
+ try {
731
+ card.scrollIntoView({ behavior: "smooth", block: "center" })
732
+ } catch (_) {}
733
+ try {
734
+ card.style.boxShadow = "0 0 0 2px rgba(127, 91, 243, 0.9)"
735
+ } catch (_) {}
736
+ focusBtn = card.querySelector(".snapshot-install")
737
+ }
738
+ }
739
+ if (!focusBtn && installButtons.length) {
740
+ focusBtn = installButtons[0]
741
+ }
742
+ if (focusBtn) {
743
+ try { focusBtn.focus() } catch (_) {}
744
+ }
745
+ }, 0)
746
+
747
+ const waitForRegistryLink = async () => {
748
+ const startedAt = Date.now()
749
+ while (Date.now() - startedAt < 120000) {
750
+ try {
751
+ const s = await fetch("/api/registry/status", { method: "GET", headers: { "Accept": "application/json" } })
752
+ if (s.ok) {
753
+ const data = await s.json()
754
+ if (data && data.linked) return true
755
+ }
756
+ } catch (_) {}
757
+ await new Promise((r) => setTimeout(r, 2000))
758
+ }
759
+ return false
760
+ }
761
+ const publishSnapshot = async (snapshotId, allowConnect = true) => {
762
+ const btn = publishButtons.find((b) => b.dataset.snapshotId === String(snapshotId))
763
+ const original = btn ? btn.textContent : "Save to Cloud"
764
+ if (btn) {
765
+ btn.disabled = true
766
+ btn.textContent = "Saving..."
767
+ }
768
+ try {
769
+ const qs = new URLSearchParams()
770
+ qs.set("snapshotId", String(snapshotId))
771
+ const res = await fetch(`/checkpoints/publish?${qs.toString()}`, { method: "POST", headers: { "Accept": "application/json" } })
772
+ const payload = res && res.ok ? await res.json() : null
773
+ if (payload && payload.publish && payload.publish.ok) {
774
+ const publishUrl = payload && payload.publish && payload.publish.url ? String(payload.publish.url) : ""
775
+ const linkHtml = publishUrl
776
+ ? `<a href="${escapeHtml(publishUrl)}" target="_blank" rel="noopener">View published checkpoint</a>`
777
+ : ""
778
+ Swal.fire({
779
+ icon: "success",
780
+ title: "Saved to Cloud",
781
+ html: linkHtml || undefined,
782
+ showConfirmButton: true,
783
+ confirmButtonText: "Close"
784
+ }).then(() => {
785
+ window.location.reload()
786
+ })
787
+ return
788
+ }
789
+ if (allowConnect && payload && payload.publish && payload.publish.code === "not_linked") {
790
+ const suggestedConnectUrl = payload.publish.connectUrl ? String(payload.publish.connectUrl) : ""
791
+ const connectUrl = await new Promise((resolve) => {
792
+ let settled = false
793
+ const cleanup = (value) => {
794
+ if (settled) return
795
+ settled = true
796
+ try { Swal.close() } catch (_) {}
797
+ resolve(value)
798
+ }
799
+ const html = `
800
+ <div class="backup-modal-wrapper">
801
+ <div class="url-modal-content backup-modal-content" role="dialog" aria-modal="true" aria-labelledby="registry-connect-title" aria-describedby="registry-connect-description">
802
+ <button type="button" class="url-modal-close" aria-label="Close">&times;</button>
803
+ <div class="backup-modal-header">
804
+ <h3 id="registry-connect-title">Connect to Registry</h3>
805
+ <p class="backup-modal-description" id="registry-connect-description">Enter your registry connect URL to link Pinokio, then we’ll retry the cloud save.</p>
806
+ </div>
807
+ <div class="backup-modal folder-row" style="margin-top: 0;">
808
+ <label for="registry-connect-url">Registry URL</label>
809
+ <input id="registry-connect-url" class="url-modal-input" type="url" value="${escapeAttr(suggestedConnectUrl)}" placeholder="https://your-registry/connect/pinokio" autocomplete="off" />
810
+ <div class="folder-hint" id="registry-connect-hint"></div>
811
+ </div>
812
+ <div class="url-modal-actions">
813
+ <button type="button" class="url-modal-button cancel" data-action="cancel">Cancel</button>
814
+ <button type="button" class="url-modal-button confirm" data-action="confirm">Open connect page</button>
815
+ </div>
816
+ </div>
817
+ </div>
818
+ `
819
+ Swal.fire({
820
+ html,
821
+ showConfirmButton: false,
822
+ showCancelButton: false,
823
+ allowOutsideClick: true,
824
+ customClass: { popup: 'backup-modal-shell' },
825
+ didOpen: () => {
826
+ const container = Swal.getHtmlContainer()
827
+ if (!container) return
828
+ const input = container.querySelector("#registry-connect-url")
829
+ const hint = container.querySelector("#registry-connect-hint")
830
+ const closeBtn = container.querySelector(".url-modal-close")
831
+ const cancelBtn = container.querySelector('[data-action="cancel"]')
832
+ const confirmBtn = container.querySelector('[data-action="confirm"]')
833
+ const validate = () => {
834
+ const val = input && input.value != null ? String(input.value).trim() : ""
835
+ if (!val) {
836
+ if (hint) hint.textContent = "Registry URL required."
837
+ return null
838
+ }
839
+ if (hint) hint.textContent = ""
840
+ return val
841
+ }
842
+ if (input) {
843
+ try { input.focus() } catch (_) {}
844
+ input.addEventListener("keydown", (e) => {
845
+ if (e.key === "Enter") {
846
+ e.preventDefault()
847
+ const val = validate()
848
+ if (val) cleanup(val)
849
+ }
850
+ })
851
+ }
852
+ if (confirmBtn) {
853
+ confirmBtn.addEventListener("click", () => {
854
+ const val = validate()
855
+ if (val) cleanup(val)
856
+ })
857
+ }
858
+ if (cancelBtn) cancelBtn.addEventListener("click", () => cleanup(null))
859
+ if (closeBtn) closeBtn.addEventListener("click", () => cleanup(null))
860
+ }
861
+ }).then(() => cleanup(null))
862
+ })
863
+
864
+ if (!connectUrl) return
865
+ try {
866
+ window.open(connectUrl, "pinokio-registry-connect")
867
+ } catch (_) {
868
+ window.location.href = connectUrl
869
+ return
870
+ }
871
+ const linked = await waitForRegistryLink()
872
+ if (linked) {
873
+ await publishSnapshot(snapshotId, false)
874
+ return
875
+ }
876
+ Swal.fire({ icon: "error", title: "Not connected", text: "Could not confirm the registry connection." })
877
+ return
878
+ }
879
+ const msg = payload && payload.publish && payload.publish.error ? payload.publish.error : "Cloud save failed"
880
+ Swal.fire({ icon: "error", title: "Error", text: msg })
881
+ } catch (error) {
882
+ Swal.fire({ icon: "error", title: "Error", text: error && error.message ? error.message : "Cloud save failed" })
883
+ } finally {
884
+ if (btn) {
885
+ btn.disabled = false
886
+ btn.textContent = original
887
+ }
888
+ }
889
+ }
890
+
891
+ publishButtons.forEach((btn) => {
892
+ btn.addEventListener("click", () => {
893
+ const snapshotId = btn.getAttribute("data-snapshot-id") || ''
894
+ if (snapshotId) publishSnapshot(snapshotId)
895
+ })
896
+ })
897
+
898
+ const handleCancel = () => Swal.close()
899
+ closeBtn && closeBtn.addEventListener("click", handleCancel)
900
+ }
901
+ })
902
+ }
903
+
904
+ document.querySelectorAll(".backup-row").forEach((row) => {
905
+ row.addEventListener("click", () => {
906
+ const key = row.getAttribute("data-remote-key")
907
+ const item = items.find((i) => i.remoteKey === key)
908
+ if (item) {
909
+ openInstallModal(item)
910
+ }
911
+ })
912
+ })
913
+
914
+ if (importError) {
915
+ Swal.fire({ icon: "error", title: "Import failed", text: importError })
916
+ } else if (autoInstall && autoInstall.remoteKey) {
917
+ const item = items.find((i) => i.remoteKey === autoInstall.remoteKey)
918
+ if (item) {
919
+ openInstallModal(item, { highlightSnapshotId: autoInstall.snapshotId })
920
+ }
921
+ }
922
+ })
923
+ </script>
924
+ </head>
925
+ <body class='<%=theme%>' data-agent="<%=agent%>">
926
+ <header class='navheader grabbable'>
927
+ <h1>
928
+ <a class='home' href="/home"><img class='icon' src="/pinokio-black.png"></a>
929
+ </h1>
930
+ </header>
931
+ <main>
932
+ <div class='container'>
933
+ <div class='content backups-container'>
934
+ <h2>Checkpoints</h2>
935
+ <p class="subtitle">Local version history for workspaces on this machine.</p>
936
+ <div style="margin: 10px 0 20px 0;">
937
+ <% if (checkpointsDir) { %>
938
+ <button type="button" class="btn" data-filepath="<%= checkpointsDir %>">
939
+ <i class="fa-solid fa-folder-open"></i> Open checkpoints folder
940
+ </button>
941
+ <% } %>
942
+ </div>
943
+ <% if (items && items.length > 0) { %>
944
+ <% items.forEach((item) => { %>
945
+ <div class='line align-top backup-row' data-remote-key="<%=item.remoteKey%>">
946
+ <h3>
947
+ <div class='item-icon'>
948
+ <% if (item.icon) { %>
949
+ <img src="<%= item.icon %>" onerror="this.onerror=null; this.style.opacity=0.4; this.src='/pinokio-black.png'">
950
+ <% } else { %>
951
+ <img src="/pinokio-black.png" style="opacity:0.4;" onerror="this.onerror=null; this.src='/pinokio-black.png'">
952
+ <% } %>
953
+ </div>
954
+ <div class='col'>
955
+ <div class='title'>
956
+ <i class="fa-solid fa-circle"></i>
957
+ <span><%= item.title || item.displayName %></span>
958
+ </div>
959
+ <div class='uri'><%= item.remoteUrl %></div>
960
+ <% if (item.description) { %>
961
+ <div class='description'><%= item.description %></div>
962
+ <% } %>
963
+ <div class='description backup-meta'>
964
+ <span class="badge"><%= (item.folders && item.folders.length) ? item.folders.length + ' local folder' + (item.folders.length === 1 ? '' : 's') : 'No local folders' %></span>
965
+ <span class="badge"><%= (item.snapshots && item.snapshots.length) ? item.snapshots.length + ' snapshot' + (item.snapshots.length === 1 ? '' : 's') : 'No snapshots yet' %></span>
966
+ </div>
967
+ <% if (item.folders && item.folders.length > 0) { %>
968
+ <div class="description backup-meta">
969
+ <i class="fa-solid fa-folder-open"></i>
970
+ <span>Local: <%= item.folders.map((f) => f.name).join(", ") %></span>
971
+ </div>
972
+ <% } %>
973
+ <% if (item.snapshots && item.snapshots.length > 0) { %>
974
+ <% const latest = item.snapshots[0]; %>
975
+ <div class="description backup-meta">
976
+ <i class="fa-regular fa-clock"></i>
977
+ <span>Latest checkpoint: <%= new Date(latest.id).toLocaleString() %></span>
978
+ </div>
979
+ <% } %>
980
+ </div>
981
+ </h3>
982
+ </div>
983
+ <% }) %>
984
+ <% } else { %>
985
+ <p>No history yet.</p>
986
+ <% } %>
987
+ </div>
988
+ </div>
989
+ <aside>
990
+ <div class='btn-tab'>
991
+ <button type='button' class='btn' id='create-launcher-button'><i class="fa-solid fa-plus"></i><div class='caption'>Create</div></button>
992
+ <a class='btn' id='explore' href="/home?mode=explore"><i class="fa-solid fa-globe"></i><div class='caption'>Discover</div></a>
993
+ </div>
994
+ <a href="/home" class='tab'><i class='fas fa-laptop-code'></i><div class='caption'>This machine</div></a>
995
+ <a href="/network" class='tab'><i class="fa-solid fa-wifi"></i><div class='caption'>Local network</div></a>
996
+ <% if (list && list.length > 0) { %>
997
+ <% let brands = { win32: "windows", darwin: "apple", linux: "Linux" } %>
998
+ <% list.forEach(({ host, name, platform }) => { %>
999
+ <a href="/net/<%=name%>" class='submenu tab'><i class="fa-brands fa-<%=brands[platform]%>"></i><div class='caption'><%=name%> (<%=current_host === host ? 'this machine' : host%>)</div></a>
1000
+ <% }) %>
1001
+ <% } %>
1002
+ <a href="/connect" class='tab'><i class="fa-solid fa-plug"></i><div class='caption'>Login</div></a>
1003
+ <a class='tab' href="<%=portal%>" target="_blank"><i class="fa-solid fa-question"></i><div class='caption'>Help</div></a>
1004
+ <a class='tab' id='genlog' href="/logs"><i class="fa-solid fa-laptop-code"></i><div class='caption'>Logs</div></a>
1005
+ <a class='tab selected' href="/checkpoints"><i class="fa-solid fa-clock-rotate-left"></i><div class='caption'>Checkpoints</div></a>
1006
+ <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
1007
+ <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
1008
+ <a class='tab' href="/agents"><i class="fa-solid fa-robot"></i><div class='caption'>Agents</div></a>
1009
+ <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
1010
+ <%- include('partials/peer_access_points', { peer_access_points, peer_url, peer_qr }) %>
1011
+ </aside>
1012
+ </main>
1013
+ </body>
1014
+ </html>