gh-here 3.0.2 → 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.
- package/.env +0 -0
- package/.playwright-mcp/fixed-alignment.png +0 -0
- package/.playwright-mcp/fixed-layout.png +0 -0
- package/.playwright-mcp/gh-here-home-header-table.png +0 -0
- package/.playwright-mcp/gh-here-home.png +0 -0
- package/.playwright-mcp/line-selection-multiline.png +0 -0
- package/.playwright-mcp/line-selection-test-after.png +0 -0
- package/.playwright-mcp/line-selection-test-before.png +0 -0
- package/.playwright-mcp/page-2026-01-03T17-58-21-336Z.png +0 -0
- package/lib/constants.js +25 -15
- package/lib/content-search.js +212 -0
- package/lib/error-handler.js +39 -28
- package/lib/file-utils.js +438 -287
- package/lib/git.js +10 -54
- package/lib/gitignore.js +70 -41
- package/lib/renderers.js +15 -19
- package/lib/server.js +70 -193
- package/lib/symbol-parser.js +600 -0
- package/package.json +1 -1
- package/public/app.js +207 -73
- package/public/js/constants.js +50 -34
- package/public/js/content-search-handler.js +551 -0
- package/public/js/file-viewer.js +437 -0
- package/public/js/focus-mode.js +280 -0
- package/public/js/inline-search.js +659 -0
- package/public/js/modal-manager.js +14 -28
- package/public/js/navigation.js +5 -0
- package/public/js/symbol-outline.js +454 -0
- package/public/js/utils.js +152 -94
- package/public/styles.css +2049 -296
- package/.claude/settings.local.json +0 -30
- package/SAMPLE.md +0 -287
- package/lib/validation.js +0 -77
- package/public/app.js.backup +0 -1902
- package/public/js/draft-manager.js +0 -36
- package/public/js/editor-manager.js +0 -159
- package/test.js +0 -138
- package/tests/draftManager.test.js +0 -241
- package/tests/fileTypeDetection.test.js +0 -111
- package/tests/httpService.test.js +0 -268
- package/tests/languageDetection.test.js +0 -145
- 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
|
+
}
|