@joinezco/codeblock 0.0.8 → 0.0.10

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 (73) hide show
  1. package/dist/editor.d.ts +30 -3
  2. package/dist/editor.js +416 -43
  3. package/dist/index.d.ts +5 -1
  4. package/dist/index.js +5 -1
  5. package/dist/lsps/index.d.ts +5 -0
  6. package/dist/lsps/index.js +9 -2
  7. package/dist/lsps/typescript.d.ts +3 -1
  8. package/dist/lsps/typescript.js +8 -17
  9. package/dist/panels/settings.d.ts +22 -0
  10. package/dist/panels/settings.js +267 -0
  11. package/dist/panels/terminal.d.ts +3 -0
  12. package/dist/panels/terminal.js +76 -0
  13. package/dist/panels/toolbar.d.ts +53 -3
  14. package/dist/panels/toolbar.js +1336 -164
  15. package/dist/panels/toolbar.test.js +20 -14
  16. package/dist/rpc/transport.d.ts +2 -11
  17. package/dist/rpc/transport.js +19 -35
  18. package/dist/themes/index.js +226 -13
  19. package/dist/themes/vscode.js +3 -2
  20. package/dist/types.d.ts +5 -0
  21. package/dist/utils/fs.d.ts +22 -3
  22. package/dist/utils/fs.js +126 -21
  23. package/dist/utils/lsp.d.ts +26 -15
  24. package/dist/utils/lsp.js +79 -44
  25. package/dist/utils/search.d.ts +2 -0
  26. package/dist/utils/search.js +13 -4
  27. package/dist/utils/typescript-defaults.d.ts +57 -0
  28. package/dist/utils/typescript-defaults.js +208 -0
  29. package/dist/utils/typescript-defaults.test.d.ts +1 -0
  30. package/dist/utils/typescript-defaults.test.js +197 -0
  31. package/dist/workers/fs.worker.d.ts +4 -8
  32. package/dist/workers/fs.worker.js +30 -60
  33. package/dist/workers/javascript.worker.js +11 -9
  34. package/package.json +8 -4
  35. package/dist/assets/clike-C8IJ2oj_.js +0 -1
  36. package/dist/assets/cmake-BQqOBYOt.js +0 -1
  37. package/dist/assets/dockerfile-C_y-rIpk.js +0 -1
  38. package/dist/assets/fs.worker-BwEqZcql.ts +0 -109
  39. package/dist/assets/go-CTD25R5P.js +0 -1
  40. package/dist/assets/haskell-BWDZoCOh.js +0 -1
  41. package/dist/assets/index-9HdhmM_Y.js +0 -1
  42. package/dist/assets/index-C-QhPFHP.js +0 -3
  43. package/dist/assets/index-C3BnE2cG.js +0 -222
  44. package/dist/assets/index-CGx5MZO7.js +0 -6
  45. package/dist/assets/index-CIuq3uTk.js +0 -1
  46. package/dist/assets/index-CXFONXS8.js +0 -1
  47. package/dist/assets/index-D5Z27j1C.js +0 -1
  48. package/dist/assets/index-DWOBdRjn.js +0 -1
  49. package/dist/assets/index-Dvu-FFzd.js +0 -1
  50. package/dist/assets/index-Dx_VuNNd.js +0 -1
  51. package/dist/assets/index-I0dlv-r3.js +0 -1
  52. package/dist/assets/index-MGle_v2x.js +0 -1
  53. package/dist/assets/index-N-GE7HTU.js +0 -1
  54. package/dist/assets/index-aEsF5o-7.js +0 -2
  55. package/dist/assets/index-as7ELo0J.js +0 -1
  56. package/dist/assets/index-gUUzXNuP.js +0 -1
  57. package/dist/assets/index-pGm0qkrJ.js +0 -13
  58. package/dist/assets/javascript.worker-C1zGArKk.js +0 -527
  59. package/dist/assets/lua-BgMRiT3U.js +0 -1
  60. package/dist/assets/perl-CdXCOZ3F.js +0 -1
  61. package/dist/assets/process-Dw9K5EnD.js +0 -1357
  62. package/dist/assets/properties-C78fOPTZ.js +0 -1
  63. package/dist/assets/ruby-B2Rjki9n.js +0 -1
  64. package/dist/assets/shell-CjFT_Tl9.js +0 -1
  65. package/dist/assets/swift-BzpIVaGY.js +0 -1
  66. package/dist/assets/toml-BXUEaScT.js +0 -1
  67. package/dist/assets/vb-CmGdzxic.js +0 -1
  68. package/dist/e2e/example.spec.d.ts +0 -5
  69. package/dist/e2e/example.spec.js +0 -44
  70. package/dist/index.html +0 -16
  71. package/dist/resources/config.json +0 -13
  72. package/dist/snapshot.bin +0 -0
  73. package/dist/styles.css +0 -7
@@ -1,12 +1,50 @@
1
+ import { EditorView } from "@codemirror/view";
1
2
  import { StateEffect, StateField } from "@codemirror/state";
2
- import { CodeblockFacet, openFileEffect, currentFileField } 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";
7
+ import { LSP, LspLog, FileChangeType } from "../utils/lsp";
8
+ import { Seti } from "@m234/nerd-fonts/fs";
9
+ import { settingsField, resolveThemeDark, updateSettingsEffect } from "./settings";
10
+ // Browser-safe file icon lookup (avoids node:path.parse used by Seti.fromPath)
11
+ const FALLBACK_ICON = { value: '\ue64e', hexCode: 0xe64e }; // nf-seti-text
12
+ function setiIconForPath(filePath) {
13
+ const base = filePath.split('/').pop() || filePath;
14
+ // Check exact basename match first (e.g. Dockerfile, Makefile)
15
+ const byBase = Seti.byBaseSeti.get(base);
16
+ if (byBase)
17
+ return byBase;
18
+ // Walk extensions from longest to shortest (e.g. .spec.ts → .ts)
19
+ let dot = base.indexOf('.');
20
+ if (dot < 0)
21
+ return FALLBACK_ICON;
22
+ let ext = base.slice(dot);
23
+ for (;;) {
24
+ const byExt = Seti.byExtensionSeti.get(ext);
25
+ if (byExt)
26
+ return byExt;
27
+ dot = ext.indexOf('.', 1);
28
+ if (dot === -1)
29
+ break;
30
+ ext = ext.slice(dot);
31
+ }
32
+ return FALLBACK_ICON;
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
+ }
4
39
  // Type guards
5
40
  function isCommandResult(result) {
6
- return 'type' in result;
41
+ return 'type' in result && result.query !== undefined;
7
42
  }
8
- function isSearchResult(result) {
9
- return 'score' in result;
43
+ function isBrowseEntry(result) {
44
+ return 'type' in result && ('fullPath' in result);
45
+ }
46
+ function isSettingsEntry(result) {
47
+ return 'type' in result && ('settingKey' in result);
10
48
  }
11
49
  // Search results state - now handles both commands and search results
12
50
  export const setSearchResults = StateEffect.define();
@@ -40,133 +78,244 @@ function isValidProgrammingLanguage(query) {
40
78
  return Object.keys(extOrLanguageToLanguageId).some(key => key.toLowerCase() === lowerQuery ||
41
79
  extOrLanguageToLanguageId[key].toLowerCase() === lowerQuery);
42
80
  }
43
- // Get appropriate icon for language/extension
44
- function getLanguageIcon(query) {
45
- const lowerQuery = query.toLowerCase();
46
- // Language/extension icons matching extOrLanguageToLanguageId
47
- const iconMap = {
48
- // JavaScript/TypeScript family
49
- 'javascript': '🟨',
50
- 'js': '🟨',
51
- 'typescript': '🔷',
52
- 'ts': '🔷',
53
- 'jsx': '⚛️',
54
- 'tsx': '⚛️',
55
- // Python
56
- 'python': '🐍',
57
- 'py': '🐍',
58
- // Ruby
59
- 'ruby': '💎',
60
- 'rb': '💎',
61
- // PHP
62
- 'php': '🐘',
63
- // Java
64
- 'java': '☕',
65
- // C/C++
66
- 'cpp': '⚙️',
67
- 'c': '⚙️',
68
- // C#
69
- 'csharp': '🔷',
70
- 'cs': '🔷',
71
- // Go
72
- 'go': '🐹',
73
- // Swift
74
- 'swift': '🦉',
75
- // Kotlin
76
- 'kotlin': '🟣',
77
- 'kt': '🟣',
78
- // Rust
79
- 'rust': '🦀',
80
- 'rs': '🦀',
81
- // Scala
82
- 'scala': '🔴',
83
- // Visual Basic
84
- 'vb': '🔵',
85
- // Haskell
86
- 'haskell': '🎭',
87
- 'hs': '🎭',
88
- // Lua
89
- 'lua': '🌙',
90
- // Perl
91
- 'perl': '🐪',
92
- 'pl': '🐪',
93
- // Shell/Bash
94
- 'bash': '🐚',
95
- 'shell': '🐚',
96
- 'sh': '🐚',
97
- 'zsh': '🐚',
98
- // SQL
99
- 'mysql': '🗃️',
100
- 'sql': '🗃️',
101
- // Web technologies
102
- 'html': '🌐',
103
- 'css': '🎨',
104
- 'scss': '🎨',
105
- 'less': '🎨',
106
- // Data formats
107
- 'json': '📋',
108
- 'yaml': '⚙️',
109
- 'yml': '⚙️',
110
- 'xml': '📄',
111
- 'toml': '⚙️',
112
- 'ini': '⚙️',
113
- 'conf': '⚙️',
114
- 'log': '📄',
115
- 'env': '🔧',
116
- // Documentation
117
- 'markdown': '📝',
118
- 'md': '📝',
119
- // Docker/Build
120
- 'dockerfile': '🐳',
121
- 'makefile': '🔨',
122
- 'dockerignore': '🐳',
123
- 'gitignore': '📝'
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',
124
97
  };
125
- return iconMap[lowerQuery] || '📄';
98
+ return nameToExt[langOrExt.toLowerCase()] || langOrExt.toLowerCase();
126
99
  }
100
+ // Icons
101
+ const SEARCH_ICON = '\uf002'; // nf-fa-search (magnifying glass)
102
+ const DEFAULT_FILE_ICON = '\ue64e'; // nf-seti-text
103
+ const COG_ICON = '\uf013'; // nf-fa-cog
104
+ const FOLDER_ICON = '\ue613'; // nf-seti-folder
105
+ const FOLDER_OPEN_ICON = '\ue614'; // nf-seti-folder (open variant)
106
+ const PARENT_DIR_ICON = '\uf112'; // nf-fa-reply (back/up arrow)
107
+ // const TERMINAL_ICON = '\uf120'; // nf-fa-terminal — awaiting WASM VM/shim
108
+ // Get nerd font icon for a file path
109
+ function getFileIcon(path) {
110
+ const result = setiIconForPath(path);
111
+ return { glyph: result.value, color: result.color || '' };
112
+ }
113
+ // Get icon for a language/extension query (used for create-file commands)
114
+ function getLanguageIcon(query) {
115
+ return getFileIcon(`file.${query}`);
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
+ };
127
130
  // Create command results for the first section
128
131
  function createCommandResults(query, view, searchResults) {
129
132
  const commands = [];
130
133
  const currentFile = view.state.field(currentFileField);
131
134
  const hasValidFile = currentFile.path && !currentFile.loading;
135
+ const hasContent = view.state.doc.length > 0;
132
136
  const isLanguageQuery = isValidProgrammingLanguage(query);
133
- // TODO: fix language ext for new file with full language names, "typescript" -> "file.ts"
134
137
  // Check if query matches an existing file (first search result with exact match)
135
138
  const hasExactFileMatch = searchResults.length > 0 && searchResults[0].id === query;
136
- if (query.trim()) {
137
- // Create new file command (only if query doesn't match existing file)
138
- if (!hasExactFileMatch) {
139
- const createFileCommand = {
140
- id: isLanguageQuery ? "Create new file" : `Create new file "${query}"`,
141
- type: 'create-file',
142
- icon: isLanguageQuery ? getLanguageIcon(query) : '📄',
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) {
151
+ const langIcon = isLanguageQuery ? getLanguageIcon(query) : null;
152
+ commands.push({
153
+ id: isLanguageQuery ? "Save as" : `Save as "${query}"`,
154
+ type: 'save-as',
155
+ icon: langIcon ? langIcon.glyph : DEFAULT_FILE_ICON,
156
+ iconColor: langIcon?.color,
143
157
  query,
144
- requiresInput: isLanguageQuery
145
- };
146
- commands.push(createFileCommand);
158
+ requiresInput: isLanguageQuery,
159
+ });
147
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()) {
148
184
  // Rename file command (only if file is open, query is not a language, and doesn't match current file)
149
185
  if (hasValidFile && !isLanguageQuery && !hasExactFileMatch) {
150
186
  const renameCommand = {
151
187
  id: `Rename to "${query}"`,
152
188
  type: 'rename-file',
153
- icon: '✏️',
189
+ icon: '\uf044', // nf-fa-pencil_square_o (edit icon)
154
190
  query
155
191
  };
156
192
  commands.push(renameCommand);
157
193
  }
158
194
  }
195
+ // Open file (filesystem browser) — always shown
196
+ commands.push({
197
+ id: 'Open file',
198
+ type: 'open-file',
199
+ icon: FOLDER_OPEN_ICON,
200
+ query: '',
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
+ // });
209
+ // Import commands — always shown
210
+ commands.push({
211
+ id: 'Import file(s)',
212
+ type: 'import-local-files',
213
+ icon: '\uf15b', // nf-fa-file
214
+ query: '',
215
+ });
216
+ commands.push({
217
+ id: 'Import folder',
218
+ type: 'import-local-folder',
219
+ icon: FOLDER_ICON,
220
+ query: '',
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
+ });
159
244
  return commands;
160
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
+ }
255
+ async function importFiles(files, view) {
256
+ const { fs, index } = view.state.facet(CodeblockFacet);
257
+ for (const file of files) {
258
+ const path = file.webkitRelativePath || file.name;
259
+ const dir = path.substring(0, path.lastIndexOf('/'));
260
+ if (dir)
261
+ await fs.mkdir(dir, { recursive: true });
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
+ }
271
+ if (index)
272
+ index.add(path);
273
+ LSP.notifyFileChanged(path, FileChangeType.Created);
274
+ }
275
+ if (index?.savePath)
276
+ await index.save(fs, index.savePath);
277
+ // Open first imported file
278
+ if (files.length > 0) {
279
+ const first = files[0].webkitRelativePath || files[0].name;
280
+ safeDispatch(view, { effects: openFileEffect.of({ path: first }) });
281
+ }
282
+ }
283
+ // Create an LSP log overlay element
284
+ function createLspLogOverlay() {
285
+ const overlay = document.createElement("div");
286
+ overlay.className = "cm-settings-overlay";
287
+ // Log content
288
+ const content = document.createElement("div");
289
+ content.className = "cm-lsp-log-content";
290
+ overlay.appendChild(content);
291
+ function render() {
292
+ const entries = LspLog.entries();
293
+ const fragment = document.createDocumentFragment();
294
+ for (const entry of entries) {
295
+ const div = document.createElement("div");
296
+ div.className = `cm-lsp-log-entry cm-lsp-log-${entry.level}`;
297
+ const time = new Date(entry.timestamp).toLocaleTimeString();
298
+ div.textContent = `[${time}] [${entry.level}] ${entry.message}`;
299
+ fragment.appendChild(div);
300
+ }
301
+ content.replaceChildren(fragment);
302
+ content.scrollTop = content.scrollHeight;
303
+ }
304
+ render();
305
+ const unsub = LspLog.subscribe(render);
306
+ overlay._lspLogUnsub = unsub;
307
+ return overlay;
308
+ }
309
+ const SPINNER_FADE_MS = 150;
161
310
  // Toolbar Panel
162
311
  export const toolbarPanel = (view) => {
163
312
  let { filepath, language, index } = view.state.facet(CodeblockFacet);
164
313
  const dom = document.createElement("div");
165
314
  dom.className = "cm-toolbar-panel";
166
- // Create state icon (left side)
315
+ // Create state icon (left side) — magnifying glass at rest
167
316
  const stateIcon = document.createElement("div");
168
317
  stateIcon.className = "cm-toolbar-state-icon";
169
- stateIcon.textContent = "📄"; // Default file icon
318
+ stateIcon.textContent = SEARCH_ICON;
170
319
  // Create container for state icon to help with alignment
171
320
  const stateIconContainer = document.createElement("div");
172
321
  stateIconContainer.className = "cm-toolbar-state-icon-container";
@@ -181,23 +330,146 @@ export const toolbarPanel = (view) => {
181
330
  input.value = filepath || language || "";
182
331
  input.className = "cm-toolbar-input";
183
332
  inputContainer.appendChild(input);
333
+ // LSP log button (shows file-type icon of current file, hidden when lspLogEnabled is false)
334
+ const lspLogBtn = document.createElement("button");
335
+ lspLogBtn.className = "cm-toolbar-lsp-log";
336
+ lspLogBtn.style.fontFamily = 'var(--cm-icon-font-family)';
337
+ function updateLspLogIcon() {
338
+ const filePath = view.state.field(currentFileField).path;
339
+ if (filePath) {
340
+ const icon = getFileIcon(filePath);
341
+ lspLogBtn.textContent = icon.glyph;
342
+ lspLogBtn.style.color = icon.color || '';
343
+ }
344
+ else {
345
+ lspLogBtn.textContent = DEFAULT_FILE_ICON;
346
+ lspLogBtn.style.color = '';
347
+ }
348
+ }
349
+ function updateLspLogVisibility() {
350
+ const enabled = view.state.field(settingsField).lspLogEnabled;
351
+ lspLogBtn.style.display = enabled ? '' : 'none';
352
+ }
353
+ updateLspLogIcon();
354
+ updateLspLogVisibility();
355
+ // LSP log overlay management (dedicated to LSP log only)
356
+ let lspLogOverlay = null;
357
+ let lspLogSavedInputValue = null;
358
+ function showLspLogOverlay(overlay) {
359
+ const panelsTop = view.dom.querySelector('.cm-panels-top');
360
+ if (panelsTop) {
361
+ overlay.style.top = `${panelsTop.getBoundingClientRect().height}px`;
362
+ }
363
+ view.dom.appendChild(overlay);
364
+ }
365
+ function openLspLogOverlay() {
366
+ lspLogSavedInputValue = input.value;
367
+ input.value = 'lsp.log';
368
+ lspLogOverlay = createLspLogOverlay();
369
+ showLspLogOverlay(lspLogOverlay);
370
+ }
371
+ function closeLspLogOverlay() {
372
+ if (lspLogOverlay) {
373
+ if (lspLogOverlay._lspLogUnsub) {
374
+ lspLogOverlay._lspLogUnsub();
375
+ }
376
+ lspLogOverlay.remove();
377
+ lspLogOverlay = null;
378
+ if (lspLogSavedInputValue !== null) {
379
+ input.value = lspLogSavedInputValue;
380
+ lspLogSavedInputValue = null;
381
+ }
382
+ }
383
+ }
384
+ lspLogBtn.addEventListener("click", () => {
385
+ if (lspLogOverlay) {
386
+ closeLspLogOverlay();
387
+ }
388
+ else {
389
+ openLspLogOverlay();
390
+ }
391
+ });
392
+ dom.appendChild(lspLogBtn);
184
393
  const resultsList = document.createElement("ul");
185
394
  resultsList.className = "cm-search-results";
186
395
  dom.appendChild(resultsList);
187
396
  let selectedIndex = 0;
188
397
  let namingMode = { active: false, type: 'create-file', originalQuery: '' };
189
- // Tracks gutter width for toolbar alignment
398
+ let browseMode = { active: false, currentPath: '/', filter: '' };
399
+ let settingsMode = { active: false, filter: '', editing: null };
400
+ let deleteMode = { active: false, filePath: '' };
401
+ let overwriteMode = { active: false, filePath: '', action: 'create-file' };
402
+ // System theme media query listener
403
+ const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
404
+ function handleSystemThemeChange() {
405
+ const settings = view.state.field(settingsField);
406
+ if (settings.theme === 'system') {
407
+ safeDispatch(view, {
408
+ effects: setThemeEffect.of({ dark: systemThemeQuery.matches })
409
+ });
410
+ }
411
+ }
412
+ systemThemeQuery.addEventListener('change', handleSystemThemeChange);
413
+ // Apply initial settings (font size, font family, theme)
414
+ function applySettings() {
415
+ const settings = view.state.field(settingsField);
416
+ view.dom.style.setProperty('--cm-font-size', `${settings.fontSize}px`);
417
+ if (settings.fontFamily) {
418
+ view.dom.style.setProperty('--cm-font-family', settings.fontFamily);
419
+ }
420
+ else {
421
+ view.dom.style.removeProperty('--cm-font-family');
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
+ }
434
+ }
435
+ applySettings();
436
+ // Apply initial theme and auto-hide
437
+ const initialSettings = view.state.field(settingsField);
438
+ const initialDark = resolveThemeDark(initialSettings.theme);
439
+ view.dom.setAttribute('data-theme', initialDark ? 'dark' : 'light');
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
190
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`);
191
454
  const gutters = view.dom.querySelector('.cm-gutters');
192
455
  if (gutters) {
193
456
  const gutterWidth = gutters.getBoundingClientRect().width;
194
- dom.style.setProperty('--cm-gutter-width', `${gutterWidth}px`);
457
+ view.dom.style.setProperty('--cm-gutter-width', `${gutterWidth}px`);
195
458
  const numberGutter = gutters.querySelector('.cm-lineNumbers');
196
459
  if (numberGutter) {
197
460
  const numberGutterWidth = numberGutter.getBoundingClientRect().width;
198
- dom.style.setProperty('--cm-gutter-lineno-width', `${numberGutterWidth}px`);
461
+ view.dom.style.setProperty('--cm-gutter-lineno-width', `${numberGutterWidth}px`);
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`);
199
466
  }
200
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`);
472
+ }
201
473
  }
202
474
  // Set up ResizeObserver to watch gutter width changes
203
475
  let gutterObserver = null;
@@ -213,14 +485,466 @@ export const toolbarPanel = (view) => {
213
485
  // Initial width setup and observer
214
486
  updateGutterWidthVariables();
215
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
+ }
216
910
  const renderItem = (result, i) => {
217
911
  const li = document.createElement("li");
218
- li.className = `cm-search-result ${isCommandResult(result) ? 'cm-command-result' : 'cm-file-result'}`;
912
+ let resultClass = 'cm-file-result';
913
+ if (isSettingsEntry(result))
914
+ resultClass = 'cm-command-result';
915
+ else if (isCommandResult(result))
916
+ resultClass = 'cm-command-result';
917
+ else if (isBrowseEntry(result))
918
+ resultClass = result.type === 'browse-file' ? 'cm-file-result' : 'cm-browse-dir-result';
919
+ li.className = `cm-search-result ${resultClass}`;
219
920
  const resultIconContainer = document.createElement("div");
220
921
  resultIconContainer.className = "cm-search-result-icon-container";
221
922
  const resultIcon = document.createElement("div");
222
923
  resultIcon.className = "cm-search-result-icon";
223
- resultIcon.textContent = isCommandResult(result) ? result.icon : '📄';
924
+ if (isSettingsEntry(result)) {
925
+ // Settings entries use emoji or text icons, not nerd fonts
926
+ resultIcon.style.fontFamily = '';
927
+ resultIcon.textContent = result.icon;
928
+ }
929
+ else {
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
+ }
947
+ }
224
948
  resultIconContainer.appendChild(resultIcon);
225
949
  li.appendChild(resultIconContainer);
226
950
  const resultLabel = document.createElement("div");
@@ -232,30 +956,34 @@ export const toolbarPanel = (view) => {
232
956
  li.addEventListener("mousedown", (ev) => {
233
957
  ev.preventDefault();
234
958
  });
235
- li.addEventListener("click", () => selectResult(result));
959
+ li.addEventListener("click", (ev) => {
960
+ ev.stopPropagation();
961
+ selectResult(result);
962
+ });
236
963
  return li;
237
964
  };
238
965
  function updateDropdown() {
239
966
  const results = view.state.field(searchResultsField);
240
967
  const children = [];
241
- // Separate commands from search results
242
- const commands = results.filter(isCommandResult);
243
- const searchResults = results.filter(isSearchResult);
244
- let currentIndex = 0;
245
- // Render commands section
246
- commands.forEach((command) => {
247
- children.push(renderItem(command, currentIndex));
248
- currentIndex++;
249
- });
250
- // Render search results section
251
- searchResults.forEach((result) => {
252
- children.push(renderItem(result, currentIndex));
253
- currentIndex++;
968
+ // Render items in state array order (search results first, commands second)
969
+ results.forEach((result, i) => {
970
+ children.push(renderItem(result, i));
254
971
  });
255
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
+ }
256
978
  }
257
979
  function selectResult(result) {
258
- if (isCommandResult(result)) {
980
+ if (isSettingsEntry(result)) {
981
+ handleSettingsEntry(result);
982
+ }
983
+ else if (isBrowseEntry(result)) {
984
+ navigateBrowse(result);
985
+ }
986
+ else if (isCommandResult(result)) {
259
987
  handleCommandResult(result);
260
988
  }
261
989
  else {
@@ -264,10 +992,13 @@ export const toolbarPanel = (view) => {
264
992
  }
265
993
  function updateStateIcon() {
266
994
  if (namingMode.active) {
267
- stateIcon.textContent = namingMode.type === 'create-file' ? '📄' : '✏️';
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;
268
999
  }
269
1000
  else {
270
- stateIcon.textContent = '📄'; // Default file icon
1001
+ stateIcon.textContent = SEARCH_ICON;
271
1002
  }
272
1003
  }
273
1004
  function enterNamingMode(type, originalQuery, languageExtension) {
@@ -286,34 +1017,175 @@ export const toolbarPanel = (view) => {
286
1017
  updateStateIcon();
287
1018
  input.placeholder = '';
288
1019
  }
1020
+ // --- Browse mode functions ---
1021
+ async function enterBrowseMode(startPath) {
1022
+ const { cwd } = view.state.facet(CodeblockFacet);
1023
+ const browsePath = startPath || cwd || '/';
1024
+ browseMode = { active: true, currentPath: browsePath, filter: '' };
1025
+ // Update state icon to folder
1026
+ stateIcon.textContent = FOLDER_OPEN_ICON;
1027
+ input.value = browsePath.endsWith('/') ? browsePath : browsePath + '/';
1028
+ input.placeholder = '';
1029
+ input.focus();
1030
+ await refreshBrowseEntries();
1031
+ // Ensure click-outside listener is active
1032
+ document.addEventListener("click", handleClickOutside);
1033
+ }
1034
+ async function refreshBrowseEntries() {
1035
+ if (!browseMode.active)
1036
+ return;
1037
+ const { fs } = view.state.facet(CodeblockFacet);
1038
+ const dir = browseMode.currentPath;
1039
+ try {
1040
+ const entries = await fs.readDir(dir);
1041
+ const browseResults = [];
1042
+ // Add parent directory entry if not at root
1043
+ if (dir !== '/' && dir !== '') {
1044
+ const parentPath = dir.split('/').slice(0, -1).join('/') || '/';
1045
+ browseResults.push({
1046
+ id: '..',
1047
+ type: 'browse-parent',
1048
+ icon: PARENT_DIR_ICON,
1049
+ fullPath: parentPath,
1050
+ });
1051
+ }
1052
+ // Separate directories and files, sort each alphabetically
1053
+ const dirs = [];
1054
+ const files = [];
1055
+ for (const [name, fileType] of entries) {
1056
+ // Skip hidden/internal files
1057
+ if (name.startsWith('.'))
1058
+ continue;
1059
+ const fullPath = dir === '/' ? `${name}` : `${dir}/${name}`;
1060
+ // FileType.Directory = 2
1061
+ if (fileType === 2) {
1062
+ dirs.push({
1063
+ id: name + '/',
1064
+ type: 'browse-directory',
1065
+ icon: FOLDER_ICON,
1066
+ fullPath,
1067
+ });
1068
+ }
1069
+ else {
1070
+ const icon = getFileIcon(name);
1071
+ files.push({
1072
+ id: name,
1073
+ type: 'browse-file',
1074
+ icon: icon.glyph,
1075
+ iconColor: icon.color,
1076
+ fullPath,
1077
+ });
1078
+ }
1079
+ }
1080
+ dirs.sort((a, b) => a.id.localeCompare(b.id));
1081
+ files.sort((a, b) => a.id.localeCompare(b.id));
1082
+ // Apply filter
1083
+ const filter = browseMode.filter.toLowerCase();
1084
+ const filtered = [...browseResults, ...dirs, ...files].filter(entry => entry.type === 'browse-parent' || entry.id.toLowerCase().includes(filter));
1085
+ selectedIndex = 0;
1086
+ safeDispatch(view, { effects: setSearchResults.of(filtered) });
1087
+ }
1088
+ catch (e) {
1089
+ console.error('Failed to read directory:', e);
1090
+ exitBrowseMode();
1091
+ }
1092
+ }
1093
+ async function navigateBrowse(entry) {
1094
+ if (entry.type === 'browse-file') {
1095
+ // Open the file and exit browse mode
1096
+ const path = entry.fullPath;
1097
+ exitBrowseMode();
1098
+ input.value = path;
1099
+ safeDispatch(view, {
1100
+ effects: [setSearchResults.of([]), openFileEffect.of({ path })]
1101
+ });
1102
+ }
1103
+ else {
1104
+ // Navigate into directory (or parent)
1105
+ browseMode.currentPath = entry.fullPath;
1106
+ browseMode.filter = '';
1107
+ const displayPath = entry.fullPath === '/' ? '/' : entry.fullPath + '/';
1108
+ input.value = displayPath;
1109
+ selectedIndex = 0;
1110
+ await refreshBrowseEntries();
1111
+ }
1112
+ }
1113
+ function exitBrowseMode() {
1114
+ browseMode = { active: false, currentPath: '/', filter: '' };
1115
+ updateStateIcon();
1116
+ input.placeholder = '';
1117
+ }
1118
+ function triggerFileImport(folder) {
1119
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1120
+ const fileInput = document.createElement('input');
1121
+ fileInput.type = 'file';
1122
+ if (folder) {
1123
+ fileInput.setAttribute('webkitdirectory', '');
1124
+ }
1125
+ else {
1126
+ fileInput.multiple = true;
1127
+ }
1128
+ fileInput.addEventListener('change', () => {
1129
+ if (fileInput.files?.length) {
1130
+ importFiles(fileInput.files, view);
1131
+ }
1132
+ });
1133
+ fileInput.click();
1134
+ }
289
1135
  function handleCommandResult(command) {
290
- if (command.type === 'create-file') {
1136
+ if (command.type === 'settings') {
1137
+ enterSettingsMode();
1138
+ return;
1139
+ }
1140
+ else if (command.type === 'open-file') {
1141
+ enterBrowseMode();
1142
+ return;
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') {
291
1150
  if (command.requiresInput) {
292
- // Enter naming mode for language-specific file
293
- enterNamingMode('create-file', command.query, command.query);
1151
+ const ext = command.query ? languageToFileExtension(command.query) : undefined;
1152
+ enterNamingMode('save-as', command.query, ext);
294
1153
  }
295
1154
  else {
296
- // Create file directly and populate toolbar
297
1155
  const pathToOpen = command.query.includes('.') ? command.query : `${command.query}.txt`;
298
1156
  input.value = pathToOpen;
299
- safeDispatch(view, {
300
- effects: [setSearchResults.of([]), openFileEffect.of({ path: pathToOpen })]
301
- });
1157
+ checkOverwriteAndExecute(pathToOpen, 'save-as', () => createAndOpenFile(pathToOpen));
1158
+ }
1159
+ }
1160
+ else if (command.type === 'create-file') {
1161
+ if (command.requiresInput) {
1162
+ const ext = command.query ? languageToFileExtension(command.query) : undefined;
1163
+ enterNamingMode('create-file', command.query, ext);
1164
+ }
1165
+ else {
1166
+ const pathToOpen = command.query.includes('.') ? command.query : `${command.query}.txt`;
1167
+ input.value = pathToOpen;
1168
+ checkOverwriteAndExecute(pathToOpen, 'create-file', () => createBlankFile(pathToOpen));
302
1169
  }
303
1170
  }
304
1171
  else if (command.type === 'rename-file') {
305
- // Rename file directly since the new name is provided by the query
306
1172
  const currentFile = view.state.field(currentFileField);
307
1173
  if (currentFile.path) {
308
1174
  const newPath = command.query.includes('.') ? command.query : `${command.query}.txt`;
309
1175
  input.value = newPath;
310
- // TODO: Implement actual file rename logic
311
- console.log(`Rename ${currentFile.path} to ${newPath}`);
312
- safeDispatch(view, {
313
- effects: [setSearchResults.of([]), openFileEffect.of({ path: newPath })]
314
- });
1176
+ checkOverwriteAndExecute(newPath, 'rename', () => performRename(currentFile.path, newPath), currentFile.path);
315
1177
  }
316
1178
  }
1179
+ else if (command.type === 'import-local-files') {
1180
+ triggerFileImport(false);
1181
+ }
1182
+ else if (command.type === 'import-local-folder') {
1183
+ triggerFileImport(true);
1184
+ }
1185
+ else if (command.type === 'file-action' && command.action) {
1186
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1187
+ command.action(view);
1188
+ }
317
1189
  }
318
1190
  function handleSearchResult(result) {
319
1191
  input.value = result.id;
@@ -321,76 +1193,174 @@ export const toolbarPanel = (view) => {
321
1193
  effects: [setSearchResults.of([]), openFileEffect.of({ path: result.id })]
322
1194
  });
323
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
+ }
324
1215
  function executeNamingMode(filename) {
325
1216
  if (!namingMode.active || !filename.trim())
326
1217
  return;
327
- if (namingMode.type === 'create-file') {
328
- const pathToOpen = namingMode.languageExtension && !filename.includes('.')
329
- ? `${filename}.${namingMode.languageExtension}`
330
- : 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);
331
1223
  input.value = pathToOpen;
332
- // TODO: handle edge-cases like trying to create folders, invalid characters, etc.
333
- safeDispatch(view, {
334
- effects: [setSearchResults.of([]), openFileEffect.of({ path: pathToOpen })]
335
- });
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;
336
1234
  }
337
1235
  else if (namingMode.type === 'rename-file') {
338
1236
  const currentFile = view.state.field(currentFileField);
339
1237
  if (currentFile.path) {
340
1238
  const newPath = filename.includes('.') ? filename : `${filename}.txt`;
341
1239
  input.value = newPath;
342
- // TODO: Implement actual file rename logic
343
- console.log(`Rename ${currentFile.path} to ${newPath}`);
344
- safeDispatch(view, {
345
- effects: [setSearchResults.of([]), openFileEffect.of({ path: newPath })]
346
- });
1240
+ exitNamingMode();
1241
+ checkOverwriteAndExecute(newPath, 'rename', () => performRename(currentFile.path, newPath), currentFile.path);
1242
+ return;
347
1243
  }
348
1244
  }
349
1245
  exitNamingMode();
350
1246
  }
1247
+ function resetInputToCurrentFile() {
1248
+ const currentFile = view.state.field(currentFileField);
1249
+ const cfg = view.state.facet(CodeblockFacet);
1250
+ input.value = currentFile.path || cfg.language || '';
1251
+ }
351
1252
  // Close dropdown when clicking outside
352
1253
  function handleClickOutside(event) {
353
1254
  if (!dom.contains(event.target)) {
1255
+ if (settingsMode.active)
1256
+ exitSettingsMode();
1257
+ if (browseMode.active)
1258
+ exitBrowseMode();
354
1259
  safeDispatch(view, { effects: setSearchResults.of([]) });
1260
+ resetInputToCurrentFile();
355
1261
  }
356
1262
  }
357
1263
  input.addEventListener("click", () => {
1264
+ // Don't interfere when in a special mode
1265
+ if (namingMode.active || settingsMode.active || browseMode.active) {
1266
+ return;
1267
+ }
358
1268
  // Open dropdown when input is clicked
359
- if (!namingMode.active) {
360
- const query = input.value;
361
- let results = [];
362
- if (query.trim()) {
363
- // Get regular search results from index first
364
- const searchResults = (index?.search(query) || []).slice(0, 100);
365
- // Add command results first (passing search results to check for existing files)
366
- const commands = createCommandResults(query, view, searchResults);
367
- results = searchResults.concat(commands);
368
- }
369
- safeDispatch(view, { effects: setSearchResults.of(results) });
370
- // Add click-outside listener when dropdown opens
371
- 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);
372
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);
373
1286
  });
374
1287
  input.addEventListener("input", (event) => {
375
1288
  const query = event.target.value;
376
1289
  selectedIndex = 0;
1290
+ // Block input during delete/overwrite confirmation
1291
+ if (deleteMode.active || overwriteMode.active) {
1292
+ input.value = '';
1293
+ return;
1294
+ }
377
1295
  // If in naming mode, don't show search results
378
1296
  if (namingMode.active) {
379
1297
  return;
380
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
+ }
1310
+ // If in browse mode, filter the directory entries
1311
+ if (browseMode.active) {
1312
+ // Extract filter text after the directory path prefix
1313
+ const prefix = browseMode.currentPath === '/' ? '/' : browseMode.currentPath + '/';
1314
+ browseMode.filter = query.startsWith(prefix) ? query.slice(prefix.length) : query;
1315
+ refreshBrowseEntries();
1316
+ return;
1317
+ }
381
1318
  let results = [];
382
1319
  if (query.trim()) {
383
1320
  // Get regular search results from index first
384
1321
  const searchResults = (index?.search(query) || []).slice(0, 1000);
385
- // Add command results first (passing search results to check for existing files)
1322
+ // Add command results (passing search results to check for existing files)
386
1323
  const commands = createCommandResults(query, view, searchResults);
387
- results.push(...commands);
388
- // Add search results
1324
+ // Search results first, then commands
389
1325
  results.push(...searchResults);
1326
+ results.push(...commands);
1327
+ }
1328
+ else {
1329
+ // Show import commands even with empty query
1330
+ results = createCommandResults('', view, []);
390
1331
  }
391
1332
  safeDispatch(view, { effects: setSearchResults.of(results) });
392
1333
  });
393
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
+ }
394
1364
  if (namingMode.active) {
395
1365
  // Handle naming mode
396
1366
  if (event.key === "Enter") {
@@ -404,6 +1374,107 @@ export const toolbarPanel = (view) => {
404
1374
  }
405
1375
  return;
406
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
+ }
1428
+ // Browse mode keyboard handling
1429
+ if (browseMode.active) {
1430
+ const results = view.state.field(searchResultsField);
1431
+ if (event.key === "ArrowDown") {
1432
+ event.preventDefault();
1433
+ if (results.length) {
1434
+ selectedIndex = mod(selectedIndex + 1, results.length);
1435
+ updateDropdown();
1436
+ }
1437
+ }
1438
+ else if (event.key === "ArrowUp") {
1439
+ event.preventDefault();
1440
+ if (results.length) {
1441
+ selectedIndex = mod(selectedIndex - 1, results.length);
1442
+ updateDropdown();
1443
+ }
1444
+ }
1445
+ else if (event.key === "Enter" && results.length && selectedIndex >= 0) {
1446
+ event.preventDefault();
1447
+ selectResult(results[selectedIndex]);
1448
+ }
1449
+ else if (event.key === "Backspace") {
1450
+ // If filter is empty and backspace pressed, go up a directory
1451
+ if (browseMode.filter === '' && browseMode.currentPath !== '/') {
1452
+ event.preventDefault();
1453
+ const parentPath = browseMode.currentPath.split('/').slice(0, -1).join('/') || '/';
1454
+ browseMode.currentPath = parentPath;
1455
+ const displayPath = parentPath === '/' ? '/' : parentPath + '/';
1456
+ input.value = displayPath;
1457
+ refreshBrowseEntries();
1458
+ }
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
+ }
1469
+ else if (event.key === "Escape") {
1470
+ event.preventDefault();
1471
+ exitBrowseMode();
1472
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1473
+ resetInputToCurrentFile();
1474
+ input.blur();
1475
+ }
1476
+ return;
1477
+ }
407
1478
  // Normal search mode
408
1479
  const results = view.state.field(searchResultsField);
409
1480
  if (event.key === "ArrowDown") {
@@ -412,6 +1483,10 @@ export const toolbarPanel = (view) => {
412
1483
  selectedIndex = mod(selectedIndex + 1, results.length);
413
1484
  updateDropdown();
414
1485
  }
1486
+ else {
1487
+ // No dropdown open — move cursor to editor body
1488
+ view.focus();
1489
+ }
415
1490
  }
416
1491
  else if (event.key === "ArrowUp") {
417
1492
  event.preventDefault();
@@ -424,6 +1499,20 @@ export const toolbarPanel = (view) => {
424
1499
  event.preventDefault();
425
1500
  selectResult(results[selectedIndex]);
426
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
+ }
1510
+ else if (event.key === "Escape") {
1511
+ event.preventDefault();
1512
+ safeDispatch(view, { effects: setSearchResults.of([]) });
1513
+ resetInputToCurrentFile();
1514
+ input.blur();
1515
+ }
427
1516
  });
428
1517
  return {
429
1518
  dom,
@@ -439,10 +1528,93 @@ export const toolbarPanel = (view) => {
439
1528
  document.removeEventListener("click", handleClickOutside);
440
1529
  }
441
1530
  }
1531
+ // Apply settings when they change
1532
+ const prevSettings = update.startState.field(settingsField);
1533
+ const nextSettings = update.state.field(settingsField);
1534
+ if (prevSettings.fontSize !== nextSettings.fontSize || prevSettings.fontFamily !== nextSettings.fontFamily || prevSettings.maxVisibleLines !== nextSettings.maxVisibleLines) {
1535
+ applySettings();
1536
+ }
1537
+ if (prevSettings.lspLogEnabled !== nextSettings.lspLogEnabled) {
1538
+ updateLspLogVisibility();
1539
+ // Close the log overlay if the user disables it
1540
+ if (!nextSettings.lspLogEnabled && lspLogOverlay) {
1541
+ closeLspLogOverlay();
1542
+ }
1543
+ }
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.
1566
+ const prevFile = update.startState.field(currentFileField);
1567
+ const nextFile = update.state.field(currentFileField);
1568
+ if (prevFile.loading !== nextFile.loading) {
1569
+ if (nextFile.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
+ }
1578
+ }
1579
+ else {
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
+ }
1598
+ }
1599
+ }
1600
+ // Update LSP log icon when file changes
1601
+ if (prevFile.path !== nextFile.path) {
1602
+ updateLspLogIcon();
1603
+ }
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) {
1606
+ input.value = nextFile.path || '';
1607
+ }
442
1608
  },
443
1609
  destroy() {
444
1610
  // Clean up event listeners when panel is destroyed
445
1611
  document.removeEventListener("click", handleClickOutside);
1612
+ systemThemeQuery.removeEventListener('change', handleSystemThemeChange);
1613
+ // Clean up auto-hide
1614
+ if (autoHideEnabled)
1615
+ disableAutoHide();
1616
+ // Clean up LSP log overlay
1617
+ closeLspLogOverlay();
446
1618
  // Clean up ResizeObserver
447
1619
  if (gutterObserver) {
448
1620
  gutterObserver.disconnect();