pinokiod 3.170.0 → 3.180.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "3.170.0",
3
+ "version": "3.180.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -230,8 +230,40 @@ class Server {
230
230
  }
231
231
  running_dynamic.push(obj)
232
232
  } else {
233
+ const normalizedFilepath = path.normalize(filepath)
234
+ const hasMenuCwd = typeof cwd === 'string'
235
+ const normalizedMenuCwd = hasMenuCwd ? (cwd.length === 0 ? '' : path.normalize(cwd)) : null
236
+ const matchesRunningEntry = (runningKey) => {
237
+ if (typeof runningKey !== 'string' || runningKey.length === 0) {
238
+ return false
239
+ }
240
+ const questionIndex = runningKey.indexOf('?')
241
+ const runningPath = questionIndex >= 0 ? runningKey.slice(0, questionIndex) : runningKey
242
+ if (path.normalize(runningPath) !== normalizedFilepath) {
243
+ return false
244
+ }
245
+ if (!hasMenuCwd) {
246
+ return questionIndex === -1
247
+ }
248
+ if (questionIndex === -1) {
249
+ return normalizedMenuCwd === ''
250
+ }
251
+ const params = querystring.parse(runningKey.slice(questionIndex + 1))
252
+ const rawCwd = typeof params.cwd === 'string' ? params.cwd : null
253
+ if (normalizedMenuCwd === '') {
254
+ return rawCwd !== null && rawCwd.length === 0
255
+ }
256
+ if (!rawCwd || rawCwd.length === 0) {
257
+ return false
258
+ }
259
+ try {
260
+ return path.normalize(rawCwd) === normalizedMenuCwd
261
+ } catch (_) {
262
+ return false
263
+ }
264
+ }
233
265
  for(let running_id in this.kernel.api.running) {
234
- if (running_id.startsWith(id)) {
266
+ if (matchesRunningEntry(running_id)) {
235
267
  let obj2 = structuredClone(obj)
236
268
  obj2.running = true
237
269
  obj2.display = "indent"
@@ -526,9 +558,13 @@ class Server {
526
558
  }
527
559
 
528
560
  let currentBranch = null
561
+ let isDetached = false
529
562
  try {
530
563
  currentBranch = await git.currentBranch({ fs, dir, fullname: false })
531
564
  } catch (_) {}
565
+ if (!currentBranch) {
566
+ isDetached = true
567
+ }
532
568
 
533
569
  let branches = []
534
570
  if (branchList.length > 0) {
@@ -595,6 +631,7 @@ class Server {
595
631
  branch: currentBranch,
596
632
  branches,
597
633
  dir,
634
+ detached: isDetached,
598
635
  logError: logError ? String(logError.message || logError) : null
599
636
  }
600
637
  }
@@ -812,6 +849,7 @@ class Server {
812
849
  let run_tab = "/p/" + name
813
850
  let dev_tab = "/p/" + name + "/dev"
814
851
  let review_tab = "/p/" + name + "/review"
852
+ let files_tab = "/p/" + name + "/files"
815
853
 
816
854
  let editor_tab = `/pinokio/fileview/${encodeURIComponent(name)}`
817
855
  let savedTabs = []
@@ -867,6 +905,7 @@ class Server {
867
905
  run_tab,
868
906
  dev_tab,
869
907
  review_tab,
908
+ files_tab,
870
909
  // paths,
871
910
  theme: this.theme,
872
911
  agent: req.agent,
@@ -6246,8 +6285,84 @@ class Server {
6246
6285
  res.json(response)
6247
6286
  }))
6248
6287
  this.app.get("/info/git/:ref/*", ex(async (req, res) => {
6249
- let response = await this.getGit(req.params.ref, req.params[0])
6250
- res.json(response)
6288
+ const repoParam = req.params[0]
6289
+ const ref = req.params.ref || 'HEAD'
6290
+ const summary = await this.getGit(ref, repoParam)
6291
+
6292
+ const repoDir = summary && summary.dir ? summary.dir : this.kernel.path('api', repoParam)
6293
+
6294
+ if (ref === 'HEAD') {
6295
+ try {
6296
+ const { changes: headChanges, git_commit_url } = await this.getRepoHeadStatus(repoParam)
6297
+ summary.changes = headChanges || []
6298
+ summary.git_commit_url = git_commit_url || null
6299
+ } catch (error) {
6300
+ console.error('[git-info] head status error', repoParam, error)
6301
+ summary.changes = []
6302
+ }
6303
+ } else {
6304
+ let changes = []
6305
+ try {
6306
+ const commitOid = await this.kernel.git.resolveCommitOid(repoDir, ref)
6307
+ const parentOid = await this.kernel.git.getParentCommit(repoDir, commitOid)
6308
+ let entries
6309
+ if (parentOid !== commitOid) {
6310
+ entries = await git.walk({
6311
+ fs,
6312
+ dir: repoDir,
6313
+ trees: [git.TREE({ ref: parentOid }), git.TREE({ ref: commitOid })],
6314
+ map: async (filepath, [A, B]) => {
6315
+ if (filepath === '.') return
6316
+ if (!A && B) return { filepath, type: 'added' }
6317
+ if (A && !B) return { filepath, type: 'deleted' }
6318
+ if (A && B) {
6319
+ const Aoid = await A.oid()
6320
+ const Boid = await B.oid()
6321
+ if (Aoid !== Boid) return { filepath, type: 'modified' }
6322
+ }
6323
+ },
6324
+ })
6325
+ } else {
6326
+ entries = await git.walk({
6327
+ fs,
6328
+ dir: repoDir,
6329
+ trees: [git.TREE({ ref: commitOid })],
6330
+ map: async (filepath, [B]) => {
6331
+ if (filepath === '.') return
6332
+ return { filepath, type: 'added' }
6333
+ },
6334
+ })
6335
+ }
6336
+ const diffFiles = (entries || []).filter(Boolean)
6337
+ for (const { filepath, type } of diffFiles) {
6338
+ const fullPath = path.join(repoDir, filepath)
6339
+ const stats = await fs.promises.stat(fullPath).catch(() => null)
6340
+ if (!stats || stats.isDirectory()) {
6341
+ continue
6342
+ }
6343
+ const relpath = path.relative(this.kernel.path('api'), fullPath)
6344
+ changes.push({
6345
+ ref,
6346
+ webpath: "/asset/" + path.relative(this.kernel.homedir, fullPath),
6347
+ file: filepath,
6348
+ path: fullPath,
6349
+ diffpath: `/gitdiff/${ref}/${repoParam}/${filepath}`,
6350
+ status: type,
6351
+ relpath,
6352
+ })
6353
+ }
6354
+ } catch (error) {
6355
+ console.error('[git-info] diff error', repoParam, ref, error)
6356
+ }
6357
+ summary.changes = changes
6358
+ }
6359
+
6360
+ if (!summary.git_commit_url) {
6361
+ summary.git_commit_url = `/run/scripts/git/commit.json?cwd=${repoDir}&callback_target=parent&callback=$location.href`
6362
+ }
6363
+ summary.dir = repoDir
6364
+
6365
+ res.json(summary)
6251
6366
  }))
6252
6367
  this.app.get("/info/gitstatus/:name", ex(async (req, res) => {
6253
6368
  try {
@@ -7052,10 +7167,12 @@ class Server {
7052
7167
  let run_tab = "/p/" + name
7053
7168
  let dev_tab = "/p/" + name + "/dev"
7054
7169
  let review_tab = "/p/" + name + "/review"
7170
+ let files_tab = "/p/" + name + "/files"
7055
7171
  res.render("review", {
7056
7172
  run_tab,
7057
7173
  dev_tab,
7058
7174
  review_tab,
7175
+ files_tab,
7059
7176
  name: req.params.name,
7060
7177
  type: "review",
7061
7178
  title: name,
@@ -7070,6 +7187,9 @@ class Server {
7070
7187
  this.app.get("/p/:name/dev", ex(async (req, res) => {
7071
7188
  await this.chrome(req, res, "browse")
7072
7189
  }))
7190
+ this.app.get("/p/:name/files", ex(async (req, res) => {
7191
+ await this.chrome(req, res, "files")
7192
+ }))
7073
7193
  this.app.get("/p/:name/browse", ex(async (req, res) => {
7074
7194
  await this.chrome(req, res, "browse")
7075
7195
  }))
@@ -35,7 +35,7 @@
35
35
  display: flex;
36
36
  align-items: center;
37
37
  justify-content: space-between;
38
- padding: 12px 18px;
38
+ padding: 8px 18px;
39
39
  background: var(--surface-color);
40
40
  border-bottom: 1px solid var(--border-color);
41
41
  gap: 12px;
@@ -46,16 +46,28 @@
46
46
  align-items: center;
47
47
  gap: 12px;
48
48
  font-size: 15px;
49
+ min-width: 0; /* allow ellipsis children */
49
50
  }
50
51
 
51
52
  .files-app__header-title i {
52
53
  font-size: 18px;
53
54
  }
54
55
 
56
+ .files-app__header-icon {
57
+ width: 28px;
58
+ height: 28px;
59
+ border-radius: 6px;
60
+ object-fit: cover;
61
+ display: block;
62
+ background: #fff;
63
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08);
64
+ }
65
+
55
66
  .files-app__header-text {
56
67
  display: flex;
57
68
  flex-direction: column;
58
69
  gap: 2px;
70
+ min-width: 0; /* allow ellipsis children */
59
71
  }
60
72
 
61
73
  .files-app__header-primary {
@@ -68,6 +80,15 @@
68
80
  word-break: break-all;
69
81
  }
70
82
 
83
+ .files-app__header-desc {
84
+ font-size: 12px;
85
+ color: var(--muted-color);
86
+ display: block;
87
+ white-space: nowrap;
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ }
91
+
71
92
  .files-app__header-actions {
72
93
  display: flex;
73
94
  gap: 8px;
@@ -262,12 +283,34 @@ body.dark .files-app__tab--stale .files-app__tab-label::after {
262
283
  transition: background 0.1s ease;
263
284
  }
264
285
 
286
+ .files-app__tree-label {
287
+ flex: 1;
288
+ min-width: 0;
289
+ overflow: hidden;
290
+ text-overflow: ellipsis;
291
+ white-space: nowrap;
292
+ }
293
+
294
+ .files-app__tree-actions {
295
+ display: inline-flex;
296
+ align-items: center;
297
+ gap: 4px;
298
+ opacity: 0;
299
+ transition: opacity 0.15s ease;
300
+ }
301
+
265
302
  .files-app__tree-row:hover,
266
303
  .files-app__tree-row:focus-visible {
267
304
  background: rgba(127, 91, 243, 0.12);
268
305
  outline: none;
269
306
  }
270
307
 
308
+ .files-app__tree-row:hover .files-app__tree-actions,
309
+ .files-app__tree-row:focus-visible .files-app__tree-actions,
310
+ .files-app__tree-row[data-selected='true'] .files-app__tree-actions {
311
+ opacity: 1;
312
+ }
313
+
271
314
  .files-app__tree-row[data-selected='true'] {
272
315
  background: rgba(127, 91, 243, 0.2);
273
316
  }
@@ -277,6 +320,35 @@ body.dark .files-app__tab--stale .files-app__tab-label::after {
277
320
  text-align: center;
278
321
  }
279
322
 
323
+ .files-app__tree-action {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ justify-content: center;
327
+ width: 22px;
328
+ height: 22px;
329
+ border-radius: 4px;
330
+ color: var(--muted-color);
331
+ cursor: pointer;
332
+ }
333
+
334
+ .files-app__tree-action--rename {
335
+ font-size: 13px;
336
+ }
337
+
338
+ .files-app__tree-action:hover {
339
+ background: rgba(127, 91, 243, 0.15);
340
+ color: var(--text-color);
341
+ }
342
+
343
+ .files-app__tree-action:focus-visible {
344
+ outline: 2px solid var(--accent-color);
345
+ outline-offset: 2px;
346
+ }
347
+
348
+ .files-app__tree-action i {
349
+ pointer-events: none;
350
+ }
351
+
280
352
  .files-app__tree-children {
281
353
  list-style: none;
282
354
  margin: 0;
@@ -263,6 +263,28 @@
263
263
  return parseJsonResponse(response);
264
264
  };
265
265
 
266
+ const remove = async (pathPosix) => {
267
+ const response = await fetch('/api/files/delete', {
268
+ method: 'POST',
269
+ headers: {
270
+ 'Content-Type': 'application/json',
271
+ },
272
+ body: JSON.stringify({ workspace, path: pathPosix, root: workspaceRoot }),
273
+ });
274
+ return parseJsonResponse(response);
275
+ };
276
+
277
+ const rename = async (pathPosix, newName) => {
278
+ const response = await fetch('/api/files/rename', {
279
+ method: 'POST',
280
+ headers: {
281
+ 'Content-Type': 'application/json',
282
+ },
283
+ body: JSON.stringify({ workspace, path: pathPosix, name: newName, root: workspaceRoot }),
284
+ });
285
+ return parseJsonResponse(response);
286
+ };
287
+
266
288
  const stat = async (pathPosix) => {
267
289
  const params = new URLSearchParams({ workspace });
268
290
  if (workspaceRoot) {
@@ -276,7 +298,7 @@
276
298
  return parseJsonResponse(response);
277
299
  };
278
300
 
279
- return { list, read, save, stat };
301
+ return { list, read, save, remove, rename, stat };
280
302
  }
281
303
 
282
304
  async function parseJsonResponse(response) {
@@ -340,10 +362,52 @@
340
362
  icon.className = entry.type === 'directory' ? 'fa-regular fa-folder' : 'fa-regular fa-file-lines';
341
363
  row.appendChild(icon);
342
364
 
343
- const label = createElement('span');
365
+ const label = createElement('span', 'files-app__tree-label');
344
366
  label.textContent = entry.name;
345
367
  row.appendChild(label);
346
368
 
369
+ if (!entry.isRoot) {
370
+ const actions = createElement('span', 'files-app__tree-actions');
371
+ const renameBtn = createElement('span', 'files-app__tree-action files-app__tree-action--rename');
372
+ renameBtn.setAttribute('role', 'button');
373
+ renameBtn.setAttribute('aria-label', entry.type === 'directory' ? 'Rename folder' : 'Rename file');
374
+ renameBtn.tabIndex = 0;
375
+ renameBtn.title = entry.type === 'directory' ? 'Rename folder' : 'Rename file';
376
+ renameBtn.innerHTML = '<i class="fa-regular fa-pen-to-square"></i>';
377
+ const triggerRename = (event) => {
378
+ event.preventDefault();
379
+ event.stopPropagation();
380
+ requestRenameEntry.call(this, entry);
381
+ };
382
+ renameBtn.addEventListener('click', triggerRename);
383
+ renameBtn.addEventListener('keydown', (event) => {
384
+ if (event.key === 'Enter' || event.key === ' ') {
385
+ triggerRename(event);
386
+ }
387
+ });
388
+ actions.appendChild(renameBtn);
389
+
390
+ const deleteBtn = createElement('span', 'files-app__tree-action files-app__tree-action--delete');
391
+ deleteBtn.setAttribute('role', 'button');
392
+ deleteBtn.setAttribute('aria-label', entry.type === 'directory' ? 'Delete folder' : 'Delete file');
393
+ deleteBtn.tabIndex = 0;
394
+ deleteBtn.title = entry.type === 'directory' ? 'Delete folder' : 'Delete file';
395
+ deleteBtn.innerHTML = '<i class="fa-regular fa-trash-can"></i>';
396
+ const triggerDelete = (event) => {
397
+ event.preventDefault();
398
+ event.stopPropagation();
399
+ requestDeleteEntry.call(this, entry);
400
+ };
401
+ deleteBtn.addEventListener('click', triggerDelete);
402
+ deleteBtn.addEventListener('keydown', (event) => {
403
+ if (event.key === 'Enter' || event.key === ' ') {
404
+ triggerDelete(event);
405
+ }
406
+ });
407
+ actions.appendChild(deleteBtn);
408
+ row.appendChild(actions);
409
+ }
410
+
347
411
  if (entry.type === 'directory') {
348
412
  row.addEventListener('click', (event) => {
349
413
  event.preventDefault();
@@ -418,6 +482,195 @@
418
482
  container.style.display = parentItem.dataset.expanded === 'true' ? 'block' : 'none';
419
483
  }
420
484
 
485
+ function requestDeleteEntry(entry) {
486
+ if (!entry || !entry.path) {
487
+ return;
488
+ }
489
+ const path = entry.path;
490
+ const displayName = entry.name || path.split('/').pop() || this.state.workspaceLabel;
491
+ const impacted = [];
492
+ for (const [sessionPath, info] of this.state.sessions.entries()) {
493
+ if (sessionPath === path || sessionPath.startsWith(`${path}/`)) {
494
+ impacted.push({ path: sessionPath, info });
495
+ }
496
+ }
497
+ let message = entry.type === 'directory'
498
+ ? `Delete folder "${displayName}" and all of its contents?`
499
+ : `Delete file "${displayName}"?`;
500
+ if (impacted.some(({ info }) => info && info.session && !isSessionClean(info.session))) {
501
+ message += '\n\nUnsaved changes in open editors will be lost.';
502
+ }
503
+ const confirmed = window.confirm(message);
504
+ if (!confirmed) {
505
+ return;
506
+ }
507
+ deleteEntry.call(this, entry, impacted.map(({ path }) => path));
508
+ }
509
+
510
+ function requestRenameEntry(entry) {
511
+ if (!entry || !entry.path) {
512
+ return;
513
+ }
514
+ const path = entry.path;
515
+ const displayName = entry.name || path.split('/').pop() || this.state.workspaceLabel;
516
+ let nextName = window.prompt('Rename to:', displayName);
517
+ if (nextName === null) {
518
+ return;
519
+ }
520
+ nextName = nextName.trim();
521
+ if (!nextName) {
522
+ setStatus.call(this, 'Name cannot be empty', 'error');
523
+ return;
524
+ }
525
+ if (nextName.includes('/') || nextName.includes('\\')) {
526
+ setStatus.call(this, 'Name cannot contain path separators', 'error');
527
+ return;
528
+ }
529
+ if (nextName === displayName) {
530
+ return;
531
+ }
532
+ renameEntry.call(this, entry, nextName);
533
+ }
534
+
535
+ async function renameEntry(entry, newName) {
536
+ if (!entry || !entry.path) {
537
+ return;
538
+ }
539
+ const path = entry.path;
540
+ const displayName = entry.name || path.split('/').pop() || this.state.workspaceLabel;
541
+ if (!this.api || typeof this.api.rename !== 'function') {
542
+ setStatus.call(this, 'Rename is not available', 'error');
543
+ return;
544
+ }
545
+ setStatus.call(this, `Renaming ${displayName}…`);
546
+ try {
547
+ const result = await this.api.rename(path, newName);
548
+ const targetPath = result && typeof result.target === 'string' ? result.target : path;
549
+
550
+ const oldPrefix = `${path}/`;
551
+
552
+ const updates = [];
553
+ for (const [sessionPath, entryRef] of this.state.sessions.entries()) {
554
+ if (sessionPath === path || sessionPath.startsWith(oldPrefix)) {
555
+ const suffix = sessionPath.slice(path.length);
556
+ const newSessionPath = `${targetPath}${suffix}`;
557
+ updates.push({ oldPath: sessionPath, newPath: newSessionPath, ref: entryRef, suffix });
558
+ }
559
+ }
560
+
561
+ if (updates.length > 0) {
562
+ for (const { oldPath, newPath, ref, suffix } of updates) {
563
+ this.state.sessions.delete(oldPath);
564
+ this.state.sessions.set(newPath, ref);
565
+ if (this.state.activePath === oldPath) {
566
+ this.state.activePath = newPath;
567
+ }
568
+ const tab = ref.tabEl;
569
+ if (tab) {
570
+ tab.dataset.path = newPath;
571
+ const label = tab.querySelector('.files-app__tab-label');
572
+ if (label && suffix.length === 0) {
573
+ label.textContent = newName;
574
+ }
575
+ }
576
+ if (suffix.length === 0) {
577
+ ref.name = newName;
578
+ }
579
+ }
580
+ this.state.openOrder = this.state.openOrder.map((existing) => {
581
+ if (existing === path) {
582
+ return targetPath;
583
+ }
584
+ if (existing.startsWith(oldPrefix)) {
585
+ return `${targetPath}${existing.slice(path.length)}`;
586
+ }
587
+ return existing;
588
+ });
589
+ }
590
+
591
+ if (this.state.selectedTreePath) {
592
+ if (this.state.selectedTreePath === path) {
593
+ this.state.selectedTreePath = targetPath;
594
+ } else if (this.state.selectedTreePath.startsWith(oldPrefix)) {
595
+ this.state.selectedTreePath = `${targetPath}${this.state.selectedTreePath.slice(path.length)}`;
596
+ }
597
+ }
598
+
599
+ pruneTreeCache.call(this, path);
600
+
601
+ const parentPath = path.split('/').slice(0, -1).join('/');
602
+ const parentItem = this.state.treeElements.get(parentPath) || this.state.treeElements.get('');
603
+ if (parentItem) {
604
+ parentItem.dataset.loaded = 'false';
605
+ await loadDirectory.call(this, parentPath, parentItem);
606
+ if (this.state.treeElements.has(targetPath)) {
607
+ setTreeSelection.call(this, targetPath);
608
+ } else {
609
+ setTreeSelection.call(this, parentPath);
610
+ }
611
+ }
612
+
613
+ setStatus.call(this, `${displayName} renamed`, 'success');
614
+ } catch (error) {
615
+ console.error(error);
616
+ setStatus.call(this, error.message || 'Failed to rename', 'error');
617
+ }
618
+ }
619
+
620
+ async function deleteEntry(entry, impactedPaths) {
621
+ if (!entry || !entry.path) {
622
+ return;
623
+ }
624
+ const path = entry.path;
625
+ const displayName = entry.name || path.split('/').pop() || this.state.workspaceLabel;
626
+ if (!this.api || typeof this.api.remove !== 'function') {
627
+ setStatus.call(this, 'Delete is not available', 'error');
628
+ return;
629
+ }
630
+ setStatus.call(this, `Deleting ${displayName}…`);
631
+ try {
632
+ await this.api.remove(path);
633
+
634
+ if (Array.isArray(impactedPaths)) {
635
+ for (const sessionPath of impactedPaths) {
636
+ this.closeFile(sessionPath, { force: true });
637
+ }
638
+ }
639
+
640
+ pruneTreeCache.call(this, path);
641
+
642
+ const parentPath = path.split('/').slice(0, -1).join('/');
643
+ const parentItem = this.state.treeElements.get(parentPath) || this.state.treeElements.get('');
644
+ if (parentItem) {
645
+ parentItem.dataset.loaded = 'false';
646
+ await loadDirectory.call(this, parentPath, parentItem);
647
+ setTreeSelection.call(this, parentPath);
648
+ }
649
+
650
+ if (this.state.selectedTreePath && (this.state.selectedTreePath === path || this.state.selectedTreePath.startsWith(`${path}/`))) {
651
+ setTreeSelection.call(this, null);
652
+ }
653
+
654
+ setStatus.call(this, `${displayName} deleted`, 'success');
655
+ } catch (error) {
656
+ console.error(error);
657
+ setStatus.call(this, error.message || 'Failed to delete', 'error');
658
+ }
659
+ }
660
+
661
+ function pruneTreeCache(path) {
662
+ if (typeof path !== 'string') {
663
+ return;
664
+ }
665
+ const prefix = path ? `${path}/` : '';
666
+ for (const key of Array.from(this.state.treeElements.keys())) {
667
+ if (!key) continue;
668
+ if (key === path || (prefix && key.startsWith(prefix))) {
669
+ this.state.treeElements.delete(key);
670
+ }
671
+ }
672
+ }
673
+
421
674
  async function toggleDirectory(treeItem, relativePath) {
422
675
  if (!treeItem || treeItem.dataset.loading === 'true') {
423
676
  return;
@@ -23,7 +23,7 @@
23
23
  const HOST_ORIGIN = window.location.origin;
24
24
  const STORAGE_PREFIX = 'pinokio:layout:';
25
25
  const MIN_PANEL_SIZE = 120;
26
- const GUTTER_SIZE = 4;
26
+ const GUTTER_SIZE = 6;
27
27
 
28
28
  const state = {
29
29
  sessionId: typeof parsedConfig.sessionId === 'string' && parsedConfig.sessionId.trim() ? parsedConfig.sessionId.trim() : null,
@@ -821,6 +821,10 @@ body {
821
821
  color: var(--light-color);
822
822
  position: relative;
823
823
  }
824
+ /* Reserve scrollbar space to prevent header layout shift */
825
+ html {
826
+ scrollbar-gutter: stable both-edges;
827
+ }
824
828
  body.dark {
825
829
  color: var(--dark-color);
826
830
  background: var(--dark-bg);
@@ -2371,7 +2375,7 @@ body.dark .mode-selector .btn2.selected {
2371
2375
  justify-content: center;
2372
2376
  padding: 5px 10px;
2373
2377
  box-sizing: border-box;
2374
- width: 100px;
2378
+ width: 70px;
2375
2379
  gap: 5px;
2376
2380
  border-radius: 5px;
2377
2381
  }