pinokiod 3.180.0 → 3.182.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.
Files changed (51) hide show
  1. package/kernel/favicon.js +91 -34
  2. package/kernel/peer.js +73 -0
  3. package/kernel/util.js +28 -4
  4. package/package.json +1 -1
  5. package/server/index.js +237 -35
  6. package/server/public/common.js +677 -240
  7. package/server/public/files-app/app.css +64 -0
  8. package/server/public/files-app/app.js +87 -0
  9. package/server/public/install.js +8 -1
  10. package/server/public/layout.js +124 -0
  11. package/server/public/nav.js +227 -64
  12. package/server/public/sound/beep.mp3 +0 -0
  13. package/server/public/sound/bell.mp3 +0 -0
  14. package/server/public/sound/bright-ring.mp3 +0 -0
  15. package/server/public/sound/clap.mp3 +0 -0
  16. package/server/public/sound/deep-ring.mp3 +0 -0
  17. package/server/public/sound/gasp.mp3 +0 -0
  18. package/server/public/sound/hehe.mp3 +0 -0
  19. package/server/public/sound/levelup.mp3 +0 -0
  20. package/server/public/sound/light-pop.mp3 +0 -0
  21. package/server/public/sound/light-ring.mp3 +0 -0
  22. package/server/public/sound/meow.mp3 +0 -0
  23. package/server/public/sound/piano.mp3 +0 -0
  24. package/server/public/sound/pop.mp3 +0 -0
  25. package/server/public/sound/uhoh.mp3 +0 -0
  26. package/server/public/sound/whistle.mp3 +0 -0
  27. package/server/public/style.css +195 -4
  28. package/server/public/tab-idle-notifier.js +700 -4
  29. package/server/public/terminal-settings.js +1131 -0
  30. package/server/public/urldropdown.css +28 -1
  31. package/server/socket.js +71 -4
  32. package/server/views/{terminals.ejs → agents.ejs} +108 -32
  33. package/server/views/app.ejs +321 -104
  34. package/server/views/bootstrap.ejs +8 -0
  35. package/server/views/connect.ejs +10 -1
  36. package/server/views/d.ejs +172 -18
  37. package/server/views/editor.ejs +8 -0
  38. package/server/views/file_browser.ejs +4 -0
  39. package/server/views/index.ejs +10 -1
  40. package/server/views/init/index.ejs +18 -3
  41. package/server/views/install.ejs +8 -0
  42. package/server/views/layout.ejs +2 -0
  43. package/server/views/net.ejs +10 -1
  44. package/server/views/network.ejs +10 -1
  45. package/server/views/pro.ejs +8 -0
  46. package/server/views/prototype/index.ejs +8 -0
  47. package/server/views/screenshots.ejs +10 -2
  48. package/server/views/settings.ejs +10 -2
  49. package/server/views/shell.ejs +8 -0
  50. package/server/views/terminal.ejs +8 -0
  51. package/server/views/tools.ejs +10 -2
@@ -891,6 +891,14 @@ body.dark .tab-link-popover {
891
891
  body.dark .tab-link-popover .tab-link-popover-header {
892
892
  color: rgba(226, 232, 240, 0.7);
893
893
  }
894
+ .tab-link-popover .tab-link-popover-separator {
895
+ height: 1px;
896
+ margin: 4px 14px;
897
+ background: rgba(15, 23, 42, 0.08);
898
+ }
899
+ body.dark .tab-link-popover .tab-link-popover-separator {
900
+ background: rgba(148, 163, 184, 0.18);
901
+ }
894
902
  .tab-link-popover .tab-link-popover-item {
895
903
  width: 100%;
896
904
  border: none;
@@ -905,6 +913,9 @@ body.dark .tab-link-popover .tab-link-popover-header {
905
913
  background: transparent;
906
914
  cursor: pointer;
907
915
  }
916
+ .tab-link-popover .tab-link-popover-item.qr-inline { flex-direction: row; align-items: center; gap: 10px; }
917
+ .tab-link-popover .tab-link-popover-item.qr-inline .textcol { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
918
+ .tab-link-popover .tab-link-popover-item.qr-inline .qr { width: 64px; height: 64px; image-rendering: pixelated; flex: 0 0 auto; margin-left: auto; }
908
919
  .tab-link-popover .tab-link-popover-item:hover,
909
920
  .tab-link-popover .tab-link-popover-item:focus-visible {
910
921
  background: rgba(15, 23, 42, 0.06);
@@ -921,6 +932,10 @@ body.dark .tab-link-popover .tab-link-popover-item:focus-visible {
921
932
  text-transform: uppercase;
922
933
  color: rgba(15, 23, 42, 0.55);
923
934
  }
935
+ .tab-link-popover .tab-link-popover-item .label i {
936
+ margin-right: 6px;
937
+ font-size: 11px;
938
+ }
924
939
  body.dark .tab-link-popover .tab-link-popover-item .label {
925
940
  color: rgba(226, 232, 240, 0.65);
926
941
  }
@@ -2232,6 +2247,19 @@ body.dark {
2232
2247
  cursor: not-allowed;
2233
2248
  }
2234
2249
 
2250
+ .pinokio-history-actions {
2251
+ display: inline-flex;
2252
+ align-items: center;
2253
+ gap: 10px;
2254
+ }
2255
+
2256
+ .pinokio-history-branch-select.pinokio-modal-input {
2257
+ width: auto;
2258
+ min-width: 160px;
2259
+ padding: 6px 10px;
2260
+ font-size: 13px;
2261
+ }
2262
+
2235
2263
  .pinokio-pill {
2236
2264
  display: inline-flex;
2237
2265
  align-items: center;
@@ -2957,6 +2985,15 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2957
2985
  overflow: auto;
2958
2986
  flex-wrap: nowrap;
2959
2987
  }
2988
+ /* Keep minimized header horizontal and compact on small screens */
2989
+ header.navheader.minimized,
2990
+ header.navheader.minimized h1 {
2991
+ display: inline-flex;
2992
+ flex-direction: row;
2993
+ }
2994
+ header.navheader.minimized h1 .btn2 {
2995
+ width: auto;
2996
+ }
2960
2997
  .appcanvas {
2961
2998
  margin-left: 0;
2962
2999
  flex: 1 1 auto;
@@ -3508,6 +3545,8 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3508
3545
  return false
3509
3546
  }
3510
3547
 
3548
+ const isIPv4Host = (host) => /^(\d{1,3}\.){3}\d{1,3}$/.test((host || '').trim())
3549
+
3511
3550
  const extractProjectSlug = (node) => {
3512
3551
  if (!node) {
3513
3552
  return ""
@@ -3662,22 +3701,36 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3662
3701
  if (!trimmed) {
3663
3702
  return ""
3664
3703
  }
3665
- if (!/^https?:\/\//i.test(trimmed)) {
3666
- trimmed = `https://${trimmed}`
3667
- } else {
3668
- trimmed = trimmed.replace(/^http:/i, "https:")
3704
+ // If it's already a URL, ensure it's HTTPS and not an IP host
3705
+ if (/^https?:\/\//i.test(trimmed)) {
3706
+ try {
3707
+ const parsed = new URL(trimmed)
3708
+ const host = (parsed.hostname || '').toLowerCase()
3709
+ if (!host || isIPv4Host(host)) {
3710
+ return ""
3711
+ }
3712
+ // Only accept domains (prefer *.localhost) for HTTPS targets
3713
+ if (!(host === 'localhost' || host.endsWith('.localhost') || host.includes('.'))) {
3714
+ return ""
3715
+ }
3716
+ let pathname = parsed.pathname || ""
3717
+ if (pathname === "/") pathname = ""
3718
+ const search = parsed.search || ""
3719
+ return `https://${host}${pathname}${search}`
3720
+ } catch (_) {
3721
+ return ""
3722
+ }
3669
3723
  }
3724
+ // Not a full URL: accept plain domains (prefer *.localhost), reject IPs
3670
3725
  try {
3671
- const parsed = new URL(trimmed)
3672
- if (!parsed.host) {
3726
+ const hostCandidate = trimmed.split('/')[0].toLowerCase()
3727
+ if (!hostCandidate || isIPv4Host(hostCandidate)) {
3673
3728
  return ""
3674
3729
  }
3675
- let pathname = parsed.pathname || ""
3676
- if (pathname === "/") {
3677
- pathname = ""
3730
+ if (!(hostCandidate === 'localhost' || hostCandidate.endsWith('.localhost') || hostCandidate.includes('.'))) {
3731
+ return ""
3678
3732
  }
3679
- const search = parsed.search || ""
3680
- return `https://${parsed.host}${pathname}${search}`
3733
+ return `https://${hostCandidate}`
3681
3734
  } catch (_) {
3682
3735
  return ""
3683
3736
  }
@@ -3736,7 +3789,8 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3736
3789
  const ensureRouterInfoMapping = async () => {
3737
3790
  const now = Date.now()
3738
3791
  if (!tabLinkRouterInfoPromise || now > tabLinkRouterInfoExpiry) {
3739
- tabLinkRouterInfoPromise = fetch("/info/system", {
3792
+ // Use lightweight router mapping to avoid favicon/installed overhead
3793
+ tabLinkRouterInfoPromise = fetch("/info/router", {
3740
3794
  method: "GET",
3741
3795
  headers: {
3742
3796
  "Accept": "application/json"
@@ -3758,6 +3812,8 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3758
3812
  : []
3759
3813
  const portMap = new Map()
3760
3814
  const hostPortMap = new Map()
3815
+ const externalHttpByExtPort = new Map() // ext port -> Set of host:port (external_ip)
3816
+ const externalHttpByIntPort = new Map() // internal port -> Set of host:port (external_ip)
3761
3817
  const hostAliasPortMap = new Map()
3762
3818
  if (data?.router && typeof data.router === "object") {
3763
3819
  Object.entries(data.router).forEach(([dial, hosts]) => {
@@ -3902,11 +3958,29 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3902
3958
  mergeTargets(entry.external_domain)
3903
3959
  mergeTargets(entry.https_href)
3904
3960
  mergeTargets(entry.app_href)
3905
- mergeTargets(entry.external_ip)
3906
- mergeTargets(entry.internal_router)
3907
- mergeTargets(entry.match)
3908
- mergeTargets(entry.host)
3961
+ // Some rewrite mapping entries expose domain candidates under `hosts`
3909
3962
  mergeTargets(entry.hosts)
3963
+ // Internal router can also include domain aliases (e.g., comfyui.localhost)
3964
+ mergeTargets(entry.internal_router)
3965
+
3966
+ // Record external http host:port candidates by external and internal ports for later
3967
+ if (entry.external_ip && typeof entry.external_ip === 'string') {
3968
+ const parsed = parseHostPort(entry.external_ip)
3969
+ if (parsed && parsed.port) {
3970
+ const keyExt = parsed.port
3971
+ if (!externalHttpByExtPort.has(keyExt)) {
3972
+ externalHttpByExtPort.set(keyExt, new Set())
3973
+ }
3974
+ externalHttpByExtPort.get(keyExt).add(`${parsed.host}:${parsed.port}`)
3975
+ const keyInt = String(entry.internal_port || '')
3976
+ if (keyInt) {
3977
+ if (!externalHttpByIntPort.has(keyInt)) {
3978
+ externalHttpByIntPort.set(keyInt, new Set())
3979
+ }
3980
+ externalHttpByIntPort.get(keyInt).add(`${parsed.host}:${parsed.port}`)
3981
+ }
3982
+ }
3983
+ }
3910
3984
 
3911
3985
  if (httpsTargets.size === 0) {
3912
3986
  return
@@ -3982,14 +4056,18 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
3982
4056
 
3983
4057
  return {
3984
4058
  portMap,
3985
- hostPortMap
4059
+ hostPortMap,
4060
+ externalHttpByExtPort,
4061
+ externalHttpByIntPort
3986
4062
  }
3987
4063
  })
3988
4064
  .catch(() => {
3989
4065
  tabLinkRouterHttpsActive = null
3990
4066
  return {
3991
4067
  portMap: new Map(),
3992
- hostPortMap: new Map()
4068
+ hostPortMap: new Map(),
4069
+ externalHttpByExtPort: new Map(),
4070
+ externalHttpByIntPort: new Map()
3993
4071
  }
3994
4072
  })
3995
4073
  tabLinkRouterInfoExpiry = now + 3000
@@ -4042,7 +4120,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4042
4120
  const projectSlug = extractProjectSlug(link).toLowerCase()
4043
4121
  const entries = []
4044
4122
  const entryByUrl = new Map()
4045
- const addEntry = (type, label, url) => {
4123
+ const addEntry = (type, label, url, opts = {}) => {
4046
4124
  if (!url) {
4047
4125
  return
4048
4126
  }
@@ -4067,13 +4145,16 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4067
4145
  return
4068
4146
  }
4069
4147
  if (entryByUrl.has(canonical)) {
4148
+ const existing = entryByUrl.get(canonical)
4149
+ if (opts && opts.qr === true) existing.qr = true
4070
4150
  return
4071
4151
  }
4072
4152
  const entry = {
4073
4153
  type,
4074
4154
  label,
4075
4155
  url: canonical,
4076
- display: formatDisplayUrl(canonical)
4156
+ display: formatDisplayUrl(canonical),
4157
+ qr: opts && opts.qr === true
4077
4158
  }
4078
4159
  entryByUrl.set(canonical, entry)
4079
4160
  entries.push(entry)
@@ -4087,11 +4168,11 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4087
4168
  addEntry("url", "URL", baseHref)
4088
4169
  }
4089
4170
 
4090
- const httpCandidates = new Set()
4171
+ const httpCandidates = new Map() // url -> { qr: boolean }
4091
4172
  const httpsCandidates = new Set()
4092
4173
 
4093
4174
  if (isHttpUrl(baseHref)) {
4094
- httpCandidates.add(canonicalizeUrl(baseHref))
4175
+ httpCandidates.set(canonicalizeUrl(baseHref), { qr: false })
4095
4176
  } else if (isHttpsUrl(baseHref)) {
4096
4177
  httpsCandidates.add(canonicalizeUrl(baseHref))
4097
4178
  }
@@ -4109,7 +4190,7 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4109
4190
  const normalizedPath = pathname.toLowerCase()
4110
4191
  if (normalizedPath.includes(`/asset/api/${projectSlug}`)) {
4111
4192
  const fallbackHttp = `http://127.0.0.1:42000${pathname}`
4112
- httpCandidates.add(canonicalizeUrl(fallbackHttp))
4193
+ httpCandidates.set(canonicalizeUrl(fallbackHttp), { qr: false })
4113
4194
  }
4114
4195
  } catch (_) {
4115
4196
  // ignore fallback errors
@@ -4133,15 +4214,16 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4133
4214
  if (isHttpsUrl(canonical)) {
4134
4215
  httpsCandidates.add(canonical)
4135
4216
  } else if (isHttpUrl(canonical)) {
4136
- httpCandidates.add(canonical)
4217
+ const prev = httpCandidates.get(canonical)
4218
+ httpCandidates.set(canonical, { qr: prev ? prev.qr === true : false })
4137
4219
  }
4138
4220
  })
4139
4221
  })
4140
4222
  }
4141
4223
 
4224
+ const routerData = await ensureRouterInfoMapping()
4142
4225
  if (httpCandidates.size > 0) {
4143
- const routerData = await ensureRouterInfoMapping()
4144
- httpCandidates.forEach((httpUrl) => {
4226
+ Array.from(httpCandidates.keys()).forEach((httpUrl) => {
4145
4227
  const mapped = collectHttpsUrlsFromRouter(httpUrl, routerData)
4146
4228
  mapped.forEach((httpsUrl) => {
4147
4229
  httpsCandidates.add(httpsUrl)
@@ -4149,6 +4231,28 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4149
4231
  })
4150
4232
  }
4151
4233
 
4234
+ // Add external 192.168.* http host:port candidates mapped from the same internal port as base HTTP
4235
+ try {
4236
+ const base = new URL(baseHref, location.origin)
4237
+ let basePort = base.port
4238
+ if (!basePort) {
4239
+ basePort = base.protocol.toLowerCase() === 'https:' ? '443' : '80'
4240
+ }
4241
+ const samePortHosts = routerData && routerData.externalHttpByIntPort ? routerData.externalHttpByIntPort.get(basePort) : null
4242
+ if (samePortHosts && samePortHosts.size > 0) {
4243
+ samePortHosts.forEach((hostport) => {
4244
+ try {
4245
+ const hpUrl = `http://${hostport}${base.pathname || '/'}${base.search || ''}`
4246
+ const canonical = canonicalizeUrl(hpUrl)
4247
+ if (isHttpUrl(canonical)) {
4248
+ const prev = httpCandidates.get(canonical)
4249
+ httpCandidates.set(canonical, { qr: true || (prev ? prev.qr === true : false) })
4250
+ }
4251
+ } catch (_) {}
4252
+ })
4253
+ }
4254
+ } catch (_) {}
4255
+
4152
4256
  const httpsList = Array.from(httpsCandidates).sort()
4153
4257
 
4154
4258
  if (httpsList.length > 0) {
@@ -4163,17 +4267,20 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4163
4267
  }
4164
4268
  const hostPort = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname
4165
4269
  const httpUrl = `http://${hostPort}${parsed.pathname || "/"}${parsed.search || ""}`
4166
- httpCandidates.add(canonicalizeUrl(httpUrl))
4270
+ const key = canonicalizeUrl(httpUrl)
4271
+ const prev = httpCandidates.get(key)
4272
+ httpCandidates.set(key, { qr: prev ? prev.qr === true : false })
4167
4273
  } catch (_) {
4168
4274
  // ignore failures
4169
4275
  }
4170
4276
  })
4171
4277
  }
4172
4278
 
4173
- const httpList = Array.from(httpCandidates).sort()
4279
+ const httpList = Array.from(httpCandidates.keys()).sort()
4174
4280
 
4175
4281
  httpList.forEach((url) => {
4176
- addEntry("http", "HTTP", url)
4282
+ const meta = httpCandidates.get(url) || { qr: false }
4283
+ addEntry("http", "HTTP", url, { qr: meta.qr === true })
4177
4284
  })
4178
4285
  httpsList.forEach((url) => {
4179
4286
  addEntry("https", "HTTPS", url)
@@ -4273,27 +4380,47 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4273
4380
  tabLinkHideTimer = null
4274
4381
  }
4275
4382
 
4276
- let entries
4383
+ // Show lightweight loading popover immediately while mapping fetch runs
4384
+ try {
4385
+ const pop = ensureTabLinkPopoverEl()
4386
+ pop.innerHTML = ''
4387
+ const header = document.createElement('div')
4388
+ header.className = 'tab-link-popover-header'
4389
+ header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
4390
+ const item = document.createElement('div')
4391
+ item.className = 'tab-link-popover-item'
4392
+ const label = document.createElement('span')
4393
+ label.className = 'label'
4394
+ label.textContent = 'Loading…'
4395
+ const value = document.createElement('span')
4396
+ value.className = 'value muted'
4397
+ value.textContent = 'Discovering routes'
4398
+ item.append(label, value)
4399
+ pop.append(header, item)
4400
+ positionTabLinkPopover(pop, link)
4401
+ } catch (_) {}
4402
+
4403
+ let entries = []
4277
4404
  try {
4278
- entries = await buildTabLinkEntries(link)
4405
+ const result = await buildTabLinkEntries(link)
4406
+ if (Array.isArray(result)) {
4407
+ entries = result.slice()
4408
+ }
4279
4409
  } catch (_) {
4280
- tabLinkPendingLink = null
4281
- return
4410
+ entries = []
4282
4411
  }
4283
4412
 
4284
4413
  if (tabLinkPendingLink !== link) {
4285
4414
  return
4286
4415
  }
4287
4416
 
4288
- if (!entries || entries.length === 0) {
4289
- hideTabLinkPopover({ immediate: true })
4290
- return
4291
- }
4417
+ let openEntries = Array.isArray(entries) ? entries.slice() : []
4418
+ let showOpenSection = openEntries.length > 0
4292
4419
 
4293
4420
  if (sameOrigin) {
4294
4421
  const slug = extractProjectSlug(link).toLowerCase()
4295
4422
  if (slug) {
4296
- entries = entries.filter((entry) => {
4423
+ openEntries = openEntries.filter((entry) => {
4297
4424
  if (!entry || !entry.url) {
4298
4425
  return false
4299
4426
  }
@@ -4328,17 +4455,21 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4328
4455
  return false
4329
4456
  })
4330
4457
  } else {
4331
- entries = entries.filter((entry) => entry.url === canonicalBase)
4458
+ openEntries = openEntries.filter((entry) => entry.url === canonicalBase)
4332
4459
  }
4333
4460
 
4334
- const hasAlternate = entries.some((entry) => entry.url !== canonicalBase)
4461
+ const hasAlternate = openEntries.some((entry) => entry.url !== canonicalBase)
4335
4462
  if (!hasAlternate) {
4336
- hideTabLinkPopover({ immediate: true })
4337
- return
4463
+ showOpenSection = false
4464
+ openEntries = []
4465
+ } else {
4466
+ showOpenSection = openEntries.length > 0
4338
4467
  }
4468
+ } else {
4469
+ showOpenSection = openEntries.length > 0
4339
4470
  }
4340
4471
 
4341
- if (!entries || entries.length === 0) {
4472
+ if (!showOpenSection && !canonicalBase) {
4342
4473
  hideTabLinkPopover({ immediate: true })
4343
4474
  return
4344
4475
  }
@@ -4346,46 +4477,93 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4346
4477
  const popover = ensureTabLinkPopoverEl()
4347
4478
  popover.innerHTML = ""
4348
4479
 
4349
- const header = document.createElement("div")
4350
- header.className = "tab-link-popover-header"
4351
- header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
4352
- popover.appendChild(header)
4353
-
4354
- const hasHttpsEntry = entries.some((entry) => entry && entry.type === "https")
4355
-
4356
- entries.forEach((entry) => {
4357
- const item = document.createElement("button")
4358
- item.type = "button"
4359
- item.className = "tab-link-popover-item"
4360
- item.setAttribute("data-url", entry.url)
4361
- const labelSpan = document.createElement("span")
4362
- labelSpan.className = "label"
4363
- labelSpan.textContent = entry.label
4364
- const valueSpan = document.createElement("span")
4365
- valueSpan.className = "value"
4366
- valueSpan.textContent = entry.display
4367
- item.append(labelSpan, valueSpan)
4368
- popover.appendChild(item)
4369
- })
4480
+ if (showOpenSection) {
4481
+ const header = document.createElement("div")
4482
+ header.className = "tab-link-popover-header"
4483
+ header.innerHTML = `<i class="fa-solid fa-arrow-up-right-from-square"></i><span>Open in browser</span>`
4484
+ popover.appendChild(header)
4485
+
4486
+ const hasHttpsEntry = openEntries.some((entry) => entry && entry.type === "https")
4487
+
4488
+ openEntries.forEach((entry) => {
4489
+ const item = document.createElement("button")
4490
+ item.type = "button"
4491
+ item.setAttribute("data-url", entry.url)
4492
+ const labelSpan = document.createElement("span")
4493
+ labelSpan.className = "label"
4494
+ labelSpan.textContent = entry.label
4495
+ const valueSpan = document.createElement("span")
4496
+ valueSpan.className = "value"
4497
+ valueSpan.textContent = entry.display
4498
+
4499
+ if (entry.type === 'http' && entry.qr === true) {
4500
+ item.className = "tab-link-popover-item qr-inline"
4501
+ const textCol = document.createElement('div')
4502
+ textCol.className = 'textcol'
4503
+ textCol.append(labelSpan, valueSpan)
4504
+ const qrImg = document.createElement('img')
4505
+ qrImg.className = 'qr'
4506
+ qrImg.alt = 'QR'
4507
+ qrImg.decoding = 'async'
4508
+ qrImg.loading = 'lazy'
4509
+ qrImg.src = `/qr?data=${encodeURIComponent(entry.url)}&s=4&m=0`
4510
+ item.append(textCol, qrImg)
4511
+ } else {
4512
+ item.className = "tab-link-popover-item"
4513
+ // Keep label and value as direct children so column layout applies
4514
+ item.append(labelSpan, valueSpan)
4515
+ }
4516
+ popover.appendChild(item)
4517
+ })
4518
+
4519
+ if (tabLinkRouterHttpsActive === false && !hasHttpsEntry) {
4520
+ const footerButton = document.createElement("button")
4521
+ footerButton.type = "button"
4522
+ footerButton.className = "tab-link-popover-item tab-link-popover-footer"
4523
+ footerButton.setAttribute("data-url", "/network")
4524
+ footerButton.setAttribute("data-target", "_self")
4525
+ footerButton.setAttribute("aria-label", "Open network settings to configure local HTTPS")
4370
4526
 
4371
- if (tabLinkRouterHttpsActive === false && !hasHttpsEntry) {
4372
- const footerButton = document.createElement("button")
4373
- footerButton.type = "button"
4374
- footerButton.className = "tab-link-popover-item tab-link-popover-footer"
4375
- footerButton.setAttribute("data-url", "/network")
4376
- footerButton.setAttribute("data-target", "_self")
4377
- footerButton.setAttribute("aria-label", "Open network settings to configure local HTTPS")
4527
+ const footerLabel = document.createElement("span")
4528
+ footerLabel.className = "label"
4529
+ footerLabel.textContent = "Custom domain not active"
4378
4530
 
4379
- const footerLabel = document.createElement("span")
4380
- footerLabel.className = "label"
4381
- footerLabel.textContent = "Custom domain not active"
4531
+ const footerValue = document.createElement("span")
4532
+ footerValue.className = "value"
4533
+ footerValue.textContent = "Click to activate"
4382
4534
 
4383
- const footerValue = document.createElement("span")
4384
- footerValue.className = "value"
4385
- footerValue.textContent = "Click to activate"
4535
+ footerButton.append(footerLabel, footerValue)
4536
+ popover.appendChild(footerButton)
4537
+ }
4538
+ }
4386
4539
 
4387
- footerButton.append(footerLabel, footerValue)
4388
- popover.appendChild(footerButton)
4540
+ if (showOpenSection && canonicalBase) {
4541
+ const separator = document.createElement("div")
4542
+ separator.className = "tab-link-popover-separator"
4543
+ popover.appendChild(separator)
4544
+ }
4545
+
4546
+ if (canonicalBase) {
4547
+ const debugHeader = document.createElement("div")
4548
+ debugHeader.className = "tab-link-popover-header"
4549
+ debugHeader.innerHTML = `<i class="fa-solid fa-cube"></i><span>Debug</span>`
4550
+ popover.appendChild(debugHeader)
4551
+
4552
+ const debugItem = document.createElement("button")
4553
+ debugItem.type = "button"
4554
+ debugItem.className = "tab-link-popover-item"
4555
+ debugItem.setAttribute("data-url", canonicalBase)
4556
+
4557
+ const debugLabel = document.createElement("span")
4558
+ debugLabel.className = "label"
4559
+ debugLabel.innerHTML = `<i class="fa-solid fa-border-none"></i><span>Select a region</span>`
4560
+
4561
+ const debugValue = document.createElement("span")
4562
+ debugValue.className = "value"
4563
+ debugValue.textContent = formatDisplayUrl(canonicalBase)
4564
+
4565
+ debugItem.append(debugLabel, debugValue)
4566
+ popover.appendChild(debugItem)
4389
4567
  }
4390
4568
 
4391
4569
  tabLinkActiveLink = link
@@ -7879,6 +8057,10 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
7879
8057
  const commits = historyData.log || []
7880
8058
  const remote = historyData.remote || ''
7881
8059
  const currentRef = historyData.ref || 'HEAD'
8060
+ const branchEntries = Array.isArray(historyData.branches) ? historyData.branches : []
8061
+ const isOid = (s) => typeof s === 'string' && /^[0-9a-f]{7,40}$/i.test(s)
8062
+ const realBranchEntries = branchEntries.filter((entry) => entry && typeof entry.branch === 'string' && entry.branch.length > 0 && !isOid(entry.branch))
8063
+ const selectedBranchName = (historyData && typeof historyData.branch === 'string' && !isOid(historyData.branch)) ? historyData.branch : null
7882
8064
 
7883
8065
  const commitCountLabel = `${commits.length} commit${commits.length === 1 ? '' : 's'}`
7884
8066
  const lastCommit = commits[0]
@@ -7910,9 +8092,21 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
7910
8092
  <div class="pinokio-history-latest-text">
7911
8093
  Currently viewing ${escapeHtml(currentRef)}
7912
8094
  </div>
7913
- <button type="button" class="pinokio-history-latest-btn" data-history-return-head>
7914
- <i class="fa-solid fa-arrow-rotate-left"></i> Return to newest commit
7915
- </button>
8095
+ <div class="pinokio-history-actions">
8096
+ <button type="button" class="pinokio-history-latest-btn" data-history-return-head>
8097
+ <i class="fa-solid fa-arrow-rotate-left"></i> Return to newest commit
8098
+ </button>
8099
+ ${realBranchEntries.length ? `
8100
+ <select class="pinokio-modal-input pinokio-history-branch-select" data-history-branch-select aria-label="Select branch">
8101
+ ${realBranchEntries.map(e => `
8102
+ <option value="${escapeHtml(e.branch)}"${selectedBranchName === e.branch ? ' selected' : ''}>${escapeHtml(e.branch)}</option>
8103
+ `).join('')}
8104
+ </select>
8105
+ <button type="button" class="pinokio-history-latest-btn" data-history-branch-switch>
8106
+ <i class="fa-solid fa-code-branch"></i> Switch
8107
+ </button>
8108
+ ` : ''}
8109
+ </div>
7916
8110
  </div>
7917
8111
  <div class="pinokio-modal-body pinokio-modal-body--history">
7918
8112
  ${commits.length === 0 ?
@@ -7949,7 +8143,9 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
7949
8143
 
7950
8144
  const returnBtn = document.querySelector('[data-history-return-head]')
7951
8145
  const banner = document.querySelector('[data-history-latest-banner]')
7952
- if (returnBtn && typeof showIframeView === 'function') {
8146
+ const branchSelect = document.querySelector('[data-history-branch-select]')
8147
+ const branchSwitchBtn = document.querySelector('[data-history-branch-switch]')
8148
+ if (typeof showIframeView === 'function') {
7953
8149
  const repoParam = options && typeof options === 'object' ? options.repoParam : null
7954
8150
  let checkoutCwd = null
7955
8151
  if (historyData && typeof historyData.dir === 'string' && historyData.dir.length > 0) {
@@ -7958,31 +8154,52 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
7958
8154
  checkoutCwd = repoParam
7959
8155
  }
7960
8156
 
8157
+ const isOid = (s) => typeof s === 'string' && /^[0-9a-f]{7,40}$/i.test(s)
7961
8158
  const branchEntries = Array.isArray(historyData.branches) ? historyData.branches : []
7962
- let checkoutTarget = null
7963
-
7964
- if (repoData && typeof repoData.branch === 'string' && repoData.branch.length > 0 && repoData.branch !== currentRef) {
7965
- checkoutTarget = repoData.branch
7966
- }
7967
- if (!checkoutTarget) {
7968
- const nonCurrent = branchEntries.find((entry) => entry && typeof entry.branch === 'string' && entry.branch.length > 0 && entry.branch !== currentRef)
7969
- if (nonCurrent) {
7970
- checkoutTarget = nonCurrent.branch
7971
- }
8159
+ const realBranches = branchEntries
8160
+ .map((entry) => entry && typeof entry.branch === 'string' ? entry.branch : null)
8161
+ .filter((name) => name && !isOid(name))
8162
+
8163
+ // Wire the branch selector
8164
+ if (branchSelect && checkoutCwd) {
8165
+ branchSelect.addEventListener('change', () => {
8166
+ const value = branchSelect.value
8167
+ if (!value) return
8168
+ const url = `/run/scripts/git/checkout.json?cwd=${encodeURIComponent(checkoutCwd)}&commit=${encodeURIComponent(value)}&callback_target=parent&callback=$location.href`
8169
+ openCheckoutTerminal(url)
8170
+ })
7972
8171
  }
7973
- if (!checkoutTarget && branchEntries.length > 0) {
7974
- checkoutTarget = branchEntries[0].branch
8172
+ if (branchSwitchBtn && branchSelect && checkoutCwd) {
8173
+ branchSwitchBtn.addEventListener('click', () => {
8174
+ const value = branchSelect.value
8175
+ if (!value) return
8176
+ const url = `/run/scripts/git/checkout.json?cwd=${encodeURIComponent(checkoutCwd)}&commit=${encodeURIComponent(value)}&callback_target=parent&callback=$location.href`
8177
+ openCheckoutTerminal(url)
8178
+ })
7975
8179
  }
7976
8180
 
7977
- if (checkoutCwd && checkoutTarget) {
7978
- const checkoutUrl = `/run/scripts/git/checkout.json?cwd=${encodeURIComponent(checkoutCwd)}&commit=${encodeURIComponent(checkoutTarget)}&callback_target=parent&callback=$location.href`
7979
- returnBtn.addEventListener('click', () => {
7980
- openCheckoutTerminal(checkoutUrl)
7981
- })
7982
- } else {
7983
- returnBtn.disabled = true
7984
- if (banner) {
7985
- banner.classList.add('pinokio-history-latest-banner--disabled')
8181
+ // Fix "Return to newest commit" target selection
8182
+ if (returnBtn) {
8183
+ let checkoutTarget = null
8184
+ if (repoData && typeof repoData.branch === 'string' && repoData.branch.length > 0 && !isOid(repoData.branch)) {
8185
+ checkoutTarget = repoData.branch
8186
+ } else if (historyData && typeof historyData.branch === 'string' && historyData.branch.length > 0 && !isOid(historyData.branch)) {
8187
+ checkoutTarget = historyData.branch
8188
+ } else if (realBranches.length > 0) {
8189
+ const prefer = ['main', 'master', 'develop', 'dev']
8190
+ checkoutTarget = prefer.find((n) => realBranches.includes(n)) || realBranches[0]
8191
+ }
8192
+
8193
+ if (checkoutCwd && checkoutTarget) {
8194
+ const checkoutUrl = `/run/scripts/git/checkout.json?cwd=${encodeURIComponent(checkoutCwd)}&commit=${encodeURIComponent(checkoutTarget)}&callback_target=parent&callback=$location.href`
8195
+ returnBtn.addEventListener('click', () => {
8196
+ openCheckoutTerminal(checkoutUrl)
8197
+ })
8198
+ } else {
8199
+ returnBtn.disabled = true
8200
+ if (banner) {
8201
+ banner.classList.add('pinokio-history-latest-banner--disabled')
8202
+ }
7986
8203
  }
7987
8204
  }
7988
8205
  } else if (banner) {