gh-here 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env +0 -0
  2. package/.playwright-mcp/fixed-alignment.png +0 -0
  3. package/.playwright-mcp/fixed-layout.png +0 -0
  4. package/.playwright-mcp/gh-here-home-header-table.png +0 -0
  5. package/.playwright-mcp/gh-here-home.png +0 -0
  6. package/.playwright-mcp/line-selection-multiline.png +0 -0
  7. package/.playwright-mcp/line-selection-test-after.png +0 -0
  8. package/.playwright-mcp/line-selection-test-before.png +0 -0
  9. package/.playwright-mcp/page-2026-01-03T17-58-21-336Z.png +0 -0
  10. package/lib/constants.js +25 -15
  11. package/lib/content-search.js +212 -0
  12. package/lib/error-handler.js +39 -28
  13. package/lib/file-utils.js +438 -287
  14. package/lib/git.js +10 -54
  15. package/lib/gitignore.js +70 -41
  16. package/lib/renderers.js +15 -19
  17. package/lib/server.js +70 -193
  18. package/lib/symbol-parser.js +600 -0
  19. package/package.json +1 -1
  20. package/public/app.js +134 -68
  21. package/public/js/constants.js +50 -34
  22. package/public/js/content-search-handler.js +551 -0
  23. package/public/js/file-viewer.js +437 -0
  24. package/public/js/focus-mode.js +280 -0
  25. package/public/js/inline-search.js +659 -0
  26. package/public/js/modal-manager.js +14 -28
  27. package/public/js/symbol-outline.js +454 -0
  28. package/public/js/utils.js +152 -94
  29. package/public/styles.css +2049 -296
  30. package/.claude/settings.local.json +0 -30
  31. package/SAMPLE.md +0 -287
  32. package/lib/validation.js +0 -77
  33. package/public/app.js.backup +0 -1902
  34. package/public/js/draft-manager.js +0 -36
  35. package/public/js/editor-manager.js +0 -159
  36. package/test.js +0 -138
  37. package/tests/draftManager.test.js +0 -241
  38. package/tests/fileTypeDetection.test.js +0 -111
  39. package/tests/httpService.test.js +0 -268
  40. package/tests/languageDetection.test.js +0 -145
  41. package/tests/pathUtils.test.js +0 -136
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Monaco-based file viewer for read-only code display
3
+ * Elite code browsing with advanced Monaco Editor features
4
+ *
5
+ * @class FileViewer
6
+ */
7
+
8
+ import { CONFIG } from './constants.js';
9
+ import { getLanguageFromExtension } from './utils.js';
10
+
11
+ export class FileViewer {
12
+ constructor() {
13
+ this.viewer = null;
14
+ this.ready = false;
15
+ this.minimapEnabled = false;
16
+ this.init();
17
+ }
18
+
19
+ /**
20
+ * Initialize Monaco Editor for file viewing
21
+ */
22
+ init() {
23
+ // If Monaco is already loaded, initialize immediately
24
+ if (window.monacoReady && typeof monaco !== 'undefined') {
25
+ this.ready = true;
26
+ this.setupTheme();
27
+ this.initializeViewer();
28
+ return;
29
+ }
30
+
31
+ if (typeof require === 'undefined') {
32
+ // Wait for Monaco to be loaded by editor-manager
33
+ const checkMonaco = setInterval(() => {
34
+ if (window.monacoReady && typeof monaco !== 'undefined') {
35
+ clearInterval(checkMonaco);
36
+ this.ready = true;
37
+ this.setupTheme();
38
+ this.initializeViewer();
39
+ }
40
+ }, 100);
41
+ return;
42
+ }
43
+
44
+ require.config({ paths: { vs: CONFIG.MONACO_CDN } });
45
+
46
+ require(['vs/editor/editor.main'], () => {
47
+ // Configure Monaco to work without web workers (avoids CORS issues)
48
+ self.MonacoEnvironment = {
49
+ getWorker: function(workerId, label) {
50
+ return undefined; // Disable workers, use main thread
51
+ }
52
+ };
53
+
54
+ window.monacoReady = true;
55
+ this.ready = true;
56
+ this.setupTheme();
57
+ this.initializeViewer();
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Setup Monaco theme based on current page theme
63
+ */
64
+ setupTheme() {
65
+ const html = document.documentElement;
66
+ const currentTheme = html.getAttribute('data-theme') || 'dark';
67
+ const monacoTheme = currentTheme === 'dark' ? 'vs-dark' : 'vs';
68
+
69
+ if (typeof monaco !== 'undefined') {
70
+ monaco.editor.setTheme(monacoTheme);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Initialize the file viewer with elite features
76
+ */
77
+ initializeViewer() {
78
+ const container = document.querySelector('.file-content');
79
+ if (!container) return;
80
+
81
+ // Check if Monaco is already initialized in this container
82
+ if (container.querySelector('.monaco-editor')) {
83
+ return;
84
+ }
85
+
86
+ // Get file content from existing highlight.js code
87
+ const codeElement = container.querySelector('pre code.hljs.with-line-numbers');
88
+ if (!codeElement) return;
89
+
90
+ // Extract content and language
91
+ const content = this.extractContentFromHighlighted(codeElement);
92
+ const filePath = this.getCurrentFilePath();
93
+ const language = this.detectLanguage(filePath, codeElement);
94
+
95
+ // Create Monaco container
96
+ const monacoContainer = document.createElement('div');
97
+ monacoContainer.className = 'monaco-file-viewer';
98
+ monacoContainer.style.height = '100%';
99
+ monacoContainer.style.minHeight = '400px';
100
+
101
+ // Replace the highlight.js content with Monaco container
102
+ const preElement = codeElement.closest('pre');
103
+ if (preElement) {
104
+ preElement.replaceWith(monacoContainer);
105
+ } else {
106
+ container.innerHTML = '';
107
+ container.appendChild(monacoContainer);
108
+ }
109
+
110
+ // Create Monaco editor in read-only mode with elite features
111
+ const html = document.documentElement;
112
+ const currentTheme = html.getAttribute('data-theme') || 'dark';
113
+ const monacoTheme = currentTheme === 'dark' ? 'vs-dark' : 'vs';
114
+
115
+ this.viewer = monaco.editor.create(monacoContainer, {
116
+ value: content,
117
+ language: language,
118
+ theme: monacoTheme,
119
+ readOnly: true,
120
+
121
+ // Layout
122
+ minimap: { enabled: this.minimapEnabled, side: 'right' },
123
+ lineNumbers: 'on',
124
+ lineNumbersMinChars: 3,
125
+ wordWrap: 'off',
126
+ scrollBeyondLastLine: false,
127
+ fontSize: 12,
128
+ lineHeight: 20,
129
+ fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
130
+ padding: { top: 16, bottom: 16 },
131
+
132
+ // Visual enhancements
133
+ renderLineHighlight: 'line',
134
+ renderWhitespace: 'selection',
135
+ selectOnLineNumbers: true,
136
+ automaticLayout: true,
137
+
138
+ // Code folding
139
+ folding: true,
140
+ foldingHighlight: true,
141
+ foldingStrategy: 'auto',
142
+ showFoldingControls: 'mouseover',
143
+ unfoldOnClickAfterEndOfLine: true,
144
+
145
+ // Bracket matching
146
+ bracketPairColorization: { enabled: true },
147
+ guides: {
148
+ bracketPairs: true,
149
+ bracketPairsHorizontal: true,
150
+ indentation: true,
151
+ highlightActiveIndentation: true
152
+ },
153
+ matchBrackets: 'always',
154
+
155
+ // Navigation features
156
+ links: true,
157
+ colorDecorators: true,
158
+ occurrencesHighlight: true,
159
+ selectionHighlight: true,
160
+
161
+ // Smooth animations
162
+ smoothScrolling: true,
163
+ cursorSmoothCaretAnimation: 'on',
164
+ cursorBlinking: 'smooth',
165
+
166
+ // Elite features
167
+ codeLens: false, // Can enable if we add language server
168
+ hover: {
169
+ enabled: true,
170
+ delay: 300
171
+ },
172
+ quickSuggestions: false, // Disable for read-only
173
+ parameterHints: {
174
+ enabled: false
175
+ },
176
+ suggestOnTriggerCharacters: false,
177
+ acceptSuggestionOnEnter: 'off',
178
+
179
+ // Breadcrumbs (elite navigation)
180
+ breadcrumbs: {
181
+ enabled: true
182
+ },
183
+
184
+ // Inlay hints (elite feature)
185
+ inlayHints: {
186
+ enabled: 'on'
187
+ },
188
+
189
+ // Semantic tokens (better highlighting)
190
+ 'semanticHighlighting.enabled': true,
191
+
192
+ // Scrollbar
193
+ scrollbar: {
194
+ vertical: 'auto',
195
+ horizontal: 'auto',
196
+ useShadows: false,
197
+ verticalHasArrows: false,
198
+ horizontalHasArrows: false,
199
+ verticalScrollbarSize: 10,
200
+ horizontalScrollbarSize: 10,
201
+ arrowSize: 11
202
+ },
203
+
204
+ // Accessibility
205
+ accessibilitySupport: 'auto',
206
+
207
+ // Multi-cursor (useful even in read-only for selection)
208
+ multiCursorModifier: 'ctrlCmd',
209
+
210
+ // Find widget (elite search)
211
+ find: {
212
+ addExtraSpaceOnTop: false,
213
+ autoFindInSelection: 'never',
214
+ seedSearchStringFromSelection: 'always'
215
+ }
216
+ });
217
+
218
+ // Setup elite keyboard shortcuts
219
+ this.setupKeyboardShortcuts();
220
+
221
+ // Handle line number clicks for hash navigation
222
+ this.viewer.onMouseDown((e) => {
223
+ if (e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS) {
224
+ const lineNumber = e.target.position.lineNumber;
225
+ window.location.hash = `L${lineNumber}`;
226
+ const url = new URL(window.location);
227
+ url.hash = `L${lineNumber}`;
228
+ window.history.replaceState({}, '', url);
229
+ }
230
+ });
231
+
232
+ // Handle hash changes to scroll to line
233
+ this.handleHashNavigation();
234
+
235
+ // Setup context menu with elite actions
236
+ this.setupContextMenu();
237
+
238
+ // Layout after a short delay to ensure container is sized
239
+ setTimeout(() => {
240
+ if (this.viewer) {
241
+ this.viewer.layout();
242
+ }
243
+ }, 100);
244
+
245
+ // Listen for theme changes
246
+ this.setupThemeListener();
247
+ }
248
+
249
+ /**
250
+ * Setup elite keyboard shortcuts
251
+ */
252
+ setupKeyboardShortcuts() {
253
+ if (!this.viewer) return;
254
+
255
+ // Go to Symbol (Cmd/Ctrl+Shift+O) - elite navigation
256
+ this.viewer.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyO, () => {
257
+ this.viewer.getAction('editor.action.gotoSymbol').run();
258
+ });
259
+
260
+ // Toggle Minimap (Cmd/Ctrl+K M)
261
+ this.viewer.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
262
+ // This is a chord, need to handle differently
263
+ });
264
+
265
+ // Go to Line (Cmd/Ctrl+G)
266
+ this.viewer.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG, () => {
267
+ this.viewer.getAction('editor.action.gotoLine').run();
268
+ });
269
+
270
+ // Toggle Word Wrap (Alt+Z)
271
+ this.viewer.addCommand(monaco.KeyMod.Alt | monaco.KeyCode.KeyZ, () => {
272
+ const currentWrap = this.viewer.getOption(monaco.editor.EditorOption.wordWrap);
273
+ this.viewer.updateOptions({ wordWrap: currentWrap === 'off' ? 'on' : 'off' });
274
+ });
275
+
276
+ // Fold All (Cmd/Ctrl+K Cmd/Ctrl+0)
277
+ this.viewer.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, () => {
278
+ // Chord command - handled by Monaco natively
279
+ });
280
+
281
+ // Unfold All (Cmd/Ctrl+K Cmd/Ctrl+J)
282
+ // Also handled natively
283
+
284
+ // Toggle Minimap with simpler shortcut
285
+ document.addEventListener('keydown', (e) => {
286
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'M') {
287
+ e.preventDefault();
288
+ this.toggleMinimap();
289
+ }
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Toggle minimap
295
+ */
296
+ toggleMinimap() {
297
+ if (!this.viewer) return;
298
+ this.minimapEnabled = !this.minimapEnabled;
299
+ this.viewer.updateOptions({ minimap: { enabled: this.minimapEnabled } });
300
+ }
301
+
302
+ /**
303
+ * Setup context menu with elite actions
304
+ */
305
+ setupContextMenu() {
306
+ if (!this.viewer) return;
307
+
308
+ // Monaco handles context menu natively, but we can add custom actions
309
+ // The native context menu already includes:
310
+ // - Cut/Copy/Paste (though paste disabled in read-only)
311
+ // - Find/Replace
312
+ // - Go to Symbol
313
+ // - etc.
314
+ }
315
+
316
+ /**
317
+ * Setup listener for theme changes
318
+ */
319
+ setupThemeListener() {
320
+ // Listen for theme attribute changes
321
+ const observer = new MutationObserver((mutations) => {
322
+ mutations.forEach((mutation) => {
323
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
324
+ const newTheme = document.documentElement.getAttribute('data-theme') || 'dark';
325
+ this.updateTheme(newTheme);
326
+ }
327
+ });
328
+ });
329
+
330
+ observer.observe(document.documentElement, {
331
+ attributes: true,
332
+ attributeFilter: ['data-theme']
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Extract plain text content from highlighted HTML
338
+ */
339
+ extractContentFromHighlighted(codeElement) {
340
+ // Clone to avoid modifying original
341
+ const clone = codeElement.cloneNode(true);
342
+
343
+ // Remove line numbers and containers
344
+ const lineContainers = clone.querySelectorAll('.line-container');
345
+ if (lineContainers.length > 0) {
346
+ const lines = Array.from(lineContainers).map(container => {
347
+ const content = container.querySelector('.line-content');
348
+ return content ? content.textContent : '';
349
+ });
350
+ return lines.join('\n');
351
+ }
352
+
353
+ // Fallback: just get text content
354
+ return clone.textContent || '';
355
+ }
356
+
357
+ /**
358
+ * Detect language from file path or code element
359
+ */
360
+ detectLanguage(filePath, codeElement) {
361
+ if (filePath) {
362
+ const language = getLanguageFromExtension(filePath);
363
+ if (language && language !== 'plaintext') {
364
+ return language;
365
+ }
366
+ }
367
+
368
+ // Try to detect from code element class
369
+ const classList = codeElement.className.split(' ');
370
+ const langClass = classList.find(cls => cls.startsWith('language-'));
371
+ if (langClass) {
372
+ return langClass.replace('language-', '');
373
+ }
374
+
375
+ return 'plaintext';
376
+ }
377
+
378
+ /**
379
+ * Get current file path from URL
380
+ */
381
+ getCurrentFilePath() {
382
+ try {
383
+ const urlParams = new URLSearchParams(window.location.search);
384
+ return urlParams.get('path') || '';
385
+ } catch {
386
+ return '';
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Handle hash navigation to scroll to specific line
392
+ */
393
+ handleHashNavigation() {
394
+ const hash = window.location.hash;
395
+ if (hash && hash.startsWith('#L')) {
396
+ const lineNumber = parseInt(hash.substring(2), 10);
397
+ if (lineNumber && this.viewer) {
398
+ this.viewer.revealLineInCenter(lineNumber);
399
+ this.viewer.setPosition({ lineNumber, column: 1 });
400
+ }
401
+ }
402
+
403
+ // Listen for hash changes
404
+ window.addEventListener('hashchange', () => {
405
+ const newHash = window.location.hash;
406
+ if (newHash && newHash.startsWith('#L')) {
407
+ const lineNumber = parseInt(newHash.substring(2), 10);
408
+ if (lineNumber && this.viewer) {
409
+ this.viewer.revealLineInCenter(lineNumber);
410
+ this.viewer.setPosition({ lineNumber, column: 1 });
411
+ }
412
+ }
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Update theme when theme changes
418
+ */
419
+ updateTheme(theme) {
420
+ if (!this.viewer) return;
421
+
422
+ const monacoTheme = theme === 'dark' ? 'vs-dark' : 'vs';
423
+ if (typeof monaco !== 'undefined') {
424
+ monaco.editor.setTheme(monacoTheme);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Cleanup: Dispose of Monaco editor
430
+ */
431
+ destroy() {
432
+ if (this.viewer) {
433
+ this.viewer.dispose();
434
+ this.viewer = null;
435
+ }
436
+ }
437
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Focus mode - hide distractions for better code reading
3
+ *
4
+ * @class FocusMode
5
+ */
6
+
7
+ // ============================================================================
8
+ // Constants
9
+ // ============================================================================
10
+
11
+ const PREFERENCES_KEY = 'gh-here-focus-preferences';
12
+
13
+ // ============================================================================
14
+ // FocusMode Class
15
+ // ============================================================================
16
+
17
+ export class FocusMode {
18
+ constructor() {
19
+ this.isActive = false;
20
+ this.sidebarVisible = true;
21
+ this.preferences = this.loadPreferences();
22
+ this.eventHandlers = new Map();
23
+ }
24
+
25
+ // ========================================================================
26
+ // Public API
27
+ // ========================================================================
28
+
29
+ /**
30
+ * Initialize focus mode
31
+ */
32
+ init() {
33
+ this.setupKeyboardShortcuts();
34
+ this.restoreState();
35
+ }
36
+
37
+ /**
38
+ * Cleanup: Remove event listeners
39
+ */
40
+ destroy() {
41
+ this.removeEventListeners();
42
+ }
43
+
44
+ // ========================================================================
45
+ // Toggle Operations
46
+ // ========================================================================
47
+
48
+ /**
49
+ * Toggle sidebar visibility
50
+ */
51
+ toggleSidebar() {
52
+ this.sidebarVisible = !this.sidebarVisible;
53
+ this.applySidebarState();
54
+ this.savePreferences();
55
+ }
56
+
57
+ /**
58
+ * Toggle full focus mode
59
+ */
60
+ toggleFullFocus() {
61
+ this.isActive = !this.isActive;
62
+ this.applyFocusState();
63
+ this.savePreferences();
64
+ }
65
+
66
+ // ========================================================================
67
+ // State Application
68
+ // ========================================================================
69
+
70
+ /**
71
+ * Apply sidebar visibility state
72
+ */
73
+ applySidebarState() {
74
+ const sidebar = this.getSidebar();
75
+ const mainContent = this.getMainContent();
76
+
77
+ if (!sidebar) return;
78
+
79
+ if (this.sidebarVisible) {
80
+ sidebar.classList.remove('hidden');
81
+ if (mainContent) {
82
+ mainContent.classList.remove('no-sidebar');
83
+ }
84
+ } else {
85
+ sidebar.classList.add('hidden');
86
+ if (mainContent) {
87
+ mainContent.classList.add('no-sidebar');
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Apply focus mode state
94
+ */
95
+ applyFocusState() {
96
+ const body = document.body;
97
+ const mainContent = this.getMainContent();
98
+ const header = document.querySelector('header');
99
+
100
+ if (this.isActive) {
101
+ this.enableFocusMode(body, mainContent, header);
102
+ } else {
103
+ this.disableFocusMode(body, mainContent, header);
104
+ }
105
+
106
+ // In full focus mode, also hide sidebar
107
+ if (this.isActive) {
108
+ this.hideSidebarForFocus();
109
+ } else {
110
+ this.applySidebarState();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Enable focus mode styling
116
+ */
117
+ enableFocusMode(body, mainContent, header) {
118
+ body.classList.add('focus-mode');
119
+ if (mainContent) {
120
+ mainContent.classList.add('focus-mode');
121
+ }
122
+ if (header) {
123
+ header.classList.add('focus-mode');
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Disable focus mode styling
129
+ */
130
+ disableFocusMode(body, mainContent, header) {
131
+ body.classList.remove('focus-mode');
132
+ if (mainContent) {
133
+ mainContent.classList.remove('focus-mode');
134
+ }
135
+ if (header) {
136
+ header.classList.remove('focus-mode');
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Hide sidebar when entering full focus mode
142
+ */
143
+ hideSidebarForFocus() {
144
+ const sidebar = this.getSidebar();
145
+ const mainContent = this.getMainContent();
146
+
147
+ if (sidebar) {
148
+ sidebar.classList.add('hidden');
149
+ }
150
+ if (mainContent) {
151
+ mainContent.classList.add('no-sidebar');
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Restore state from preferences
157
+ */
158
+ restoreState() {
159
+ if (this.preferences.sidebarVisible !== undefined) {
160
+ this.sidebarVisible = Boolean(this.preferences.sidebarVisible);
161
+ this.applySidebarState();
162
+ }
163
+
164
+ if (this.preferences.fullFocus !== undefined) {
165
+ this.isActive = Boolean(this.preferences.fullFocus);
166
+ this.applyFocusState();
167
+ }
168
+ }
169
+
170
+ // ========================================================================
171
+ // Event Handling
172
+ // ========================================================================
173
+
174
+ /**
175
+ * Setup keyboard shortcuts
176
+ */
177
+ setupKeyboardShortcuts() {
178
+ const keyHandler = (e) => this.handleKeydown(e);
179
+ document.addEventListener('keydown', keyHandler);
180
+ this.eventHandlers.set('keydown', keyHandler);
181
+ }
182
+
183
+ /**
184
+ * Handle keyboard events
185
+ */
186
+ handleKeydown(e) {
187
+ // Don't interfere if user is typing in an input
188
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
189
+ return;
190
+ }
191
+
192
+ // Cmd/Ctrl+B: Toggle sidebar
193
+ if ((e.ctrlKey || e.metaKey) && e.key === 'b' && !e.shiftKey) {
194
+ e.preventDefault();
195
+ this.toggleSidebar();
196
+ return;
197
+ }
198
+
199
+ // F11: Toggle full focus mode
200
+ if (e.key === 'F11') {
201
+ e.preventDefault();
202
+ this.toggleFullFocus();
203
+ return;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Remove all event listeners
209
+ */
210
+ removeEventListeners() {
211
+ this.eventHandlers.forEach((handler, event) => {
212
+ document.removeEventListener(event, handler);
213
+ });
214
+ this.eventHandlers.clear();
215
+ }
216
+
217
+ // ========================================================================
218
+ // Preferences Management
219
+ // ========================================================================
220
+
221
+ /**
222
+ * Load preferences from localStorage
223
+ */
224
+ loadPreferences() {
225
+ try {
226
+ const stored = localStorage.getItem(PREFERENCES_KEY);
227
+ if (!stored) return {};
228
+
229
+ const parsed = JSON.parse(stored);
230
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
231
+ } catch (error) {
232
+ console.warn('Failed to load focus preferences:', error);
233
+ return {};
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Save preferences to localStorage
239
+ */
240
+ savePreferences() {
241
+ try {
242
+ const prefs = {
243
+ sidebarVisible: this.sidebarVisible,
244
+ fullFocus: this.isActive
245
+ };
246
+ localStorage.setItem(PREFERENCES_KEY, JSON.stringify(prefs));
247
+ } catch (error) {
248
+ this.handleStorageError(error);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Handle storage errors gracefully
254
+ */
255
+ handleStorageError(error) {
256
+ if (error.name === 'QuotaExceededError') {
257
+ console.warn('Focus preferences storage quota exceeded');
258
+ } else {
259
+ console.error('Failed to save focus preferences:', error);
260
+ }
261
+ }
262
+
263
+ // ========================================================================
264
+ // Utilities
265
+ // ========================================================================
266
+
267
+ /**
268
+ * Get sidebar element
269
+ */
270
+ getSidebar() {
271
+ return document.querySelector('.file-tree-sidebar');
272
+ }
273
+
274
+ /**
275
+ * Get main content wrapper element
276
+ */
277
+ getMainContent() {
278
+ return document.querySelector('.main-content-wrapper');
279
+ }
280
+ }