gh-here 2.0.0 → 3.0.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/lib/renderers.js CHANGED
@@ -8,6 +8,23 @@ const { exec } = require('child_process');
8
8
  const { getFileIcon, getLanguageFromExtension, getLanguageColor, formatBytes, isImageFile, isBinaryFile, isTextFile } = require('./file-utils');
9
9
  const { getGitStatusIcon, getGitStatusDescription } = require('./git');
10
10
 
11
+ // Configure marked to use highlight.js for syntax highlighting
12
+ marked.use({
13
+ renderer: {
14
+ code(code, language) {
15
+ if (language && hljs.getLanguage(language)) {
16
+ try {
17
+ return `<pre><code class="hljs language-${language}">${hljs.highlight(code, { language }).value}</code></pre>`;
18
+ } catch (err) {
19
+ // Fall back to auto-detection if language-specific highlighting fails
20
+ }
21
+ }
22
+ // Auto-detect language if not specified or language not found
23
+ return `<pre><code class="hljs">${hljs.highlightAuto(code).value}</code></pre>`;
24
+ }
25
+ }
26
+ });
27
+
11
28
  /**
12
29
  * HTML rendering module
13
30
  * Handles all HTML template generation for different views
@@ -21,8 +38,21 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
21
38
  // Only show language stats on root/top-level directory
22
39
  const languageStats = (!currentPath || currentPath === '.') ? generateLanguageStats(items) : '';
23
40
 
24
- const itemsHtml = items.map(item => `
25
- <tr class="file-row" data-name="${item.name.toLowerCase()}" data-type="${item.isDirectory ? 'dir' : 'file'}" data-path="${item.path}">
41
+ const itemsHtml = items.map(item => {
42
+ const statusMap = {
43
+ 'A': 'added',
44
+ 'M': 'modified',
45
+ 'D': 'deleted',
46
+ 'R': 'renamed',
47
+ '??': 'untracked',
48
+ 'MM': 'mixed',
49
+ 'AM': 'mixed',
50
+ 'AD': 'mixed'
51
+ };
52
+ const statusKey = item.gitStatus ? statusMap[item.gitStatus.status] || '' : '';
53
+ const rowStatusClass = statusKey ? ` file-row--${statusKey}` : '';
54
+ return `
55
+ <tr class="file-row${rowStatusClass}" data-name="${item.name.toLowerCase()}" data-type="${item.isDirectory ? 'dir' : 'file'}" data-path="${item.path}">
26
56
  <td class="icon">
27
57
  ${item.isDirectory ? octicons['file-directory-fill'].toSVG({ class: 'octicon-directory' }) : getFileIcon(item.name)}
28
58
  </td>
@@ -66,7 +96,7 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
66
96
  ${item.modified.toLocaleDateString()}
67
97
  </td>
68
98
  </tr>
69
- `).join('');
99
+ `}).join('');
70
100
 
71
101
  return `
72
102
  <!DOCTYPE html>
@@ -74,7 +104,23 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
74
104
  <head>
75
105
  <title>gh-here: ${currentPath || 'Root'}</title>
76
106
  <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
77
- <script src="/static/app.js"></script>
107
+ <script>
108
+ // Check localStorage and add showGitignored param if needed (before page renders)
109
+ (function() {
110
+ const showGitignored = localStorage.getItem('gh-here-show-gitignored') === 'true';
111
+ const url = new URL(window.location.href);
112
+ const hasParam = url.searchParams.has('showGitignored');
113
+
114
+ if (showGitignored && !hasParam) {
115
+ url.searchParams.set('showGitignored', 'true');
116
+ window.location.replace(url.toString());
117
+ } else if (!showGitignored && hasParam) {
118
+ url.searchParams.delete('showGitignored');
119
+ window.location.replace(url.toString());
120
+ }
121
+ })();
122
+ </script>
123
+ <script type="module" src="/static/app.js"></script>
78
124
  </head>
79
125
  <body>
80
126
  <header>
@@ -83,13 +129,7 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
83
129
  <h1>gh-here</h1>
84
130
  </div>
85
131
  <div class="header-right">
86
- ${Object.keys(gitStatus).length > 0 ? `
87
- <button id="commit-btn" class="commit-btn" title="Commit changes">
88
- ${octicons['git-commit'].toSVG({ class: 'commit-icon' })}
89
- <span class="commit-text">Commit</span>
90
- </button>
91
- ` : ''}
92
- <button id="gitignore-toggle" class="gitignore-toggle ${showGitignored ? 'showing-ignored' : ''}" aria-label="Toggle .gitignore filtering" title="${showGitignored ? 'Hide' : 'Show'} gitignored files">
132
+ <button id="gitignore-toggle" class="gitignore-toggle" aria-label="Toggle .gitignore filtering" title="Toggle gitignored files">
93
133
  ${octicons.eye.toSVG({ class: 'gitignore-icon' })}
94
134
  </button>
95
135
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
@@ -98,41 +138,60 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
98
138
  </div>
99
139
  </div>
100
140
  </header>
101
-
102
- <!-- White canvas section like GitHub -->
103
- <div class="repo-canvas">
104
- <div class="repo-canvas-content">
105
- <div class="breadcrumb-section">${breadcrumbs}</div>
106
- <hr class="repo-divider">
107
-
108
- <div class="repo-controls">
109
- <div class="repo-controls-left">
110
- ${gitBranch ? `
111
- <button class="branch-button">
112
- ${octicons['git-branch'].toSVG({ class: 'octicon-branch' })}
113
- <span class="branch-name">${gitBranch}</span>
114
- ${octicons['chevron-down'].toSVG({ class: 'octicon-chevron' })}
115
- </button>
116
- ` : ''}
117
- ${languageStats}
118
- </div>
119
- <div class="repo-controls-right">
120
- <div class="search-container">
121
- ${octicons.search.toSVG({ class: 'search-icon' })}
122
- <input type="text" id="file-search" placeholder="Go to file" class="search-input">
123
- <kbd class="search-hotkey">t</kbd>
124
- </div>
125
- <button id="new-file-btn" class="btn btn-outline">
126
- <span class="btn-text">New file</span>
141
+
142
+ <main>
143
+ <aside class="file-tree-sidebar ${!currentPath || currentPath === '' ? 'hidden' : ''}">
144
+ <div class="file-tree-header">
145
+ <svg class="files-icon" viewBox="0 0 16 16" width="16" height="16">
146
+ <path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"></path>
147
+ </svg>
148
+ <span>Files</span>
149
+ </div>
150
+ <div class="sidebar-controls">
151
+ ${gitBranch ? `
152
+ <button class="branch-button sidebar-branch">
153
+ ${octicons['git-branch'].toSVG({ class: 'octicon-branch' })}
154
+ <span class="branch-name">${gitBranch}</span>
155
+ ${octicons['chevron-down'].toSVG({ class: 'octicon-chevron' })}
127
156
  </button>
157
+ ` : ''}
158
+ <div class="search-container sidebar-search">
159
+ ${octicons.search.toSVG({ class: 'search-icon' })}
160
+ <input type="text" id="file-search" placeholder="Go to file" class="search-input">
161
+ <kbd class="search-hotkey">t</kbd>
128
162
  </div>
129
163
  </div>
130
- </div>
131
- </div>
132
- <main>
133
- <div class="repo-canvas">
134
- <div class="repo-canvas-content">
135
- <div class="file-table-container">
164
+ <div id="file-tree" class="file-tree-container"></div>
165
+ </aside>
166
+ <div class="main-content-wrapper ${!currentPath || currentPath === '' ? 'no-sidebar' : ''}">
167
+ <div class="repo-canvas">
168
+ <div class="repo-canvas-content">
169
+ <div class="breadcrumb-section">${breadcrumbs}</div>
170
+ ${(!currentPath || currentPath === '.') ? `
171
+ <hr class="repo-divider">
172
+
173
+ <div class="repo-controls">
174
+ <div class="repo-controls-left">
175
+ ${gitBranch ? `
176
+ <button class="branch-button">
177
+ ${octicons['git-branch'].toSVG({ class: 'octicon-branch' })}
178
+ <span class="branch-name">${gitBranch}</span>
179
+ ${octicons['chevron-down'].toSVG({ class: 'octicon-chevron' })}
180
+ </button>
181
+ ` : ''}
182
+ ${languageStats}
183
+ </div>
184
+ <div class="repo-controls-right">
185
+ <div class="search-container">
186
+ ${octicons.search.toSVG({ class: 'search-icon' })}
187
+ <input type="text" id="root-file-search" placeholder="Go to file" class="search-input">
188
+ <kbd class="search-hotkey">/</kbd>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ ` : ''}
193
+
194
+ <div class="file-table-container">
136
195
  <table class="file-table" id="file-table">
137
196
  <thead>
138
197
  <tr>
@@ -159,7 +218,8 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
159
218
  </tbody>
160
219
  </table>
161
220
  </div>
162
- ${readmePreview}
221
+ ${readmePreview}
222
+ </div>
163
223
  </div>
164
224
  </div>
165
225
  </main>
@@ -243,7 +303,7 @@ function generateLanguageStats(items) {
243
303
  `;
244
304
  }
245
305
 
246
- async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir = null) {
306
+ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir = null, gitBranch = null) {
247
307
  const workingDirName = workingDir ? path.basename(workingDir) : null;
248
308
  const breadcrumbs = generateBreadcrumbs(filePath, null, workingDirName);
249
309
 
@@ -260,26 +320,88 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
260
320
 
261
321
  const diffContent = renderRawDiff(stdout, ext);
262
322
  const currentParams = new URLSearchParams({ path: filePath });
263
- const viewUrl = `/?${currentParams.toString()}&view=rendered`;
264
- const rawUrl = `/?${currentParams.toString()}&view=raw`;
323
+ const viewUrl = `/?${currentParams.toString()}`;
265
324
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
266
-
267
- const viewToggle = `
268
- <div class="view-toggle">
269
- <a href="${viewUrl}" class="view-btn">
270
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
271
- </a>
272
- ${ext === 'md' ? `
273
- <a href="${rawUrl}" class="view-btn">
274
- ${octicons['file-code'].toSVG({ class: 'view-icon' })} Raw
275
- </a>
276
- ` : ''}
277
- <a href="${diffUrl}" class="view-btn active">
278
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
279
- </a>
325
+
326
+ // Get file stats for header
327
+ const fullPath = path.join(workingDir, filePath);
328
+ let fileSize = '';
329
+ let lineCount = 0;
330
+ let locCount = 0;
331
+ try {
332
+ const stats = fs.statSync(fullPath);
333
+ fileSize = formatBytes(stats.size);
334
+ if (isTextFile(ext)) {
335
+ const content = fs.readFileSync(fullPath, 'utf8');
336
+ lineCount = content.split('\n').length;
337
+ locCount = content.split('\n').filter(line => line.trim().length > 0).length;
338
+ }
339
+ } catch {
340
+ // File stats optional
341
+ }
342
+
343
+ const rawUrlApi = `/api/file-content?path=${encodeURIComponent(filePath)}`;
344
+ const downloadUrl = `/download?path=${encodeURIComponent(filePath)}`;
345
+
346
+ // Clean file header with separated view toggle and action buttons
347
+ const fileHeader = `
348
+ <div class="file-header">
349
+ <div class="file-header-main">
350
+ <div class="file-path-info">
351
+ <span class="file-path-text">${filePath}</span>
352
+ <button class="file-path-copy-btn" data-path="${filePath}" title="Copy file path">
353
+ ${octicons.copy.toSVG({ class: 'octicon-copy', width: 16, height: 16 })}
354
+ </button>
355
+ </div>
356
+ ${isTextFile(ext) && lineCount > 0 ? `
357
+ <div class="file-stats">
358
+ <span class="file-stat">${lineCount} line${lineCount !== 1 ? 's' : ''}</span>
359
+ <span class="file-stat-separator">·</span>
360
+ <span class="file-stat">${locCount} loc</span>
361
+ <span class="file-stat-separator">·</span>
362
+ <span class="file-stat">${fileSize}</span>
363
+ </div>
364
+ ` : fileSize ? `
365
+ <div class="file-stats">
366
+ <span class="file-stat">${fileSize}</span>
367
+ </div>
368
+ ` : ''}
369
+ </div>
370
+ <div class="file-header-actions">
371
+ <div class="view-toggle">
372
+ <a href="${viewUrl}" class="view-btn">
373
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
374
+ <span>View</span>
375
+ </a>
376
+ <a href="${diffUrl}" class="view-btn active">
377
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
378
+ <span>Diff</span>
379
+ </a>
380
+ </div>
381
+ ${isTextFile(ext) ? `
382
+ <div class="file-action-group">
383
+ <a href="${rawUrlApi}" class="file-action-btn" target="_blank" title="View raw file">
384
+ ${octicons['file-code'].toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
385
+ </a>
386
+ <button class="file-action-btn copy-raw-btn" data-path="${filePath}" title="Copy raw content">
387
+ ${octicons.copy.toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
388
+ </button>
389
+ <a href="${downloadUrl}" class="file-action-btn" download="${path.basename(filePath)}" title="Download file">
390
+ ${octicons.download.toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
391
+ </a>
392
+ </div>
393
+ ` : `
394
+ <div class="file-action-group">
395
+ <a href="${downloadUrl}" class="file-action-btn" download="${path.basename(filePath)}" title="Download file">
396
+ ${octicons.download.toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
397
+ </a>
398
+ </div>
399
+ `}
400
+ </div>
280
401
  </div>
281
402
  `;
282
403
 
404
+ // Use same structure as renderFile - with sidebar
283
405
  const html = `
284
406
  <!DOCTYPE html>
285
407
  <html data-theme="dark">
@@ -287,7 +409,23 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
287
409
  <title>gh-here: ${path.basename(filePath)} (diff)</title>
288
410
  <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
289
411
  <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
290
- <script src="/static/app.js"></script>
412
+ <script>
413
+ // Check localStorage and add showGitignored param if needed (before page renders)
414
+ (function() {
415
+ const showGitignored = localStorage.getItem('gh-here-show-gitignored') === 'true';
416
+ const url = new URL(window.location.href);
417
+ const hasParam = url.searchParams.has('showGitignored');
418
+
419
+ if (showGitignored && !hasParam) {
420
+ url.searchParams.set('showGitignored', 'true');
421
+ window.location.replace(url.toString());
422
+ } else if (!showGitignored && hasParam) {
423
+ url.searchParams.delete('showGitignored');
424
+ window.location.replace(url.toString());
425
+ }
426
+ })();
427
+ </script>
428
+ <script type="module" src="/static/app.js"></script>
291
429
  </head>
292
430
  <body>
293
431
  <header>
@@ -296,7 +434,9 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
296
434
  <h1>gh-here</h1>
297
435
  </div>
298
436
  <div class="header-right">
299
- ${viewToggle}
437
+ <button id="gitignore-toggle" class="gitignore-toggle" aria-label="Toggle .gitignore filtering" title="Toggle gitignored files">
438
+ ${octicons.eye.toSVG({ class: 'gitignore-icon' })}
439
+ </button>
300
440
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
301
441
  ${octicons.moon.toSVG({ class: 'theme-icon' })}
302
442
  </button>
@@ -304,11 +444,32 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
304
444
  </div>
305
445
  </header>
306
446
  <main>
307
- <div class="main-content">
308
- <div class="breadcrumb-section">${breadcrumbs}</div>
309
- <div class="diff-container">
310
- <div class="diff-content">
311
- ${diffContent}
447
+ <aside class="file-tree-sidebar">
448
+ <div class="file-tree-header">
449
+ <svg class="files-icon" viewBox="0 0 16 16" width="16" height="16">
450
+ <path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"></path>
451
+ </svg>
452
+ <span>Files</span>
453
+ </div>
454
+ <div class="sidebar-controls">
455
+ <div class="search-container sidebar-search">
456
+ ${octicons.search.toSVG({ class: 'search-icon' })}
457
+ <input type="text" id="file-search" placeholder="Go to file" class="search-input">
458
+ <kbd class="search-hotkey">t</kbd>
459
+ </div>
460
+ </div>
461
+ <div id="file-tree" class="file-tree-container"></div>
462
+ </aside>
463
+ <div class="main-content-wrapper">
464
+ <div class="main-content">
465
+ <div class="breadcrumb-section">${breadcrumbs}</div>
466
+ ${fileHeader}
467
+ <div class="file-content">
468
+ <div class="diff-container">
469
+ <div class="diff-content">
470
+ ${diffContent}
471
+ </div>
472
+ </div>
312
473
  </div>
313
474
  </div>
314
475
  </div>
@@ -316,7 +477,7 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
316
477
  </body>
317
478
  </html>
318
479
  `;
319
-
480
+
320
481
  resolve(html);
321
482
  });
322
483
  });
@@ -326,51 +487,111 @@ function renderRawDiff(diffOutput, ext) {
326
487
  if (!diffOutput.trim()) {
327
488
  return '<div class="no-changes">No changes to display</div>';
328
489
  }
329
-
330
- const language = getLanguageFromExtension(ext);
331
-
332
- // Apply syntax highlighting to the entire diff
333
- let highlighted;
334
- try {
335
- // Use diff language for syntax highlighting if available, otherwise use the file's language
336
- highlighted = hljs.highlight(diffOutput, { language: 'diff' }).value;
337
- } catch {
338
- // Fallback to plain text if diff highlighting fails
339
- highlighted = diffOutput.replace(/&/g, '&amp;')
340
- .replace(/</g, '&lt;')
341
- .replace(/>/g, '&gt;');
342
- }
343
-
344
- // Split into lines and add line numbers
345
- const lines = highlighted.split('\n');
346
- let lineNumber = 1;
347
-
490
+
491
+ // Parse diff to extract line numbers and render properly
492
+ const lines = diffOutput.split('\n');
493
+ let oldLineNum = 0;
494
+ let newLineNum = 0;
495
+ let inHunk = false;
496
+
348
497
  const linesHtml = lines.map(line => {
349
- // Determine line type based on first character
498
+ // Detect hunk header: @@ -oldStart,oldCount +newStart,newCount @@
499
+ if (line.startsWith('@@')) {
500
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
501
+ if (match) {
502
+ oldLineNum = parseInt(match[1], 10);
503
+ newLineNum = parseInt(match[2], 10);
504
+ inHunk = true;
505
+ }
506
+ // Hunk headers get no line numbers, just styling
507
+ const escapedLine = escapeHtml(line);
508
+ return `<div class="diff-line diff-line-hunk">
509
+ <span class="diff-line-number old"></span>
510
+ <span class="diff-line-number new"></span>
511
+ <span class="diff-line-content">${escapedLine}</span>
512
+ </div>`;
513
+ }
514
+
515
+ // File headers (diff --git, index, ---, +++)
516
+ if (line.startsWith('diff --git') || line.startsWith('index ') ||
517
+ line.startsWith('---') || line.startsWith('+++')) {
518
+ const escapedLine = escapeHtml(line);
519
+ return `<div class="diff-line diff-line-header">
520
+ <span class="diff-line-number old"></span>
521
+ <span class="diff-line-number new"></span>
522
+ <span class="diff-line-content">${escapedLine}</span>
523
+ </div>`;
524
+ }
525
+
526
+ if (!inHunk || line.length === 0) {
527
+ // Empty lines or lines before first hunk
528
+ const escapedLine = escapeHtml(line);
529
+ return `<div class="diff-line diff-line-context">
530
+ <span class="diff-line-number old"></span>
531
+ <span class="diff-line-number new"></span>
532
+ <span class="diff-line-content">${escapedLine}</span>
533
+ </div>`;
534
+ }
535
+
536
+ const firstChar = line.charAt(0);
350
537
  let lineType = 'context';
351
- let displayLine = line;
352
-
353
- if (line.startsWith('<span class="hljs-deletion">-') || line.startsWith('-')) {
538
+ let oldNum = '';
539
+ let newNum = '';
540
+ let content = line;
541
+
542
+ if (firstChar === '-') {
543
+ // Removed line: show old number, blank new number
354
544
  lineType = 'removed';
355
- } else if (line.startsWith('<span class="hljs-addition">+') || line.startsWith('+')) {
545
+ oldNum = oldLineNum.toString();
546
+ newNum = '';
547
+ oldLineNum++;
548
+ // Keep the - prefix in content for visual consistency
549
+ content = line;
550
+ } else if (firstChar === '+') {
551
+ // Added line: blank old number, show new number
356
552
  lineType = 'added';
357
- } else if (line.startsWith('@@') || line.includes('hljs-meta')) {
358
- lineType = 'hunk';
359
- } else if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
360
- lineType = 'header';
553
+ oldNum = '';
554
+ newNum = newLineNum.toString();
555
+ newLineNum++;
556
+ // Keep the + prefix in content
557
+ content = line;
558
+ } else {
559
+ // Context line: show both numbers
560
+ lineType = 'context';
561
+ oldNum = oldLineNum.toString();
562
+ newNum = newLineNum.toString();
563
+ oldLineNum++;
564
+ newLineNum++;
565
+ content = line;
361
566
  }
362
-
363
- const currentLineNumber = (lineType === 'context' || lineType === 'removed' || lineType === 'added') ? lineNumber++ : '';
364
-
567
+
568
+ // Apply syntax highlighting to the content
569
+ let highlightedContent;
570
+ try {
571
+ highlightedContent = hljs.highlight(content, { language: 'diff' }).value;
572
+ } catch {
573
+ highlightedContent = escapeHtml(content);
574
+ }
575
+
365
576
  return `<div class="diff-line diff-line-${lineType}">
366
- <span class="diff-line-number">${currentLineNumber}</span>
367
- <span class="diff-line-content">${displayLine}</span>
577
+ <span class="diff-line-number old">${oldNum}</span>
578
+ <span class="diff-line-number new">${newNum}</span>
579
+ <span class="diff-line-content">${highlightedContent}</span>
368
580
  </div>`;
369
581
  }).join('');
370
-
582
+
371
583
  return `<div class="raw-diff-container">${linesHtml}</div>`;
372
584
  }
373
585
 
586
+ // Helper function to escape HTML
587
+ function escapeHtml(text) {
588
+ return text.replace(/&/g, '&amp;')
589
+ .replace(/</g, '&lt;')
590
+ .replace(/>/g, '&gt;')
591
+ .replace(/"/g, '&quot;')
592
+ .replace(/'/g, '&#039;');
593
+ }
594
+
374
595
 
375
596
  async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStatus = null, workingDir = null) {
376
597
  const workingDirName = workingDir ? path.basename(workingDir) : null;
@@ -378,10 +599,21 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
378
599
  let displayContent;
379
600
  let viewToggle = '';
380
601
 
602
+ // Get file stats
603
+ const fullPath = path.join(workingDir, filePath);
604
+ const stats = fs.statSync(fullPath);
605
+ const fileSize = formatBytes(stats.size);
606
+ const lineCount = isTextFile(ext) ? content.split('\n').length : 0;
607
+ const locCount = isTextFile(ext) ? content.split('\n').filter(line => line.trim().length > 0).length : 0;
608
+
381
609
  // Check if file has git changes
382
610
  const absolutePath = path.resolve(path.join(workingDir, filePath));
383
611
  const hasGitChanges = gitStatus && gitStatus[absolutePath];
384
612
 
613
+ // URLs for file actions (will be used in fileHeader after viewToggle is set)
614
+ const rawUrl = `/api/file-content?path=${encodeURIComponent(filePath)}`;
615
+ const downloadUrl = `/download?path=${encodeURIComponent(filePath)}`;
616
+
385
617
  // Determine file category and handle accordingly
386
618
  if (isImageFile(ext)) {
387
619
  // Handle image files
@@ -399,14 +631,16 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
399
631
  if (hasGitChanges) {
400
632
  const currentParams = new URLSearchParams({ path: filePath });
401
633
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
402
-
634
+
403
635
  viewToggle = `
404
636
  <div class="view-toggle">
405
637
  <a href="/?path=${encodeURIComponent(filePath)}" class="view-btn active">
406
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
638
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
639
+ <span>View</span>
407
640
  </a>
408
641
  <a href="${diffUrl}" class="view-btn">
409
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
642
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
643
+ <span>Diff</span>
410
644
  </a>
411
645
  </div>
412
646
  `;
@@ -427,35 +661,38 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
427
661
  } else if (ext === 'md') {
428
662
  if (viewMode === 'raw') {
429
663
  const highlighted = hljs.highlight(content, { language: 'markdown' }).value;
430
-
664
+
431
665
  // Add line numbers for raw markdown view
432
666
  const lines = highlighted.split('\n');
433
667
  const numberedLines = lines.map((line, index) => {
434
668
  const lineNum = index + 1;
435
669
  return `<span class="line-container" data-line="${lineNum}"><a class="line-number" href="#L${lineNum}" id="L${lineNum}">${lineNum}</a><span class="line-content">${line}</span></span>`;
436
670
  }).join('');
437
-
671
+
438
672
  displayContent = `<pre><code class="hljs with-line-numbers">${numberedLines}</code></pre>`;
439
673
  } else {
440
674
  displayContent = `<div class="markdown">${marked.parse(content)}</div>`;
441
675
  }
442
-
676
+
443
677
  const currentParams = new URLSearchParams({ path: filePath });
444
- const rawUrl = `/?${currentParams.toString()}&view=raw`;
678
+ const rawUrlView = `/?${currentParams.toString()}&view=raw`;
445
679
  const renderedUrl = `/?${currentParams.toString()}&view=rendered`;
446
680
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
447
-
681
+
448
682
  viewToggle = `
449
683
  <div class="view-toggle">
450
684
  <a href="${renderedUrl}" class="view-btn ${viewMode === 'rendered' ? 'active' : ''}">
451
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
685
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
686
+ <span>View</span>
452
687
  </a>
453
- <a href="${rawUrl}" class="view-btn ${viewMode === 'raw' ? 'active' : ''}">
454
- ${octicons['file-code'].toSVG({ class: 'view-icon' })} Raw
688
+ <a href="${rawUrlView}" class="view-btn ${viewMode === 'raw' ? 'active' : ''}">
689
+ ${octicons['file-code'].toSVG({ class: 'view-icon' })}
690
+ <span>Raw</span>
455
691
  </a>
456
692
  ${hasGitChanges ? `
457
693
  <a href="${diffUrl}" class="view-btn ${viewMode === 'diff' ? 'active' : ''}">
458
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
694
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
695
+ <span>Diff</span>
459
696
  </a>
460
697
  ` : ''}
461
698
  </div>
@@ -480,93 +717,95 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
480
717
  const currentParams = new URLSearchParams({ path: filePath });
481
718
  const viewUrl = `/?${currentParams.toString()}&view=rendered`;
482
719
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
483
-
720
+
484
721
  viewToggle = `
485
722
  <div class="view-toggle">
486
723
  <a href="${viewUrl}" class="view-btn ${viewMode === 'rendered' ? 'active' : ''}">
487
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
724
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
725
+ <span>View</span>
488
726
  </a>
489
727
  <a href="${diffUrl}" class="view-btn ${viewMode === 'diff' ? 'active' : ''}">
490
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
728
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
729
+ <span>Diff</span>
491
730
  </a>
492
731
  </div>
493
732
  `;
494
733
  }
495
734
  }
496
735
 
497
- return `
498
- <!DOCTYPE html>
499
- <html data-theme="dark">
500
- <head>
501
- <title>gh-here: ${path.basename(filePath)}</title>
502
- <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
503
- <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
504
- <script src="/static/app.js"></script>
505
- <script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
506
- </head>
507
- <body>
508
- <header>
509
- <div class="header-content">
510
- <div class="header-left">
511
- <h1>gh-here</h1>
512
- </div>
513
- <div class="header-right">
514
- <div id="filename-input-container" class="filename-input-container" style="display: none;">
515
- <input type="text" id="filename-input" class="filename-input" placeholder="Name your file...">
516
- </div>
517
- ${isTextFile(ext) ? `
518
- <button id="edit-btn" class="edit-btn" aria-label="Edit file">
519
- ${octicons.pencil.toSVG({ class: 'edit-icon' })}
520
- </button>
521
- ` : ''}
522
- ${viewToggle}
523
- <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
524
- ${octicons.moon.toSVG({ class: 'theme-icon' })}
525
- </button>
526
- </div>
736
+ // Build file header after viewToggle is set (so it includes diff button if applicable)
737
+ const fileHeader = `
738
+ <div class="file-header">
739
+ <div class="file-header-main">
740
+ <div class="file-path-info">
741
+ <span class="file-path-text">${filePath}</span>
742
+ <button class="file-path-copy-btn" data-path="${filePath}" title="Copy file path">
743
+ ${octicons.copy.toSVG({ class: 'octicon-copy', width: 16, height: 16 })}
744
+ </button>
527
745
  </div>
528
- </header>
529
- <main>
530
- <div class="main-content">
531
- <div class="breadcrumb-section">${breadcrumbs}</div>
532
- <div class="file-content">
533
- ${displayContent}
746
+ ${isTextFile(ext) ? `
747
+ <div class="file-stats">
748
+ <span class="file-stat">${lineCount} line${lineCount !== 1 ? 's' : ''}</span>
749
+ <span class="file-stat-separator">·</span>
750
+ <span class="file-stat">${locCount} loc</span>
751
+ <span class="file-stat-separator">·</span>
752
+ <span class="file-stat">${fileSize}</span>
753
+ </div>
754
+ ` : `
755
+ <div class="file-stats">
756
+ <span class="file-stat">${fileSize}</span>
534
757
  </div>
758
+ `}
759
+ </div>
760
+ <div class="file-header-actions">
761
+ ${viewToggle}
535
762
  ${isTextFile(ext) ? `
536
- <div id="editor-container" class="editor-container" style="display: none;">
537
- <div class="editor-header">
538
- <div class="editor-title">Edit ${path.basename(filePath)}</div>
539
- <div class="editor-actions">
540
- <button id="word-wrap-btn" class="btn btn-secondary" title="Toggle word wrap (Alt+Z)">↩ Wrap</button>
541
- <button id="cancel-btn" class="btn btn-secondary">Cancel</button>
542
- <button id="save-btn" class="btn btn-primary">Save</button>
543
- </div>
763
+ <div class="file-action-group">
764
+ <a href="${rawUrl}" class="file-action-btn" target="_blank" title="View raw file">
765
+ ${octicons['file-code'].toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
766
+ </a>
767
+ <button class="file-action-btn copy-raw-btn" data-path="${filePath}" title="Copy raw content">
768
+ ${octicons.copy.toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
769
+ </button>
770
+ <a href="${downloadUrl}" class="file-action-btn" download="${path.basename(filePath)}" title="Download file">
771
+ ${octicons.download.toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
772
+ </a>
544
773
  </div>
545
- <div class="monaco-editor-container">
546
- <div id="file-editor" class="monaco-editor"></div>
774
+ ` : `
775
+ <div class="file-action-group">
776
+ <a href="${downloadUrl}" class="file-action-btn" download="${path.basename(filePath)}" title="Download file">
777
+ ${octicons.download.toSVG({ class: 'file-action-icon', width: 16, height: 16 })}
778
+ </a>
547
779
  </div>
548
- </div>
549
- ` : ''}
550
- </div>
551
- </main>
552
- </body>
553
- </html>
780
+ `}
781
+ </div>
782
+ </div>
554
783
  `;
555
- }
556
784
 
557
- function renderNewFile(currentPath, workingDir = null) {
558
- const workingDirName = workingDir ? path.basename(workingDir) : null;
559
- const breadcrumbs = generateBreadcrumbs(currentPath, null, workingDirName);
560
-
561
785
  return `
562
786
  <!DOCTYPE html>
563
787
  <html data-theme="dark">
564
788
  <head>
565
- <title>gh-here: Create new file</title>
789
+ <title>gh-here: ${path.basename(filePath)}</title>
566
790
  <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
567
791
  <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
568
- <script src="/static/app.js"></script>
569
- <script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
792
+ <script>
793
+ // Check localStorage and add showGitignored param if needed (before page renders)
794
+ (function() {
795
+ const showGitignored = localStorage.getItem('gh-here-show-gitignored') === 'true';
796
+ const url = new URL(window.location.href);
797
+ const hasParam = url.searchParams.has('showGitignored');
798
+
799
+ if (showGitignored && !hasParam) {
800
+ url.searchParams.set('showGitignored', 'true');
801
+ window.location.replace(url.toString());
802
+ } else if (!showGitignored && hasParam) {
803
+ url.searchParams.delete('showGitignored');
804
+ window.location.replace(url.toString());
805
+ }
806
+ })();
807
+ </script>
808
+ <script type="module" src="/static/app.js"></script>
570
809
  </head>
571
810
  <body>
572
811
  <header>
@@ -575,6 +814,9 @@ function renderNewFile(currentPath, workingDir = null) {
575
814
  <h1>gh-here</h1>
576
815
  </div>
577
816
  <div class="header-right">
817
+ <button id="gitignore-toggle" class="gitignore-toggle" aria-label="Toggle .gitignore filtering" title="Toggle gitignored files">
818
+ ${octicons.eye.toSVG({ class: 'gitignore-icon' })}
819
+ </button>
578
820
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
579
821
  ${octicons.moon.toSVG({ class: 'theme-icon' })}
580
822
  </button>
@@ -582,26 +824,31 @@ function renderNewFile(currentPath, workingDir = null) {
582
824
  </div>
583
825
  </header>
584
826
  <main>
585
- <div class="main-content">
586
- <div class="breadcrumb-section">${breadcrumbs}</div>
587
- <div class="new-file-container">
588
- <div class="new-file-header">
589
- <div class="filename-section">
590
- <span class="filename-label">Name your file...</span>
591
- <input type="text" id="new-filename-input" class="new-filename-input" placeholder="README.md" autofocus>
592
- </div>
593
- <div class="new-file-actions">
594
- <button id="cancel-new-file" class="btn btn-secondary">Cancel</button>
595
- <button id="create-new-file" class="btn btn-primary">Create file</button>
827
+ <aside class="file-tree-sidebar">
828
+ <div class="file-tree-header">
829
+ <svg class="files-icon" viewBox="0 0 16 16" width="16" height="16">
830
+ <path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"></path>
831
+ </svg>
832
+ <span>Files</span>
833
+ </div>
834
+ <div class="sidebar-controls">
835
+ <div class="search-container sidebar-search">
836
+ ${octicons.search.toSVG({ class: 'search-icon' })}
837
+ <input type="text" id="file-search" placeholder="Go to file" class="search-input">
838
+ <kbd class="search-hotkey">t</kbd>
596
839
  </div>
597
840
  </div>
598
- <div class="new-file-editor">
599
- <div class="monaco-editor-container">
600
- <div id="new-file-content" class="monaco-editor"></div>
841
+ <div id="file-tree" class="file-tree-container"></div>
842
+ </aside>
843
+ <div class="main-content-wrapper">
844
+ <div class="main-content">
845
+ <div class="breadcrumb-section">${breadcrumbs}</div>
846
+ ${fileHeader}
847
+ <div class="file-content">
848
+ ${displayContent}
601
849
  </div>
602
850
  </div>
603
851
  </div>
604
- </div>
605
852
  </main>
606
853
  </body>
607
854
  </html>
@@ -644,7 +891,6 @@ module.exports = {
644
891
  renderDirectory,
645
892
  renderFileDiff,
646
893
  renderFile,
647
- renderNewFile,
648
894
  generateBreadcrumbs,
649
895
  findReadmeFile,
650
896
  generateReadmePreview,