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.
Files changed (42) 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 +207 -73
  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/navigation.js +5 -0
  28. package/public/js/symbol-outline.js +454 -0
  29. package/public/js/utils.js +152 -94
  30. package/public/styles.css +2049 -296
  31. package/.claude/settings.local.json +0 -30
  32. package/SAMPLE.md +0 -287
  33. package/lib/validation.js +0 -77
  34. package/public/app.js.backup +0 -1902
  35. package/public/js/draft-manager.js +0 -36
  36. package/public/js/editor-manager.js +0 -159
  37. package/test.js +0 -138
  38. package/tests/draftManager.test.js +0 -241
  39. package/tests/fileTypeDetection.test.js +0 -111
  40. package/tests/httpService.test.js +0 -268
  41. package/tests/languageDetection.test.js +0 -145
  42. package/tests/pathUtils.test.js +0 -136
package/public/app.js CHANGED
@@ -1,16 +1,30 @@
1
1
  /**
2
2
  * Main application entry point
3
3
  * Coordinates all modules and initializes the application
4
+ * @module app
4
5
  */
5
6
 
6
- import { ThemeManager } from './js/theme-manager.js';
7
- import { SearchHandler } from './js/search-handler.js';
8
- import { KeyboardHandler } from './js/keyboard-handler.js';
7
+ // ============================================================================
8
+ // Imports (alpha-sorted)
9
+ // ============================================================================
10
+
11
+ import { ContentSearchHandler } from './js/content-search-handler.js';
12
+ import { copyToClipboard } from './js/clipboard-utils.js';
9
13
  import { FileTreeNavigator } from './js/file-tree.js';
14
+ import { FileViewer } from './js/file-viewer.js';
15
+ import { FocusMode } from './js/focus-mode.js';
16
+ import { InlineSearch } from './js/inline-search.js';
17
+ import { KeyboardHandler } from './js/keyboard-handler.js';
10
18
  import { NavigationHandler } from './js/navigation.js';
11
19
  import { PathUtils } from './js/utils.js';
20
+ import { SearchHandler } from './js/search-handler.js';
12
21
  import { showNotification } from './js/notification.js';
13
- import { copyToClipboard } from './js/clipboard-utils.js';
22
+ import { SymbolOutline } from './js/symbol-outline.js';
23
+ import { ThemeManager } from './js/theme-manager.js';
24
+
25
+ // ============================================================================
26
+ // Application Class
27
+ // ============================================================================
14
28
 
15
29
  class Application {
16
30
  constructor() {
@@ -19,6 +33,12 @@ class Application {
19
33
  this.keyboardHandler = null;
20
34
  this.fileTree = null;
21
35
  this.navigationHandler = null;
36
+ this.lastSelectedLine = null;
37
+ this.fileViewer = null;
38
+ this.inlineSearch = null;
39
+ this.focusMode = null;
40
+ this.contentSearch = null;
41
+ this.symbolOutline = null;
22
42
  }
23
43
 
24
44
  init() {
@@ -31,19 +51,22 @@ class Application {
31
51
  // Re-initialize components after client-side navigation
32
52
  document.addEventListener('content-loaded', () => {
33
53
  try {
54
+ // Cleanup existing components before re-initializing
55
+ this.cleanupComponents();
56
+
34
57
  // Re-initialize theme manager listeners (button might be re-rendered)
35
58
  if (this.themeManager) {
36
59
  this.themeManager.setupListeners();
37
60
  }
38
-
61
+
39
62
  // Re-initialize components that need fresh DOM references
40
63
  this.searchHandler = new SearchHandler();
41
64
  this.keyboardHandler = new KeyboardHandler(this.searchHandler);
42
-
65
+
43
66
  // Re-initialize file tree when sidebar becomes visible
44
67
  const sidebar = document.querySelector('.file-tree-sidebar');
45
68
  const treeContainer = document.getElementById('file-tree');
46
-
69
+
47
70
  if (sidebar && treeContainer && !sidebar.classList.contains('hidden')) {
48
71
  // Sidebar is visible - initialize or re-initialize file tree
49
72
  if (!this.fileTree || !this.fileTree.isInitialized || this.fileTree.treeContainer !== treeContainer) {
@@ -53,10 +76,14 @@ class Application {
53
76
  // Sidebar is hidden - don't initialize but keep reference for when it becomes visible
54
77
  this.fileTree = null;
55
78
  }
56
-
79
+
57
80
  this.setupGlobalEventListeners();
58
81
  this.setupGitignoreToggle();
59
- this.setupFileOperations();
82
+ this.highlightLinesFromHash();
83
+ this.initializeFileViewer();
84
+ this.initializeInlineSearch();
85
+ this.initializeFocusMode();
86
+ this.initializeContentSearch();
60
87
  } catch (error) {
61
88
  console.error('Error re-initializing components:', error);
62
89
  }
@@ -72,7 +99,7 @@ class Application {
72
99
  // Initialize components
73
100
  this.searchHandler = new SearchHandler();
74
101
  this.keyboardHandler = new KeyboardHandler(this.searchHandler);
75
-
102
+
76
103
  // Initialize file tree if sidebar is visible (not hidden)
77
104
  const sidebar = document.querySelector('.file-tree-sidebar');
78
105
  const treeContainer = document.getElementById('file-tree');
@@ -82,7 +109,84 @@ class Application {
82
109
 
83
110
  this.setupGlobalEventListeners();
84
111
  this.setupGitignoreToggle();
85
- this.setupFileOperations();
112
+ this.highlightLinesFromHash();
113
+ this.initializeFileViewer();
114
+ this.initializeInlineSearch();
115
+ this.initializeFocusMode();
116
+ this.initializeContentSearch();
117
+ this.initializeSymbolOutline();
118
+ }
119
+
120
+ // ========================================================================
121
+ // Component Initialization
122
+ // ========================================================================
123
+
124
+ /**
125
+ * Check if current page is a file view page
126
+ */
127
+ isFileViewPage() {
128
+ const fileContent = document.querySelector('.file-content');
129
+ return fileContent?.querySelector('pre code.hljs.with-line-numbers') !== null;
130
+ }
131
+
132
+ /**
133
+ * Initialize Monaco-based file viewer
134
+ */
135
+ initializeFileViewer() {
136
+ if (!this.isFileViewPage()) return;
137
+
138
+ // Wait for Monaco to be ready
139
+ if (typeof require === 'undefined' || !window.monacoReady) {
140
+ // Wait a bit and try again
141
+ setTimeout(() => this.initializeFileViewer(), 100);
142
+ return;
143
+ }
144
+
145
+ if (!this.fileViewer) {
146
+ this.fileViewer = new FileViewer();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Initialize inline search component
152
+ * Note: Skipped when Monaco viewer is active (Monaco has built-in search)
153
+ */
154
+ initializeInlineSearch() {
155
+ if (!this.isFileViewPage()) return;
156
+
157
+ // Skip if Monaco viewer is active
158
+ if (document.querySelector('.monaco-file-viewer')) {
159
+ return;
160
+ }
161
+
162
+ if (!this.inlineSearch) {
163
+ this.inlineSearch = new InlineSearch();
164
+ }
165
+ this.inlineSearch.init();
166
+ }
167
+
168
+ initializeFocusMode() {
169
+ if (!this.focusMode) {
170
+ this.focusMode = new FocusMode();
171
+ }
172
+ this.focusMode.init();
173
+ }
174
+
175
+ /**
176
+ * Initialize symbol outline panel
177
+ */
178
+ initializeSymbolOutline() {
179
+ // Cleanup previous instance
180
+ if (this.symbolOutline) {
181
+ this.symbolOutline.destroy();
182
+ this.symbolOutline = null;
183
+ }
184
+
185
+ // Only initialize on file view pages
186
+ if (!this.isFileViewPage()) return;
187
+
188
+ this.symbolOutline = new SymbolOutline();
189
+ this.symbolOutline.init();
86
190
  }
87
191
 
88
192
  setupGlobalEventListeners() {
@@ -104,6 +208,16 @@ class Application {
104
208
  }
105
209
 
106
210
  handleGlobalClick(e) {
211
+ // Line number selection (like GitHub)
212
+ const lineNumber = e.target.closest('.line-number');
213
+ if (lineNumber) {
214
+ e.preventDefault();
215
+ e.stopPropagation();
216
+ const lineNum = parseInt(lineNumber.textContent.trim(), 10);
217
+ this.handleLineSelection(lineNum, e.shiftKey);
218
+ return;
219
+ }
220
+
107
221
  // Copy path button
108
222
  const copyPathBtn = e.target.closest('.copy-path-btn, .file-path-copy-btn');
109
223
  if (copyPathBtn) {
@@ -180,68 +294,6 @@ class Application {
180
294
  }
181
295
 
182
296
 
183
- setupFileOperations() {
184
- document.addEventListener('click', async e => {
185
- if (e.target.closest('.delete-btn')) {
186
- const btn = e.target.closest('.delete-btn');
187
- const itemPath = btn.dataset.path;
188
- const itemName = btn.dataset.name;
189
- const isDirectory = btn.dataset.isDirectory === 'true';
190
-
191
- const message = `Are you sure you want to delete ${isDirectory ? 'folder' : 'file'} "${itemName}"?`;
192
- if (!confirm(message)) {
193
- return;
194
- }
195
-
196
- try {
197
- const response = await fetch('/api/delete', {
198
- method: 'POST',
199
- headers: { 'Content-Type': 'application/json' },
200
- body: JSON.stringify({ path: itemPath })
201
- });
202
-
203
- if (!response.ok) {
204
- throw new Error('Delete failed');
205
- }
206
-
207
- showNotification(`${isDirectory ? 'Folder' : 'File'} deleted successfully`, 'success');
208
- setTimeout(() => window.location.reload(), 600);
209
- } catch (error) {
210
- showNotification('Failed to delete item', 'error');
211
- }
212
- }
213
-
214
- if (e.target.closest('.rename-btn')) {
215
- const btn = e.target.closest('.rename-btn');
216
- const itemPath = btn.dataset.path;
217
- const currentName = btn.dataset.name;
218
- const isDirectory = btn.dataset.isDirectory === 'true';
219
-
220
- const newName = prompt(`Rename ${isDirectory ? 'folder' : 'file'}:`, currentName);
221
- if (!newName || newName.trim() === currentName) {
222
- return;
223
- }
224
-
225
- try {
226
- const response = await fetch('/api/rename', {
227
- method: 'POST',
228
- headers: { 'Content-Type': 'application/json' },
229
- body: JSON.stringify({ path: itemPath, newName: newName.trim() })
230
- });
231
-
232
- if (!response.ok) {
233
- throw new Error('Rename failed');
234
- }
235
-
236
- showNotification(`Renamed to "${newName.trim()}"`, 'success');
237
- setTimeout(() => window.location.reload(), 600);
238
- } catch (error) {
239
- showNotification('Failed to rename item', 'error');
240
- }
241
- }
242
- });
243
- }
244
-
245
297
  showDiffViewer(filePath) {
246
298
  // Simplified - redirect to diff view
247
299
  const url = new URL(window.location.href);
@@ -264,6 +316,88 @@ class Application {
264
316
  showNotification('Failed to copy raw content', 'error');
265
317
  }
266
318
  }
319
+
320
+ handleLineSelection(lineNum, shiftKey) {
321
+ // If shift is held and we have a previous selection, select range
322
+ if (shiftKey && this.lastSelectedLine) {
323
+ const start = Math.min(this.lastSelectedLine, lineNum);
324
+ const end = Math.max(this.lastSelectedLine, lineNum);
325
+ this.highlightLines(start, end);
326
+ this.updateUrlHash(start, end);
327
+ } else {
328
+ // Single line selection
329
+ this.highlightLines(lineNum, lineNum);
330
+ this.updateUrlHash(lineNum, lineNum);
331
+ this.lastSelectedLine = lineNum;
332
+ }
333
+ }
334
+
335
+ highlightLines(start, end) {
336
+ // Clear all existing selections and highlight new range in one pass
337
+ document.querySelectorAll('.line-container').forEach(el => {
338
+ const lineNum = parseInt(el.dataset.line, 10);
339
+ if (lineNum >= start && lineNum <= end) {
340
+ el.classList.add('selected');
341
+ } else {
342
+ el.classList.remove('selected');
343
+ }
344
+ });
345
+ }
346
+
347
+ updateUrlHash(start, end) {
348
+ const hash = start === end ? `L${start}` : `L${start}-L${end}`;
349
+ // Use history API to update URL without scrolling - preserve path and query params
350
+ const url = new URL(window.location);
351
+ url.hash = hash;
352
+ history.replaceState(null, null, url);
353
+ }
354
+
355
+ highlightLinesFromHash() {
356
+ const hash = window.location.hash.slice(1); // Remove #
357
+ if (!hash.startsWith('L')) return;
358
+
359
+ const match = hash.match(/^L(\d+)(?:-L(\d+))?$/);
360
+ if (!match) return;
361
+
362
+ const start = parseInt(match[1], 10);
363
+ const end = match[2] ? parseInt(match[2], 10) : start;
364
+
365
+ this.highlightLines(start, end);
366
+ this.lastSelectedLine = start;
367
+
368
+ // Scroll to the first selected line
369
+ const firstLine = document.querySelector(`.line-container[data-line="${start}"]`);
370
+ if (firstLine) {
371
+ firstLine.scrollIntoView({ behavior: 'smooth', block: 'center' });
372
+ }
373
+ }
374
+
375
+ initializeContentSearch() {
376
+ if (!this.contentSearch) {
377
+ this.contentSearch = new ContentSearchHandler();
378
+ }
379
+ this.contentSearch.init();
380
+ }
381
+
382
+ /**
383
+ * Cleanup components before navigation/re-initialization
384
+ */
385
+ cleanupComponents() {
386
+ // Cleanup file viewer
387
+ if (this.fileViewer && typeof this.fileViewer.destroy === 'function') {
388
+ this.fileViewer.destroy();
389
+ this.fileViewer = null;
390
+ }
391
+
392
+ // Cleanup inline search
393
+ if (this.inlineSearch && typeof this.inlineSearch.destroy === 'function') {
394
+ this.inlineSearch.destroy();
395
+ this.inlineSearch = null;
396
+ }
397
+
398
+ // Focus mode persists across navigations, no cleanup needed
399
+ // Content search persists across navigations, no cleanup needed
400
+ }
267
401
  }
268
402
 
269
403
  const app = new Application();
@@ -1,60 +1,76 @@
1
1
  /**
2
2
  * Application constants and configuration
3
+ * @module constants
3
4
  */
4
5
 
6
+ // ============================================================================
7
+ // Configuration
8
+ // ============================================================================
9
+
5
10
  export const CONFIG = {
6
- MONACO_CDN: 'https://unpkg.com/monaco-editor@0.45.0/min/vs',
7
- MONACO_VERSION: '0.45.0',
8
- EDITOR_HEIGHT: 600,
9
11
  DEFAULT_PORT: 5555,
12
+ MONACO_CDN: 'https://unpkg.com/monaco-editor@0.45.0/min/vs',
10
13
  NOTIFICATION_DURATION: 4000
11
14
  };
12
15
 
16
+ // ============================================================================
17
+ // Enums
18
+ // ============================================================================
19
+
13
20
  export const THEME = {
14
21
  DARK: 'dark',
15
22
  LIGHT: 'light'
16
23
  };
17
24
 
25
+ // ============================================================================
26
+ // Storage Keys
27
+ // ============================================================================
28
+
18
29
  export const STORAGE_KEYS = {
19
- THEME: 'gh-here-theme',
20
- DRAFT_PREFIX: 'gh-here-draft-'
30
+ THEME: 'gh-here-theme'
21
31
  };
22
32
 
33
+ // ============================================================================
34
+ // Keyboard Shortcuts (alpha-sorted)
35
+ // ============================================================================
36
+
37
+ export const KEYBOARD_SHORTCUTS = {
38
+ ESCAPE: 'Escape',
39
+ GO_UP: 'h',
40
+ HELP: '?',
41
+ NAV_DOWN: 'j',
42
+ NAV_UP: 'k',
43
+ OPEN: 'o',
44
+ REFRESH: 'r',
45
+ SEARCH: ['/', 's'],
46
+ SHOW_DIFF: 'd',
47
+ THEME_TOGGLE: 't',
48
+ TOGGLE_GITIGNORE: 'i'
49
+ };
50
+
51
+ // ============================================================================
52
+ // Monaco Editor Options (for FileViewer read-only mode)
53
+ // ============================================================================
54
+
23
55
  export const EDITOR_OPTIONS = {
24
- minimap: { enabled: false },
25
- lineNumbers: 'on',
26
- wordWrap: 'off',
27
- scrollBeyondLastLine: false,
28
- fontSize: 12,
29
- lineHeight: 20,
30
- fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
31
- padding: { top: 16, bottom: 16 },
32
- renderLineHighlight: 'line',
33
- selectOnLineNumbers: true,
34
56
  automaticLayout: true,
57
+ bracketPairColorization: { enabled: true },
35
58
  folding: true,
36
59
  foldingHighlight: true,
37
60
  foldingStrategy: 'auto',
38
- showFoldingControls: 'mouseover',
39
- bracketPairColorization: { enabled: true },
61
+ fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
62
+ fontSize: 12,
40
63
  guides: {
41
64
  bracketPairs: true,
42
65
  indentation: true
43
- }
44
- };
45
-
46
- export const KEYBOARD_SHORTCUTS = {
47
- SEARCH: ['/', 's'],
48
- THEME_TOGGLE: 't',
49
- HELP: '?',
50
- ESCAPE: 'Escape',
51
- GO_UP: 'h',
52
- REFRESH: 'r',
53
- CREATE_FILE: 'c',
54
- EDIT_FILE: 'e',
55
- SHOW_DIFF: 'd',
56
- TOGGLE_GITIGNORE: 'i',
57
- NAV_DOWN: 'j',
58
- NAV_UP: 'k',
59
- OPEN: 'o'
66
+ },
67
+ lineHeight: 20,
68
+ lineNumbers: 'on',
69
+ minimap: { enabled: false },
70
+ padding: { top: 16, bottom: 16 },
71
+ renderLineHighlight: 'line',
72
+ scrollBeyondLastLine: false,
73
+ selectOnLineNumbers: true,
74
+ showFoldingControls: 'mouseover',
75
+ wordWrap: 'off'
60
76
  };