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,551 @@
1
+ /**
2
+ * Full-text content search handler
3
+ * Search across entire codebase
4
+ *
5
+ * @class ContentSearchHandler
6
+ */
7
+
8
+ import { escapeHtml } from './utils.js';
9
+
10
+ // ============================================================================
11
+ // Constants (alpha-sorted)
12
+ // ============================================================================
13
+
14
+ const API_ENDPOINT = '/api/search-content';
15
+ const DEBOUNCE_DELAY = 300;
16
+ const MAX_RESULTS = 100;
17
+ const SEARCH_OVERLAY_CLASS = 'content-search-overlay';
18
+
19
+ // ============================================================================
20
+ // ContentSearchHandler Class
21
+ // ============================================================================
22
+
23
+ export class ContentSearchHandler {
24
+ constructor() {
25
+ this.isActive = false;
26
+ this.searchOverlay = null;
27
+ this.searchInput = null;
28
+ this.results = [];
29
+ this.currentQuery = '';
30
+ this.regexMode = false;
31
+ this.caseSensitive = false;
32
+ this.debounceTimer = null;
33
+ this.abortController = null;
34
+ this.eventHandlers = new Map();
35
+ }
36
+
37
+ // ========================================================================
38
+ // Public API
39
+ // ========================================================================
40
+
41
+ /**
42
+ * Initialize content search
43
+ */
44
+ init() {
45
+ this.setupKeyboardShortcuts();
46
+ }
47
+
48
+ /**
49
+ * Show search overlay
50
+ */
51
+ show() {
52
+ if (this.isActive) {
53
+ this.focusSearchInput();
54
+ return;
55
+ }
56
+
57
+ this.isActive = true;
58
+ this.createSearchOverlay();
59
+ document.body.appendChild(this.searchOverlay);
60
+ this.focusSearchInput();
61
+ }
62
+
63
+ /**
64
+ * Hide search overlay
65
+ */
66
+ hide() {
67
+ if (!this.isActive) return;
68
+
69
+ this.cancelSearch();
70
+ this.cleanupDebounce();
71
+ this.removeSearchOverlay();
72
+ this.resetState();
73
+ }
74
+
75
+ /**
76
+ * Cleanup: Remove event listeners and cancel requests
77
+ */
78
+ destroy() {
79
+ this.hide();
80
+ this.removeEventListeners();
81
+ this.results = [];
82
+ }
83
+
84
+ // ========================================================================
85
+ // Search Operations
86
+ // ========================================================================
87
+
88
+ /**
89
+ * Perform search with debouncing and cancellation
90
+ */
91
+ async performSearch() {
92
+ if (!this.searchInput) return;
93
+
94
+ const query = this.searchInput.value.trim();
95
+ this.currentQuery = query;
96
+
97
+ const statusEl = this.getStatusElement();
98
+ const resultsContainer = this.getResultsContainer();
99
+
100
+ if (!query) {
101
+ this.clearResults(statusEl, resultsContainer);
102
+ return;
103
+ }
104
+
105
+ this.cancelSearch();
106
+ this.abortController = new AbortController();
107
+ this.updateStatus(statusEl, 'Searching...');
108
+
109
+ try {
110
+ const data = await this.fetchSearchResults(query);
111
+
112
+ // Check if query changed during async operation
113
+ if (this.currentQuery !== query) return;
114
+
115
+ this.results = data.results || [];
116
+ this.displayResults(data);
117
+ } catch (error) {
118
+ if (error.name === 'AbortError') return;
119
+ this.handleSearchError(error, statusEl, resultsContainer);
120
+ } finally {
121
+ this.abortController = null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Fetch search results from API
127
+ */
128
+ async fetchSearchResults(query) {
129
+ const params = new URLSearchParams({
130
+ q: query,
131
+ regex: String(this.regexMode),
132
+ caseSensitive: String(this.caseSensitive),
133
+ maxResults: String(MAX_RESULTS)
134
+ });
135
+
136
+ const response = await fetch(`${API_ENDPOINT}?${params.toString()}`, {
137
+ signal: this.abortController.signal
138
+ });
139
+
140
+ if (!response.ok) {
141
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
142
+ }
143
+
144
+ const data = await response.json();
145
+
146
+ if (!data.success) {
147
+ throw new Error(data.error || 'Search failed');
148
+ }
149
+
150
+ return data;
151
+ }
152
+
153
+ /**
154
+ * Display search results
155
+ */
156
+ displayResults(data) {
157
+ const statusEl = this.getStatusElement();
158
+ const resultsContainer = this.getResultsContainer();
159
+
160
+ if (!statusEl || !resultsContainer) return;
161
+
162
+ statusEl.classList.remove('error');
163
+
164
+ if (data.total === 0) {
165
+ statusEl.textContent = 'No results found';
166
+ resultsContainer.innerHTML = '';
167
+ return;
168
+ }
169
+
170
+ this.updateStatusWithCounts(statusEl, data);
171
+ this.renderResults(resultsContainer, data.results);
172
+ }
173
+
174
+ /**
175
+ * Render search results HTML
176
+ */
177
+ renderResults(container, results) {
178
+ const resultsHtml = results.map(result => this.renderResultItem(result)).join('');
179
+ container.innerHTML = resultsHtml;
180
+ this.attachResultClickHandlers(container);
181
+ }
182
+
183
+ /**
184
+ * Render a single result item
185
+ */
186
+ renderResultItem(result) {
187
+ const matchesHtml = result.matches.slice(0, 5).map(match =>
188
+ this.renderMatchLine(match)
189
+ ).join('');
190
+
191
+ const moreMatches = result.matchCount > 5
192
+ ? `<div class="content-search-more">+${result.matchCount - 5} more matches</div>`
193
+ : '';
194
+
195
+ const escapedPath = escapeHtml(result.path);
196
+
197
+ return `
198
+ <div class="content-search-result" data-path="${escapedPath}">
199
+ <div class="content-search-result-header">
200
+ <span class="result-path">${escapedPath}</span>
201
+ <span class="result-count">${result.matchCount} match${result.matchCount !== 1 ? 'es' : ''}</span>
202
+ </div>
203
+ <div class="content-search-matches">
204
+ ${matchesHtml}
205
+ ${moreMatches}
206
+ </div>
207
+ </div>
208
+ `;
209
+ }
210
+
211
+ /**
212
+ * Render a single match line
213
+ */
214
+ renderMatchLine(match) {
215
+ const highlighted = this.highlightMatch(match.text, match.match);
216
+ return `
217
+ <div class="content-search-match">
218
+ <span class="match-line-number">${match.line}</span>
219
+ <span class="match-content">${highlighted}</span>
220
+ </div>
221
+ `;
222
+ }
223
+
224
+ /**
225
+ * Attach click handlers to result items
226
+ */
227
+ attachResultClickHandlers(container) {
228
+ container.querySelectorAll('.content-search-result').forEach(resultEl => {
229
+ const clickHandler = () => {
230
+ const path = resultEl.getAttribute('data-path');
231
+ if (path) {
232
+ window.location.href = `/?path=${encodeURIComponent(path)}`;
233
+ }
234
+ };
235
+ resultEl.addEventListener('click', clickHandler);
236
+ resultEl._clickHandler = clickHandler;
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Highlight match in text
242
+ */
243
+ highlightMatch(text, match) {
244
+ if (!match || !text) return escapeHtml(text);
245
+
246
+ const escapedText = escapeHtml(text);
247
+ const escapedMatch = escapeHtml(match);
248
+ const escapedPattern = escapedMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
249
+ const regex = new RegExp(escapedPattern, 'gi');
250
+
251
+ return escapedText.replace(regex, `<mark class="content-search-highlight">$&</mark>`);
252
+ }
253
+
254
+ // ========================================================================
255
+ // UI Management
256
+ // ========================================================================
257
+
258
+ /**
259
+ * Create search overlay UI
260
+ */
261
+ createSearchOverlay() {
262
+ this.searchOverlay = document.createElement('div');
263
+ this.searchOverlay.className = SEARCH_OVERLAY_CLASS;
264
+ this.searchOverlay.setAttribute('role', 'dialog');
265
+ this.searchOverlay.setAttribute('aria-label', 'Search in files');
266
+ this.searchOverlay.innerHTML = this.getSearchOverlayHTML();
267
+
268
+ this.attachSearchOverlayHandlers();
269
+ }
270
+
271
+ /**
272
+ * Get search overlay HTML template
273
+ */
274
+ getSearchOverlayHTML() {
275
+ return `
276
+ <div class="content-search-container">
277
+ <div class="content-search-header">
278
+ <h3>Search in files</h3>
279
+ <button class="content-search-close" aria-label="Close search">×</button>
280
+ </div>
281
+ <div class="content-search-input-wrapper">
282
+ <input
283
+ type="text"
284
+ class="content-search-input"
285
+ placeholder="Search for text in files..."
286
+ autocomplete="off"
287
+ aria-label="Search query"
288
+ />
289
+ <div class="content-search-options">
290
+ <button class="content-search-option" data-option="case" title="Case sensitive" aria-pressed="false">
291
+ <span class="option-label">Aa</span>
292
+ </button>
293
+ <button class="content-search-option" data-option="regex" title="Use regular expression" aria-pressed="false">
294
+ <span class="option-label">.*</span>
295
+ </button>
296
+ </div>
297
+ </div>
298
+ <div class="content-search-info">
299
+ <span class="content-search-status">Ready to search</span>
300
+ </div>
301
+ <div class="content-search-results"></div>
302
+ </div>
303
+ `;
304
+ }
305
+
306
+ /**
307
+ * Attach event handlers to search overlay elements
308
+ */
309
+ attachSearchOverlayHandlers() {
310
+ this.searchInput = this.searchOverlay.querySelector('.content-search-input');
311
+ const closeBtn = this.searchOverlay.querySelector('.content-search-close');
312
+ const caseBtn = this.searchOverlay.querySelector('[data-option="case"]');
313
+ const regexBtn = this.searchOverlay.querySelector('[data-option="regex"]');
314
+
315
+ this.attachCloseHandler(closeBtn);
316
+ this.attachCaseHandler(caseBtn);
317
+ this.attachRegexHandler(regexBtn);
318
+ this.attachInputHandler(this.searchInput);
319
+ this.updateButtonStates(caseBtn, regexBtn);
320
+ }
321
+
322
+ /**
323
+ * Attach close button handler
324
+ */
325
+ attachCloseHandler(closeBtn) {
326
+ const handler = () => this.hide();
327
+ closeBtn.addEventListener('click', handler);
328
+ this.eventHandlers.set('close', handler);
329
+ }
330
+
331
+ /**
332
+ * Attach case sensitive toggle handler
333
+ */
334
+ attachCaseHandler(caseBtn) {
335
+ const handler = () => {
336
+ this.caseSensitive = !this.caseSensitive;
337
+ caseBtn.classList.toggle('active', this.caseSensitive);
338
+ caseBtn.setAttribute('aria-pressed', String(this.caseSensitive));
339
+ this.performSearch();
340
+ };
341
+ caseBtn.addEventListener('click', handler);
342
+ this.eventHandlers.set('case', handler);
343
+ }
344
+
345
+ /**
346
+ * Attach regex toggle handler
347
+ */
348
+ attachRegexHandler(regexBtn) {
349
+ const handler = () => {
350
+ this.regexMode = !this.regexMode;
351
+ regexBtn.classList.toggle('active', this.regexMode);
352
+ regexBtn.setAttribute('aria-pressed', String(this.regexMode));
353
+ this.performSearch();
354
+ };
355
+ regexBtn.addEventListener('click', handler);
356
+ this.eventHandlers.set('regex', handler);
357
+ }
358
+
359
+ /**
360
+ * Attach input handler with debounce
361
+ */
362
+ attachInputHandler(input) {
363
+ const handler = () => {
364
+ this.cleanupDebounce();
365
+ this.debounceTimer = setTimeout(() => {
366
+ this.performSearch();
367
+ }, DEBOUNCE_DELAY);
368
+ };
369
+ input.addEventListener('input', handler);
370
+ this.eventHandlers.set('input', handler);
371
+ }
372
+
373
+ /**
374
+ * Update button states to reflect current settings
375
+ */
376
+ updateButtonStates(caseBtn, regexBtn) {
377
+ if (this.caseSensitive) {
378
+ caseBtn.classList.add('active');
379
+ caseBtn.setAttribute('aria-pressed', 'true');
380
+ }
381
+ if (this.regexMode) {
382
+ regexBtn.classList.add('active');
383
+ regexBtn.setAttribute('aria-pressed', 'true');
384
+ }
385
+ }
386
+
387
+ // ========================================================================
388
+ // Event Handling
389
+ // ========================================================================
390
+
391
+ /**
392
+ * Setup keyboard shortcuts
393
+ */
394
+ setupKeyboardShortcuts() {
395
+ const keyHandler = (e) => this.handleKeydown(e);
396
+ document.addEventListener('keydown', keyHandler);
397
+ this.eventHandlers.set('keydown', keyHandler);
398
+ }
399
+
400
+ /**
401
+ * Handle keyboard events
402
+ */
403
+ handleKeydown(e) {
404
+ // Don't interfere if user is typing in an input
405
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
406
+ return;
407
+ }
408
+
409
+ // Cmd/Ctrl+Shift+F: Show content search
410
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
411
+ e.preventDefault();
412
+ this.show();
413
+ return;
414
+ }
415
+
416
+ // Only handle other shortcuts when search is active
417
+ if (!this.isActive) return;
418
+
419
+ // Escape: Close search
420
+ if (e.key === 'Escape') {
421
+ e.preventDefault();
422
+ this.hide();
423
+ return;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Remove all event listeners
429
+ */
430
+ removeEventListeners() {
431
+ this.eventHandlers.forEach((handler, event) => {
432
+ if (event === 'keydown') {
433
+ document.removeEventListener(event, handler);
434
+ }
435
+ });
436
+ this.eventHandlers.clear();
437
+ }
438
+
439
+ // ========================================================================
440
+ // State Management
441
+ // ========================================================================
442
+
443
+ /**
444
+ * Reset search state
445
+ */
446
+ resetState() {
447
+ this.isActive = false;
448
+ this.results = [];
449
+ this.currentQuery = '';
450
+ this.searchInput = null;
451
+ }
452
+
453
+ /**
454
+ * Clear search results
455
+ */
456
+ clearResults(statusEl, resultsContainer) {
457
+ if (statusEl) statusEl.textContent = 'Ready to search';
458
+ if (resultsContainer) resultsContainer.innerHTML = '';
459
+ this.results = [];
460
+ }
461
+
462
+ /**
463
+ * Handle search errors
464
+ */
465
+ handleSearchError(error, statusEl, resultsContainer) {
466
+ if (statusEl) {
467
+ statusEl.textContent = `Error: ${error.message}`;
468
+ statusEl.classList.add('error');
469
+ }
470
+ if (resultsContainer) resultsContainer.innerHTML = '';
471
+ this.results = [];
472
+ }
473
+
474
+ // ========================================================================
475
+ // Utilities
476
+ // ========================================================================
477
+
478
+ /**
479
+ * Get status element from overlay
480
+ */
481
+ getStatusElement() {
482
+ return this.searchOverlay?.querySelector('.content-search-status');
483
+ }
484
+
485
+ /**
486
+ * Get results container from overlay
487
+ */
488
+ getResultsContainer() {
489
+ return this.searchOverlay?.querySelector('.content-search-results');
490
+ }
491
+
492
+ /**
493
+ * Update status text
494
+ */
495
+ updateStatus(statusEl, text) {
496
+ if (!statusEl) return;
497
+ statusEl.textContent = text;
498
+ statusEl.classList.remove('error');
499
+ }
500
+
501
+ /**
502
+ * Update status with result counts
503
+ */
504
+ updateStatusWithCounts(statusEl, data) {
505
+ const fileCount = data.results.length;
506
+ const resultCount = data.total;
507
+ statusEl.textContent = `Found ${resultCount} result${resultCount !== 1 ? 's' : ''} in ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
508
+ }
509
+
510
+ /**
511
+ * Focus search input
512
+ */
513
+ focusSearchInput() {
514
+ if (!this.searchInput) return;
515
+
516
+ requestAnimationFrame(() => {
517
+ this.searchInput.focus();
518
+ this.searchInput.select();
519
+ });
520
+ }
521
+
522
+ /**
523
+ * Remove search overlay from DOM
524
+ */
525
+ removeSearchOverlay() {
526
+ if (this.searchOverlay) {
527
+ this.searchOverlay.remove();
528
+ this.searchOverlay = null;
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Cancel in-flight search request
534
+ */
535
+ cancelSearch() {
536
+ if (this.abortController) {
537
+ this.abortController.abort();
538
+ this.abortController = null;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Cleanup debounce timer
544
+ */
545
+ cleanupDebounce() {
546
+ if (this.debounceTimer) {
547
+ clearTimeout(this.debounceTimer);
548
+ this.debounceTimer = null;
549
+ }
550
+ }
551
+ }