pinokiod 3.111.0 → 3.112.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.
package/kernel/plugin.js CHANGED
@@ -14,13 +14,25 @@ class Plugin {
14
14
  let plugins = []
15
15
  for(let plugin_path of plugin_paths) {
16
16
  let config = await this.kernel.require(path.resolve(plugin_dir, plugin_path))
17
- let chunks = plugin_path.split(path.sep)
18
- let cwd = chunks.slice(0, -1).join("/")
19
- config.image = "/asset/plugin/" + cwd + "/" + config.icon
20
- plugins.push({
21
- href: "/run/plugin/" + chunks.join("/"),
22
- ...config
23
- })
17
+ if (config && config.run && Array.isArray(config.run)) {
18
+ let invalid
19
+ for(let key in config) {
20
+ if (typeof config[key] === "function") {
21
+ invalid = true
22
+ }
23
+ }
24
+ if (invalid) {
25
+ continue
26
+ }
27
+
28
+ let chunks = plugin_path.split(path.sep)
29
+ let cwd = chunks.slice(0, -1).join("/")
30
+ config.image = "/asset/plugin/" + cwd + "/" + config.icon
31
+ plugins.push({
32
+ href: "/run/plugin/" + chunks.join("/"),
33
+ ...config
34
+ })
35
+ }
24
36
  }
25
37
 
26
38
  this.config = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "3.111.0",
3
+ "version": "3.112.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -85,20 +85,21 @@ class Server {
85
85
  this.cf = new Cloudflare()
86
86
  this.virtualEnvCache = new Map()
87
87
  this.gitStatusIgnorePatterns = [
88
- /^node_modules\//,
89
- /^vendor\//,
90
- /^\.venv\//,
91
- /^venv\//,
92
- /^\.virtualenv\//,
93
- /^env\//,
94
- /^__pycache__\//,
95
- /^build\//,
96
- /^dist\//,
97
- /^tmp\//,
98
- /^\.cache\//,
99
- /^\.mypy_cache\//,
100
- /^\.pytest_cache\//,
101
- /^\.git\//
88
+ /(^|\/)node_modules\//,
89
+ // /(^|\/)vendor\//,
90
+ /(^|\/)__pycache__\//,
91
+ // /(^|\/)build\//,
92
+ // /(^|\/)dist\//,
93
+ // /(^|\/)tmp\//,
94
+ /(^|\/)\.cache\//,
95
+ /(^|\/)\.ruff_cache\//,
96
+ /(^|\/)\.tox\//,
97
+ /(^|\/)\.terraform\//,
98
+ /(^|\/)\.parcel-cache\//,
99
+ /(^|\/)\.webpack\//,
100
+ /(^|\/)\.mypy_cache\//,
101
+ /(^|\/)\.pytest_cache\//,
102
+ /(^|\/)\.git\//
102
103
  ]
103
104
 
104
105
  // sometimes the C:\Windows\System32 is not in PATH, need to add
@@ -1374,6 +1375,25 @@ class Server {
1374
1375
  let { requirements, install_required, requirements_pending, error } = await this.kernel.bin.check({
1375
1376
  bin: this.kernel.bin.preset("ai"),
1376
1377
  })
1378
+ let sanitizedPath = null
1379
+ if (typeof req.query.path === 'string') {
1380
+ let trimmed = req.query.path.trim()
1381
+ if (trimmed) {
1382
+ trimmed = trimmed.replace(/^~[\\/]?/, '').replace(/^[\\/]+/, '')
1383
+ if (trimmed) {
1384
+ const segments = trimmed.split(/[\\/]+/).filter(Boolean)
1385
+ if (segments.length > 0 && !segments.some((segment) => segment === '.' || segment === '..')) {
1386
+ sanitizedPath = segments.join('/')
1387
+ try {
1388
+ await fs.promises.mkdir(path.resolve(this.kernel.homedir, sanitizedPath), { recursive: true })
1389
+ } catch (mkdirErr) {
1390
+ console.warn('Failed to ensure download path exists', mkdirErr)
1391
+ sanitizedPath = null
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+ }
1377
1397
  res.render("download", {
1378
1398
  portal: this.portal,
1379
1399
  error,
@@ -1386,7 +1406,7 @@ class Server {
1386
1406
  agent: req.agent,
1387
1407
  userdir: this.kernel.api.userdir,
1388
1408
  display: ["form"],
1389
- query: req.query
1409
+ query: sanitizedPath ? { ...req.query, path: sanitizedPath } : req.query
1390
1410
  })
1391
1411
  } else if (pathComponents.length === 0 && req.query.mode === "settings") {
1392
1412
  let system_env = {}
@@ -3766,6 +3786,94 @@ class Server {
3766
3786
  list,
3767
3787
  })
3768
3788
  }))
3789
+ this.app.get("/terminals", ex(async (req, res) => {
3790
+ if (!this.kernel.plugin.config) {
3791
+ try {
3792
+ await this.kernel.plugin.init()
3793
+ } catch (err) {
3794
+ console.warn('Failed to initialize plugins', err)
3795
+ }
3796
+ }
3797
+ const pluginMenu = this.kernel.plugin && this.kernel.plugin.config && Array.isArray(this.kernel.plugin.config.menu)
3798
+ ? this.kernel.plugin.config.menu
3799
+ : []
3800
+
3801
+ const apps = []
3802
+ try {
3803
+ const apipath = this.kernel.path("api")
3804
+ const entries = await fs.promises.readdir(apipath, { withFileTypes: true })
3805
+ for (const entry of entries) {
3806
+ let type
3807
+ try {
3808
+ type = await Util.file_type(apipath, entry)
3809
+ } catch (typeErr) {
3810
+ console.warn('Failed to inspect api entry', entry.name, typeErr)
3811
+ continue
3812
+ }
3813
+ if (!type || !type.directory) {
3814
+ continue
3815
+ }
3816
+ try {
3817
+ const meta = await this.kernel.api.meta(entry.name)
3818
+ const absolutePath = meta && meta.path ? meta.path : this.kernel.path("api", entry.name)
3819
+ let displayPath = absolutePath
3820
+ if (this.kernel.homedir && absolutePath.startsWith(this.kernel.homedir)) {
3821
+ const relative = path.relative(this.kernel.homedir, absolutePath)
3822
+ if (!relative || relative === '.' || relative === '') {
3823
+ displayPath = '~'
3824
+ } else if (!relative.startsWith('..')) {
3825
+ const normalized = relative.split(path.sep).join('/')
3826
+ displayPath = `~/${normalized}`
3827
+ }
3828
+ }
3829
+ apps.push({
3830
+ name: entry.name,
3831
+ title: meta && meta.title ? meta.title : entry.name,
3832
+ description: meta && meta.description ? meta.description : '',
3833
+ icon: meta && meta.icon ? meta.icon : "/pinokio-black.png",
3834
+ cwd: absolutePath,
3835
+ displayPath
3836
+ })
3837
+ } catch (metaError) {
3838
+ console.warn('Failed to load app metadata', entry.name, metaError)
3839
+ const fallbackPath = this.kernel.path("api", entry.name)
3840
+ apps.push({
3841
+ name: entry.name,
3842
+ title: entry.name,
3843
+ description: '',
3844
+ icon: "/pinokio-black.png",
3845
+ cwd: fallbackPath,
3846
+ displayPath: fallbackPath
3847
+ })
3848
+ }
3849
+ }
3850
+ } catch (enumerationError) {
3851
+ console.warn('Failed to enumerate api apps for plugin modal', enumerationError)
3852
+ }
3853
+
3854
+ apps.sort((a, b) => {
3855
+ const at = (a.title || a.name || '').toLowerCase()
3856
+ const bt = (b.title || b.name || '').toLowerCase()
3857
+ if (at < bt) return -1
3858
+ if (at > bt) return 1
3859
+ return (a.name || '').localeCompare(b.name || '')
3860
+ })
3861
+
3862
+ const list = this.getPeers()
3863
+ res.render("terminals", {
3864
+ current_host: this.kernel.peer.host,
3865
+ pluginMenu,
3866
+ apps,
3867
+ portal: this.portal,
3868
+ logo: this.logo,
3869
+ theme: this.theme,
3870
+ agent: req.agent,
3871
+ list,
3872
+ })
3873
+ }))
3874
+ this.app.get("/plugins", (req, res) => {
3875
+ res.redirect(301, "/terminals")
3876
+ })
3769
3877
  this.app.get("/screenshots", ex(async (req, res) => {
3770
3878
  let list = this.getPeers()
3771
3879
  res.render("screenshots", {
@@ -1592,6 +1592,8 @@ document.addEventListener("DOMContentLoaded", () => {
1592
1592
  }
1593
1593
  if (document.querySelector("#refresh-page")) {
1594
1594
  document.querySelector("#refresh-page").addEventListener("click", (e) => {
1595
+ location.reload()
1596
+ /*
1595
1597
  let browserview = document.querySelector(".browserview")
1596
1598
  if (browserview) {
1597
1599
  let iframe = browserview.querySelector("iframe:not(.hidden)")
@@ -1604,6 +1606,7 @@ document.addEventListener("DOMContentLoaded", () => {
1604
1606
  } else {
1605
1607
  location.reload()
1606
1608
  }
1609
+ */
1607
1610
  })
1608
1611
  }
1609
1612
  const handleSplitNavigation = async (anchor) => {
@@ -38,9 +38,38 @@ const installname = async (url, name) => {
38
38
  return null
39
39
  }
40
40
  }
41
+ const DEFAULT_INSTALL_RELATIVE_PATH = 'api'
42
+
43
+ // Ensure the requested install path stays within the Pinokio home directory
44
+ const normalizeInstallPath = (rawPath) => {
45
+ if (typeof rawPath !== 'string') {
46
+ return null
47
+ }
48
+ let trimmed = rawPath.trim()
49
+ if (!trimmed) {
50
+ return null
51
+ }
52
+ // drop leading ~/ or any absolute indicators
53
+ trimmed = trimmed.replace(/^~[\\/]?/, '').replace(/^[\\/]+/, '')
54
+ if (!trimmed) {
55
+ return null
56
+ }
57
+ const segments = trimmed.split(/[\\/]+/).filter(Boolean)
58
+ if (!segments.length) {
59
+ return null
60
+ }
61
+ if (segments.some((segment) => segment === '.' || segment === '..')) {
62
+ return null
63
+ }
64
+ return segments.join('/')
65
+ }
66
+
41
67
  const install = async (name, url, term, socket, options) => {
42
68
  console.log("options", options)
43
69
  const n = new N()
70
+ const normalizedPath = options && options.path ? normalizeInstallPath(options.path) : null
71
+ const targetPath = normalizedPath ? `~/${normalizedPath}` : `~/${DEFAULT_INSTALL_RELATIVE_PATH}`
72
+
44
73
  await new Promise((resolve, reject) => {
45
74
  socket.close()
46
75
 
@@ -68,7 +97,7 @@ const install = async (name, url, term, socket, options) => {
68
97
  },
69
98
  params: {
70
99
  message: cmd,
71
- path: "~/api"
100
+ path: targetPath
72
101
  }
73
102
  }, (packet) => {
74
103
  if (packet.type === 'stream') {
@@ -107,10 +136,18 @@ const install = async (name, url, term, socket, options) => {
107
136
  }
108
137
  })
109
138
  } else {
110
- // ask the backend to create install.json and start.json if gradio
111
- //location.href = `/pinokio/browser/${name}`
112
- location.href = `/initialize/${name}`
113
-
139
+ const shouldInitialize = !normalizedPath || normalizedPath === DEFAULT_INSTALL_RELATIVE_PATH
140
+ if (shouldInitialize) {
141
+ // ask the backend to create install.json and start.json if gradio
142
+ //location.href = `/pinokio/browser/${name}`
143
+ location.href = `/initialize/${name}`
144
+ } else {
145
+ n.Noty({
146
+ text: `Downloaded to ~/${normalizedPath}/${name}`,
147
+ timeout: 4000
148
+ })
149
+ location.href = "/terminals"
150
+ }
114
151
  }
115
152
  }
116
153
  const createTerm = async (_theme) => {
@@ -919,6 +919,7 @@ document.addEventListener('DOMContentLoaded', function() {
919
919
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
920
920
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
921
921
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
922
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
922
923
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
923
924
  </aside>
924
925
  </main>
@@ -726,6 +726,7 @@ body.dark aside .current.selected {
726
726
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
727
727
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
728
728
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
729
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
729
730
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
730
731
  </aside>
731
732
  </main>
@@ -1554,7 +1554,7 @@ body.dark .ace-editor {
1554
1554
  </button>
1555
1555
  <button type="button" class="tab-button" data-tab="cli">
1556
1556
  <div class='row'>
1557
- <i class="fa-solid fa-terminal"></i>
1557
+ <i class="fa-solid fa-desktop"></i>
1558
1558
  <div><h5>Terminal App Launcher</h5></div>
1559
1559
  </div>
1560
1560
  </button>
@@ -1948,6 +1948,7 @@ body.dark .ace-editor {
1948
1948
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
1949
1949
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
1950
1950
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
1951
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
1951
1952
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
1952
1953
  </aside>
1953
1954
  </main>
@@ -819,6 +819,7 @@ document.addEventListener('DOMContentLoaded', function() {
819
819
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
820
820
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
821
821
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
822
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
822
823
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
823
824
  </aside>
824
825
  </main>
@@ -1179,6 +1179,7 @@ document.addEventListener('DOMContentLoaded', function() {
1179
1179
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
1180
1180
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
1181
1181
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
1182
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
1182
1183
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
1183
1184
  </aside>
1184
1185
  </main>
@@ -771,6 +771,7 @@ document.addEventListener('DOMContentLoaded', function() {
771
771
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
772
772
  <a class='tab selected' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
773
773
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
774
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
774
775
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
775
776
  </aside>
776
777
  </main>
@@ -566,6 +566,7 @@ document.addEventListener('DOMContentLoaded', function() {
566
566
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
567
567
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
568
568
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
569
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
569
570
  <a class='tab selected' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
570
571
  </aside>
571
572
  </main>
@@ -0,0 +1,807 @@
1
+ <html>
2
+ <head>
3
+ <title>Pinokio Terminals</title>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
6
+ <link href="/xterm.min.css" rel="stylesheet" />
7
+ <link href="/css/fontawesome.min.css" rel="stylesheet">
8
+ <link href="/css/solid.min.css" rel="stylesheet">
9
+ <link href="/css/regular.min.css" rel="stylesheet">
10
+ <link href="/css/brands.min.css" rel="stylesheet">
11
+ <link href="/markdown.css" rel="stylesheet"/>
12
+ <link href="/noty.css" rel="stylesheet"/>
13
+ <link href="/urldropdown.css" rel="stylesheet"/>
14
+ <link href="/style.css" rel="stylesheet"/>
15
+ <% if (agent === "electron") { %>
16
+ <link href="/electron.css" rel="stylesheet"/>
17
+ <% } %>
18
+ <style>
19
+ body.plugin-page main {
20
+ display: flex;
21
+ }
22
+ body.plugin-page aside {
23
+ width: 200px;
24
+ display: block;
25
+ flex-shrink: 0;
26
+ }
27
+ body.plugin-page aside .tab {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 5px;
31
+ color: inherit;
32
+ text-decoration: none;
33
+ padding: 10px;
34
+ font-size: 12px;
35
+ opacity: 0.5;
36
+ border-left: 10px solid transparent;
37
+ transition: color 0.2s ease, opacity 0.2s ease;
38
+ }
39
+ body.plugin-page aside .tab.selected {
40
+ font-weight: 600;
41
+ opacity: 1;
42
+ }
43
+ body.plugin-page aside .tab i {
44
+ width: 20px;
45
+ text-align: center;
46
+ }
47
+ body.plugin-page .plugin-container {
48
+ flex: 1;
49
+ padding: 30px 40px;
50
+ box-sizing: border-box;
51
+ }
52
+ body.plugin-page .btn-tab {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 10px;
56
+ margin-bottom: 15px;
57
+ }
58
+ body.plugin-page .btn-tab .btn {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 6px;
62
+ }
63
+ .plugin-container h1 {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 10px;
67
+ margin: 0 0 20px;
68
+ }
69
+ /*
70
+ .plugin-container h1 i {
71
+ color: rgba(127, 91, 243, 0.9);
72
+ }
73
+ */
74
+ .plugin-grid {
75
+ display: grid;
76
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
77
+ gap: 14px;
78
+ }
79
+ .plugin-card {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 12px;
83
+ padding: 12px;
84
+ border-radius: 10px;
85
+ background: rgba(0, 0, 0, 0.03);
86
+ cursor: pointer;
87
+ transition: background 0.15s ease, transform 0.15s ease;
88
+ text-decoration: none;
89
+ color: inherit;
90
+ min-height: 68px;
91
+ }
92
+ .plugin-card:hover,
93
+ .plugin-card:focus-visible {
94
+ background: rgba(0, 0, 0, 0.08);
95
+ }
96
+ .plugin-card:focus-visible {
97
+ outline: 2px solid rgba(127, 91, 243, 0.6);
98
+ outline-offset: 2px;
99
+ }
100
+ body.dark .plugin-card {
101
+ background: rgba(255, 255, 255, 0.05);
102
+ color: white;
103
+ }
104
+ body.dark .plugin-card:hover,
105
+ body.dark .plugin-card:focus-visible {
106
+ background: rgba(255, 255, 255, 0.12);
107
+ }
108
+ .plugin-card img,
109
+ .plugin-card .plugin-icon {
110
+ width: 44px;
111
+ height: 44px;
112
+ border-radius: 8px;
113
+ object-fit: cover;
114
+ flex-shrink: 0;
115
+ background: white;
116
+ }
117
+ body.dark .plugin-card img,
118
+ body.dark .plugin-card .plugin-icon {
119
+ background: rgba(255, 255, 255, 0.08);
120
+ }
121
+ .plugin-card .plugin-icon {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ font-size: 20px;
126
+ color: rgba(0, 0, 0, 0.65);
127
+ }
128
+ body.dark .plugin-card .plugin-icon {
129
+ color: rgba(255, 255, 255, 0.85);
130
+ }
131
+ .plugin-card .plugin-details {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 4px;
135
+ }
136
+ .plugin-card h2 {
137
+ margin: 0;
138
+ font-size: 15px;
139
+ font-weight: 600;
140
+ }
141
+ .plugin-card .subtitle {
142
+ font-size: 12px;
143
+ opacity: 0.65;
144
+ }
145
+ body.dark .plugin-card .subtitle {
146
+ opacity: 0.75;
147
+ }
148
+ .plugin-card .plugin-info {
149
+ background: none;
150
+ border: none;
151
+ color: rgba(0, 0, 0, 0.55);
152
+ padding: 6px;
153
+ border-radius: 18px;
154
+ cursor: pointer;
155
+ transition: background 0.15s ease, color 0.15s ease;
156
+ }
157
+ body.dark .plugin-card .plugin-info {
158
+ color: rgba(255, 255, 255, 0.7);
159
+ }
160
+ .plugin-card .plugin-info:hover,
161
+ .plugin-card .plugin-info:focus-visible {
162
+ background: rgba(0, 0, 0, 0.08);
163
+ color: rgba(0, 0, 0, 0.9);
164
+ }
165
+ body.dark .plugin-card .plugin-info:hover,
166
+ body.dark .plugin-card .plugin-info:focus-visible {
167
+ background: rgba(255, 255, 255, 0.18);
168
+ color: rgba(255, 255, 255, 0.95);
169
+ }
170
+ .plugin-card .disclosure-indicator {
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: center;
174
+ width: 18px;
175
+ color: rgba(0, 0, 0, 0.35);
176
+ pointer-events: none;
177
+ transition: transform 0.15s ease, color 0.15s ease;
178
+ }
179
+ .plugin-card:hover .disclosure-indicator,
180
+ .plugin-card:focus-visible .disclosure-indicator {
181
+ transform: translateX(2px);
182
+ color: rgba(0, 0, 0, 0.65);
183
+ }
184
+ body.dark .plugin-card .disclosure-indicator {
185
+ color: rgba(255, 255, 255, 0.4);
186
+ }
187
+ body.dark .plugin-card:hover .disclosure-indicator,
188
+ body.dark .plugin-card:focus-visible .disclosure-indicator {
189
+ color: rgba(255, 255, 255, 0.85);
190
+ }
191
+ .plugin-empty {
192
+ padding: 35px;
193
+ border-radius: 12px;
194
+ border: 1px dashed rgba(0, 0, 0, 0.12);
195
+ background: rgba(0, 0, 0, 0.02);
196
+ text-align: center;
197
+ }
198
+ body.dark .plugin-empty {
199
+ border-color: rgba(255, 255, 255, 0.16);
200
+ background: rgba(255, 255, 255, 0.04);
201
+ }
202
+ .plugin-empty p {
203
+ margin: 0 0 12px;
204
+ }
205
+ .plugin-empty code {
206
+ font-weight: 600;
207
+ }
208
+ .plugin-modal-overlay {
209
+ z-index: 900;
210
+ }
211
+ .plugin-modal-overlay .url-modal-content {
212
+ max-width: 520px;
213
+ }
214
+ .plugin-modal-overlay .plugin-modal-dropdown {
215
+ max-height: 360px;
216
+ overflow-y: auto;
217
+ margin-top: 14px;
218
+ position: static;
219
+ display: block;
220
+ border: 1px solid rgba(0, 0, 0, 0.08);
221
+ border-radius: 10px;
222
+ background: inherit;
223
+ top: auto;
224
+ left: auto;
225
+ right: auto;
226
+ }
227
+ .plugin-modal-overlay .plugin-modal-dropdown .url-dropdown-item {
228
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
229
+ }
230
+ .plugin-modal-overlay .plugin-modal-dropdown .url-dropdown-item:last-child {
231
+ border-bottom: none;
232
+ }
233
+ body.dark .plugin-modal-overlay .url-modal-content {
234
+ background: rgba(25, 26, 27, 0.98);
235
+ }
236
+ body.dark .plugin-modal-overlay .plugin-modal-dropdown {
237
+ border-color: rgba(255, 255, 255, 0.15);
238
+ background: inherit;
239
+ }
240
+ body.dark .plugin-modal-overlay .plugin-modal-dropdown .url-dropdown-item {
241
+ border-bottom-color: rgba(255, 255, 255, 0.12);
242
+ }
243
+ .plugin-option {
244
+ display: flex;
245
+ flex-direction: row;
246
+ gap: 10px;
247
+ align-items: center;
248
+ }
249
+ .plugin-option .option-icon {
250
+ width: 26px;
251
+ height: 26px;
252
+ border-radius: 6px;
253
+ background: rgba(0, 0, 0, 0.08);
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: center;
257
+ font-size: 14px;
258
+ flex-shrink: 0;
259
+ }
260
+ .plugin-option img.option-icon {
261
+ object-fit: cover;
262
+ background: white;
263
+ }
264
+ body.dark .plugin-option .option-icon {
265
+ background: rgba(255, 255, 255, 0.12);
266
+ }
267
+ .plugin-option .option-body {
268
+ display: flex;
269
+ flex-direction: column;
270
+ }
271
+ .plugin-option .option-name {
272
+ font-weight: 600;
273
+ }
274
+ .plugin-option .option-path {
275
+ font-size: 12px;
276
+ opacity: 0.7;
277
+ }
278
+ .plugin-option .option-description {
279
+ font-size: 12px;
280
+ opacity: 0.62;
281
+ margin-top: 2px;
282
+ }
283
+ .plugin-option.selected {
284
+ border: 1px solid rgba(127, 91, 243, 0.6);
285
+ background: rgba(127, 91, 243, 0.08);
286
+ }
287
+ body.dark .plugin-option.selected {
288
+ border-color: rgba(127, 91, 243, 0.8);
289
+ background: rgba(127, 91, 243, 0.18);
290
+ }
291
+ .plugin-option:hover {
292
+ background: rgba(0, 0, 0, 0.04);
293
+ }
294
+ body.dark .plugin-option:hover {
295
+ background: rgba(255, 255, 255, 0.08);
296
+ }
297
+ .plugin-modal-overlay .url-modal-input {
298
+ margin-top: 18px;
299
+ }
300
+ @media (max-width: 800px) {
301
+ body.plugin-page .plugin-container {
302
+ padding: 20px;
303
+ }
304
+ .plugin-grid {
305
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
306
+ }
307
+ }
308
+ @media (max-width: 600px) {
309
+ body.plugin-page main {
310
+ flex-direction: column;
311
+ }
312
+ body.plugin-page aside {
313
+ width: auto;
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ gap: 6px;
317
+ padding: 0 20px 20px;
318
+ }
319
+ body.plugin-page aside .tab,
320
+ body.plugin-page aside .btn-tab {
321
+ border-left: none;
322
+ opacity: 1;
323
+ }
324
+ body.plugin-page aside .caption {
325
+ display: none;
326
+ }
327
+ body.plugin-page .btn-tab {
328
+ width: 100%;
329
+ justify-content: center;
330
+ gap: 8px;
331
+ }
332
+ }
333
+ </style>
334
+ <% const serializedPlugins = pluginMenu.map((plugin) => {
335
+ const hrefValue = plugin.href || ''
336
+ let pluginPath = plugin.src || ''
337
+ const extraParams = []
338
+ if (hrefValue) {
339
+ try {
340
+ const parsed = new URL(hrefValue.startsWith('http') ? hrefValue : `http://localhost${hrefValue}`)
341
+ if (!pluginPath) {
342
+ pluginPath = parsed.pathname.replace(/^\/run/, '') || ''
343
+ }
344
+ parsed.searchParams.forEach((value, key) => {
345
+ if (key === 'cwd') {
346
+ return
347
+ }
348
+ extraParams.push([key, value])
349
+ })
350
+ } catch (err) {
351
+ console.warn('Failed to parse plugin href for serialization', hrefValue, err)
352
+ }
353
+ }
354
+ if (pluginPath && !pluginPath.startsWith('/')) {
355
+ pluginPath = `/${pluginPath}`
356
+ }
357
+ return {
358
+ title: plugin.title || plugin.text || plugin.name || 'Plugin',
359
+ description: plugin.description || '',
360
+ href: hrefValue,
361
+ link: plugin.link || '',
362
+ image: plugin.image || null,
363
+ icon: plugin.icon || null,
364
+ pluginPath,
365
+ extraParams
366
+ }
367
+ }) %>
368
+ </head>
369
+ <body class='<%=theme%> plugin-page' data-agent="<%=agent%>">
370
+ <header class='grabbable'>
371
+ <h1>
372
+ <a class='home' href="/home"><img class='icon' src="/pinokio-black.png" alt="Pinokio"></a>
373
+ <button class='btn2' id='back' data-tippy-content="back"><div><i class="fa-solid fa-chevron-left"></i></div></button>
374
+ <button class='btn2' id='forward' data-tippy-content="forward"><div><i class="fa-solid fa-chevron-right"></i></div></button>
375
+ <button class='btn2' id='refresh-page' data-tippy-content="refresh"><div><i class="fa-solid fa-rotate-right"></i></div></button>
376
+ <button class='btn2' id='screenshot' data-tippy-content="take a screenshot"><i class="fa-solid fa-camera"></i></button>
377
+ <div class='flexible'></div>
378
+ <a class='btn2' href="/columns" data-tippy-content="split into 2 columns">
379
+ <div><i class="fa-solid fa-table-columns"></i></div>
380
+ </a>
381
+ <a class='btn2' href="/rows" data-tippy-content="split into 2 rows">
382
+ <div><i class="fa-solid fa-table-columns fa-rotate-270"></i></div>
383
+ </a>
384
+ <button class='btn2' id='new-window' data-tippy-content="open a new window" title='open a new window' data-agent="<%=agent%>">
385
+ <div><i class="fa-solid fa-plus"></i></div>
386
+ </button>
387
+ <button class='btn2 hidden' id='close-window' data-tippy-content='close this section'>
388
+ <div><i class="fa-solid fa-xmark"></i></div>
389
+ </button>
390
+ </h1>
391
+ </header>
392
+ <main class='plugin-main'>
393
+ <div class='plugin-container'>
394
+ <h1><i class="fa-solid fa-desktop"></i>Terminals</h1>
395
+ <% if (pluginMenu.length === 0) { %>
396
+ <div class='plugin-empty'>
397
+ <p>No terminals found. Clone or add terminal definitions under <code>~/pinokio/plugin</code> to see them listed here.</p>
398
+ <a class='btn' href="/home?mode=download"><i class="fa-solid fa-download"></i> Download Terminals</a>
399
+ </div>
400
+ <% } else { %>
401
+ <div class='plugin-grid'>
402
+ <% pluginMenu.forEach((pluginItem, index) => { %>
403
+ <div class='plugin-card' role="button" tabindex="0" data-plugin-index="<%=index%>">
404
+ <% if (pluginItem.image) { %>
405
+ <img src="<%=pluginItem.image%>" alt="<%=pluginItem.title || pluginItem.text || 'Plugin'%> icon">
406
+ <% } else if (pluginItem.icon) { %>
407
+ <div class='plugin-icon'><i class="<%=pluginItem.icon%>"></i></div>
408
+ <% } else { %>
409
+ <div class='plugin-icon'><i class="fa-solid fa-desktop"></i></div>
410
+ <% } %>
411
+ <div class='plugin-details'>
412
+ <h2><%=pluginItem.title || pluginItem.text || pluginItem.name || 'Plugin'%></h2>
413
+ <% if (pluginItem.description) { %>
414
+ <div class='subtitle'><%=pluginItem.description%></div>
415
+ <% } %>
416
+ </div>
417
+ <div class='flexible'></div>
418
+ <% if (pluginItem.link) { %>
419
+ <button type='button' class='plugin-info' data-link="<%=pluginItem.link.replace(/"/g, '&quot;')%>" aria-label="Open info"><i class="fa-solid fa-circle-info"></i></button>
420
+ <% } %>
421
+ <div class='disclosure-indicator' aria-hidden="true">
422
+ <i class="fa-solid fa-chevron-right"></i>
423
+ </div>
424
+ </div>
425
+ <% }) %>
426
+ </div>
427
+ <% } %>
428
+ </div>
429
+ <aside>
430
+ <div class='btn-tab'>
431
+ <button type='button' class='btn' id='create-launcher-button'><i class="fa-solid fa-plus"></i><div class='caption'>Create</div></button>
432
+ <a class='btn' id='explore' href="/home?mode=explore"><i class="fa-solid fa-globe"></i><div class='caption'>Discover</div></a>
433
+ </div>
434
+ <a href="/" class='tab'><i class='fas fa-laptop-code'></i><div class='caption'>This machine</div></a>
435
+ <a href="/network" class='tab'><i class="fa-solid fa-wifi"></i><div class='caption'>Local network</div></a>
436
+ <% if (list.length > 0) { %>
437
+ <% let brands = { win32: "windows", darwin: "apple", linux: "Linux" } %>
438
+ <% list.forEach(({ host, name, platform }) => { %>
439
+ <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>
440
+ <% }) %>
441
+ <% } %>
442
+ <a href="/connect" class='tab'><i class="fa-solid fa-plug"></i><div class='caption'>Login</div></a>
443
+ <a class='tab' href="<%=portal%>" target="_blank"><i class="fa-solid fa-question"></i><div class='caption'>Help</div></a>
444
+ <a class='tab' id='genlog'><i class="fa-solid fa-laptop-code"></i><div class='caption'>Logs</div></a>
445
+ <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
446
+ <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
447
+ <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
448
+ <a class='tab selected' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
449
+ <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
450
+ </aside>
451
+ </main>
452
+ <script type="application/json" id="plugin-data"><%- JSON.stringify(serializedPlugins) %></script>
453
+ <script type="application/json" id="plugin-app-data"><%- JSON.stringify(apps) %></script>
454
+ <script src="/popper.min.js"></script>
455
+ <script src="/tippy-bundle.umd.min.js"></script>
456
+ <script src="/hotkeys.min.js"></script>
457
+ <script src="/sweetalert2.js"></script>
458
+ <script src="/noty.js"></script>
459
+ <script src="/notyq.js"></script>
460
+ <script src="/Socket.js"></script>
461
+ <script src="/common.js"></script>
462
+ <script src="/urldropdown.js"></script>
463
+ <script>
464
+ (function() {
465
+ const pluginDataEl = document.getElementById('plugin-data')
466
+ const appDataEl = document.getElementById('plugin-app-data')
467
+ let plugins = []
468
+ let apps = []
469
+ try {
470
+ plugins = pluginDataEl ? JSON.parse(pluginDataEl.textContent) : []
471
+ } catch (error) {
472
+ console.error('Failed to parse plugin data', error)
473
+ }
474
+ try {
475
+ apps = appDataEl ? JSON.parse(appDataEl.textContent) : []
476
+ } catch (error) {
477
+ console.error('Failed to parse app data', error)
478
+ }
479
+
480
+ function escapeHtml(value) {
481
+ const div = document.createElement('div')
482
+ div.textContent = value || ''
483
+ return div.innerHTML
484
+ }
485
+
486
+ function createPluginModal(appList) {
487
+ const overlay = document.createElement('div')
488
+ overlay.id = 'plugin-modal-overlay'
489
+ overlay.className = 'modal-overlay url-modal-overlay plugin-modal-overlay'
490
+
491
+ overlay.innerHTML = `
492
+ <div class="url-modal-content" role="dialog" aria-modal="true" aria-labelledby="plugin-modal-title" aria-describedby="plugin-modal-description">
493
+ <button type="button" class="url-modal-close" aria-label="Close">&times;</button>
494
+ <h3 id="plugin-modal-title">Run Plugin</h3>
495
+ <p class="url-modal-description" id="plugin-modal-description">Choose a project to open the terminal from.</p>
496
+ <input type="search" class="url-modal-input" placeholder="Filter projects" autocomplete="off" />
497
+ <div class="url-dropdown plugin-modal-dropdown"></div>
498
+ <div class="url-modal-actions single">
499
+ <button type="button" class="url-modal-button cancel">Close</button>
500
+ </div>
501
+ </div>
502
+ `
503
+
504
+ document.body.appendChild(overlay)
505
+
506
+ const content = overlay.querySelector('.url-modal-content')
507
+ const closeButton = overlay.querySelector('.url-modal-close')
508
+ const cancelButton = overlay.querySelector('.url-modal-button.cancel')
509
+ const titleEl = overlay.querySelector('#plugin-modal-title')
510
+ const descriptionEl = overlay.querySelector('#plugin-modal-description')
511
+ const inputEl = overlay.querySelector('.url-modal-input')
512
+ const dropdownEl = overlay.querySelector('.plugin-modal-dropdown')
513
+
514
+ const state = {
515
+ apps: Array.isArray(appList) ? appList : [],
516
+ plugin: null,
517
+ selectedIndex: -1,
518
+ selectedElement: null,
519
+ visibleIndices: []
520
+ }
521
+
522
+ function clearSelection() {
523
+ if (state.selectedElement) {
524
+ state.selectedElement.classList.remove('selected')
525
+ }
526
+ state.selectedElement = null
527
+ state.selectedIndex = -1
528
+ }
529
+
530
+ function ensureVisible(element) {
531
+ if (!element) return
532
+ element.scrollIntoView({ block: 'nearest' })
533
+ }
534
+
535
+ function select(index, element, { autoLaunch = false } = {}) {
536
+ if (state.selectedElement && state.selectedElement !== element) {
537
+ state.selectedElement.classList.remove('selected')
538
+ }
539
+ state.selectedElement = element || null
540
+ state.selectedIndex = typeof index === 'number' ? index : -1
541
+ if (state.selectedElement) {
542
+ state.selectedElement.classList.add('selected')
543
+ if (autoLaunch) {
544
+ confirm()
545
+ }
546
+ }
547
+ }
548
+
549
+ function buildOption(app, index) {
550
+ const option = document.createElement('div')
551
+ option.className = 'url-dropdown-item plugin-option'
552
+ option.setAttribute('data-app-index', index)
553
+ option.tabIndex = 0
554
+ const iconHtml = app.icon
555
+ ? `<img class="option-icon" src="${escapeHtml(app.icon)}" alt="${escapeHtml(app.title || app.name || 'App')} icon">`
556
+ : '<div class="option-icon"><i class="fa-solid fa-folder"></i></div>'
557
+ const description = app.description ? `<div class="option-description">${escapeHtml(app.description)}</div>` : ''
558
+ const pathLabel = app.displayPath || app.cwd || ''
559
+ option.innerHTML = `
560
+ ${iconHtml}
561
+ <div class="option-body">
562
+ <div class="option-name">${escapeHtml(app.title || app.name || 'Project')}</div>
563
+ <div class="option-path">${escapeHtml(pathLabel)}</div>
564
+ ${description}
565
+ </div>
566
+ `
567
+ return option
568
+ }
569
+
570
+ function renderList(query) {
571
+ const term = (query || '').toLowerCase().trim()
572
+ dropdownEl.innerHTML = ''
573
+ state.visibleIndices = []
574
+ clearSelection()
575
+
576
+ const matches = []
577
+ for (let i = 0; i < state.apps.length; i++) {
578
+ const app = state.apps[i]
579
+ const searchable = [app.title, app.name, app.description, app.displayPath, app.cwd]
580
+ .filter(Boolean)
581
+ .join(' ')
582
+ .toLowerCase()
583
+ if (!term || searchable.includes(term)) {
584
+ matches.push({ app, index: i })
585
+ }
586
+ }
587
+
588
+ if (matches.length === 0) {
589
+ dropdownEl.innerHTML = '<div class="url-dropdown-empty">No projects found</div>'
590
+ return
591
+ }
592
+
593
+ const fragment = document.createDocumentFragment()
594
+ matches.forEach(({ app, index }) => {
595
+ const option = buildOption(app, index)
596
+ state.visibleIndices.push(index)
597
+ option.addEventListener('click', () => {
598
+ select(index, option, { autoLaunch: true })
599
+ })
600
+ option.addEventListener('keydown', (event) => {
601
+ if (event.key === 'Enter' || event.key === ' ') {
602
+ event.preventDefault()
603
+ select(index, option, { autoLaunch: true })
604
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
605
+ event.preventDefault()
606
+ navigate(event.key === 'ArrowDown' ? 1 : -1)
607
+ }
608
+ })
609
+ fragment.appendChild(option)
610
+ })
611
+ dropdownEl.appendChild(fragment)
612
+ }
613
+
614
+ function navigate(delta) {
615
+ if (state.visibleIndices.length === 0) return
616
+ const currentPos = state.visibleIndices.indexOf(state.selectedIndex)
617
+ let nextPos
618
+ if (currentPos === -1) {
619
+ nextPos = delta > 0 ? 0 : state.visibleIndices.length - 1
620
+ } else {
621
+ nextPos = currentPos + delta
622
+ if (nextPos < 0) nextPos = 0
623
+ if (nextPos >= state.visibleIndices.length) nextPos = state.visibleIndices.length - 1
624
+ }
625
+ const nextIndex = state.visibleIndices[nextPos]
626
+ const element = dropdownEl.querySelector(`[data-app-index="${nextIndex}"]`)
627
+ if (element) {
628
+ select(nextIndex, element, { autoLaunch: false })
629
+ element.focus()
630
+ ensureVisible(element)
631
+ }
632
+ }
633
+
634
+ function closeModal() {
635
+ overlay.classList.remove('is-visible')
636
+ document.removeEventListener('keydown', handleKeydown, true)
637
+ setTimeout(() => {
638
+ dropdownEl.innerHTML = ''
639
+ inputEl.value = ''
640
+ clearSelection()
641
+ dropdownEl.style.display = 'none'
642
+ }, 150)
643
+ }
644
+
645
+ function confirm() {
646
+ if (state.selectedIndex === -1) {
647
+ return
648
+ }
649
+ const plugin = state.plugin
650
+ const app = state.apps[state.selectedIndex]
651
+ if (!plugin || !plugin.pluginPath) {
652
+ closeModal()
653
+ alert('This plugin is missing a launch target.')
654
+ return
655
+ }
656
+ if (!app) {
657
+ closeModal()
658
+ alert('Select a project to continue.')
659
+ return
660
+ }
661
+ const queryPairs = []
662
+ const pushPair = (key, value, { rawValue = false } = {}) => {
663
+ if (value === undefined || value === null) {
664
+ return
665
+ }
666
+ const encodedKey = encodeURIComponent(key)
667
+ const encodedValue = rawValue ? String(value) : encodeURIComponent(String(value))
668
+ queryPairs.push(`${encodedKey}=${encodedValue}`)
669
+ }
670
+ pushPair('plugin', plugin.pluginPath, { rawValue: true })
671
+ if (Array.isArray(plugin.extraParams)) {
672
+ plugin.extraParams.forEach(([key, value]) => {
673
+ pushPair(key, value)
674
+ })
675
+ }
676
+ const queryString = queryPairs.join('&')
677
+ const target = queryPairs.length > 0 ? `/p/${app.name}/dev?${queryString}` : `/p/${app.name}/dev`
678
+ overlay.classList.remove('is-visible')
679
+ document.removeEventListener('keydown', handleKeydown, true)
680
+ location.href = target
681
+ }
682
+
683
+ function handleKeydown(event) {
684
+ if (!overlay.classList.contains('is-visible')) return
685
+ if (event.key === 'Escape') {
686
+ event.preventDefault()
687
+ closeModal()
688
+ } else if (event.key === 'ArrowDown') {
689
+ event.preventDefault()
690
+ navigate(1)
691
+ } else if (event.key === 'ArrowUp') {
692
+ event.preventDefault()
693
+ navigate(-1)
694
+ } else if (event.key === 'Enter' && document.activeElement === inputEl) {
695
+ event.preventDefault()
696
+ if (state.visibleIndices.length === 1 && state.selectedIndex === -1) {
697
+ const nextIndex = state.visibleIndices[0]
698
+ const element = dropdownEl.querySelector(`[data-app-index="${nextIndex}"]`)
699
+ select(nextIndex, element, { autoLaunch: true })
700
+ } else if (state.selectedIndex !== -1) {
701
+ confirm()
702
+ }
703
+ }
704
+ }
705
+
706
+ closeButton.addEventListener('click', (event) => {
707
+ event.preventDefault()
708
+ closeModal()
709
+ })
710
+ cancelButton.addEventListener('click', (event) => {
711
+ event.preventDefault()
712
+ closeModal()
713
+ })
714
+ overlay.addEventListener('click', (event) => {
715
+ if (event.target === overlay) {
716
+ closeModal()
717
+ }
718
+ })
719
+ inputEl.addEventListener('input', (event) => {
720
+ renderList(event.target.value)
721
+ })
722
+
723
+ function openModal(plugin) {
724
+ state.plugin = plugin
725
+ const title = plugin && plugin.title ? `Run ${plugin.title}` : 'Run Plugin'
726
+ titleEl.textContent = title
727
+ if (state.apps.length === 0) {
728
+ descriptionEl.textContent = 'No projects found under ~/pinokio/api. Create or download a project to continue.'
729
+ } else {
730
+ descriptionEl.textContent = 'Choose a project to open the terminal from.'
731
+ }
732
+ renderList('')
733
+ dropdownEl.style.display = 'block'
734
+ overlay.classList.add('is-visible')
735
+ document.addEventListener('keydown', handleKeydown, true)
736
+ requestAnimationFrame(() => {
737
+ inputEl.focus()
738
+ inputEl.select()
739
+ })
740
+ }
741
+
742
+ return {
743
+ open: openModal,
744
+ close: closeModal,
745
+ setApps(newApps) {
746
+ state.apps = Array.isArray(newApps) ? newApps : []
747
+ }
748
+ }
749
+ }
750
+
751
+ function initUrlFeatures() {
752
+ if (typeof initUrlDropdown === 'function') {
753
+ initUrlDropdown({ clearBehavior: 'empty' })
754
+ }
755
+ }
756
+
757
+ const modal = createPluginModal(apps)
758
+
759
+ function attachHandlers() {
760
+ const cards = document.querySelectorAll('.plugin-card')
761
+ if (!cards.length) return
762
+ cards.forEach((card) => {
763
+ const indexAttr = card.getAttribute('data-plugin-index')
764
+ const index = parseInt(indexAttr, 10)
765
+ if (Number.isNaN(index) || !plugins[index]) {
766
+ return
767
+ }
768
+ const plugin = plugins[index]
769
+ const infoButton = card.querySelector('.plugin-info')
770
+ if (infoButton) {
771
+ infoButton.addEventListener('click', (event) => {
772
+ event.preventDefault()
773
+ event.stopPropagation()
774
+ const link = infoButton.getAttribute('data-link')
775
+ if (link) {
776
+ window.open(link, '_blank', 'noopener,noreferrer')
777
+ }
778
+ })
779
+ }
780
+ const open = (event) => {
781
+ if (event) {
782
+ event.preventDefault()
783
+ }
784
+ if (!apps.length) {
785
+ alert('No projects found under ~/pinokio/api.')
786
+ return
787
+ }
788
+ modal.open(plugin)
789
+ }
790
+ card.addEventListener('click', open)
791
+ card.addEventListener('keydown', (event) => {
792
+ if (event.key === 'Enter' || event.key === ' ') {
793
+ open(event)
794
+ }
795
+ })
796
+ })
797
+ }
798
+
799
+ document.addEventListener('DOMContentLoaded', () => {
800
+ initUrlFeatures()
801
+ attachHandlers()
802
+ })
803
+ })()
804
+ </script>
805
+ <script src="/opener.js"></script>
806
+ </body>
807
+ </html>
@@ -1269,6 +1269,7 @@ document.addEventListener('DOMContentLoaded', function() {
1269
1269
  <a class='tab' id='genlog'><i class="fa-solid fa-laptop-code"></i><div class='caption'>Logs</div></a>
1270
1270
  <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
1271
1271
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
1272
+ <a class='tab' href="/terminals"><i class="fa-solid fa-desktop"></i><div class='caption'>Terminals</div></a>
1272
1273
  <a class='tab selected' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
1273
1274
  <a class='tab' href="/home?mode=settings"><i class="fa-solid fa-gear"></i><div class='caption'>Settings</div></a>
1274
1275
  </aside>