@joinezco/codeblock 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/editor.d.ts +23 -2
  2. package/dist/editor.js +346 -41
  3. package/dist/index.d.ts +3 -2
  4. package/dist/index.js +3 -2
  5. package/dist/lsps/index.d.ts +5 -0
  6. package/dist/lsps/index.js +9 -2
  7. package/dist/panels/{footer.d.ts → settings.d.ts} +7 -1
  8. package/dist/panels/{footer.js → settings.js} +12 -3
  9. package/dist/panels/terminal.d.ts +3 -0
  10. package/dist/panels/terminal.js +76 -0
  11. package/dist/panels/toolbar.d.ts +40 -3
  12. package/dist/panels/toolbar.js +919 -160
  13. package/dist/themes/index.js +63 -17
  14. package/dist/types.d.ts +5 -0
  15. package/dist/utils/fs.d.ts +7 -0
  16. package/dist/utils/fs.js +41 -15
  17. package/dist/workers/fs.worker.d.ts +4 -8
  18. package/dist/workers/fs.worker.js +27 -53
  19. package/package.json +14 -12
  20. package/dist/assets/clike-C8IJ2oj_.js +0 -1
  21. package/dist/assets/cmake-BQqOBYOt.js +0 -1
  22. package/dist/assets/dockerfile-C_y-rIpk.js +0 -1
  23. package/dist/assets/fs.worker-DfanUHpQ.js +0 -21
  24. package/dist/assets/go-CTD25R5P.js +0 -1
  25. package/dist/assets/haskell-BWDZoCOh.js +0 -1
  26. package/dist/assets/index-BAnLzvMk.js +0 -1
  27. package/dist/assets/index-BBC9WDX6.js +0 -1
  28. package/dist/assets/index-BEXYxRro.js +0 -1
  29. package/dist/assets/index-BfYmUKH9.js +0 -13
  30. package/dist/assets/index-BhaTNAWE.js +0 -1
  31. package/dist/assets/index-CCbYDSng.js +0 -1
  32. package/dist/assets/index-CIi8tLT6.js +0 -1
  33. package/dist/assets/index-CaANcgI2.js +0 -3
  34. package/dist/assets/index-CkWzFNzm.js +0 -208
  35. package/dist/assets/index-D_XGv9QZ.js +0 -1
  36. package/dist/assets/index-DkmiPfkD.js +0 -1
  37. package/dist/assets/index-DmNlLMQ4.js +0 -6
  38. package/dist/assets/index-DmX_vI7D.js +0 -1
  39. package/dist/assets/index-DogEEevD.js +0 -1
  40. package/dist/assets/index-DsDl5qZV.js +0 -2
  41. package/dist/assets/index-gAy5mDg-.js +0 -1
  42. package/dist/assets/index-i5qJLB2h.js +0 -1
  43. package/dist/assets/javascript.worker-ClsyHOLi.js +0 -552
  44. package/dist/assets/lua-BgMRiT3U.js +0 -1
  45. package/dist/assets/perl-CdXCOZ3F.js +0 -1
  46. package/dist/assets/process-Dw9K5EnD.js +0 -1357
  47. package/dist/assets/properties-C78fOPTZ.js +0 -1
  48. package/dist/assets/ruby-B2Rjki9n.js +0 -1
  49. package/dist/assets/shell-CjFT_Tl9.js +0 -1
  50. package/dist/assets/swift-BzpIVaGY.js +0 -1
  51. package/dist/assets/toml-BXUEaScT.js +0 -1
  52. package/dist/assets/vb-CmGdzxic.js +0 -1
  53. package/dist/e2e/editor.spec.d.ts +0 -1
  54. package/dist/e2e/editor.spec.js +0 -309
  55. package/dist/e2e/example.spec.d.ts +0 -5
  56. package/dist/e2e/example.spec.js +0 -44
  57. package/dist/index.html +0 -15
  58. package/dist/resources/config.json +0 -13
  59. package/dist/styles.css +0 -7
@@ -1,9 +1,12 @@
1
+ import { EditorView } from "@codemirror/view";
1
2
  import { StateEffect, StateField } from "@codemirror/state";
2
- import { CodeblockFacet, openFileEffect, currentFileField, setThemeEffect } from "../editor";
3
+ import { CodeblockFacet, openFileEffect, fileLoadedEffect, currentFileField, setThemeEffect, lineWrappingCompartment, lineNumbersCompartment, foldGutterCompartment } from "../editor";
4
+ import { lineNumbers, highlightActiveLineGutter } from "@codemirror/view";
5
+ import { foldGutter } from "@codemirror/language";
3
6
  import { extOrLanguageToLanguageId } from "../lsps";
4
7
  import { LSP, LspLog, FileChangeType } from "../utils/lsp";
5
8
  import { Seti } from "@m234/nerd-fonts/fs";
6
- import { settingsField, resolveThemeDark, createSettingsOverlay } from "./footer";
9
+ import { settingsField, resolveThemeDark, updateSettingsEffect } from "./settings";
7
10
  // Browser-safe file icon lookup (avoids node:path.parse used by Seti.fromPath)
8
11
  const FALLBACK_ICON = { value: '\ue64e', hexCode: 0xe64e }; // nf-seti-text
9
12
  function setiIconForPath(filePath) {
@@ -28,6 +31,11 @@ function setiIconForPath(filePath) {
28
31
  }
29
32
  return FALLBACK_ICON;
30
33
  }
34
+ const fileActionRegistry = [];
35
+ /** Register a command that appears when files with matching extensions are open. */
36
+ export function registerFileAction(entry) {
37
+ fileActionRegistry.push(entry);
38
+ }
31
39
  // Type guards
32
40
  function isCommandResult(result) {
33
41
  return 'type' in result && result.query !== undefined;
@@ -35,6 +43,9 @@ function isCommandResult(result) {
35
43
  function isBrowseEntry(result) {
36
44
  return 'type' in result && ('fullPath' in result);
37
45
  }
46
+ function isSettingsEntry(result) {
47
+ return 'type' in result && ('settingKey' in result);
48
+ }
38
49
  // Search results state - now handles both commands and search results
39
50
  export const setSearchResults = StateEffect.define();
40
51
  export const searchResultsField = StateField.define({
@@ -67,6 +78,25 @@ function isValidProgrammingLanguage(query) {
67
78
  return Object.keys(extOrLanguageToLanguageId).some(key => key.toLowerCase() === lowerQuery ||
68
79
  extOrLanguageToLanguageId[key].toLowerCase() === lowerQuery);
69
80
  }
81
+ // Map language names to canonical file extensions
82
+ function languageToFileExtension(langOrExt) {
83
+ const nameToExt = {
84
+ javascript: 'js',
85
+ typescript: 'ts',
86
+ python: 'py',
87
+ ruby: 'rb',
88
+ csharp: 'cs',
89
+ kotlin: 'kt',
90
+ rust: 'rs',
91
+ haskell: 'hs',
92
+ perl: 'pl',
93
+ bash: 'sh',
94
+ shell: 'sh',
95
+ mysql: 'sql',
96
+ markdown: 'md',
97
+ };
98
+ return nameToExt[langOrExt.toLowerCase()] || langOrExt.toLowerCase();
99
+ }
70
100
  // Icons
71
101
  const SEARCH_ICON = '\uf002'; // nf-fa-search (magnifying glass)
72
102
  const DEFAULT_FILE_ICON = '\ue64e'; // nf-seti-text
@@ -74,6 +104,7 @@ const COG_ICON = '\uf013'; // nf-fa-cog
74
104
  const FOLDER_ICON = '\ue613'; // nf-seti-folder
75
105
  const FOLDER_OPEN_ICON = '\ue614'; // nf-seti-folder (open variant)
76
106
  const PARENT_DIR_ICON = '\uf112'; // nf-fa-reply (back/up arrow)
107
+ // const TERMINAL_ICON = '\uf120'; // nf-fa-terminal — awaiting WASM VM/shim
77
108
  // Get nerd font icon for a file path
78
109
  function getFileIcon(path) {
79
110
  const result = setiIconForPath(path);
@@ -83,29 +114,73 @@ function getFileIcon(path) {
83
114
  function getLanguageIcon(query) {
84
115
  return getFileIcon(`file.${query}`);
85
116
  }
117
+ // Theme cycle values and icons
118
+ const themeCycleValues = ['light', 'dark', 'system'];
119
+ const themeIcons = {
120
+ light: '\u2600\uFE0F', // ☀️
121
+ dark: '\uD83C\uDF19', // 🌙
122
+ system: '\uD83C\uDF13', // 🌓
123
+ };
124
+ // Font family cycle values
125
+ const fontFamilyCycleValues = ['', '"UbuntuMono NF", monospace'];
126
+ const fontFamilyLabels = {
127
+ '': 'System default',
128
+ '"UbuntuMono NF", monospace': 'UbuntuMono NF',
129
+ };
86
130
  // Create command results for the first section
87
131
  function createCommandResults(query, view, searchResults) {
88
132
  const commands = [];
89
133
  const currentFile = view.state.field(currentFileField);
90
134
  const hasValidFile = currentFile.path && !currentFile.loading;
135
+ const hasContent = view.state.doc.length > 0;
91
136
  const isLanguageQuery = isValidProgrammingLanguage(query);
92
- // TODO: fix language ext for new file with full language names, "typescript" -> "file.ts"
93
137
  // Check if query matches an existing file (first search result with exact match)
94
138
  const hasExactFileMatch = searchResults.length > 0 && searchResults[0].id === query;
95
- if (query.trim()) {
96
- // Create new file command (only if query doesn't match existing file)
97
- if (!hasExactFileMatch) {
139
+ // "Save as" — shown when the editor has content or a file is open
140
+ if (hasContent || hasValidFile) {
141
+ if (!query.trim()) {
142
+ commands.push({
143
+ id: 'Save as',
144
+ type: 'save-as',
145
+ icon: DEFAULT_FILE_ICON,
146
+ query: '',
147
+ requiresInput: true,
148
+ });
149
+ }
150
+ else if (!hasExactFileMatch) {
98
151
  const langIcon = isLanguageQuery ? getLanguageIcon(query) : null;
99
- const createFileCommand = {
100
- id: isLanguageQuery ? "Create new file" : `Create new file "${query}"`,
101
- type: 'create-file',
152
+ commands.push({
153
+ id: isLanguageQuery ? "Save as" : `Save as "${query}"`,
154
+ type: 'save-as',
102
155
  icon: langIcon ? langIcon.glyph : DEFAULT_FILE_ICON,
103
156
  iconColor: langIcon?.color,
104
157
  query,
105
- requiresInput: isLanguageQuery
106
- };
107
- commands.push(createFileCommand);
158
+ requiresInput: isLanguageQuery,
159
+ });
108
160
  }
161
+ }
162
+ // "Create new file" — always available, creates a blank file
163
+ if (!query.trim()) {
164
+ commands.push({
165
+ id: 'Create new file',
166
+ type: 'create-file',
167
+ icon: DEFAULT_FILE_ICON,
168
+ query: '',
169
+ requiresInput: true,
170
+ });
171
+ }
172
+ else if (!hasExactFileMatch) {
173
+ const langIcon = isLanguageQuery ? getLanguageIcon(query) : null;
174
+ commands.push({
175
+ id: isLanguageQuery ? "Create new file" : `Create new file "${query}"`,
176
+ type: 'create-file',
177
+ icon: langIcon ? langIcon.glyph : DEFAULT_FILE_ICON,
178
+ iconColor: langIcon?.color,
179
+ query,
180
+ requiresInput: isLanguageQuery,
181
+ });
182
+ }
183
+ if (query.trim()) {
109
184
  // Rename file command (only if file is open, query is not a language, and doesn't match current file)
110
185
  if (hasValidFile && !isLanguageQuery && !hasExactFileMatch) {
111
186
  const renameCommand = {
@@ -124,6 +199,13 @@ function createCommandResults(query, view, searchResults) {
124
199
  icon: FOLDER_OPEN_ICON,
125
200
  query: '',
126
201
  });
202
+ // Open terminal — awaiting a backing WASM VM/shim for implementation
203
+ // commands.push({
204
+ // id: 'Open terminal',
205
+ // type: 'open-terminal',
206
+ // icon: TERMINAL_ICON,
207
+ // query: '',
208
+ // });
127
209
  // Import commands — always shown
128
210
  commands.push({
129
211
  id: 'Import file(s)',
@@ -132,13 +214,44 @@ function createCommandResults(query, view, searchResults) {
132
214
  query: '',
133
215
  });
134
216
  commands.push({
135
- id: 'Import folder(s)',
217
+ id: 'Import folder',
136
218
  type: 'import-local-folder',
137
219
  icon: FOLDER_ICON,
138
220
  query: '',
139
221
  });
222
+ // File-extension-specific commands — shown when a matching file is open
223
+ if (hasValidFile && currentFile.path) {
224
+ const ext = currentFile.path.split('.').pop()?.toLowerCase() || '';
225
+ for (const entry of fileActionRegistry) {
226
+ if (entry.extensions.includes(ext)) {
227
+ commands.push({
228
+ id: entry.label,
229
+ type: 'file-action',
230
+ icon: entry.icon,
231
+ query: '',
232
+ action: entry.action,
233
+ });
234
+ }
235
+ }
236
+ }
237
+ // Settings command — always shown
238
+ commands.push({
239
+ id: 'Settings',
240
+ type: 'settings',
241
+ icon: COG_ICON,
242
+ query: '',
243
+ });
140
244
  return commands;
141
245
  }
246
+ const BINARY_IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif']);
247
+ function fileToDataUrl(file) {
248
+ return new Promise((resolve, reject) => {
249
+ const reader = new FileReader();
250
+ reader.onload = () => resolve(reader.result);
251
+ reader.onerror = reject;
252
+ reader.readAsDataURL(file);
253
+ });
254
+ }
142
255
  async function importFiles(files, view) {
143
256
  const { fs, index } = view.state.facet(CodeblockFacet);
144
257
  for (const file of files) {
@@ -146,7 +259,15 @@ async function importFiles(files, view) {
146
259
  const dir = path.substring(0, path.lastIndexOf('/'));
147
260
  if (dir)
148
261
  await fs.mkdir(dir, { recursive: true });
149
- await fs.writeFile(path, await file.text());
262
+ // Store binary images as data URLs so they can be rendered
263
+ const ext = path.split('.').pop()?.toLowerCase() || '';
264
+ if (BINARY_IMAGE_EXTS.has(ext)) {
265
+ const dataUrl = await fileToDataUrl(file);
266
+ await fs.writeFile(path, dataUrl);
267
+ }
268
+ else {
269
+ await fs.writeFile(path, await file.text());
270
+ }
150
271
  if (index)
151
272
  index.add(path);
152
273
  LSP.notifyFileChanged(path, FileChangeType.Created);
@@ -185,7 +306,7 @@ function createLspLogOverlay() {
185
306
  overlay._lspLogUnsub = unsub;
186
307
  return overlay;
187
308
  }
188
- const MIN_LOADING_MS = 400;
309
+ const SPINNER_FADE_MS = 150;
189
310
  // Toolbar Panel
190
311
  export const toolbarPanel = (view) => {
191
312
  let { filepath, language, index } = view.state.facet(CodeblockFacet);
@@ -231,90 +352,53 @@ export const toolbarPanel = (view) => {
231
352
  }
232
353
  updateLspLogIcon();
233
354
  updateLspLogVisibility();
234
- // Settings cog button (far right of toolbar)
235
- const settingsCog = document.createElement("button");
236
- settingsCog.className = "cm-toolbar-settings-cog";
237
- settingsCog.style.fontFamily = 'var(--cm-icon-font-family)';
238
- settingsCog.textContent = COG_ICON;
239
- // Overlay management
240
- let activeOverlay = null;
241
- let activeOverlayType = null;
242
- let savedInputValue = null;
243
- const overlayLabels = {
244
- 'settings': 'settings',
245
- 'lsp-log': 'lsp.log',
246
- };
247
- function updateCogAppearance() {
248
- if (activeOverlayType) {
249
- settingsCog.textContent = '\u2715'; // ✕
250
- settingsCog.style.fontFamily = '';
251
- settingsCog.classList.add('cm-cog-active');
252
- }
253
- else {
254
- settingsCog.textContent = COG_ICON;
255
- settingsCog.style.fontFamily = 'var(--cm-icon-font-family)';
256
- settingsCog.classList.remove('cm-cog-active');
257
- }
258
- }
259
- function showOverlay(overlay) {
355
+ // LSP log overlay management (dedicated to LSP log only)
356
+ let lspLogOverlay = null;
357
+ let lspLogSavedInputValue = null;
358
+ function showLspLogOverlay(overlay) {
260
359
  const panelsTop = view.dom.querySelector('.cm-panels-top');
261
360
  if (panelsTop) {
262
361
  overlay.style.top = `${panelsTop.getBoundingClientRect().height}px`;
263
362
  }
264
363
  view.dom.appendChild(overlay);
265
364
  }
266
- function openOverlay(type, overlay) {
267
- // Save current input and show overlay label
268
- savedInputValue = input.value;
269
- input.value = overlayLabels[type];
270
- activeOverlay = overlay;
271
- activeOverlayType = type;
272
- showOverlay(overlay);
273
- updateCogAppearance();
365
+ function openLspLogOverlay() {
366
+ lspLogSavedInputValue = input.value;
367
+ input.value = 'lsp.log';
368
+ lspLogOverlay = createLspLogOverlay();
369
+ showLspLogOverlay(lspLogOverlay);
274
370
  }
275
- function closeOverlay() {
276
- if (activeOverlay) {
277
- // Clean up LSP log subscription if applicable
278
- if (activeOverlay._lspLogUnsub) {
279
- activeOverlay._lspLogUnsub();
371
+ function closeLspLogOverlay() {
372
+ if (lspLogOverlay) {
373
+ if (lspLogOverlay._lspLogUnsub) {
374
+ lspLogOverlay._lspLogUnsub();
280
375
  }
281
- activeOverlay.remove();
282
- activeOverlay = null;
283
- activeOverlayType = null;
284
- // Restore input text
285
- if (savedInputValue !== null) {
286
- input.value = savedInputValue;
287
- savedInputValue = null;
376
+ lspLogOverlay.remove();
377
+ lspLogOverlay = null;
378
+ if (lspLogSavedInputValue !== null) {
379
+ input.value = lspLogSavedInputValue;
380
+ lspLogSavedInputValue = null;
288
381
  }
289
- updateCogAppearance();
290
382
  }
291
383
  }
292
- settingsCog.addEventListener("click", () => {
293
- if (activeOverlayType) {
294
- closeOverlay();
295
- }
296
- else {
297
- openOverlay('settings', createSettingsOverlay(view));
298
- }
299
- });
300
384
  lspLogBtn.addEventListener("click", () => {
301
- if (activeOverlayType === 'lsp-log') {
302
- closeOverlay();
385
+ if (lspLogOverlay) {
386
+ closeLspLogOverlay();
303
387
  }
304
388
  else {
305
- closeOverlay();
306
- openOverlay('lsp-log', createLspLogOverlay());
389
+ openLspLogOverlay();
307
390
  }
308
391
  });
309
392
  dom.appendChild(lspLogBtn);
310
- dom.appendChild(settingsCog);
311
393
  const resultsList = document.createElement("ul");
312
394
  resultsList.className = "cm-search-results";
313
395
  dom.appendChild(resultsList);
314
396
  let selectedIndex = 0;
315
397
  let namingMode = { active: false, type: 'create-file', originalQuery: '' };
316
398
  let browseMode = { active: false, currentPath: '/', filter: '' };
317
- let loadingStartTime = null;
399
+ let settingsMode = { active: false, filter: '', editing: null };
400
+ let deleteMode = { active: false, filePath: '' };
401
+ let overwriteMode = { active: false, filePath: '', action: 'create-file' };
318
402
  // System theme media query listener
319
403
  const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
320
404
  function handleSystemThemeChange() {
@@ -336,14 +420,37 @@ export const toolbarPanel = (view) => {
336
420
  else {
337
421
  view.dom.style.removeProperty('--cm-font-family');
338
422
  }
423
+ // Max visible lines: set max-height on the scroller
424
+ const scroller = view.dom.querySelector('.cm-scroller');
425
+ if (scroller) {
426
+ if (settings.maxVisibleLines > 0) {
427
+ const lineHeight = settings.fontSize * 1.5; // approximate
428
+ scroller.style.maxHeight = `${settings.maxVisibleLines * lineHeight}px`;
429
+ }
430
+ else {
431
+ scroller.style.maxHeight = '';
432
+ }
433
+ }
339
434
  }
340
435
  applySettings();
341
- // Apply initial theme
436
+ // Apply initial theme and auto-hide
342
437
  const initialSettings = view.state.field(settingsField);
343
438
  const initialDark = resolveThemeDark(initialSettings.theme);
344
439
  view.dom.setAttribute('data-theme', initialDark ? 'dark' : 'light');
345
- // Tracks gutter width for toolbar alignment
440
+ // Auto-hide is enabled after the panel is mounted (needs parent ref).
441
+ // Defer to first update cycle.
442
+ let autoHidePendingInit = initialSettings.autoHideToolbar;
443
+ // Tracks gutter width for toolbar alignment.
444
+ // Sets CSS variables used by icon containers and alignment:
445
+ // --cm-gutter-width: total width of all gutters combined
446
+ // --cm-gutter-lineno-width: width of the line numbers gutter
447
+ // --cm-icon-col-width: character-width-based minimum for icon column
346
448
  function updateGutterWidthVariables() {
449
+ // Character width for ch-based sizing (2 columns: icon occupies ~0.5ch advance,
450
+ // but we want the container to be a clean multiple of character width)
451
+ const chWidth = view.defaultCharacterWidth;
452
+ const iconColWidth = Math.ceil(2 * chWidth); // 2 character columns minimum
453
+ view.dom.style.setProperty('--cm-icon-col-width', `${iconColWidth}px`);
347
454
  const gutters = view.dom.querySelector('.cm-gutters');
348
455
  if (gutters) {
349
456
  const gutterWidth = gutters.getBoundingClientRect().width;
@@ -353,6 +460,15 @@ export const toolbarPanel = (view) => {
353
460
  const numberGutterWidth = numberGutter.getBoundingClientRect().width;
354
461
  view.dom.style.setProperty('--cm-gutter-lineno-width', `${numberGutterWidth}px`);
355
462
  }
463
+ else {
464
+ // Line numbers hidden — icon column sized to character-width multiple
465
+ view.dom.style.setProperty('--cm-gutter-lineno-width', `${iconColWidth}px`);
466
+ }
467
+ }
468
+ else {
469
+ // No gutters at all — icon column sized to character-width multiple
470
+ view.dom.style.setProperty('--cm-gutter-width', `${iconColWidth}px`);
471
+ view.dom.style.setProperty('--cm-gutter-lineno-width', `${iconColWidth}px`);
356
472
  }
357
473
  }
358
474
  // Set up ResizeObserver to watch gutter width changes
@@ -369,10 +485,434 @@ export const toolbarPanel = (view) => {
369
485
  // Initial width setup and observer
370
486
  updateGutterWidthVariables();
371
487
  setupGutterObserver();
488
+ // --- Auto-hide toolbar management ---
489
+ // When enabled, the toolbar retracts (height → 0). It expands when:
490
+ // - the mouse enters the first visible code line (top of scroller), OR
491
+ // - the toolbar input receives focus.
492
+ // It retracts when:
493
+ // - the mouse leaves the toolbar AND the input doesn't have focus.
494
+ let autoHideEnabled = false;
495
+ let panelsTopEl = null;
496
+ function getPanelsTop() {
497
+ if (!panelsTopEl)
498
+ panelsTopEl = dom.parentElement;
499
+ return panelsTopEl;
500
+ }
501
+ function retractToolbar() {
502
+ const pt = getPanelsTop();
503
+ if (pt && autoHideEnabled)
504
+ pt.classList.add('cm-toolbar-retracted');
505
+ }
506
+ function expandToolbar() {
507
+ const pt = getPanelsTop();
508
+ if (pt)
509
+ pt.classList.remove('cm-toolbar-retracted');
510
+ }
511
+ function isToolbarInteractive() {
512
+ return dom.contains(document.activeElement);
513
+ }
514
+ // Single mousemove handler on the editor root checks whether the mouse
515
+ // is inside the toolbar region (panels-top + any overflow like dropdowns)
516
+ // or inside the first code line (trigger zone). This is more reliable than
517
+ // mouseleave on cm-panels-top, which misses absolutely-positioned children.
518
+ function handleEditorMouseMove(e) {
519
+ if (!autoHideEnabled)
520
+ return;
521
+ const pt = getPanelsTop();
522
+ if (!pt)
523
+ return;
524
+ // Check if mouse is inside the toolbar panel or its dropdown
525
+ // (dropdown overflows below cm-panels-top, so check dom directly)
526
+ if (dom.contains(e.target) || pt.contains(e.target)) {
527
+ return; // still in toolbar region — stay expanded
528
+ }
529
+ // Check if mouse is within the first code line (trigger to expand)
530
+ const scroller = view.dom.querySelector('.cm-scroller');
531
+ if (scroller) {
532
+ const scrollRect = scroller.getBoundingClientRect();
533
+ if (e.clientY >= scrollRect.top && e.clientY < scrollRect.top + view.defaultLineHeight) {
534
+ expandToolbar();
535
+ return;
536
+ }
537
+ }
538
+ // Mouse is elsewhere in the editor — retract if not interactive
539
+ if (!isToolbarInteractive())
540
+ retractToolbar();
541
+ }
542
+ function handleEditorMouseLeave() {
543
+ if (!autoHideEnabled)
544
+ return;
545
+ if (!isToolbarInteractive())
546
+ retractToolbar();
547
+ }
548
+ function handleInputBlur() {
549
+ if (!autoHideEnabled)
550
+ return;
551
+ // Delay slightly so click-to-focus-another-element events settle
552
+ setTimeout(() => {
553
+ if (autoHideEnabled && !isToolbarInteractive()) {
554
+ retractToolbar();
555
+ }
556
+ }, 100);
557
+ }
558
+ function enableAutoHide() {
559
+ autoHideEnabled = true;
560
+ view.dom.addEventListener('mousemove', handleEditorMouseMove);
561
+ view.dom.addEventListener('mouseleave', handleEditorMouseLeave);
562
+ input.addEventListener('blur', handleInputBlur);
563
+ retractToolbar();
564
+ }
565
+ function disableAutoHide() {
566
+ autoHideEnabled = false;
567
+ view.dom.removeEventListener('mousemove', handleEditorMouseMove);
568
+ view.dom.removeEventListener('mouseleave', handleEditorMouseLeave);
569
+ input.removeEventListener('blur', handleInputBlur);
570
+ expandToolbar();
571
+ }
572
+ // --- Settings mode functions ---
573
+ function buildSettingsEntries(filter) {
574
+ const settings = view.state.field(settingsField);
575
+ const entries = [];
576
+ // Theme: cycle through light/dark/system
577
+ entries.push({
578
+ id: `Theme: ${settings.theme}`,
579
+ settingKey: 'theme',
580
+ type: 'settings-cycle',
581
+ icon: themeIcons[settings.theme],
582
+ currentValue: settings.theme,
583
+ });
584
+ // Font size: input
585
+ entries.push({
586
+ id: `Font size: ${settings.fontSize}px`,
587
+ settingKey: 'fontSize',
588
+ type: 'settings-input',
589
+ icon: 'Aa',
590
+ currentValue: String(settings.fontSize),
591
+ });
592
+ // Font family: cycle
593
+ const fontLabel = fontFamilyLabels[settings.fontFamily] || settings.fontFamily || 'System default';
594
+ entries.push({
595
+ id: `Font family: ${fontLabel}`,
596
+ settingKey: 'fontFamily',
597
+ type: 'settings-cycle',
598
+ icon: 'Aa',
599
+ currentValue: settings.fontFamily,
600
+ });
601
+ // Autosave: toggle
602
+ entries.push({
603
+ id: `Autosave: ${settings.autosave ? 'on' : 'off'}`,
604
+ settingKey: 'autosave',
605
+ type: 'settings-toggle',
606
+ icon: settings.autosave ? '\u2713' : '\u2717', // ✓ or ✗
607
+ currentValue: String(settings.autosave),
608
+ });
609
+ // Line wrap: toggle
610
+ entries.push({
611
+ id: `Line wrap: ${settings.lineWrap ? 'on' : 'off'}`,
612
+ settingKey: 'lineWrap',
613
+ type: 'settings-toggle',
614
+ icon: settings.lineWrap ? '\u2713' : '\u2717',
615
+ currentValue: String(settings.lineWrap),
616
+ });
617
+ // LSP log: toggle
618
+ entries.push({
619
+ id: `LSP log: ${settings.lspLogEnabled ? 'on' : 'off'}`,
620
+ settingKey: 'lspLogEnabled',
621
+ type: 'settings-toggle',
622
+ icon: settings.lspLogEnabled ? '\u2713' : '\u2717',
623
+ currentValue: String(settings.lspLogEnabled),
624
+ });
625
+ // Max visible lines: input
626
+ entries.push({
627
+ id: `Max lines: ${settings.maxVisibleLines || 'unlimited'}`,
628
+ settingKey: 'maxVisibleLines',
629
+ type: 'settings-input',
630
+ icon: '\u2195', // ↕
631
+ currentValue: String(settings.maxVisibleLines || ''),
632
+ });
633
+ // Line numbers: toggle
634
+ entries.push({
635
+ id: `Line numbers: ${settings.showLineNumbers ? 'on' : 'off'}`,
636
+ settingKey: 'showLineNumbers',
637
+ type: 'settings-toggle',
638
+ icon: settings.showLineNumbers ? '\u2713' : '\u2717',
639
+ currentValue: String(settings.showLineNumbers),
640
+ });
641
+ // Fold gutter: toggle
642
+ entries.push({
643
+ id: `Fold gutter: ${settings.showFoldGutter ? 'on' : 'off'}`,
644
+ settingKey: 'showFoldGutter',
645
+ type: 'settings-toggle',
646
+ icon: settings.showFoldGutter ? '\u2713' : '\u2717',
647
+ currentValue: String(settings.showFoldGutter),
648
+ });
649
+ // Auto-hide toolbar: toggle
650
+ entries.push({
651
+ id: `Auto-hide toolbar: ${settings.autoHideToolbar ? 'on' : 'off'}`,
652
+ settingKey: 'autoHideToolbar',
653
+ type: 'settings-toggle',
654
+ icon: settings.autoHideToolbar ? '\u2713' : '\u2717',
655
+ currentValue: String(settings.autoHideToolbar),
656
+ });
657
+ // Filter entries
658
+ if (filter) {
659
+ const lowerFilter = filter.toLowerCase();
660
+ return entries.filter(e => e.id.toLowerCase().includes(lowerFilter));
661
+ }
662
+ return entries;
663
+ }
664
+ function refreshSettingsEntries() {
665
+ if (!settingsMode.active)
666
+ return;
667
+ const entries = buildSettingsEntries(settingsMode.filter);
668
+ selectedIndex = Math.min(selectedIndex, Math.max(0, entries.length - 1));
669
+ safeDispatch(view, { effects: setSearchResults.of(entries) });
670
+ }
671
+ function enterSettingsMode() {
672
+ settingsMode = { active: true, filter: '', editing: null };
673
+ stateIcon.textContent = COG_ICON;
674
+ input.value = 'settings/';
675
+ input.placeholder = '';
676
+ input.focus();
677
+ selectedIndex = 0;
678
+ const entries = buildSettingsEntries('');
679
+ safeDispatch(view, { effects: setSearchResults.of(entries) });
680
+ // Ensure click-outside listener is active
681
+ document.addEventListener("click", handleClickOutside);
682
+ }
683
+ function exitSettingsMode() {
684
+ settingsMode = { active: false, filter: '', editing: null };
685
+ updateStateIcon();
686
+ input.placeholder = '';
687
+ }
688
+ function handleSettingsEntry(entry) {
689
+ const settings = view.state.field(settingsField);
690
+ if (entry.type === 'settings-toggle') {
691
+ const key = entry.settingKey;
692
+ const newValue = !settings[key];
693
+ const effects = [updateSettingsEffect.of({ [key]: newValue })];
694
+ // Special handling for lineWrap: reconfigure compartment
695
+ if (key === 'lineWrap') {
696
+ effects.push(lineWrappingCompartment.reconfigure(newValue ? EditorView.lineWrapping : []));
697
+ }
698
+ // Special handling for showLineNumbers: reconfigure compartment
699
+ if (key === 'showLineNumbers') {
700
+ effects.push(lineNumbersCompartment.reconfigure(newValue ? [lineNumbers(), highlightActiveLineGutter()] : []));
701
+ }
702
+ // Special handling for showFoldGutter: reconfigure compartment
703
+ if (key === 'showFoldGutter') {
704
+ effects.push(foldGutterCompartment.reconfigure(newValue ? [foldGutter()] : []));
705
+ }
706
+ // Special handling for autoHideToolbar: JS-managed retract/expand
707
+ if (key === 'autoHideToolbar') {
708
+ if (newValue)
709
+ enableAutoHide();
710
+ else
711
+ disableAutoHide();
712
+ }
713
+ safeDispatch(view, { effects });
714
+ }
715
+ else if (entry.type === 'settings-cycle') {
716
+ if (entry.settingKey === 'theme') {
717
+ const currentIdx = themeCycleValues.indexOf(settings.theme);
718
+ const nextTheme = themeCycleValues[(currentIdx + 1) % themeCycleValues.length];
719
+ safeDispatch(view, {
720
+ effects: [
721
+ updateSettingsEffect.of({ theme: nextTheme }),
722
+ setThemeEffect.of({ dark: resolveThemeDark(nextTheme) }),
723
+ ]
724
+ });
725
+ }
726
+ else if (entry.settingKey === 'fontFamily') {
727
+ const currentIdx = fontFamilyCycleValues.indexOf(settings.fontFamily);
728
+ const nextIdx = (currentIdx + 1) % fontFamilyCycleValues.length;
729
+ safeDispatch(view, {
730
+ effects: [updateSettingsEffect.of({ fontFamily: fontFamilyCycleValues[nextIdx] })]
731
+ });
732
+ }
733
+ }
734
+ else if (entry.type === 'settings-input') {
735
+ // Enter editing mode: show the current value in the input for inline editing
736
+ settingsMode.editing = entry.settingKey;
737
+ input.value = entry.currentValue;
738
+ input.select();
739
+ }
740
+ }
741
+ function confirmSettingsEdit() {
742
+ if (!settingsMode.editing)
743
+ return;
744
+ const key = settingsMode.editing;
745
+ const rawValue = input.value.trim();
746
+ if (key === 'fontSize') {
747
+ const size = Number(rawValue);
748
+ if (!isNaN(size) && size >= 1 && size <= 128) {
749
+ safeDispatch(view, { effects: [updateSettingsEffect.of({ fontSize: size })] });
750
+ }
751
+ }
752
+ else if (key === 'maxVisibleLines') {
753
+ const lines = rawValue === '' ? 0 : Number(rawValue);
754
+ if (!isNaN(lines) && lines >= 0) {
755
+ safeDispatch(view, { effects: [updateSettingsEffect.of({ maxVisibleLines: Math.floor(lines) })] });
756
+ }
757
+ }
758
+ settingsMode.editing = null;
759
+ input.value = 'settings/' + settingsMode.filter;
760
+ // Refresh entries after a microtask so the state update has landed
761
+ queueMicrotask(() => refreshSettingsEntries());
762
+ }
763
+ function cancelSettingsEdit() {
764
+ settingsMode.editing = null;
765
+ input.value = 'settings/' + settingsMode.filter;
766
+ }
767
+ // --- Delete mode functions ---
768
+ function enterDeleteMode(filePath) {
769
+ deleteMode = { active: true, filePath };
770
+ stateIcon.textContent = '\u2717'; // ✗
771
+ input.value = '';
772
+ input.placeholder = `Delete "${filePath}"? (Enter to confirm, Esc to cancel)`;
773
+ input.focus();
774
+ safeDispatch(view, { effects: setSearchResults.of([]) });
775
+ }
776
+ function exitDeleteMode() {
777
+ deleteMode = { active: false, filePath: '' };
778
+ updateStateIcon();
779
+ input.placeholder = '';
780
+ }
781
+ async function confirmDelete() {
782
+ if (!deleteMode.active)
783
+ return;
784
+ const path = deleteMode.filePath;
785
+ const { fs, index } = view.state.facet(CodeblockFacet);
786
+ try {
787
+ const currentFile = view.state.field(currentFileField);
788
+ const wasOpen = currentFile.path === path;
789
+ // Delete from VFS
790
+ await fs.unlink(path).catch((e) => console.warn('VFS unlink failed:', e));
791
+ // Remove from search index
792
+ if (index) {
793
+ try {
794
+ index.index.discard(path);
795
+ }
796
+ catch { /* not in index */ }
797
+ if (index.savePath)
798
+ index.save(fs, index.savePath);
799
+ }
800
+ // Notify LSP
801
+ LSP.notifyFileChanged(path, FileChangeType.Deleted);
802
+ exitDeleteMode();
803
+ safeDispatch(view, { effects: setSearchResults.of([]) });
804
+ resetInputToCurrentFile();
805
+ // If the deleted file was currently open, clear the editor
806
+ if (wasOpen) {
807
+ safeDispatch(view, {
808
+ changes: { from: 0, to: view.state.doc.length, insert: '' },
809
+ effects: [
810
+ fileLoadedEffect.of({ path: '', content: '', language: null }),
811
+ ]
812
+ });
813
+ input.value = '';
814
+ }
815
+ }
816
+ catch (e) {
817
+ console.error('Failed to delete file:', e);
818
+ exitDeleteMode();
819
+ }
820
+ }
821
+ // --- Overwrite confirmation mode ---
822
+ function enterOverwriteMode(filePath, action, oldPath) {
823
+ overwriteMode = { active: true, filePath, action, oldPath };
824
+ stateIcon.textContent = '\u26A0'; // ⚠
825
+ input.value = '';
826
+ input.placeholder = `"${filePath}" exists. Overwrite? (Enter/Esc)`;
827
+ input.focus();
828
+ safeDispatch(view, { effects: setSearchResults.of([]) });
829
+ }
830
+ function exitOverwriteMode() {
831
+ overwriteMode = { active: false, filePath: '', action: 'create-file' };
832
+ updateStateIcon();
833
+ input.placeholder = '';
834
+ }
835
+ async function confirmOverwrite() {
836
+ if (!overwriteMode.active)
837
+ return;
838
+ const { filePath, action, oldPath } = overwriteMode;
839
+ exitOverwriteMode();
840
+ if (action === 'save-as') {
841
+ input.value = filePath;
842
+ await createAndOpenFile(filePath);
843
+ }
844
+ else if (action === 'create-file') {
845
+ input.value = filePath;
846
+ await createBlankFile(filePath);
847
+ }
848
+ else if (action === 'rename' && oldPath) {
849
+ input.value = filePath;
850
+ await performRename(oldPath, filePath);
851
+ }
852
+ }
853
+ /** Check if file exists before executing; enters overwrite mode if it does. */
854
+ async function checkOverwriteAndExecute(path, action, execute, oldPath) {
855
+ const { fs } = view.state.facet(CodeblockFacet);
856
+ const exists = await fs.exists(path);
857
+ if (exists) {
858
+ enterOverwriteMode(path, action, oldPath);
859
+ }
860
+ else {
861
+ await execute();
862
+ }
863
+ }
864
+ /** Create a blank (empty) file in the VFS and open it. */
865
+ async function createBlankFile(pathToOpen) {
866
+ const { fs, index } = view.state.facet(CodeblockFacet);
867
+ const dir = pathToOpen.substring(0, pathToOpen.lastIndexOf('/'));
868
+ if (dir)
869
+ await fs.mkdir(dir, { recursive: true }).catch(() => { });
870
+ await fs.writeFile(pathToOpen, '').catch(console.error);
871
+ if (index) {
872
+ index.add(pathToOpen);
873
+ if (index.savePath)
874
+ index.save(fs, index.savePath);
875
+ }
876
+ safeDispatch(view, {
877
+ effects: [setSearchResults.of([]), openFileEffect.of({ path: pathToOpen })]
878
+ });
879
+ }
880
+ /** Rename a file: save current content to newPath, delete oldPath. */
881
+ async function performRename(oldPath, newPath) {
882
+ const { fs, index } = view.state.facet(CodeblockFacet);
883
+ const content = view.state.doc.toString();
884
+ // Write content to new path
885
+ const dir = newPath.substring(0, newPath.lastIndexOf('/'));
886
+ if (dir)
887
+ await fs.mkdir(dir, { recursive: true }).catch(() => { });
888
+ await fs.writeFile(newPath, content).catch(console.error);
889
+ // Delete old path
890
+ await fs.unlink(oldPath).catch((e) => console.warn('VFS unlink failed during rename:', e));
891
+ // Update search index
892
+ if (index) {
893
+ try {
894
+ index.index.discard(oldPath);
895
+ }
896
+ catch { /* not in index */ }
897
+ index.add(newPath);
898
+ if (index.savePath)
899
+ index.save(fs, index.savePath);
900
+ }
901
+ // Notify LSP
902
+ LSP.notifyFileChanged(oldPath, FileChangeType.Deleted);
903
+ LSP.notifyFileChanged(newPath, FileChangeType.Created);
904
+ // Open the new file — skipSave prevents handleOpen's save-on-switch
905
+ // from re-creating the old file we just deleted
906
+ safeDispatch(view, {
907
+ effects: [setSearchResults.of([]), openFileEffect.of({ path: newPath, skipSave: true })]
908
+ });
909
+ }
372
910
  const renderItem = (result, i) => {
373
911
  const li = document.createElement("li");
374
912
  let resultClass = 'cm-file-result';
375
- if (isCommandResult(result))
913
+ if (isSettingsEntry(result))
914
+ resultClass = 'cm-command-result';
915
+ else if (isCommandResult(result))
376
916
  resultClass = 'cm-command-result';
377
917
  else if (isBrowseEntry(result))
378
918
  resultClass = result.type === 'browse-file' ? 'cm-file-result' : 'cm-browse-dir-result';
@@ -381,22 +921,29 @@ export const toolbarPanel = (view) => {
381
921
  resultIconContainer.className = "cm-search-result-icon-container";
382
922
  const resultIcon = document.createElement("div");
383
923
  resultIcon.className = "cm-search-result-icon";
384
- resultIcon.style.fontFamily = 'var(--cm-icon-font-family)';
385
- if (isBrowseEntry(result)) {
924
+ if (isSettingsEntry(result)) {
925
+ // Settings entries use emoji or text icons, not nerd fonts
926
+ resultIcon.style.fontFamily = '';
386
927
  resultIcon.textContent = result.icon;
387
- if (result.iconColor)
388
- resultIcon.style.color = result.iconColor;
389
- }
390
- else if (isCommandResult(result)) {
391
- resultIcon.textContent = result.icon;
392
- if (result.iconColor)
393
- resultIcon.style.color = result.iconColor;
394
928
  }
395
929
  else {
396
- const icon = getFileIcon(result.id);
397
- resultIcon.textContent = icon.glyph;
398
- if (icon.color)
399
- resultIcon.style.color = icon.color;
930
+ resultIcon.style.fontFamily = 'var(--cm-icon-font-family)';
931
+ if (isBrowseEntry(result)) {
932
+ resultIcon.textContent = result.icon;
933
+ if (result.iconColor)
934
+ resultIcon.style.color = result.iconColor;
935
+ }
936
+ else if (isCommandResult(result)) {
937
+ resultIcon.textContent = result.icon;
938
+ if (result.iconColor)
939
+ resultIcon.style.color = result.iconColor;
940
+ }
941
+ else {
942
+ const icon = getFileIcon(result.id);
943
+ resultIcon.textContent = icon.glyph;
944
+ if (icon.color)
945
+ resultIcon.style.color = icon.color;
946
+ }
400
947
  }
401
948
  resultIconContainer.appendChild(resultIcon);
402
949
  li.appendChild(resultIconContainer);
@@ -409,7 +956,10 @@ export const toolbarPanel = (view) => {
409
956
  li.addEventListener("mousedown", (ev) => {
410
957
  ev.preventDefault();
411
958
  });
412
- li.addEventListener("click", () => selectResult(result));
959
+ li.addEventListener("click", (ev) => {
960
+ ev.stopPropagation();
961
+ selectResult(result);
962
+ });
413
963
  return li;
414
964
  };
415
965
  function updateDropdown() {
@@ -420,9 +970,17 @@ export const toolbarPanel = (view) => {
420
970
  children.push(renderItem(result, i));
421
971
  });
422
972
  resultsList.replaceChildren(...children);
973
+ // Scroll the selected item into view
974
+ const selected = resultsList.querySelector('.selected');
975
+ if (selected) {
976
+ selected.scrollIntoView({ block: 'nearest' });
977
+ }
423
978
  }
424
979
  function selectResult(result) {
425
- if (isBrowseEntry(result)) {
980
+ if (isSettingsEntry(result)) {
981
+ handleSettingsEntry(result);
982
+ }
983
+ else if (isBrowseEntry(result)) {
426
984
  navigateBrowse(result);
427
985
  }
428
986
  else if (isCommandResult(result)) {
@@ -434,7 +992,10 @@ export const toolbarPanel = (view) => {
434
992
  }
435
993
  function updateStateIcon() {
436
994
  if (namingMode.active) {
437
- stateIcon.textContent = namingMode.type === 'create-file' ? DEFAULT_FILE_ICON : '\uf044';
995
+ stateIcon.textContent = (namingMode.type === 'create-file' || namingMode.type === 'save-as') ? DEFAULT_FILE_ICON : '\uf044';
996
+ }
997
+ else if (settingsMode.active) {
998
+ stateIcon.textContent = COG_ICON;
438
999
  }
439
1000
  else {
440
1001
  stateIcon.textContent = SEARCH_ICON;
@@ -467,6 +1028,8 @@ export const toolbarPanel = (view) => {
467
1028
  input.placeholder = '';
468
1029
  input.focus();
469
1030
  await refreshBrowseEntries();
1031
+ // Ensure click-outside listener is active
1032
+ document.addEventListener("click", handleClickOutside);
470
1033
  }
471
1034
  async function refreshBrowseEntries() {
472
1035
  if (!browseMode.active)
@@ -570,35 +1133,47 @@ export const toolbarPanel = (view) => {
570
1133
  fileInput.click();
571
1134
  }
572
1135
  function handleCommandResult(command) {
573
- if (command.type === 'open-file') {
1136
+ if (command.type === 'settings') {
1137
+ enterSettingsMode();
1138
+ return;
1139
+ }
1140
+ else if (command.type === 'open-file') {
574
1141
  enterBrowseMode();
575
1142
  return;
576
1143
  }
1144
+ else if (command.type === 'open-terminal') {
1145
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1146
+ import('./terminal').then(({ openTerminal }) => openTerminal(view));
1147
+ return;
1148
+ }
1149
+ else if (command.type === 'save-as') {
1150
+ if (command.requiresInput) {
1151
+ const ext = command.query ? languageToFileExtension(command.query) : undefined;
1152
+ enterNamingMode('save-as', command.query, ext);
1153
+ }
1154
+ else {
1155
+ const pathToOpen = command.query.includes('.') ? command.query : `${command.query}.txt`;
1156
+ input.value = pathToOpen;
1157
+ checkOverwriteAndExecute(pathToOpen, 'save-as', () => createAndOpenFile(pathToOpen));
1158
+ }
1159
+ }
577
1160
  else if (command.type === 'create-file') {
578
1161
  if (command.requiresInput) {
579
- // Enter naming mode for language-specific file
580
- enterNamingMode('create-file', command.query, command.query);
1162
+ const ext = command.query ? languageToFileExtension(command.query) : undefined;
1163
+ enterNamingMode('create-file', command.query, ext);
581
1164
  }
582
1165
  else {
583
- // Create file directly and populate toolbar
584
1166
  const pathToOpen = command.query.includes('.') ? command.query : `${command.query}.txt`;
585
1167
  input.value = pathToOpen;
586
- safeDispatch(view, {
587
- effects: [setSearchResults.of([]), openFileEffect.of({ path: pathToOpen })]
588
- });
1168
+ checkOverwriteAndExecute(pathToOpen, 'create-file', () => createBlankFile(pathToOpen));
589
1169
  }
590
1170
  }
591
1171
  else if (command.type === 'rename-file') {
592
- // Rename file directly since the new name is provided by the query
593
1172
  const currentFile = view.state.field(currentFileField);
594
1173
  if (currentFile.path) {
595
1174
  const newPath = command.query.includes('.') ? command.query : `${command.query}.txt`;
596
1175
  input.value = newPath;
597
- // TODO: Implement actual file rename logic
598
- console.log(`Rename ${currentFile.path} to ${newPath}`);
599
- safeDispatch(view, {
600
- effects: [setSearchResults.of([]), openFileEffect.of({ path: newPath })]
601
- });
1176
+ checkOverwriteAndExecute(newPath, 'rename', () => performRename(currentFile.path, newPath), currentFile.path);
602
1177
  }
603
1178
  }
604
1179
  else if (command.type === 'import-local-files') {
@@ -607,6 +1182,10 @@ export const toolbarPanel = (view) => {
607
1182
  else if (command.type === 'import-local-folder') {
608
1183
  triggerFileImport(true);
609
1184
  }
1185
+ else if (command.type === 'file-action' && command.action) {
1186
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1187
+ command.action(view);
1188
+ }
610
1189
  }
611
1190
  function handleSearchResult(result) {
612
1191
  input.value = result.id;
@@ -614,40 +1193,67 @@ export const toolbarPanel = (view) => {
614
1193
  effects: [setSearchResults.of([]), openFileEffect.of({ path: result.id })]
615
1194
  });
616
1195
  }
1196
+ /** Save the current editor content to a new file path in the VFS, then open it. */
1197
+ async function createAndOpenFile(pathToOpen) {
1198
+ const { fs } = view.state.facet(CodeblockFacet);
1199
+ const content = view.state.doc.toString();
1200
+ const dir = pathToOpen.substring(0, pathToOpen.lastIndexOf('/'));
1201
+ if (dir)
1202
+ await fs.mkdir(dir, { recursive: true }).catch(() => { });
1203
+ await fs.writeFile(pathToOpen, content).catch(console.error);
1204
+ // Add to search index
1205
+ const { index } = view.state.facet(CodeblockFacet);
1206
+ if (index) {
1207
+ index.add(pathToOpen);
1208
+ if (index.savePath)
1209
+ index.save(fs, index.savePath);
1210
+ }
1211
+ safeDispatch(view, {
1212
+ effects: [setSearchResults.of([]), openFileEffect.of({ path: pathToOpen })]
1213
+ });
1214
+ }
617
1215
  function executeNamingMode(filename) {
618
1216
  if (!namingMode.active || !filename.trim())
619
1217
  return;
620
- if (namingMode.type === 'create-file') {
621
- const pathToOpen = namingMode.languageExtension && !filename.includes('.')
622
- ? `${filename}.${namingMode.languageExtension}`
623
- : filename;
1218
+ const resolvePath = (fn) => namingMode.languageExtension && !fn.includes('.')
1219
+ ? `${fn}.${namingMode.languageExtension}`
1220
+ : fn;
1221
+ if (namingMode.type === 'save-as') {
1222
+ const pathToOpen = resolvePath(filename);
624
1223
  input.value = pathToOpen;
625
- // TODO: handle edge-cases like trying to create folders, invalid characters, etc.
626
- safeDispatch(view, {
627
- effects: [setSearchResults.of([]), openFileEffect.of({ path: pathToOpen })]
628
- });
1224
+ exitNamingMode();
1225
+ checkOverwriteAndExecute(pathToOpen, 'save-as', () => createAndOpenFile(pathToOpen));
1226
+ return;
1227
+ }
1228
+ else if (namingMode.type === 'create-file') {
1229
+ const pathToOpen = resolvePath(filename);
1230
+ input.value = pathToOpen;
1231
+ exitNamingMode();
1232
+ checkOverwriteAndExecute(pathToOpen, 'create-file', () => createBlankFile(pathToOpen));
1233
+ return;
629
1234
  }
630
1235
  else if (namingMode.type === 'rename-file') {
631
1236
  const currentFile = view.state.field(currentFileField);
632
1237
  if (currentFile.path) {
633
1238
  const newPath = filename.includes('.') ? filename : `${filename}.txt`;
634
1239
  input.value = newPath;
635
- // TODO: Implement actual file rename logic
636
- console.log(`Rename ${currentFile.path} to ${newPath}`);
637
- safeDispatch(view, {
638
- effects: [setSearchResults.of([]), openFileEffect.of({ path: newPath })]
639
- });
1240
+ exitNamingMode();
1241
+ checkOverwriteAndExecute(newPath, 'rename', () => performRename(currentFile.path, newPath), currentFile.path);
1242
+ return;
640
1243
  }
641
1244
  }
642
1245
  exitNamingMode();
643
1246
  }
644
1247
  function resetInputToCurrentFile() {
645
1248
  const currentFile = view.state.field(currentFileField);
646
- input.value = currentFile.path || '';
1249
+ const cfg = view.state.facet(CodeblockFacet);
1250
+ input.value = currentFile.path || cfg.language || '';
647
1251
  }
648
1252
  // Close dropdown when clicking outside
649
1253
  function handleClickOutside(event) {
650
1254
  if (!dom.contains(event.target)) {
1255
+ if (settingsMode.active)
1256
+ exitSettingsMode();
651
1257
  if (browseMode.active)
652
1258
  exitBrowseMode();
653
1259
  safeDispatch(view, { effects: setSearchResults.of([]) });
@@ -655,34 +1261,52 @@ export const toolbarPanel = (view) => {
655
1261
  }
656
1262
  }
657
1263
  input.addEventListener("click", () => {
1264
+ // Don't interfere when in a special mode
1265
+ if (namingMode.active || settingsMode.active || browseMode.active) {
1266
+ return;
1267
+ }
658
1268
  // Open dropdown when input is clicked
659
- if (!namingMode.active) {
660
- const query = input.value;
661
- let results = [];
662
- if (query.trim()) {
663
- // Get regular search results from index first
664
- const searchResults = (index?.search(query) || []).slice(0, 100);
665
- // Add command results (passing search results to check for existing files)
666
- const commands = createCommandResults(query, view, searchResults);
667
- // Search results first, then commands
668
- results = searchResults.concat(commands);
669
- }
670
- else {
671
- // Show import commands when dropdown opens with empty query
672
- results = createCommandResults('', view, []);
673
- }
674
- safeDispatch(view, { effects: setSearchResults.of(results) });
675
- // Add click-outside listener when dropdown opens
676
- document.addEventListener("click", handleClickOutside);
1269
+ const query = input.value;
1270
+ let results = [];
1271
+ if (query.trim()) {
1272
+ // Get regular search results from index first
1273
+ const searchResults = (index?.search(query) || []).slice(0, 100);
1274
+ // Add command results (passing search results to check for existing files)
1275
+ const commands = createCommandResults(query, view, searchResults);
1276
+ // Search results first, then commands
1277
+ results = searchResults.concat(commands);
677
1278
  }
1279
+ else {
1280
+ // Show import commands when dropdown opens with empty query
1281
+ results = createCommandResults('', view, []);
1282
+ }
1283
+ safeDispatch(view, { effects: setSearchResults.of(results) });
1284
+ // Add click-outside listener when dropdown opens
1285
+ document.addEventListener("click", handleClickOutside);
678
1286
  });
679
1287
  input.addEventListener("input", (event) => {
680
1288
  const query = event.target.value;
681
1289
  selectedIndex = 0;
1290
+ // Block input during delete/overwrite confirmation
1291
+ if (deleteMode.active || overwriteMode.active) {
1292
+ input.value = '';
1293
+ return;
1294
+ }
682
1295
  // If in naming mode, don't show search results
683
1296
  if (namingMode.active) {
684
1297
  return;
685
1298
  }
1299
+ // If editing a settings value, don't interfere
1300
+ if (settingsMode.active && settingsMode.editing) {
1301
+ return;
1302
+ }
1303
+ // If in settings mode, filter the settings entries
1304
+ if (settingsMode.active) {
1305
+ const prefix = 'settings/';
1306
+ settingsMode.filter = query.startsWith(prefix) ? query.slice(prefix.length) : query;
1307
+ refreshSettingsEntries();
1308
+ return;
1309
+ }
686
1310
  // If in browse mode, filter the directory entries
687
1311
  if (browseMode.active) {
688
1312
  // Extract filter text after the directory path prefix
@@ -708,6 +1332,35 @@ export const toolbarPanel = (view) => {
708
1332
  safeDispatch(view, { effects: setSearchResults.of(results) });
709
1333
  });
710
1334
  input.addEventListener("keydown", (event) => {
1335
+ // Overwrite confirmation mode
1336
+ if (overwriteMode.active) {
1337
+ if (event.key === "Enter") {
1338
+ event.preventDefault();
1339
+ confirmOverwrite();
1340
+ }
1341
+ else if (event.key === "Escape") {
1342
+ event.preventDefault();
1343
+ exitOverwriteMode();
1344
+ resetInputToCurrentFile();
1345
+ }
1346
+ event.preventDefault();
1347
+ return;
1348
+ }
1349
+ // Delete confirmation mode
1350
+ if (deleteMode.active) {
1351
+ if (event.key === "Enter") {
1352
+ event.preventDefault();
1353
+ confirmDelete();
1354
+ }
1355
+ else if (event.key === "Escape") {
1356
+ event.preventDefault();
1357
+ exitDeleteMode();
1358
+ resetInputToCurrentFile();
1359
+ }
1360
+ // Block all other keys in delete mode
1361
+ event.preventDefault();
1362
+ return;
1363
+ }
711
1364
  if (namingMode.active) {
712
1365
  // Handle naming mode
713
1366
  if (event.key === "Enter") {
@@ -721,6 +1374,57 @@ export const toolbarPanel = (view) => {
721
1374
  }
722
1375
  return;
723
1376
  }
1377
+ // Settings mode keyboard handling
1378
+ if (settingsMode.active) {
1379
+ const results = view.state.field(searchResultsField);
1380
+ // If currently editing a settings value
1381
+ if (settingsMode.editing) {
1382
+ if (event.key === "Enter") {
1383
+ event.preventDefault();
1384
+ confirmSettingsEdit();
1385
+ }
1386
+ else if (event.key === "Escape") {
1387
+ event.preventDefault();
1388
+ cancelSettingsEdit();
1389
+ }
1390
+ return;
1391
+ }
1392
+ if (event.key === "ArrowDown") {
1393
+ event.preventDefault();
1394
+ if (results.length) {
1395
+ selectedIndex = mod(selectedIndex + 1, results.length);
1396
+ updateDropdown();
1397
+ }
1398
+ }
1399
+ else if (event.key === "ArrowUp") {
1400
+ event.preventDefault();
1401
+ if (results.length) {
1402
+ selectedIndex = mod(selectedIndex - 1, results.length);
1403
+ updateDropdown();
1404
+ }
1405
+ }
1406
+ else if (event.key === "Enter" && results.length && selectedIndex >= 0) {
1407
+ event.preventDefault();
1408
+ selectResult(results[selectedIndex]);
1409
+ }
1410
+ else if (event.key === "Backspace") {
1411
+ // If filter is empty and not editing, exit settings mode
1412
+ if (settingsMode.filter === '') {
1413
+ event.preventDefault();
1414
+ exitSettingsMode();
1415
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1416
+ resetInputToCurrentFile();
1417
+ }
1418
+ }
1419
+ else if (event.key === "Escape") {
1420
+ event.preventDefault();
1421
+ exitSettingsMode();
1422
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1423
+ resetInputToCurrentFile();
1424
+ input.blur();
1425
+ }
1426
+ return;
1427
+ }
724
1428
  // Browse mode keyboard handling
725
1429
  if (browseMode.active) {
726
1430
  const results = view.state.field(searchResultsField);
@@ -753,6 +1457,15 @@ export const toolbarPanel = (view) => {
753
1457
  refreshBrowseEntries();
754
1458
  }
755
1459
  }
1460
+ else if (event.key === "Delete" && results.length && selectedIndex >= 0) {
1461
+ // Delete a file from the browse dropdown
1462
+ const result = results[selectedIndex];
1463
+ if (isBrowseEntry(result) && result.type === 'browse-file') {
1464
+ event.preventDefault();
1465
+ exitBrowseMode();
1466
+ enterDeleteMode(result.fullPath);
1467
+ }
1468
+ }
756
1469
  else if (event.key === "Escape") {
757
1470
  event.preventDefault();
758
1471
  exitBrowseMode();
@@ -786,6 +1499,14 @@ export const toolbarPanel = (view) => {
786
1499
  event.preventDefault();
787
1500
  selectResult(results[selectedIndex]);
788
1501
  }
1502
+ else if (event.key === "Delete" && results.length && selectedIndex >= 0) {
1503
+ // Delete key on a highlighted file result → enter delete confirmation
1504
+ const result = results[selectedIndex];
1505
+ if (!isCommandResult(result) && !isBrowseEntry(result) && !isSettingsEntry(result)) {
1506
+ event.preventDefault();
1507
+ enterDeleteMode(result.id);
1508
+ }
1509
+ }
789
1510
  else if (event.key === "Escape") {
790
1511
  event.preventDefault();
791
1512
  safeDispatch(view, { effects: setSearchResults.of([]) });
@@ -810,43 +1531,78 @@ export const toolbarPanel = (view) => {
810
1531
  // Apply settings when they change
811
1532
  const prevSettings = update.startState.field(settingsField);
812
1533
  const nextSettings = update.state.field(settingsField);
813
- if (prevSettings.fontSize !== nextSettings.fontSize || prevSettings.fontFamily !== nextSettings.fontFamily) {
1534
+ if (prevSettings.fontSize !== nextSettings.fontSize || prevSettings.fontFamily !== nextSettings.fontFamily || prevSettings.maxVisibleLines !== nextSettings.maxVisibleLines) {
814
1535
  applySettings();
815
1536
  }
816
1537
  if (prevSettings.lspLogEnabled !== nextSettings.lspLogEnabled) {
817
1538
  updateLspLogVisibility();
818
1539
  // Close the log overlay if the user disables it
819
- if (!nextSettings.lspLogEnabled && activeOverlayType === 'lsp-log') {
820
- closeOverlay();
1540
+ if (!nextSettings.lspLogEnabled && lspLogOverlay) {
1541
+ closeLspLogOverlay();
821
1542
  }
822
1543
  }
823
- // Update loading indicator with minimum animation duration
1544
+ // Sync autoHideToolbar via JS event handlers
1545
+ if (prevSettings.autoHideToolbar !== nextSettings.autoHideToolbar) {
1546
+ if (nextSettings.autoHideToolbar)
1547
+ enableAutoHide();
1548
+ else
1549
+ disableAutoHide();
1550
+ }
1551
+ // Deferred auto-hide init (needs parent element to be mounted)
1552
+ if (autoHidePendingInit && getPanelsTop()) {
1553
+ autoHidePendingInit = false;
1554
+ enableAutoHide();
1555
+ }
1556
+ // Refresh gutter width variables when gutter-related settings change
1557
+ if (prevSettings.showLineNumbers !== nextSettings.showLineNumbers || prevSettings.showFoldGutter !== nextSettings.showFoldGutter) {
1558
+ queueMicrotask(() => updateGutterWidthVariables());
1559
+ }
1560
+ // Refresh settings dropdown when settings change and settings mode is active
1561
+ if (settingsMode.active && prevSettings !== nextSettings) {
1562
+ refreshSettingsEntries();
1563
+ }
1564
+ // Loading spinner — separate element so the container keeps
1565
+ // tracking gutter width while the spinner has fixed dimensions.
824
1566
  const prevFile = update.startState.field(currentFileField);
825
1567
  const nextFile = update.state.field(currentFileField);
826
1568
  if (prevFile.loading !== nextFile.loading) {
827
1569
  if (nextFile.loading) {
828
- loadingStartTime = Date.now();
829
- stateIcon.textContent = ''; // clear glyph; CSS border spinner handles the visual
830
- stateIcon.classList.add('cm-loading');
1570
+ // Immediately swap icon → spinner (no delay to avoid race conditions)
1571
+ stateIcon.style.display = 'none';
1572
+ stateIcon.style.opacity = '0';
1573
+ if (!stateIconContainer.querySelector('.cm-loading')) {
1574
+ const spinner = document.createElement('div');
1575
+ spinner.className = 'cm-loading';
1576
+ stateIconContainer.appendChild(spinner);
1577
+ }
831
1578
  }
832
1579
  else {
833
- const elapsed = loadingStartTime ? Date.now() - loadingStartTime : Infinity;
834
- const remaining = Math.max(0, MIN_LOADING_MS - elapsed);
835
- loadingStartTime = null;
836
- setTimeout(() => {
837
- if (!view.state.field(currentFileField).loading) {
838
- stateIcon.textContent = SEARCH_ICON;
839
- stateIcon.classList.remove('cm-loading');
840
- }
841
- }, remaining);
1580
+ // Fade spinner out, then crossfade icon in
1581
+ const spinner = stateIconContainer.querySelector('.cm-loading');
1582
+ if (spinner) {
1583
+ spinner.style.opacity = '0';
1584
+ setTimeout(() => {
1585
+ spinner.remove();
1586
+ if (!view.state.field(currentFileField).loading) {
1587
+ stateIcon.style.display = '';
1588
+ // Force reflow so the browser sees opacity:0 before transitioning to 1
1589
+ stateIcon.offsetHeight;
1590
+ stateIcon.style.opacity = '1';
1591
+ }
1592
+ }, SPINNER_FADE_MS);
1593
+ }
1594
+ else {
1595
+ stateIcon.style.display = '';
1596
+ stateIcon.style.opacity = '1';
1597
+ }
842
1598
  }
843
1599
  }
844
1600
  // Update LSP log icon when file changes
845
1601
  if (prevFile.path !== nextFile.path) {
846
1602
  updateLspLogIcon();
847
1603
  }
848
- // Sync input value when file path changes (unless overlay is open or user is naming)
849
- if (prevFile.path !== nextFile.path && !namingMode.active && !activeOverlayType) {
1604
+ // Sync input value when file path changes (unless overlay/mode is active or user is naming)
1605
+ if (prevFile.path !== nextFile.path && !namingMode.active && !lspLogOverlay && !settingsMode.active) {
850
1606
  input.value = nextFile.path || '';
851
1607
  }
852
1608
  },
@@ -854,8 +1610,11 @@ export const toolbarPanel = (view) => {
854
1610
  // Clean up event listeners when panel is destroyed
855
1611
  document.removeEventListener("click", handleClickOutside);
856
1612
  systemThemeQuery.removeEventListener('change', handleSystemThemeChange);
857
- // Clean up overlay
858
- closeOverlay();
1613
+ // Clean up auto-hide
1614
+ if (autoHideEnabled)
1615
+ disableAutoHide();
1616
+ // Clean up LSP log overlay
1617
+ closeLspLogOverlay();
859
1618
  // Clean up ResizeObserver
860
1619
  if (gutterObserver) {
861
1620
  gutterObserver.disconnect();