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
@@ -35,6 +35,11 @@ export class NavigationHandler {
35
35
  const link = e.target.closest('a');
36
36
  if (!link) return;
37
37
 
38
+ // Skip line number links (hash-only navigation like #L10)
39
+ if (link.classList.contains('line-number')) {
40
+ return;
41
+ }
42
+
38
43
  // Skip if clicking inside quick actions or other interactive elements
39
44
  if (e.target.closest('.quick-actions, button, .file-action-btn')) {
40
45
  return;
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Symbol Outline - Code structure navigation panel
3
+ * Shows functions, classes, and other symbols in the current file
4
+ * @module symbol-outline
5
+ */
6
+
7
+ import { escapeHtml, PathUtils } from './utils.js';
8
+
9
+ // ============================================================================
10
+ // Constants (alpha-sorted by key)
11
+ // ============================================================================
12
+
13
+ const SYMBOL_ICONS = {
14
+ class: 'C',
15
+ constant: 'K',
16
+ export: 'E',
17
+ function: 'f',
18
+ import: 'i',
19
+ interface: 'I',
20
+ keyframes: '@',
21
+ media: '@',
22
+ method: 'm',
23
+ mixin: '@',
24
+ selector: '#',
25
+ type: 'T',
26
+ variable: 'v'
27
+ };
28
+
29
+ const SYMBOL_LABELS = {
30
+ class: 'Classes',
31
+ constant: 'Constants',
32
+ function: 'Functions',
33
+ interface: 'Interfaces',
34
+ keyframes: 'Keyframes',
35
+ media: 'Media Queries',
36
+ method: 'Methods',
37
+ mixin: 'Mixins',
38
+ selector: 'Selectors',
39
+ type: 'Types',
40
+ variable: 'Variables'
41
+ };
42
+
43
+ export class SymbolOutline {
44
+ constructor() {
45
+ this.panel = null;
46
+ this.button = null;
47
+ this.isOpen = false;
48
+ this.symbols = [];
49
+ this.grouped = {};
50
+ this.currentPath = null;
51
+ this.selectedIndex = -1;
52
+ this.abortController = null;
53
+ }
54
+
55
+ /**
56
+ * Initialize the symbol outline
57
+ */
58
+ init() {
59
+ this.currentPath = PathUtils.getCurrentPath();
60
+
61
+ // Only initialize on file view pages (not directories)
62
+ if (!this.isFileViewPage()) {
63
+ return;
64
+ }
65
+
66
+ this.createButton();
67
+ this.createPanel();
68
+ this.setupKeyboardShortcuts();
69
+ this.loadSymbols();
70
+ }
71
+
72
+ /**
73
+ * Check if we're on a file view page
74
+ */
75
+ isFileViewPage() {
76
+ const fileContent = document.querySelector('.file-content');
77
+ const codeBlock = document.querySelector('.file-content pre code');
78
+ return fileContent && codeBlock;
79
+ }
80
+
81
+ /**
82
+ * Create the toggle button in the file header
83
+ */
84
+ createButton() {
85
+ const fileHeaderActions = document.querySelector('.file-header-actions');
86
+ if (!fileHeaderActions) return;
87
+
88
+ // Check if button already exists
89
+ if (document.querySelector('.symbol-outline-btn')) return;
90
+
91
+ this.button = document.createElement('button');
92
+ this.button.className = 'symbol-outline-btn file-action-btn';
93
+ this.button.title = 'Symbol outline (Cmd+Shift+O)';
94
+ this.button.innerHTML = `
95
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
96
+ <path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75zm0 5a.75.75 0 0 1 .75-.75h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1-.75-.75z"/>
97
+ </svg>
98
+ `;
99
+
100
+ this.button.addEventListener('click', () => this.toggle());
101
+
102
+ // Insert at the beginning of actions
103
+ fileHeaderActions.insertBefore(this.button, fileHeaderActions.firstChild);
104
+ }
105
+
106
+ /**
107
+ * Create the dropdown panel
108
+ */
109
+ createPanel() {
110
+ // Remove existing panel if any
111
+ const existing = document.querySelector('.symbol-outline-panel');
112
+ if (existing) existing.remove();
113
+
114
+ this.panel = document.createElement('div');
115
+ this.panel.className = 'symbol-outline-panel';
116
+ this.panel.innerHTML = `
117
+ <div class="symbol-outline-header">
118
+ <span class="symbol-outline-title">Outline</span>
119
+ <span class="symbol-outline-count"></span>
120
+ </div>
121
+ <div class="symbol-outline-search">
122
+ <input type="text" placeholder="Filter symbols..." class="symbol-search-input">
123
+ </div>
124
+ <div class="symbol-outline-content">
125
+ <div class="symbol-outline-loading">Loading symbols...</div>
126
+ </div>
127
+ `;
128
+
129
+ // Append to body to avoid z-index issues with Monaco editor
130
+ document.body.appendChild(this.panel);
131
+
132
+ // Setup search filtering
133
+ const searchInput = this.panel.querySelector('.symbol-search-input');
134
+ searchInput.addEventListener('input', (e) => this.filterSymbols(e.target.value));
135
+ searchInput.addEventListener('keydown', (e) => this.handleSearchKeydown(e));
136
+
137
+ // Close when clicking outside
138
+ document.addEventListener('click', (e) => {
139
+ if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
140
+ this.close();
141
+ }
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Setup keyboard shortcuts
147
+ */
148
+ setupKeyboardShortcuts() {
149
+ document.addEventListener('keydown', (e) => {
150
+ // Cmd+Shift+O to toggle outline
151
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'o') {
152
+ e.preventDefault();
153
+ this.toggle();
154
+ }
155
+
156
+ // Escape to close
157
+ if (e.key === 'Escape' && this.isOpen) {
158
+ e.preventDefault();
159
+ this.close();
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Toggle panel open/closed
166
+ */
167
+ toggle() {
168
+ if (this.isOpen) {
169
+ this.close();
170
+ } else {
171
+ this.open();
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Open the panel
177
+ */
178
+ open() {
179
+ if (!this.panel) return;
180
+
181
+ this.isOpen = true;
182
+ this.panel.classList.add('open');
183
+ this.button?.classList.add('active');
184
+
185
+ // Position panel relative to button
186
+ this.positionPanel();
187
+
188
+ // Focus search input
189
+ setTimeout(() => {
190
+ const searchInput = this.panel.querySelector('.symbol-search-input');
191
+ searchInput?.focus();
192
+ }, 50);
193
+ }
194
+
195
+ /**
196
+ * Position the panel relative to the button
197
+ */
198
+ positionPanel() {
199
+ if (!this.button || !this.panel) return;
200
+
201
+ const buttonRect = this.button.getBoundingClientRect();
202
+ const panelWidth = 300;
203
+
204
+ // Position below the button, aligned to the right
205
+ let left = buttonRect.right - panelWidth;
206
+ let top = buttonRect.bottom + 8;
207
+
208
+ // Make sure it doesn't go off the left edge
209
+ if (left < 8) left = 8;
210
+
211
+ // Make sure it doesn't go off the right edge
212
+ if (left + panelWidth > window.innerWidth - 8) {
213
+ left = window.innerWidth - panelWidth - 8;
214
+ }
215
+
216
+ this.panel.style.left = `${left}px`;
217
+ this.panel.style.top = `${top}px`;
218
+ }
219
+
220
+ /**
221
+ * Close the panel
222
+ */
223
+ close() {
224
+ if (!this.panel) return;
225
+
226
+ this.isOpen = false;
227
+ this.panel.classList.remove('open');
228
+ this.button?.classList.remove('active');
229
+ this.selectedIndex = -1;
230
+
231
+ // Clear search
232
+ const searchInput = this.panel.querySelector('.symbol-search-input');
233
+ if (searchInput) searchInput.value = '';
234
+ this.filterSymbols('');
235
+ }
236
+
237
+ /**
238
+ * Load symbols from API
239
+ */
240
+ async loadSymbols() {
241
+ if (!this.currentPath) return;
242
+
243
+ // Cancel previous request
244
+ if (this.abortController) {
245
+ this.abortController.abort();
246
+ }
247
+ this.abortController = new AbortController();
248
+
249
+ try {
250
+ const response = await fetch(`/api/symbols?path=${encodeURIComponent(this.currentPath)}`, {
251
+ signal: this.abortController.signal
252
+ });
253
+
254
+ const data = await response.json();
255
+
256
+ if (data.success) {
257
+ this.symbols = data.symbols || [];
258
+ this.grouped = data.grouped || {};
259
+ this.renderSymbols();
260
+ this.updateCount();
261
+ } else {
262
+ this.showError('Could not parse symbols');
263
+ }
264
+ } catch (error) {
265
+ if (error.name !== 'AbortError') {
266
+ this.showError('Failed to load symbols');
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Render symbols in the panel
273
+ */
274
+ renderSymbols() {
275
+ const content = this.panel.querySelector('.symbol-outline-content');
276
+
277
+ if (this.symbols.length === 0) {
278
+ content.innerHTML = '<div class="symbol-outline-empty">No symbols found</div>';
279
+ return;
280
+ }
281
+
282
+ let html = '';
283
+
284
+ // Render grouped symbols
285
+ const kindOrder = ['class', 'interface', 'type', 'function', 'method', 'constant', 'variable', 'selector', 'keyframes', 'media', 'mixin'];
286
+
287
+ for (const kind of kindOrder) {
288
+ const symbols = this.grouped[kind];
289
+ if (!symbols || symbols.length === 0) continue;
290
+
291
+ html += `
292
+ <div class="symbol-group" data-kind="${kind}">
293
+ <div class="symbol-group-header">
294
+ <span class="symbol-group-label">${SYMBOL_LABELS[kind] || kind}</span>
295
+ <span class="symbol-group-count">${symbols.length}</span>
296
+ </div>
297
+ <div class="symbol-group-items">
298
+ ${symbols.map((s, i) => this.renderSymbolItem(s, i)).join('')}
299
+ </div>
300
+ </div>
301
+ `;
302
+ }
303
+
304
+ content.innerHTML = html;
305
+
306
+ // Add click handlers
307
+ content.querySelectorAll('.symbol-item').forEach(item => {
308
+ item.addEventListener('click', () => {
309
+ const line = parseInt(item.dataset.line, 10);
310
+ this.navigateToLine(line);
311
+ });
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Render a single symbol item
317
+ */
318
+ renderSymbolItem(symbol, index) {
319
+ const icon = SYMBOL_ICONS[symbol.kind] || '?';
320
+ return `
321
+ <div class="symbol-item" data-line="${symbol.line}" data-index="${index}" data-name="${symbol.name.toLowerCase()}">
322
+ <span class="symbol-icon symbol-icon-${symbol.kind}">${icon}</span>
323
+ <span class="symbol-name">${escapeHtml(symbol.name)}</span>
324
+ <span class="symbol-line">:${symbol.line}</span>
325
+ </div>
326
+ `;
327
+ }
328
+
329
+ /**
330
+ * Update symbol count in header
331
+ */
332
+ updateCount() {
333
+ const countEl = this.panel.querySelector('.symbol-outline-count');
334
+ if (countEl) {
335
+ countEl.textContent = this.symbols.length > 0 ? `(${this.symbols.length})` : '';
336
+ }
337
+
338
+ // Update button to show count badge if symbols exist
339
+ if (this.button && this.symbols.length > 0) {
340
+ this.button.title = `Symbol outline (${this.symbols.length} symbols) - Cmd+Shift+O`;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Filter symbols by search query
346
+ */
347
+ filterSymbols(query) {
348
+ const items = this.panel.querySelectorAll('.symbol-item');
349
+ const groups = this.panel.querySelectorAll('.symbol-group');
350
+ const lowerQuery = query.toLowerCase();
351
+
352
+ items.forEach(item => {
353
+ const name = item.dataset.name || '';
354
+ const matches = !query || name.includes(lowerQuery);
355
+ item.style.display = matches ? '' : 'none';
356
+ });
357
+
358
+ // Hide empty groups
359
+ groups.forEach(group => {
360
+ const visibleItems = group.querySelectorAll('.symbol-item[style=""], .symbol-item:not([style])');
361
+ const hasVisible = Array.from(group.querySelectorAll('.symbol-item')).some(item => item.style.display !== 'none');
362
+ group.style.display = hasVisible ? '' : 'none';
363
+ });
364
+
365
+ this.selectedIndex = -1;
366
+ }
367
+
368
+ /**
369
+ * Handle keyboard navigation in search
370
+ */
371
+ handleSearchKeydown(e) {
372
+ const visibleItems = Array.from(this.panel.querySelectorAll('.symbol-item'))
373
+ .filter(item => item.style.display !== 'none');
374
+
375
+ if (e.key === 'ArrowDown') {
376
+ e.preventDefault();
377
+ this.selectedIndex = Math.min(this.selectedIndex + 1, visibleItems.length - 1);
378
+ this.highlightSelected(visibleItems);
379
+ } else if (e.key === 'ArrowUp') {
380
+ e.preventDefault();
381
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
382
+ this.highlightSelected(visibleItems);
383
+ } else if (e.key === 'Enter') {
384
+ e.preventDefault();
385
+ if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
386
+ const line = parseInt(visibleItems[this.selectedIndex].dataset.line, 10);
387
+ this.navigateToLine(line);
388
+ }
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Highlight selected item
394
+ */
395
+ highlightSelected(items) {
396
+ items.forEach((item, i) => {
397
+ item.classList.toggle('selected', i === this.selectedIndex);
398
+ });
399
+
400
+ // Scroll selected into view
401
+ if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
402
+ items[this.selectedIndex].scrollIntoView({ block: 'nearest' });
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Navigate to a specific line
408
+ */
409
+ navigateToLine(line) {
410
+ // Close panel
411
+ this.close();
412
+
413
+ // Find the line element
414
+ const lineElement = document.querySelector(`#L${line}`) ||
415
+ document.querySelector(`[data-line="${line}"]`) ||
416
+ document.querySelector(`.line-container[data-line="${line}"]`);
417
+
418
+ if (lineElement) {
419
+ // Scroll to line
420
+ lineElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
421
+
422
+ // Highlight the line briefly
423
+ const container = lineElement.closest('.line-container') || lineElement.parentElement;
424
+ if (container) {
425
+ container.classList.add('line-highlight-flash');
426
+ setTimeout(() => {
427
+ container.classList.remove('line-highlight-flash');
428
+ }, 2000);
429
+ }
430
+
431
+ // Update URL hash
432
+ window.history.replaceState(null, '', `#L${line}`);
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Show error message
438
+ */
439
+ showError(message) {
440
+ const content = this.panel.querySelector('.symbol-outline-content');
441
+ content.innerHTML = `<div class="symbol-outline-error">${escapeHtml(message)}</div>`;
442
+ }
443
+
444
+ /**
445
+ * Cleanup
446
+ */
447
+ destroy() {
448
+ if (this.abortController) {
449
+ this.abortController.abort();
450
+ }
451
+ this.panel?.remove();
452
+ this.button?.remove();
453
+ }
454
+ }