pinokiod 3.147.0 → 3.150.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.
@@ -284,15 +284,16 @@ body.dark .navheader2 {
284
284
  background: rgba(255,255,255,0.07) !important;
285
285
  }
286
286
  body.dark .navheader2 .btn {
287
- background: rgba(0,0,0,0.4);
287
+ background: rgba(255,255,255,0.1);
288
288
  }
289
289
  .navheader2 {
290
- margin-top: 1px;
291
- padding: 5px 0;
290
+ padding: 10px 0 10px;
292
291
  /*
293
292
  background: white;
294
293
  */
294
+ /*
295
295
  background: #F1F1F1 !important;
296
+ */
296
297
  }
297
298
  .navheader {
298
299
  backdrop-filter: blur(16px);
@@ -926,7 +927,6 @@ body.columns .containers {
926
927
  height: 100%;
927
928
  }
928
929
  .menu-btns {
929
- display: flex;
930
930
  align-items: flex-start;
931
931
  flex-wrap: wrap;
932
932
  }
@@ -2092,7 +2092,7 @@ body.dark header .home {
2092
2092
  }
2093
2093
  header .home {
2094
2094
  color: var(--light-color);
2095
- padding: 5px 10px;
2095
+ padding: 10px;
2096
2096
  cursor: pointer !important;
2097
2097
  }
2098
2098
  /*
@@ -10,6 +10,7 @@
10
10
  const FRAME_LINK_SELECTOR = '.frame-link';
11
11
  const LIVE_CLASS = 'is-live';
12
12
  const MAX_MESSAGE_PREVIEW = 140;
13
+ const MIN_COMMAND_DURATION_MS = 2000;
13
14
 
14
15
  const tabStates = new Map();
15
16
  const observedIndicators = new WeakSet();
@@ -135,7 +136,9 @@
135
136
  isLive: false,
136
137
  notified: false,
137
138
  lastInput: '',
139
+ commandStartTimestamp: 0,
138
140
  lastLiveTimestamp: 0,
141
+ lastActivityTimestamp: 0,
139
142
  notifyEnabled: getPreference(frameName),
140
143
  };
141
144
  tabStates.set(frameName, state);
@@ -449,7 +452,7 @@ const ensureTabAccessories = aggregateDebounce(() => {
449
452
  if (observedIndicators.has(node)) {
450
453
  return;
451
454
  }
452
- indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class'] });
455
+ indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class', 'data-timestamp'] });
453
456
  observedIndicators.add(node);
454
457
  log('Observing indicator', node);
455
458
  });
@@ -516,7 +519,17 @@ const ensureTabAccessories = aggregateDebounce(() => {
516
519
  return true;
517
520
  };
518
521
 
519
- const handleIndicatorChange = (indicator) => {
522
+ const updateActivityTimestamp = (indicator, state) => {
523
+ if (!indicator || !state) {
524
+ return;
525
+ }
526
+ const rawTimestamp = Number(indicator.dataset?.timestamp);
527
+ if (Number.isFinite(rawTimestamp)) {
528
+ state.lastActivityTimestamp = rawTimestamp;
529
+ }
530
+ };
531
+
532
+ const handleIndicatorChange = (indicator, changedAttribute = 'class') => {
520
533
  const link = indicator.closest(FRAME_LINK_SELECTOR);
521
534
  if (!link) {
522
535
  return;
@@ -529,12 +542,21 @@ const ensureTabAccessories = aggregateDebounce(() => {
529
542
  if (!state) {
530
543
  return;
531
544
  }
545
+ if (changedAttribute === 'data-timestamp') {
546
+ updateActivityTimestamp(indicator, state);
547
+ return;
548
+ }
549
+
532
550
  const isLive = indicator.classList.contains(LIVE_CLASS);
533
551
  state.isLive = isLive;
552
+ updateActivityTimestamp(indicator, state);
534
553
  log('Indicator change', { frameName, isLive, awaitingLive: state.awaitingLive, awaitingIdle: state.awaitingIdle });
535
554
 
536
555
  if (isLive) {
537
556
  state.lastLiveTimestamp = Date.now();
557
+ if (!state.commandStartTimestamp) {
558
+ state.commandStartTimestamp = state.lastActivityTimestamp || state.lastLiveTimestamp;
559
+ }
538
560
  if (state.awaitingLive && state.hasRecentInput) {
539
561
  state.awaitingLive = false;
540
562
  state.awaitingIdle = true;
@@ -543,24 +565,41 @@ const ensureTabAccessories = aggregateDebounce(() => {
543
565
  }
544
566
 
545
567
  if (state.awaitingIdle && state.hasRecentInput && !state.notified) {
546
- if (!state.notifyEnabled) {
568
+ const activityTs = Number.isFinite(state.lastActivityTimestamp)
569
+ ? state.lastActivityTimestamp
570
+ : Number(indicator.dataset?.timestamp);
571
+ const startTs = Number.isFinite(state.commandStartTimestamp)
572
+ ? state.commandStartTimestamp
573
+ : null;
574
+
575
+ let runtimeMs = null;
576
+ if (Number.isFinite(activityTs) && Number.isFinite(startTs) && activityTs >= startTs) {
577
+ runtimeMs = activityTs - startTs;
578
+ }
579
+
580
+ if (runtimeMs !== null && runtimeMs < MIN_COMMAND_DURATION_MS) {
581
+ log('Skipping idle notification (command completed quickly)', { frameName, runtimeMs });
582
+ } else if (!state.notifyEnabled) {
547
583
  log('Notifications disabled for frame', frameName);
548
584
  } else if (shouldNotify(link)) {
549
585
  sendNotification(link, state);
550
586
  state.notified = true;
551
- log('Idle notification dispatched', frameName);
587
+ log('Idle notification dispatched', { frameName, runtimeMs });
552
588
  }
553
589
  }
554
590
 
555
591
  state.hasRecentInput = false;
556
592
  state.awaitingIdle = false;
557
593
  state.awaitingLive = false;
594
+ state.commandStartTimestamp = 0;
595
+ state.lastLiveTimestamp = 0;
596
+ state.lastActivityTimestamp = 0;
558
597
  };
559
598
 
560
599
  const indicatorObserver = new MutationObserver((mutations) => {
561
600
  for (const mutation of mutations) {
562
601
  if (mutation.type === 'attributes' && mutation.target instanceof HTMLElement) {
563
- handleIndicatorChange(mutation.target);
602
+ handleIndicatorChange(mutation.target, mutation.attributeName || 'class');
564
603
  }
565
604
  }
566
605
  });
@@ -573,7 +612,7 @@ const ensureTabAccessories = aggregateDebounce(() => {
573
612
  continue;
574
613
  }
575
614
  if (node.matches(TAB_UPDATED_SELECTOR)) {
576
- indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class'] });
615
+ indicatorObserver.observe(node, { attributes: true, attributeFilter: ['class', 'data-timestamp'] });
577
616
  observedIndicators.add(node);
578
617
  log('Observed newly added indicator', node);
579
618
  shouldRescan = true;
@@ -615,6 +654,9 @@ const ensureTabAccessories = aggregateDebounce(() => {
615
654
  state.awaitingIdle = false;
616
655
  state.notified = false;
617
656
  state.lastInput = sanitisePreview(data.line || '');
657
+ state.commandStartTimestamp = Date.now();
658
+ state.lastActivityTimestamp = 0;
659
+ state.lastLiveTimestamp = 0;
618
660
  log('Terminal input captured', { frameName, line: data.line, state: { ...state } });
619
661
 
620
662
  const indicator = findIndicatorForFrame(frameName);
@@ -35,14 +35,14 @@
35
35
  if (!isLast) {
36
36
  const line = this.buffer + segment
37
37
  this.buffer = ""
38
- this.submit(line)
38
+ this.submit(line, { hadLineBreak: true })
39
39
  } else {
40
40
  this.buffer += segment
41
41
  }
42
42
  }
43
43
  }
44
44
 
45
- submit(line) {
45
+ submit(line, meta = {}) {
46
46
  const win = this.getWindow()
47
47
  if (!win || !win.parent || typeof win.parent.postMessage !== "function") {
48
48
  return
@@ -50,11 +50,13 @@
50
50
  const safeLine = (line || "").replace(/[\x00-\x1F\x7F]/g, "")
51
51
  const preview = safeLine.trim()
52
52
  const truncated = preview.length > this.limit ? `${preview.slice(0, this.limit)}...` : preview
53
+ const hadLineBreak = Boolean(meta && meta.hadLineBreak)
54
+ const meaningful = truncated.length > 0 || hadLineBreak
53
55
  win.parent.postMessage({
54
56
  type: "terminal-input",
55
57
  frame: this.getFrameName(),
56
58
  line: truncated,
57
- hasContent: truncated.length > 0
59
+ hasContent: meaningful
58
60
  }, "*")
59
61
  }
60
62
  }
@@ -0,0 +1,284 @@
1
+ const express = require('express');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const { isBinaryFile } = require('isbinaryfile');
5
+
6
+ const MAX_EDITABLE_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB safety limit
7
+
8
+ const createHttpError = (status, message) => {
9
+ const error = new Error(message);
10
+ error.status = status;
11
+ return error;
12
+ };
13
+
14
+ const sanitizeSegments = (value) => {
15
+ const normalized = path.posix.normalize(String(value || '').replace(/\\/g, '/'));
16
+ if (normalized === '.' || normalized === '') {
17
+ return [];
18
+ }
19
+ const segments = normalized.split('/').filter((segment) => segment && segment !== '.');
20
+ if (segments.some((segment) => segment === '..')) {
21
+ throw createHttpError(400, 'Invalid path');
22
+ }
23
+ return segments;
24
+ };
25
+
26
+ module.exports = function registerFileRoutes(app, { kernel, getTheme, exists }) {
27
+ if (!app || !kernel) {
28
+ throw new Error('File routes require an express app and kernel instance');
29
+ }
30
+
31
+ const router = express.Router();
32
+
33
+ const asyncHandler = (fn) => (req, res, next) => {
34
+ Promise.resolve(fn(req, res, next)).catch(next);
35
+ };
36
+
37
+
38
+ const ensureWorkspace = async (workspaceParam, rootParam) => {
39
+ const apiRoot = kernel.path('api');
40
+ if (rootParam) {
41
+ let decodedRoot;
42
+ try {
43
+ decodedRoot = Buffer.from(String(rootParam), 'base64').toString('utf8');
44
+ } catch (error) {
45
+ throw createHttpError(400, 'Invalid workspace descriptor');
46
+ }
47
+ if (!decodedRoot) {
48
+ throw createHttpError(400, 'Invalid workspace descriptor');
49
+ }
50
+ const normalizedRoot = path.resolve(decodedRoot);
51
+ if (!path.isAbsolute(normalizedRoot)) {
52
+ throw createHttpError(400, 'Workspace path must be absolute');
53
+ }
54
+ const relativeToHome = path.relative(kernel.homedir, normalizedRoot);
55
+ if (relativeToHome.startsWith('..') || path.isAbsolute(relativeToHome)) {
56
+ throw createHttpError(400, 'Workspace outside Pinokio home');
57
+ }
58
+ const relativeToApi = path.relative(apiRoot, normalizedRoot);
59
+ if (relativeToApi.startsWith('..') || path.isAbsolute(relativeToApi)) {
60
+ throw createHttpError(400, 'Workspace outside api directory');
61
+ }
62
+ const existsResult = await exists(normalizedRoot);
63
+ if (!existsResult) {
64
+ throw createHttpError(404, 'Workspace not found');
65
+ }
66
+ const segments = sanitizeSegments(workspaceParam || relativeToApi);
67
+ const effectiveSegments = segments.length > 0 ? segments : sanitizeSegments(relativeToApi);
68
+ const slugSegments = effectiveSegments.length > 0 ? effectiveSegments : [path.basename(normalizedRoot)];
69
+ return {
70
+ apiRoot,
71
+ segments: slugSegments,
72
+ workspaceRoot: normalizedRoot,
73
+ workspaceLabel: slugSegments[slugSegments.length - 1],
74
+ workspaceSlug: slugSegments.join('/'),
75
+ };
76
+ }
77
+
78
+ if (!workspaceParam || typeof workspaceParam !== 'string') {
79
+ throw createHttpError(400, 'Missing workspace');
80
+ }
81
+
82
+ const segments = sanitizeSegments(workspaceParam);
83
+ if (segments.length === 0) {
84
+ throw createHttpError(400, 'Workspace path is required');
85
+ }
86
+
87
+ const workspaceRoot = path.resolve(apiRoot, ...segments);
88
+ const relativeToApi = path.relative(apiRoot, workspaceRoot);
89
+ if (relativeToApi.startsWith('..') || path.isAbsolute(relativeToApi)) {
90
+ throw createHttpError(400, 'Workspace outside api directory');
91
+ }
92
+ const existsResult = await exists(workspaceRoot);
93
+ if (!existsResult) {
94
+ throw createHttpError(404, 'Workspace not found');
95
+ }
96
+ return {
97
+ apiRoot,
98
+ segments,
99
+ workspaceRoot,
100
+ workspaceLabel: segments[segments.length - 1],
101
+ workspaceSlug: segments.join('/'),
102
+ };
103
+ };
104
+
105
+ const resolveWorkspacePath = async (workspaceParam, relativeParam = '', rootParam) => {
106
+ const workspaceInfo = await ensureWorkspace(workspaceParam, rootParam);
107
+ const relativeSegments = sanitizeSegments(relativeParam);
108
+ const absolutePath = path.resolve(workspaceInfo.workspaceRoot, ...relativeSegments);
109
+ const relativeToWorkspace = path.relative(workspaceInfo.workspaceRoot, absolutePath);
110
+ if (relativeToWorkspace.startsWith('..') || path.isAbsolute(relativeToWorkspace)) {
111
+ throw createHttpError(400, 'Path escapes workspace');
112
+ }
113
+ const relativePosix = relativeSegments.join('/');
114
+ return {
115
+ ...workspaceInfo,
116
+ absolutePath,
117
+ relativeSegments,
118
+ relativePosix,
119
+ };
120
+ };
121
+
122
+ router.get('/pinokio/fileview/*', asyncHandler(async (req, res) => {
123
+ const workspaceParam = req.params[0] || '';
124
+ const initialRelative = req.query.path || '';
125
+ const { workspaceRoot, workspaceLabel, workspaceSlug, relativePosix, absolutePath } = await resolveWorkspacePath(workspaceParam, initialRelative);
126
+ const initialPosixPath = relativePosix;
127
+ const initialStats = await fs.promises.stat(absolutePath).catch(() => null);
128
+ const initialType = initialStats ? (initialStats.isDirectory() ? 'directory' : initialStats.isFile() ? 'file' : null) : null;
129
+
130
+ const workspaceMeta = {
131
+ title: '',
132
+ description: '',
133
+ icon: '',
134
+ iconpath: ''
135
+ };
136
+ try {
137
+ const meta = await kernel.api.meta(workspaceSlug);
138
+ if (meta && typeof meta === 'object') {
139
+ workspaceMeta.title = typeof meta.title === 'string' ? meta.title : '';
140
+ workspaceMeta.description = typeof meta.description === 'string' ? meta.description : '';
141
+ workspaceMeta.icon = typeof meta.icon === 'string' ? meta.icon : '';
142
+ workspaceMeta.iconpath = typeof meta.iconpath === 'string' ? meta.iconpath : '';
143
+ }
144
+ } catch (error) {
145
+ // Metadata is optional for the file editor; ignore failures.
146
+ }
147
+
148
+ const workspaceRootEncoded = Buffer.from(workspaceRoot).toString('base64');
149
+ res.render('file_browser', {
150
+ theme: getTheme ? getTheme() : 'light',
151
+ agent: req.agent,
152
+ workspace: workspaceSlug,
153
+ workspaceLabel,
154
+ workspaceRoot,
155
+ workspaceSlug,
156
+ workspaceRootEncoded,
157
+ initialPath: initialPosixPath,
158
+ initialPathType: initialType,
159
+ workspaceMeta
160
+ });
161
+ }));
162
+
163
+ router.get('/api/files/list', asyncHandler(async (req, res) => {
164
+ const { workspace, path: relativeQuery } = req.query;
165
+ const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativeQuery, req.query.root);
166
+
167
+ const stats = await fs.promises.stat(absolutePath).catch(() => null);
168
+ if (!stats) {
169
+ throw createHttpError(404, 'Directory not found');
170
+ }
171
+ if (!stats.isDirectory()) {
172
+ throw createHttpError(400, 'Path must be a directory');
173
+ }
174
+
175
+ const entries = await fs.promises.readdir(absolutePath, { withFileTypes: true }).catch(() => []);
176
+ const sorted = entries.sort((a, b) => {
177
+ if (a.isDirectory() && !b.isDirectory()) return -1;
178
+ if (!a.isDirectory() && b.isDirectory()) return 1;
179
+ return a.name.localeCompare(b.name);
180
+ });
181
+
182
+ const items = await Promise.all(sorted.map(async (dirent) => {
183
+ const entrySegments = [...(relativePosix ? relativePosix.split('/') : []), dirent.name];
184
+ const entryPosix = entrySegments.filter(Boolean).join('/');
185
+ let hasChildren = false;
186
+ if (dirent.isDirectory()) {
187
+ const candidatePath = path.resolve(absolutePath, dirent.name);
188
+ try {
189
+ const childDir = await fs.promises.opendir(candidatePath);
190
+ let count = 0;
191
+ for await (const child of childDir) {
192
+ count += 1;
193
+ if (count > 0) {
194
+ hasChildren = true;
195
+ break;
196
+ }
197
+ }
198
+ await childDir.close();
199
+ } catch (err) {
200
+ hasChildren = false;
201
+ }
202
+ }
203
+ return {
204
+ name: dirent.name,
205
+ path: entryPosix,
206
+ type: dirent.isDirectory() ? 'directory' : 'file',
207
+ workspace: workspaceSlug,
208
+ hasChildren,
209
+ };
210
+ }));
211
+
212
+ res.json({
213
+ workspace: workspaceSlug,
214
+ path: relativePosix,
215
+ entries: items,
216
+ });
217
+ }));
218
+
219
+ router.get('/api/files/read', asyncHandler(async (req, res) => {
220
+ const { workspace, path: relativeQuery } = req.query;
221
+ const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativeQuery, req.query.root);
222
+
223
+ const stats = await fs.promises.stat(absolutePath).catch(() => null);
224
+ if (!stats) {
225
+ throw createHttpError(404, 'File not found');
226
+ }
227
+ if (!stats.isFile()) {
228
+ throw createHttpError(400, 'Path must be a file');
229
+ }
230
+ const metaOnly = Object.prototype.hasOwnProperty.call(req.query, 'meta');
231
+ if (metaOnly) {
232
+ res.json({
233
+ workspace: workspaceSlug,
234
+ path: relativePosix,
235
+ size: stats.size,
236
+ mtime: stats.mtimeMs,
237
+ meta: true,
238
+ });
239
+ return;
240
+ }
241
+ if (stats.size > MAX_EDITABLE_FILE_SIZE_BYTES) {
242
+ throw createHttpError(413, 'File is too large to open in the editor');
243
+ }
244
+ const isBinary = await isBinaryFile(absolutePath);
245
+ if (isBinary) {
246
+ throw createHttpError(415, 'Binary files cannot be opened in the editor');
247
+ }
248
+ const content = await fs.promises.readFile(absolutePath, 'utf8');
249
+ res.json({
250
+ workspace: workspaceSlug,
251
+ path: relativePosix,
252
+ content,
253
+ size: stats.size,
254
+ mtime: stats.mtimeMs,
255
+ });
256
+ }));
257
+
258
+ router.post('/api/files/save', asyncHandler(async (req, res) => {
259
+ const { workspace, path: relativePath, content, root: rootParam } = req.body || {};
260
+ if (typeof content !== 'string') {
261
+ throw createHttpError(400, 'File content must be a string');
262
+ }
263
+
264
+ const { absolutePath, relativePosix, workspaceSlug } = await resolveWorkspacePath(workspace, relativePath, rootParam);
265
+ const stats = await fs.promises.stat(absolutePath).catch(() => null);
266
+ if (!stats) {
267
+ throw createHttpError(404, 'File not found');
268
+ }
269
+ if (!stats.isFile()) {
270
+ throw createHttpError(400, 'Path must be a file');
271
+ }
272
+
273
+ await fs.promises.writeFile(absolutePath, content, 'utf8');
274
+ const updatedStats = await fs.promises.stat(absolutePath);
275
+ res.json({
276
+ workspace: workspaceSlug,
277
+ path: relativePosix,
278
+ size: updatedStats.size,
279
+ mtime: updatedStats.mtimeMs,
280
+ });
281
+ }));
282
+
283
+ app.use(router);
284
+ };
@@ -22,6 +22,9 @@ body.dark #devtab.selected {
22
22
  border: none;
23
23
  background: rgba(255,255,255,0.07);
24
24
  }
25
+ #editortab {
26
+ color: #7f5bf3;
27
+ }
25
28
  #devtab {
26
29
  align-items: center;
27
30
  justify-content: center;
@@ -393,10 +396,36 @@ body.dark .appcanvas > aside .header-item.btn:not(.selected) {
393
396
  .appcanvas > aside .header-item .tab {
394
397
  display: flex;
395
398
  align-items: center;
399
+ /*
396
400
  gap: 6px;
401
+ */
397
402
  flex: 1;
398
403
  min-width: 0;
399
404
  }
405
+ .appcanvas > aside .header-item .tab .tab-metric {
406
+ display: inline-flex;
407
+ align-items: baseline;
408
+ gap: 6px;
409
+ font-size: 11px;
410
+ white-space: nowrap;
411
+ flex: 0 0 auto;
412
+ padding-left: 6px;
413
+ }
414
+ .appcanvas > aside .header-item .tab .tab-metric__label {
415
+ text-transform: uppercase;
416
+ letter-spacing: 0.06em;
417
+ font-size: 10px;
418
+ opacity: 0.7;
419
+ }
420
+ .appcanvas > aside .header-item .tab .tab-metric__value {
421
+ font-weight: 600;
422
+ font-size: 11px;
423
+ }
424
+ .appcanvas > aside .header-item .tab .tab-metric .disk-usage {
425
+ flex: 0 0 auto;
426
+ min-width: 0;
427
+ text-align: right;
428
+ }
400
429
 
401
430
  .appcanvas > aside .header-item:hover {
402
431
  background: var(--pinokio-sidebar-tab-hover);
@@ -1261,6 +1290,7 @@ body.dark .submenu {
1261
1290
  flex-grow: 1;
1262
1291
  font-weight: bold;
1263
1292
  text-align: right;
1293
+ opacity: 0.5;
1264
1294
  }
1265
1295
  .disk-usage i {
1266
1296
  margin-right: 5px;
@@ -1426,8 +1456,8 @@ body.dark #fs-status {
1426
1456
 
1427
1457
  .fs-status-dropdown.git-fork .fs-dropdown-menu,
1428
1458
  .fs-status-dropdown.git-publish .fs-dropdown-menu {
1429
- left: auto;
1430
- right: 0;
1459
+ left: 0;
1460
+ right: auto;
1431
1461
  width: min(420px, calc(100vw - 24px));
1432
1462
  max-width: min(420px, calc(100vw - 24px));
1433
1463
  white-space: normal;
@@ -1504,9 +1534,6 @@ body.dark #fs-status .fs-dropdown-menu .frame-link:hover {
1504
1534
  background: rgba(148, 163, 184, 0.15);
1505
1535
  }
1506
1536
 
1507
- #fs-status .git-changes {
1508
- margin-left: auto;
1509
- }
1510
1537
 
1511
1538
  #fs-changes-menu .fs-dropdown-empty {
1512
1539
  padding: 8px 14px;
@@ -1515,8 +1542,8 @@ body.dark #fs-status .fs-dropdown-menu .frame-link:hover {
1515
1542
  }
1516
1543
 
1517
1544
  #fs-changes-menu {
1518
- left: auto;
1519
- right: 0;
1545
+ left: 0;
1546
+ right: auto;
1520
1547
  width: max-content;
1521
1548
  max-width: min(460px, calc(100vw - 32px));
1522
1549
  max-height: min(70vh, 420px);
@@ -2900,6 +2927,15 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2900
2927
  <div class='loader'><i class='fa-solid fa-angle-right'></i></div>
2901
2928
  -->
2902
2929
  </a>
2930
+ <a id='editortab' data-mode="refresh" target="<%=editor_tab%>" href="<%=editor_tab%>" class="btn header-item frame-link edit-tab" data-index="11">
2931
+ <div class='tab'>
2932
+ <i class="fa-solid fa-file-lines"></i>
2933
+ <div class='display'>Files</div>
2934
+ <div class='tab-metric'>
2935
+ <span class='disk-usage tab-metric__value' data-path="/">--</span>
2936
+ </div>
2937
+ </div>
2938
+ </a>
2903
2939
  <div class="dynamic <%=type==='run' ? '' : 'selected'%>">
2904
2940
  <div class='submenu'>
2905
2941
  <% if (plugin_menu) { %>
@@ -2914,6 +2950,13 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2914
2950
  <% if (config.menu) { %>
2915
2951
  <%- include('./partials/menu', { menu: config.menu, }) %>
2916
2952
  <% } %>
2953
+ <a id='settings-tab' data-mode="refresh" target="env-settings" href="/env/api/<%=name%>" class="btn header-item frame-link" data-index="settings" data-static="retain">
2954
+ <div class='tab'>
2955
+ <i class="fa-solid fa-gear"></i>
2956
+ <div class='display'>Settings</div>
2957
+ <div class='flexible'></div>
2958
+ </div>
2959
+ </a>
2917
2960
  </div>
2918
2961
  <% } %>
2919
2962
  <div class='m s temp-menu' data-type='s'>
@@ -2933,27 +2976,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2933
2976
  <div class='container'>
2934
2977
  <% if (type === "browse") { %>
2935
2978
  <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%>">
2936
- <a target="<%=src%>" href="<%=src%>" class='fs-status-btn frame-link' data-index="0" data-mode="refresh" data-type="n">
2937
- <span class='fs-status-label'>
2938
- <i class="fa-regular fa-folder-open"></i>
2939
- Files |
2940
- <div class='disk-usage' data-path="/"></div>
2941
- </span>
2942
- </a>
2943
- <div class='fs-status-dropdown'>
2944
- <button id='edit-toggle' class='fs-status-btn revealer' data-group='#fs-edit-menu' type='button'>
2945
- <span class='fs-status-label'>
2946
- <i class='fa-solid fa-pen-to-square'></i>
2947
- Edit
2948
- </span>
2949
- </button>
2950
- <div class='fs-dropdown-menu submenu hidden' id='fs-edit-menu'>
2951
- <button type='button' class='fs-dropdown-item' id='edit'><i class="fa-solid fa-pencil"></i> Edit</button>
2952
- <button type='button' class='fs-dropdown-item' id='move'><i class="fa-solid fa-right-to-bracket"></i> Move</button>
2953
- <button type='button' class='fs-dropdown-item' id='copy'><i class="fa-solid fa-copy"></i> Copy</button>
2954
- <button type='button' class='fs-dropdown-item' id='delete' data-name="<%=name%>"><i class="fa-solid fa-trash-can"></i> Delete</button>
2955
- </div>
2956
- </div>
2957
2979
  <!--
2958
2980
  <div class='fs-status-dropdown nested-menu git blue'>
2959
2981
  <button type='button' class='fs-status-btn frame-link reveal'>
@@ -2966,11 +2988,6 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
2966
2988
  </div>
2967
2989
  </div>
2968
2990
  -->
2969
- <div class='fs-status-dropdown'>
2970
- <a target="/env/api/<%=name%>" href="/env/api/<%=name%>" class='fs-status-btn frame-link selected' data-index="3" data-mode="refresh" data-type="n">
2971
- <span class='fs-status-label'><i class='fa-solid fa-gear'></i> Settings</span>
2972
- </a>
2973
- </div>
2974
2991
  <div class='fs-status-dropdown git-changes'>
2975
2992
  <button id='fs-changes-btn' class='fs-status-btn revealer' data-group='#fs-changes-menu' type='button'>
2976
2993
  <span class='fs-status-label'><i class="fa-solid fa-code-compare"></i> Changes</span>
@@ -4842,6 +4859,9 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4842
4859
  if (!targetContainer) {
4843
4860
  return
4844
4861
  }
4862
+ const staticNodes = Array.from(targetContainer.children || []).filter((node) => {
4863
+ return node && node.dataset && node.dataset.static === 'retain'
4864
+ })
4845
4865
  const template = document.createElement('template')
4846
4866
  template.innerHTML = html || ''
4847
4867
  const fragment = template.content
@@ -4857,6 +4877,28 @@ body.dark .pinokio-fork-dropdown-remote, body.dark .pinokio-publish-dropdown-rem
4857
4877
  }
4858
4878
  const nodes = Array.from(fragment.childNodes)
4859
4879
  targetContainer.replaceChildren(...nodes)
4880
+ if (staticNodes.length > 0) {
4881
+ staticNodes.forEach((node) => {
4882
+ if (!node) {
4883
+ return
4884
+ }
4885
+ if (node.parentElement === targetContainer) {
4886
+ return
4887
+ }
4888
+ if (node.dataset && node.dataset.static) {
4889
+ const duplicates = Array.from(targetContainer.children || []).filter((candidate) => {
4890
+ if (candidate === node) {
4891
+ return false
4892
+ }
4893
+ return candidate.dataset && candidate.dataset.static === node.dataset.static
4894
+ })
4895
+ duplicates.forEach((duplicate) => {
4896
+ duplicate.remove()
4897
+ })
4898
+ }
4899
+ targetContainer.appendChild(node)
4900
+ })
4901
+ }
4860
4902
  }
4861
4903
  let timestampIntervalId = null
4862
4904
  const ensureTimestampInterval = () => {