pinokiod 3.306.0 → 3.308.0

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.
@@ -32,12 +32,16 @@ class HtmlModalAPI {
32
32
  req.params = {}
33
33
  }
34
34
  const packet = this.buildPacket(req, action)
35
+ const awaitKey = this.resolveParentPath(req)
35
36
  if (options.forceAwait === false) {
36
37
  packet.await = false
37
38
  }
39
+ if (packet.await) {
40
+ packet.awaitKey = awaitKey
41
+ }
38
42
  ondata(packet, 'htmlmodal')
39
43
  if (packet.await) {
40
- const waitKey = this.resolveParentPath(req)
44
+ const waitKey = packet.awaitKey || awaitKey
41
45
  const response = await kernel.api.wait(waitKey)
42
46
  return response
43
47
  }
@@ -277,10 +277,22 @@ class Api {
277
277
  }
278
278
  }
279
279
  respond(req) {
280
- let requestPath = this.filePath(req.uri)
281
- if (this.waiter[requestPath]) {
282
- this.waiter[requestPath].resolve(req.response)
283
- delete this.waiter[requestPath]
280
+ let requestPath
281
+ try {
282
+ requestPath = this.filePath(req.uri)
283
+ } catch (_) {
284
+ requestPath = req.uri
285
+ }
286
+ const candidates = [requestPath]
287
+ if (req && req.uri && req.uri !== requestPath) {
288
+ candidates.push(req.uri)
289
+ }
290
+ for (const key of candidates) {
291
+ if (this.waiter[key]) {
292
+ this.waiter[key].resolve(req.response)
293
+ delete this.waiter[key]
294
+ return
295
+ }
284
296
  }
285
297
  }
286
298
  wait(scriptPath) {
@@ -587,6 +587,11 @@ const init = async (options, kernel) => {
587
587
  await fs.promises.writeFile(destination, rendered_recipe)
588
588
  }
589
589
  }
590
+ await fs.promises.writeFile(path.resolve(root, ".geminiignore"), `ENVIRONMENT
591
+ !/logs
592
+ !/GEMINI.md
593
+ !/SPEC.md
594
+ !/app`)
590
595
  }
591
596
 
592
597
  const gitDir = path.resolve(root, ".git")
@@ -14,38 +14,37 @@ class Proto {
14
14
 
15
15
  // if ~/pinokio/prototype doesn't exist, clone
16
16
  let exists = await this.kernel.exists("prototype/system")
17
- if (!exists) {
17
+ if (exists) {
18
+ await this.kernel.exec({
19
+ message: "git pull",
20
+ path: this.kernel.path("prototype/system")
21
+ }, (e) => {
22
+ process.stdout.write(e.raw)
23
+ })
24
+ } else {
18
25
  console.log("prototype doesn't exist. cloning...")
19
26
  await fs.promises.mkdir(this.kernel.path("prototype"), { recursive: true }).catch((e) => { })
20
27
  await this.kernel.exec({
21
- //message: "git clone https://github.com/peanutcocktail/prototype system",
22
- //message: "git clone https://github.com/pinokiocomputer/prototype system",
23
28
  message: "git clone https://github.com/pinokiocomputer/proto system",
24
29
  path: this.kernel.path("prototype")
25
30
  }, (e) => {
26
31
  process.stdout.write(e.raw)
27
32
  })
28
33
  }
29
- let exists2 = await this.kernel.exists("prototype/PINOKIO.md")
30
- if (!exists2) {
31
- await this.kernel.download({
32
- uri: "https://raw.githubusercontent.com/pinokiocomputer/home/refs/heads/main/docs/README.md",
33
- path: this.kernel.path("prototype"),
34
- filename: "PINOKIO.md"
35
- }, (e) => {
36
- process.stdout.write(e.raw)
37
- })
38
- }
39
- let exists3 = await this.kernel.exists("prototype/PTERM.md")
40
- if (!exists3) {
41
- await this.kernel.download({
42
- uri: "https://raw.githubusercontent.com/pinokiocomputer/pterm/refs/heads/main/README.md",
43
- path: this.kernel.path("prototype"),
44
- filename: "PTERM.md"
45
- }, (e) => {
46
- process.stdout.write(e.raw)
47
- })
48
- }
34
+ await this.kernel.download({
35
+ uri: "https://raw.githubusercontent.com/pinokiocomputer/home/refs/heads/main/docs/README.md",
36
+ path: this.kernel.path("prototype"),
37
+ filename: "PINOKIO.md"
38
+ }, (e) => {
39
+ process.stdout.write(e.raw)
40
+ })
41
+ await this.kernel.download({
42
+ uri: "https://raw.githubusercontent.com/pinokiocomputer/pterm/refs/heads/main/README.md",
43
+ path: this.kernel.path("prototype"),
44
+ filename: "PTERM.md"
45
+ }, (e) => {
46
+ process.stdout.write(e.raw)
47
+ })
49
48
  }
50
49
  }
51
50
  async ai() {
package/kernel/util.js CHANGED
@@ -770,15 +770,38 @@ function u2p(urlPath) {
770
770
  }
771
771
 
772
772
  function classifyChange(head, workdir, stage) {
773
- if (head === 0 && workdir === 0 && stage === 0) return null; // shouldn't appear
774
- if (head === 0 && workdir === 3) return 'untracked';
775
- if (head === 0 && stage === 3) return 'added (staged)';
776
- if (head === 1 && workdir === 0 && stage === 0) return 'deleted (unstaged)';
777
- if (head === 1 && workdir === 0 && stage === 0) return 'deleted (staged)';
778
- if (head === 1 && workdir === 2 && stage === 0) return 'modified (unstaged)';
779
- if (head === 1 && workdir === 1 && stage === 2) return 'modified (staged)';
780
- if (head === 1 && workdir === 2 && stage === 2) return 'modified (staged + unstaged)';
781
- if (head === 1 && workdir === 1 && stage === 0) return 'clean';
773
+ // isomorphic-git statusMatrix codes:
774
+ // 0: absent, 1: unmodified, 2: modified, 3: added
775
+ const headExists = head !== 0;
776
+ const workdirMissing = workdir === 0;
777
+ const workdirUnmodified = workdir === 1;
778
+ const workdirTouched = !workdirMissing && !workdirUnmodified; // modified or added
779
+ const stageMissing = stage === 0;
780
+ const stageUnmodified = stage === 1;
781
+ const stageTouched = !stageMissing && !stageUnmodified; // staged change (added/modified/deleted)
782
+
783
+ // Untracked file: nothing in HEAD, something in workdir, nothing staged
784
+ if (!headExists && workdirTouched && stageMissing) return 'untracked';
785
+
786
+ // Added (staged): nothing in HEAD, staged entry present
787
+ if (!headExists && stageTouched) return 'added (staged)';
788
+
789
+ // Deleted
790
+ if (headExists && workdirMissing) {
791
+ if (stageMissing || stageUnmodified) return 'deleted (unstaged)';
792
+ return 'deleted (staged)';
793
+ }
794
+
795
+ // Modified
796
+ if (headExists && workdirTouched) {
797
+ if (stageMissing || stageUnmodified) return 'modified (unstaged)';
798
+ if (!workdirUnmodified && stageTouched) return 'modified (staged + unstaged)';
799
+ }
800
+
801
+ // Staged-only modification (workdir clean, stage touched)
802
+ if (headExists && workdirUnmodified && stageTouched) return 'modified (staged)';
803
+
804
+ if (headExists && workdirUnmodified && stageUnmodified) return 'clean';
782
805
  return `unknown (${head},${workdir},${stage})`;
783
806
  }
784
807
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "3.306.0",
3
+ "version": "3.308.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -24,6 +24,7 @@ const fse = require('fs-extra')
24
24
  const QRCode = require('qrcode')
25
25
  const axios = require('axios')
26
26
  const crypto = require('crypto')
27
+ const system = require('systeminformation')
27
28
  const serveIndex = require('./serveIndex')
28
29
  const registerFileRoutes = require('./routes/files')
29
30
  const TerminalApi = require('../kernel/api/terminal')
@@ -647,6 +648,25 @@ class Server {
647
648
  async getGit(ref, filepath) {
648
649
  const dir = this.kernel.path("api", filepath)
649
650
 
651
+ const gitDirPath = path.join(dir, '.git')
652
+ let gitDirExists = false
653
+ try {
654
+ const gitStats = await fs.promises.stat(gitDirPath)
655
+ gitDirExists = gitStats.isDirectory()
656
+ } catch (_) {
657
+ gitDirExists = false
658
+ }
659
+
660
+ let hasHead = false
661
+ if (gitDirExists) {
662
+ try {
663
+ await git.resolveRef({ fs, dir, ref: 'HEAD' })
664
+ hasHead = true
665
+ } catch (_) {
666
+ hasHead = false
667
+ }
668
+ }
669
+
650
670
  let branchList = []
651
671
  try {
652
672
  branchList = await git.listBranches({ fs, dir })
@@ -752,6 +772,8 @@ class Server {
752
772
  log,
753
773
  branch: currentBranch,
754
774
  branches,
775
+ gitDirExists,
776
+ hasHead,
755
777
  dir,
756
778
  detached: isDetached,
757
779
  logError: logError ? String(logError.message || logError) : null
@@ -3480,6 +3502,54 @@ class Server {
3480
3502
  throw new Error("Invalid path: " + config.home)
3481
3503
  }
3482
3504
 
3505
+ const findExistingAncestor = async (p) => {
3506
+ let current = p
3507
+ while (true) {
3508
+ if (await fse.pathExists(current)) {
3509
+ return current
3510
+ }
3511
+ const parent = path.dirname(current)
3512
+ if (!parent || parent === current) {
3513
+ return null
3514
+ }
3515
+ current = parent
3516
+ }
3517
+ }
3518
+
3519
+ const normalizeMountPath = (p) => {
3520
+ if (!p) return null
3521
+ const normalized = path.normalize(p)
3522
+ const { root } = path.parse(normalized)
3523
+ if (normalized === root) {
3524
+ return root.replace(/\\/g, '/')
3525
+ }
3526
+ return normalized.replace(/[\\/]+$/g, '').replace(/\\/g, '/')
3527
+ }
3528
+
3529
+ const resolvedHome = path.resolve(config.home)
3530
+ const ancestor = await findExistingAncestor(resolvedHome)
3531
+ if (!ancestor) {
3532
+ throw new Error("Invalid path: unable to locate parent volume for " + config.home)
3533
+ }
3534
+
3535
+ const mounts = await system.fsSize().catch(() => [])
3536
+ const normalizedAncestor = normalizeMountPath(ancestor)
3537
+ let bestMount = null
3538
+ for (const volume of mounts) {
3539
+ const mountPath = normalizeMountPath(volume.mount)
3540
+ if (!mountPath || !normalizedAncestor) continue
3541
+ const isParent = mountPath === "/" ? normalizedAncestor.startsWith("/") : (normalizedAncestor === mountPath || normalizedAncestor.startsWith(mountPath + "/"))
3542
+ if (isParent) {
3543
+ if (!bestMount || mountPath.length > bestMount.mount.length) {
3544
+ bestMount = { mount: mountPath, type: (volume.type || '').toLowerCase() }
3545
+ }
3546
+ }
3547
+ }
3548
+
3549
+ if (bestMount && bestMount.type.includes("exfat")) {
3550
+ throw new Error("Pinokio home cannot be located on an exFAT drive. Please choose a different location.")
3551
+ }
3552
+
3483
3553
  // // check if the destination already exists => throw error
3484
3554
  // let exists = await fse.pathExists(config.home)
3485
3555
  // if (exists) {
@@ -5315,18 +5385,12 @@ class Server {
5315
5385
  let list = this.getPeers()
5316
5386
  let current_urls = await this.current_urls(req.originalUrl.slice(1))
5317
5387
  let items = [{
5318
- image: "/pinokio-black.png",
5319
- name: "pinokio",
5320
- title: "pinokio.co",
5321
- description: "Connect with pinokio.co",
5322
- url: "/connect/pinokio"
5323
- }, {
5324
- icon: "fa-brands fa-square-x-twitter",
5325
- name: "x",
5326
- title: "x.com",
5327
- description: "Connect with X.com",
5328
- url: "/connect/x"
5329
- }, {
5388
+ // image: "/pinokio-black.png",
5389
+ // name: "pinokio",
5390
+ // title: "pinokio.co",
5391
+ // description: "Connect with pinokio.co",
5392
+ // url: "/connect/pinokio"
5393
+ // }, {
5330
5394
  emoji: "🤗",
5331
5395
  name: "huggingface",
5332
5396
  title: "huggingface.co",
@@ -5338,6 +5402,12 @@ class Server {
5338
5402
  title: "github.com",
5339
5403
  description: "Connect with GitHub.com",
5340
5404
  url: "/github"
5405
+ }, {
5406
+ icon: "fa-brands fa-square-x-twitter",
5407
+ name: "x",
5408
+ title: "x.com",
5409
+ description: "Connect with X.com",
5410
+ url: "/connect/x"
5341
5411
  }]
5342
5412
  let github_hosts = await this.get_github_hosts()
5343
5413
  for(let i=0; i<items.length; i++) {
@@ -7396,8 +7466,12 @@ class Server {
7396
7466
  mode = "shell"
7397
7467
  break
7398
7468
  }
7469
+ if (step.method === "app.launch") {
7470
+ mode = "launch"
7471
+ break
7472
+ }
7399
7473
  }
7400
- if (mode === "exec") {
7474
+ if (mode === "exec" || mode === "launch") {
7401
7475
  item.type = "Open"
7402
7476
  exec_menus.push(item)
7403
7477
  } else if (mode === "shell") {
@@ -8797,7 +8871,7 @@ class Server {
8797
8871
  let message = await this.setConfig(req.body)
8798
8872
  res.json({ success: true, message })
8799
8873
  } catch (e) {
8800
- res.json({ error: e.stack })
8874
+ res.json({ error: e && e.message ? e.message : e })
8801
8875
  }
8802
8876
 
8803
8877
  // update homedir
@@ -8,6 +8,49 @@ const createLauncherDebugLog = (...args) => {
8
8
  }
9
9
  };
10
10
 
11
+ const guardedRoutePrefixes = [
12
+ '/pinokio/launch/',
13
+ '/pinokio/browser/',
14
+ '/v/',
15
+ '/p/',
16
+ '/api/',
17
+ '/_api/',
18
+ '/run/',
19
+ '/tools',
20
+ '/bundle/',
21
+ '/init',
22
+ '/connect/',
23
+ '/github',
24
+ '/setup/',
25
+ '/requirements_check/',
26
+ '/agents',
27
+ '/network',
28
+ '/net/',
29
+ '/git/',
30
+ '/dev/',
31
+ ];
32
+
33
+ function needsRequirementsGuard(targetUrl) {
34
+ try {
35
+ const url = typeof targetUrl === 'string' ? new URL(targetUrl, window.location.href) : targetUrl;
36
+ const path = url.pathname || '';
37
+ const query = url.searchParams || new URLSearchParams(url.search || '');
38
+ if (path === '/home') {
39
+ const mode = (query.get('mode') || '').toLowerCase();
40
+ return mode === 'download';
41
+ }
42
+ for (const prefix of guardedRoutePrefixes) {
43
+ if (path === prefix || path.startsWith(prefix)) {
44
+ return true;
45
+ }
46
+ }
47
+ return false;
48
+ } catch (_) {
49
+ // Be safe and keep guarding if we cannot parse
50
+ return true;
51
+ }
52
+ }
53
+
11
54
  function createMinimalLoadingSwal () {
12
55
  if (typeof window === 'undefined' || typeof window.Swal === 'undefined') {
13
56
  return () => {};
@@ -22,7 +65,7 @@ function createMinimalLoadingSwal () {
22
65
  }
23
66
  };
24
67
  swal.fire({
25
- html: "<i class='fa-solid fa-circle-notch fa-spin'></i> Loading...",
68
+ html: "<i class='fa-solid fa-circle-notch fa-spin'></i> Backend still warming up...",
26
69
  allowOutsideClick: false,
27
70
  allowEscapeKey: false,
28
71
  showConfirmButton: false,
@@ -97,8 +140,20 @@ if (onfinish) {
97
140
  }
98
141
  // The original task
99
142
  */
100
- function wait_ready () {
143
+ function wait_ready (targetUrl = null) {
101
144
  createLauncherDebugLog('wait_ready invoked');
145
+ let navTarget = null;
146
+ if (targetUrl) {
147
+ try {
148
+ navTarget = targetUrl instanceof URL ? targetUrl : new URL(targetUrl, window.location.href);
149
+ } catch (_) {
150
+ navTarget = null;
151
+ }
152
+ if (navTarget && !needsRequirementsGuard(navTarget)) {
153
+ createLauncherDebugLog('wait_ready short-circuit (unguarded route)', { path: navTarget.pathname });
154
+ return Promise.resolve({ ready: true, closeModal: null });
155
+ }
156
+ }
102
157
  return new Promise((resolve, reject) => {
103
158
  check_ready().then((ready) => {
104
159
  createLauncherDebugLog('wait_ready initial requirements readiness', ready);
@@ -399,6 +399,10 @@ body.dark .files-app__tab--stale .files-app__tab-label::after {
399
399
  font-size: 13px;
400
400
  }
401
401
 
402
+ .files-app__tree-action--open {
403
+ font-size: 13px;
404
+ }
405
+
402
406
  .files-app__tree-action:hover {
403
407
  background: rgba(127, 91, 243, 0.15);
404
408
  color: var(--text-color);
@@ -12,6 +12,49 @@
12
12
  return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
13
13
  };
14
14
 
15
+ const decodeBase64 = (value) => {
16
+ if (!value) return '';
17
+ try {
18
+ return window.atob(value);
19
+ } catch (err) {
20
+ console.warn('Failed to decode workspace root', err);
21
+ return '';
22
+ }
23
+ };
24
+
25
+ const joinFsPath = (base, relativePosix) => {
26
+ if (!base) return '';
27
+ if (!relativePosix) return base;
28
+ const separator = base.includes('\\') ? '\\' : '/';
29
+ const normalizedBase = base.endsWith(separator) ? base.slice(0, -1) : base;
30
+ const rel = relativePosix
31
+ .split('/')
32
+ .filter(Boolean)
33
+ .join(separator);
34
+ return `${normalizedBase}${separator}${rel}`;
35
+ };
36
+
37
+ async function openInFileExplorer(path) {
38
+ if (!path) return;
39
+ try {
40
+ const response = await fetch('/openfs', {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ },
45
+ body: JSON.stringify({ path, mode: 'view' }),
46
+ });
47
+ if (!response.ok) {
48
+ throw new Error(`Failed to open file explorer (${response.status})`);
49
+ }
50
+ } catch (error) {
51
+ console.error('Failed to open file explorer', error);
52
+ if (this && typeof setStatus === 'function') {
53
+ setStatus.call(this, 'Failed to open in file explorer', 'error');
54
+ }
55
+ }
56
+ }
57
+
15
58
  const createElement = (tag, className) => {
16
59
  const el = document.createElement(tag);
17
60
  if (className) {
@@ -44,6 +87,7 @@
44
87
  initialPath: config.initialPath || '',
45
88
  initialPathType: config.initialPathType || null,
46
89
  workspaceRoot: config.workspaceRoot || '',
90
+ workspaceRootDecoded: decodeBase64(config.workspaceRoot || ''),
47
91
  treeElements: new Map(),
48
92
  sessions: new Map(),
49
93
  openOrder: [],
@@ -453,8 +497,30 @@
453
497
  label.textContent = entry.name;
454
498
  row.appendChild(label);
455
499
 
500
+ const actions = createElement('span', 'files-app__tree-actions');
501
+ const absoluteFsPath = resolveAbsolutePath.call(this, entry.path);
502
+ if (absoluteFsPath) {
503
+ const openBtn = createElement('span', 'files-app__tree-action files-app__tree-action--open');
504
+ openBtn.setAttribute('role', 'button');
505
+ openBtn.setAttribute('aria-label', 'Open in file explorer');
506
+ openBtn.tabIndex = 0;
507
+ openBtn.title = 'Open in file explorer';
508
+ openBtn.innerHTML = '<i class="fa-solid fa-up-right-from-square"></i>';
509
+ const triggerOpen = (event) => {
510
+ event.preventDefault();
511
+ event.stopPropagation();
512
+ openInFileExplorer.call(this, absoluteFsPath);
513
+ };
514
+ openBtn.addEventListener('click', triggerOpen);
515
+ openBtn.addEventListener('keydown', (event) => {
516
+ if (event.key === 'Enter' || event.key === ' ') {
517
+ triggerOpen(event);
518
+ }
519
+ });
520
+ actions.appendChild(openBtn);
521
+ }
522
+
456
523
  if (!entry.isRoot) {
457
- const actions = createElement('span', 'files-app__tree-actions');
458
524
  const renameBtn = createElement('span', 'files-app__tree-action files-app__tree-action--rename');
459
525
  renameBtn.setAttribute('role', 'button');
460
526
  renameBtn.setAttribute('aria-label', entry.type === 'directory' ? 'Rename folder' : 'Rename file');
@@ -492,6 +558,8 @@
492
558
  }
493
559
  });
494
560
  actions.appendChild(deleteBtn);
561
+ }
562
+ if (actions.children.length > 0) {
495
563
  row.appendChild(actions);
496
564
  }
497
565
 
@@ -985,6 +1053,17 @@
985
1053
  }
986
1054
  }
987
1055
 
1056
+ function resolveAbsolutePath(relativePosix) {
1057
+ if (!this || !this.state) {
1058
+ return null;
1059
+ }
1060
+ const root = this.state.workspaceRootDecoded || '';
1061
+ if (!root) {
1062
+ return null;
1063
+ }
1064
+ return joinFsPath(root, relativePosix);
1065
+ }
1066
+
988
1067
  async function expandInitialPath(initialPath, initialType) {
989
1068
  const segments = String(initialPath).split('/').filter(Boolean);
990
1069
  if (segments.length === 0) {
@@ -35,8 +35,8 @@
35
35
  .htmlmodal-actions .btn.primary:hover { background: #0ea5e9; transform: translateY(-1px); }
36
36
  .htmlmodal-actions .btn.secondary { background: rgba(148,163,184,0.18); color: #f8fafc; border-color: rgba(148,163,184,0.35); }
37
37
  .htmlmodal-actions .btn.secondary:hover { background: rgba(148,163,184,0.3); }
38
- .htmlmodal-actions .btn.link { background: transparent; border-color: transparent; color: #38bdf8; padding-left: 0; }
39
- .htmlmodal-actions .btn.link:hover { color: #7dd3fc; }
38
+ .htmlmodal-actions .btn.link { background: rgba(56,189,248,0.12); border-color: rgba(56,189,248,0.55); color: #38bdf8; padding: 10px 18px; }
39
+ .htmlmodal-actions .btn.link:hover { background: rgba(56,189,248,0.2); color: #7dd3fc; transform: translateY(-1px); }
40
40
  .htmlmodal-actions .btn:disabled { opacity: 0.6; cursor: not-allowed; }
41
41
  `
42
42
  document.head.appendChild(style)
@@ -104,9 +104,10 @@
104
104
  this.render(payload)
105
105
  }
106
106
  if (Object.prototype.hasOwnProperty.call(payload, 'await')) {
107
+ const awaitTarget = payload.awaitKey || packet.id
107
108
  if (payload.await) {
108
- this.current.awaiting = packet.id
109
- } else if (this.current.awaiting === packet.id) {
109
+ this.current.awaiting = awaitTarget
110
+ } else if (this.current.awaiting === awaitTarget) {
110
111
  this.current.awaiting = null
111
112
  }
112
113
  }
@@ -689,6 +689,7 @@
689
689
  hasRecentInput: false,
690
690
  awaitingLive: false,
691
691
  awaitingIdle: false,
692
+ autoDetected: false,
692
693
  isLive: false,
693
694
  notified: false,
694
695
  lastInput: '',
@@ -1231,9 +1232,17 @@ const ensureTabAccessories = aggregateDebounce(() => {
1231
1232
  }
1232
1233
  if (changedAttribute === 'data-timestamp') {
1233
1234
  updateActivityTimestamp(indicator, state);
1235
+ state.isLive = indicator.classList.contains(LIVE_CLASS);
1236
+ if (!state.hasRecentInput && !state.awaitingLive && !state.awaitingIdle && state.isLive && Number.isFinite(state.lastActivityTimestamp)) {
1237
+ state.commandStartTimestamp = state.commandStartTimestamp || state.lastActivityTimestamp || Date.now();
1238
+ state.awaitingIdle = true;
1239
+ state.autoDetected = true;
1240
+ state.notified = false;
1241
+ }
1234
1242
  return;
1235
1243
  }
1236
1244
 
1245
+ const wasLive = state.isLive;
1237
1246
  const isLive = indicator.classList.contains(LIVE_CLASS);
1238
1247
  state.isLive = isLive;
1239
1248
  updateActivityTimestamp(indicator, state);
@@ -1246,11 +1255,18 @@ const ensureTabAccessories = aggregateDebounce(() => {
1246
1255
  if (state.awaitingLive && state.hasRecentInput) {
1247
1256
  state.awaitingLive = false;
1248
1257
  state.awaitingIdle = true;
1258
+ } else if (!state.hasRecentInput && !state.awaitingLive && !state.awaitingIdle && !wasLive && Number.isFinite(state.lastActivityTimestamp)) {
1259
+ // Auto-run scenario: activity started without explicit terminal input.
1260
+ state.awaitingIdle = true;
1261
+ state.autoDetected = true;
1262
+ state.notified = false;
1249
1263
  }
1250
1264
  return;
1251
1265
  }
1252
1266
 
1253
- if (state.awaitingIdle && state.hasRecentInput && !state.notified) {
1267
+ const shouldProcessIdle = state.awaitingIdle && !state.notified && (state.hasRecentInput || state.autoDetected);
1268
+
1269
+ if (shouldProcessIdle) {
1254
1270
  const activityTs = Number.isFinite(state.lastActivityTimestamp)
1255
1271
  ? state.lastActivityTimestamp
1256
1272
  : Number(indicator.dataset?.timestamp);
@@ -1276,6 +1292,7 @@ const ensureTabAccessories = aggregateDebounce(() => {
1276
1292
  state.hasRecentInput = false;
1277
1293
  state.awaitingIdle = false;
1278
1294
  state.awaitingLive = false;
1295
+ state.autoDetected = false;
1279
1296
  state.commandStartTimestamp = 0;
1280
1297
  state.lastLiveTimestamp = 0;
1281
1298
  state.lastActivityTimestamp = 0;
@@ -1336,6 +1353,7 @@ const ensureTabAccessories = aggregateDebounce(() => {
1336
1353
  state.hasRecentInput = true;
1337
1354
  state.awaitingLive = true;
1338
1355
  state.awaitingIdle = false;
1356
+ state.autoDetected = false;
1339
1357
  state.notified = false;
1340
1358
  state.lastInput = sanitisePreview(data.line || '');
1341
1359
  state.commandStartTimestamp = Date.now();
@@ -1516,9 +1516,9 @@ body.dark #fs-changes-menu .git-changes-item.git-changes-item--active {
1516
1516
  }
1517
1517
 
1518
1518
  #fs-status .app-info-card img {
1519
- width: 34px;
1520
- height: 34px;
1521
- border-radius: 8px;
1519
+ width: 25px;
1520
+ height: 25px;
1521
+ border-radius: 4px;
1522
1522
  object-fit: cover;
1523
1523
  }
1524
1524
 
@@ -2349,6 +2349,12 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2349
2349
  color: rgba(255, 255, 255, 0.45);
2350
2350
  }
2351
2351
 
2352
+ .pinokio-modal-icon--warning {
2353
+ background: rgba(255, 189, 68, 0.16);
2354
+ color: #ffbd44;
2355
+ }
2356
+
2357
+
2352
2358
  .fs-dropdown-item--disabled {
2353
2359
  opacity: 0.5;
2354
2360
  cursor: not-allowed;
@@ -3070,6 +3076,11 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3070
3076
  <div class='container'>
3071
3077
  <% if (type === "browse") { %>
3072
3078
  <div id='fs-status' data-workspace="<%=name%>" data-create-uri="<%=git_create_url%>" data-history-uri="<%=git_history_url%>" data-status-uri="<%=git_status_url%>" data-uri="<%=git_monitor_url%>" data-push-uri="<%=git_push_url%>" data-fork-uri="<%=git_fork_url%>">
3079
+ <div class="app-info" title="<%=config.title || name%>">
3080
+ <div class="app-info-card">
3081
+ <img src="<%= config.icon %>" onerror="this.onerror=null; this.src='/pinokio-black.png'" alt="Project icon">
3082
+ </div>
3083
+ </div>
3073
3084
  <!--
3074
3085
  <div class='fs-status-dropdown nested-menu git blue'>
3075
3086
  <button type='button' class='fs-status-btn frame-link reveal'>
@@ -3126,6 +3137,11 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3126
3137
  </div>
3127
3138
  <% } else if (type === 'files') { %>
3128
3139
  <div id='fs-status' data-workspace="<%=name%>" data-create-uri="<%=git_create_url%>" data-history-uri="<%=git_history_url%>" data-status-uri="<%=git_status_url%>" data-uri="<%=git_monitor_url%>" data-push-uri="<%=git_push_url%>" data-fork-uri="<%=git_fork_url%>">
3140
+ <div class="app-info" title="<%=config.title || name%>">
3141
+ <div class="app-info-card">
3142
+ <img src="<%= config.icon %>" onerror="this.onerror=null; this.src='/pinokio-black.png'" alt="Project icon">
3143
+ </div>
3144
+ </div>
3129
3145
  <!--
3130
3146
  <div class='fs-status-dropdown nested-menu git blue'>
3131
3147
  <button type='button' class='fs-status-btn frame-link reveal'>
@@ -3823,7 +3839,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3823
3839
  if (typeof timestamp === "number") {
3824
3840
  const relative = formatRelativeTime(timestamp)
3825
3841
  updatedEl.dataset.timestamp = String(timestamp)
3826
- const isLive = (Date.now() - timestamp) < 1500
3842
+ const isLive = (Date.now() - timestamp) < 3000
3827
3843
  updatedEl.classList.toggle('is-live', isLive)
3828
3844
  if (label) {
3829
3845
  label.textContent = isLive ? `${relative} · live` : `idle · ${relative}`
@@ -3856,7 +3872,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3856
3872
  const dot = updatedEl.querySelector('.indicator .dot')
3857
3873
  const relative = formatRelativeTime(timestamp)
3858
3874
  updatedEl.dataset.timestamp = String(timestamp)
3859
- const isLive = (Date.now() - timestamp) < 1500
3875
+ const isLive = (Date.now() - timestamp) < 3000
3860
3876
  updatedEl.classList.toggle('is-live', isLive)
3861
3877
  if (label) {
3862
3878
  label.textContent = isLive ? `${relative} · live` : `idle · ${relative}`
@@ -4022,7 +4038,7 @@ const rerenderMenuSection = (container, html) => {
4022
4038
  }
4023
4039
  const label = node.querySelector('.indicator .label')
4024
4040
  const relative = formatRelativeTime(value)
4025
- const isLive = (now - value) < 1500
4041
+ const isLive = (now - value) < 3000
4026
4042
  node.classList.toggle('is-live', isLive)
4027
4043
  if (label) {
4028
4044
  label.textContent = isLive ? `${relative} · live` : `idle · ${relative}`
@@ -5923,6 +5939,196 @@ const rerenderMenuSection = (container, html) => {
5923
5939
  forkMenu.appendChild(fragment)
5924
5940
  }
5925
5941
 
5942
+ const fetchPublishPreflightState = async (repoParam) => {
5943
+ if (!repoParam) {
5944
+ return { ok: false, reason: 'missing-repo' }
5945
+ }
5946
+ const encodedParam = encodeRepoPath(repoParam)
5947
+ const infoUrl = `/info/git/HEAD/${encodedParam}`
5948
+ try {
5949
+ const response = await fetch(infoUrl, { cache: 'no-store' })
5950
+ if (!response.ok) {
5951
+ throw new Error(`HTTP ${response.status}`)
5952
+ }
5953
+ const payload = await response.json()
5954
+ const repoExists = typeof payload.gitDirExists === 'boolean' ? payload.gitDirExists : true
5955
+ const hasHead = typeof payload.hasHead === 'boolean'
5956
+ ? payload.hasHead
5957
+ : (Array.isArray(payload.log) && payload.log.length > 0)
5958
+ let reason = null
5959
+ if (!repoExists) {
5960
+ reason = 'missing-git'
5961
+ } else if (!hasHead) {
5962
+ reason = 'missing-commit'
5963
+ }
5964
+ return {
5965
+ ok: !reason,
5966
+ reason,
5967
+ repoExists,
5968
+ hasHead,
5969
+ payload,
5970
+ }
5971
+ } catch (error) {
5972
+ console.error('publish preflight error:', error)
5973
+ return { ok: false, reason: 'network', error }
5974
+ }
5975
+ }
5976
+
5977
+ async function getRepoData(repoParam) {
5978
+ if (!repoParam) {
5979
+ return null
5980
+ }
5981
+ let repoData = repoStatusCache.get(repoParam) || null
5982
+ if (!repoData && typeof check_git === 'function') {
5983
+ await check_git()
5984
+ repoData = repoStatusCache.get(repoParam) || null
5985
+ }
5986
+ return repoData
5987
+ }
5988
+
5989
+ async function ensureRemoteForPublish({ repoParam, repoName }) {
5990
+ const label = repoName || repoParam || 'Repository'
5991
+ const initialRepo = await getRepoData(repoParam)
5992
+ if (hasRemoteConfigured(initialRepo)) {
5993
+ return true
5994
+ }
5995
+
5996
+ const creationResult = await showCreateModal({ skipReload: true })
5997
+ if (!creationResult || creationResult.completed === false) {
5998
+ return false
5999
+ }
6000
+
6001
+ if (typeof check_git === 'function') {
6002
+ await check_git()
6003
+ }
6004
+ const refreshedRepo = await getRepoData(repoParam)
6005
+ if (hasRemoteConfigured(refreshedRepo)) {
6006
+ return true
6007
+ }
6008
+
6009
+ Swal.fire({
6010
+ icon: 'info',
6011
+ title: `${escapeHtml(label)} still needs a Git remote`,
6012
+ text: 'Add a remote (for example, origin) and try publishing again.'
6013
+ })
6014
+ return false
6015
+ }
6016
+
6017
+ const buildPublishPreflightCopy = (repoName, reason) => {
6018
+ const name = repoName && repoName.trim().length > 0 ? repoName.trim() : 'Repository'
6019
+ if (reason === 'missing-git') {
6020
+ return {
6021
+ title: `${name} isn't a Git repository yet`,
6022
+ message: 'Initialize git in this folder and save a version before publishing.',
6023
+ actionLabel: 'Review files'
6024
+ }
6025
+ }
6026
+ if (reason === 'missing-commit') {
6027
+ return {
6028
+ title: `Create your first commit for ${name}`,
6029
+ message: 'Save a version on the main branch before publishing to GitHub.',
6030
+ actionLabel: 'Review files'
6031
+ }
6032
+ }
6033
+ return {
6034
+ title: 'Unable to publish',
6035
+ message: 'Resolve the repository issues before retrying.',
6036
+ actionLabel: 'Review files'
6037
+ }
6038
+ }
6039
+
6040
+ const showPublishPreflightModal = ({ title, message, actionLabel, onAction }) => {
6041
+ const safeTitle = escapeHtml(title || 'Repository is not ready to publish')
6042
+ const safeMessage = escapeHtml(message || 'Resolve the issue and try again.')
6043
+ const safeLabel = escapeHtml(actionLabel || 'Review files')
6044
+ const html = `
6045
+ <div class="pinokio-modal-surface">
6046
+ <div class="pinokio-modal-header">
6047
+ <div class="pinokio-modal-icon pinokio-modal-icon--warning"><i class="fa-solid fa-circle-exclamation"></i></div>
6048
+ <div class="pinokio-modal-heading">
6049
+ <div class="pinokio-modal-title">${safeTitle}</div>
6050
+ <div class="pinokio-modal-subtitle">${safeMessage}</div>
6051
+ </div>
6052
+ </div>
6053
+ </div>
6054
+ `
6055
+
6056
+ Swal.fire({
6057
+ html,
6058
+ backdrop: 'rgba(9,11,15,0.65)',
6059
+ width: 'min(440px, 92vw)',
6060
+ showConfirmButton: true,
6061
+ showCancelButton: false,
6062
+ showCloseButton: true,
6063
+ buttonsStyling: false,
6064
+ focusConfirm: true,
6065
+ confirmButtonText: safeLabel,
6066
+ customClass: {
6067
+ popup: 'pinokio-modern-modal pinokio-publish-preflight-modal',
6068
+ htmlContainer: 'pinokio-modern-html',
6069
+ closeButton: 'pinokio-modern-close',
6070
+ confirmButton: 'pinokio-modern-confirm'
6071
+ }
6072
+ })
6073
+ .then(async (result) => {
6074
+ if (result.isConfirmed && typeof onAction === 'function') {
6075
+ try {
6076
+ await onAction()
6077
+ } catch (error) {
6078
+ console.error('preflight action failed:', error)
6079
+ }
6080
+ }
6081
+ })
6082
+ }
6083
+
6084
+ const launchPublishPreflightDiffFlow = async ({ repoParam, repoName }) => {
6085
+ try {
6086
+ await showGitDiffModal({ repoParam, repoName, forceRefresh: true })
6087
+ } catch (error) {
6088
+ console.error('Failed to open diff modal for publish preflight:', error)
6089
+ return
6090
+ }
6091
+ try {
6092
+ const retryState = await fetchPublishPreflightState(repoParam)
6093
+ if (retryState.ok) {
6094
+ closeStatusDropdowns()
6095
+ const remoteReady = await ensureRemoteForPublish({ repoParam, repoName })
6096
+ if (remoteReady) {
6097
+ showPublishModal({ repoParam, repoName })
6098
+ }
6099
+ }
6100
+ } catch (error) {
6101
+ console.error('Failed to retry publish after diff:', error)
6102
+ }
6103
+ }
6104
+
6105
+ const handlePublishDropdownClick = async ({ repoParam, repoName }) => {
6106
+ closeStatusDropdowns()
6107
+ const preflight = await fetchPublishPreflightState(repoParam)
6108
+ if (preflight.ok) {
6109
+ const remoteReady = await ensureRemoteForPublish({ repoParam, repoName })
6110
+ if (remoteReady) {
6111
+ showPublishModal({ repoParam, repoName })
6112
+ }
6113
+ return
6114
+ }
6115
+ if (preflight.reason === 'network') {
6116
+ Swal.fire({
6117
+ icon: 'error',
6118
+ title: 'Unable to check repository state',
6119
+ text: 'Please try again in a moment.'
6120
+ })
6121
+ return
6122
+ }
6123
+ const copy = buildPublishPreflightCopy(repoName, preflight.reason)
6124
+ showPublishPreflightModal({
6125
+ title: copy.title,
6126
+ message: copy.message,
6127
+ actionLabel: copy.actionLabel,
6128
+ onAction: () => launchPublishPreflightDiffFlow({ repoParam, repoName })
6129
+ })
6130
+ }
6131
+
5926
6132
  const renderPublishDropdown = (repos, options = {}) => {
5927
6133
  if (!publishMenu) {
5928
6134
  return
@@ -5972,21 +6178,11 @@ const rerenderMenuSection = (container, html) => {
5972
6178
  <div class="${remoteClass}">${remoteDisplay}</div>
5973
6179
  `
5974
6180
 
5975
- if (!canPublish) {
5976
- item.addEventListener('click', (event) => {
5977
- event.preventDefault()
5978
- event.stopPropagation()
5979
- closeStatusDropdowns()
5980
- showCreateModal()
5981
- })
5982
- } else {
5983
- item.addEventListener('click', (event) => {
5984
- event.preventDefault()
5985
- event.stopPropagation()
5986
- closeStatusDropdowns()
5987
- showPublishModal({ repoParam: key, repoName: name })
5988
- })
5989
- }
6181
+ item.addEventListener('click', (event) => {
6182
+ event.preventDefault()
6183
+ event.stopPropagation()
6184
+ handlePublishDropdownClick({ repoParam: key, repoName: name })
6185
+ })
5990
6186
 
5991
6187
  fragment.appendChild(item)
5992
6188
  })
@@ -7239,8 +7435,8 @@ const rerenderMenuSection = (container, html) => {
7239
7435
  reloadOnModalClose(publishModalPromise)
7240
7436
  }
7241
7437
 
7242
- const showCreateModal = () => {
7243
- showCreateRepoModal()
7438
+ const showCreateModal = (options = {}) => {
7439
+ return showCreateRepoModal(options)
7244
7440
  }
7245
7441
 
7246
7442
  const showRemoteSelectionModal = (remotes, pushUri) => {
@@ -7363,7 +7559,9 @@ const rerenderMenuSection = (container, html) => {
7363
7559
  reloadOnModalClose(forkShellModalPromise)
7364
7560
  }
7365
7561
 
7366
- const showCreateRepoModal = () => {
7562
+ const showCreateRepoModal = (options = {}) => {
7563
+ const opts = typeof options === 'object' && options !== null ? options : {}
7564
+ const skipReload = Boolean(opts.skipReload)
7367
7565
  const modalHtml = `
7368
7566
  <div class="pinokio-modal-surface">
7369
7567
  <div class="pinokio-modal-header">
@@ -7419,17 +7617,27 @@ const rerenderMenuSection = (container, html) => {
7419
7617
  }
7420
7618
  })
7421
7619
 
7422
- reloadOnModalClose(createRepoModalPromise, (result) => !result || result.isDismissed)
7620
+ if (!skipReload) {
7621
+ reloadOnModalClose(createRepoModalPromise, (result) => !result || result.isDismissed)
7622
+ }
7423
7623
 
7424
- createRepoModalPromise.then((result) => {
7425
- if (result.isConfirmed) {
7426
- const { name, isPrivate } = result.value
7427
- showCreateRepoIframeModal(name, isPrivate)
7624
+ return createRepoModalPromise.then((result) => {
7625
+ if (!result || !result.isConfirmed) {
7626
+ return { completed: false, result }
7428
7627
  }
7628
+ const { name, isPrivate } = result.value
7629
+ return showCreateRepoIframeModal(name, isPrivate, opts).then((iframeResult) => {
7630
+ if (iframeResult && iframeResult.completed === false) {
7631
+ return { completed: false, result: iframeResult }
7632
+ }
7633
+ return { completed: true, result: iframeResult }
7634
+ })
7429
7635
  })
7430
7636
  }
7431
7637
 
7432
- const showCreateRepoIframeModal = (name, isPrivate) => {
7638
+ const showCreateRepoIframeModal = (name, isPrivate, options = {}) => {
7639
+ const opts = typeof options === 'object' && options !== null ? options : {}
7640
+ const skipReload = Boolean(opts.skipReload)
7433
7641
  const createUri = document.querySelector('#fs-status').getAttribute('data-create-uri')
7434
7642
 
7435
7643
  if (!createUri) {
@@ -7438,7 +7646,7 @@ const rerenderMenuSection = (container, html) => {
7438
7646
  text: 'Create repository URL not available.',
7439
7647
  icon: 'error'
7440
7648
  })
7441
- return
7649
+ return Promise.resolve({ completed: false })
7442
7650
  }
7443
7651
 
7444
7652
  const urlParams = new URLSearchParams()
@@ -7475,7 +7683,11 @@ const rerenderMenuSection = (container, html) => {
7475
7683
  focusConfirm: false
7476
7684
  })
7477
7685
 
7478
- reloadOnModalClose(createRepoIframePromise)
7686
+ if (!skipReload) {
7687
+ reloadOnModalClose(createRepoIframePromise)
7688
+ }
7689
+
7690
+ return createRepoIframePromise
7479
7691
  }
7480
7692
 
7481
7693
  async function showForkModalForRepo(repoParam) {
@@ -7808,9 +8020,6 @@ const rerenderMenuSection = (container, html) => {
7808
8020
  }
7809
8021
 
7810
8022
  const repos = getRepoListSnapshot()
7811
- const publishTargets = Array.isArray(repos)
7812
- ? repos.filter((repo) => hasRemoteConfigured(repo) && resolvePushUri(repo))
7813
- : []
7814
8023
 
7815
8024
  detachHandlers()
7816
8025
 
@@ -7822,7 +8031,8 @@ const rerenderMenuSection = (container, html) => {
7822
8031
  setPublishMenuMessage('Git integration unavailable')
7823
8032
  setLabel('Publish')
7824
8033
  disableDropdown()
7825
- pushBtn.disabled = publishTargets.length === 0
8034
+ const hasRepos = Array.isArray(repos) && repos.length > 0
8035
+ pushBtn.disabled = !hasRepos
7826
8036
  if (pushBtn.disabled) {
7827
8037
  pushBtn.classList.add('fs-status-btn--disabled')
7828
8038
  } else {
@@ -7879,15 +8089,6 @@ const rerenderMenuSection = (container, html) => {
7879
8089
  pushBtn.disabled = false
7880
8090
  pushBtn.classList.remove('fs-status-btn--disabled')
7881
8091
 
7882
- if (publishTargets.length === 0) {
7883
- setLabel('Create')
7884
- pushBtn.setAttribute('title', 'Create a GitHub repository for this project')
7885
- disableDropdown()
7886
- pushBtn.addEventListener('click', showCreateModal)
7887
- syncForkButton()
7888
- return
7889
- }
7890
-
7891
8092
  setLabel('Publish')
7892
8093
  pushBtn.removeAttribute('title')
7893
8094
  enableDropdown()
@@ -7908,8 +8109,13 @@ const rerenderMenuSection = (container, html) => {
7908
8109
 
7909
8110
  // Initialize the publish/create button
7910
8111
  updatePublishButton()
7911
-
8112
+
7912
8113
  check_git()
8114
+ setInterval(() => {
8115
+ if (typeof check_git === 'function') {
8116
+ check_git()
8117
+ }
8118
+ }, 5000)
7913
8119
  <% } %>
7914
8120
 
7915
8121
  setInterval(() => {
@@ -761,12 +761,13 @@ window.PinokioHomeGuardNavigate = null;
761
761
  document.addEventListener("DOMContentLoaded", () => {
762
762
  // Run wait_ready for every /home navigation so we only leave when requirements are satisfied
763
763
  const guardNavigate = (href, options = {}) => {
764
- wait_ready().then(({ closeModal, ready }) => {
764
+ const url = new URL(href, location.href);
765
+ wait_ready(url).then(({ closeModal, ready }) => {
765
766
  if (ready) {
766
767
  if (closeModal) {
767
768
  closeModal()
768
769
  }
769
- options?.replace ? location.replace(href) : location.assign(href);
770
+ options?.replace ? location.replace(url) : location.assign(url);
770
771
  } else {
771
772
  const callback = encodeURIComponent(window.location.pathname + window.location.search + window.location.hash);
772
773
  window.location.href = `/setup/dev?callback=${callback}`;