gh-here 2.1.0 → 3.0.1

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
@@ -38,8 +38,21 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
38
38
  // Only show language stats on root/top-level directory
39
39
  const languageStats = (!currentPath || currentPath === '.') ? generateLanguageStats(items) : '';
40
40
 
41
- const itemsHtml = items.map(item => `
42
- <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}">
43
56
  <td class="icon">
44
57
  ${item.isDirectory ? octicons['file-directory-fill'].toSVG({ class: 'octicon-directory' }) : getFileIcon(item.name)}
45
58
  </td>
@@ -83,7 +96,7 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
83
96
  ${item.modified.toLocaleDateString()}
84
97
  </td>
85
98
  </tr>
86
- `).join('');
99
+ `}).join('');
87
100
 
88
101
  return `
89
102
  <!DOCTYPE html>
@@ -91,7 +104,23 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
91
104
  <head>
92
105
  <title>gh-here: ${currentPath || 'Root'}</title>
93
106
  <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
94
- <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>
95
124
  </head>
96
125
  <body>
97
126
  <header>
@@ -100,13 +129,7 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
100
129
  <h1>gh-here</h1>
101
130
  </div>
102
131
  <div class="header-right">
103
- ${Object.keys(gitStatus).length > 0 ? `
104
- <button id="commit-btn" class="commit-btn" title="Commit changes">
105
- ${octicons['git-commit'].toSVG({ class: 'commit-icon' })}
106
- <span class="commit-text">Commit</span>
107
- </button>
108
- ` : ''}
109
- <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">
110
133
  ${octicons.eye.toSVG({ class: 'gitignore-icon' })}
111
134
  </button>
112
135
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
@@ -115,41 +138,60 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
115
138
  </div>
116
139
  </div>
117
140
  </header>
118
-
119
- <!-- White canvas section like GitHub -->
120
- <div class="repo-canvas">
121
- <div class="repo-canvas-content">
122
- <div class="breadcrumb-section">${breadcrumbs}</div>
123
- <hr class="repo-divider">
124
-
125
- <div class="repo-controls">
126
- <div class="repo-controls-left">
127
- ${gitBranch ? `
128
- <button class="branch-button">
129
- ${octicons['git-branch'].toSVG({ class: 'octicon-branch' })}
130
- <span class="branch-name">${gitBranch}</span>
131
- ${octicons['chevron-down'].toSVG({ class: 'octicon-chevron' })}
132
- </button>
133
- ` : ''}
134
- ${languageStats}
135
- </div>
136
- <div class="repo-controls-right">
137
- <div class="search-container">
138
- ${octicons.search.toSVG({ class: 'search-icon' })}
139
- <input type="text" id="file-search" placeholder="Go to file" class="search-input">
140
- <kbd class="search-hotkey">t</kbd>
141
- </div>
142
- <button id="new-file-btn" class="btn btn-outline">
143
- <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' })}
144
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>
145
162
  </div>
146
163
  </div>
147
- </div>
148
- </div>
149
- <main>
150
- <div class="repo-canvas">
151
- <div class="repo-canvas-content">
152
- <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">
153
195
  <table class="file-table" id="file-table">
154
196
  <thead>
155
197
  <tr>
@@ -176,7 +218,8 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
176
218
  </tbody>
177
219
  </table>
178
220
  </div>
179
- ${readmePreview}
221
+ ${readmePreview}
222
+ </div>
180
223
  </div>
181
224
  </div>
182
225
  </main>
@@ -260,7 +303,7 @@ function generateLanguageStats(items) {
260
303
  `;
261
304
  }
262
305
 
263
- async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir = null) {
306
+ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir = null, gitBranch = null) {
264
307
  const workingDirName = workingDir ? path.basename(workingDir) : null;
265
308
  const breadcrumbs = generateBreadcrumbs(filePath, null, workingDirName);
266
309
 
@@ -277,26 +320,88 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
277
320
 
278
321
  const diffContent = renderRawDiff(stdout, ext);
279
322
  const currentParams = new URLSearchParams({ path: filePath });
280
- const viewUrl = `/?${currentParams.toString()}&view=rendered`;
281
- const rawUrl = `/?${currentParams.toString()}&view=raw`;
323
+ const viewUrl = `/?${currentParams.toString()}`;
282
324
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
283
-
284
- const viewToggle = `
285
- <div class="view-toggle">
286
- <a href="${viewUrl}" class="view-btn">
287
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
288
- </a>
289
- ${ext === 'md' ? `
290
- <a href="${rawUrl}" class="view-btn">
291
- ${octicons['file-code'].toSVG({ class: 'view-icon' })} Raw
292
- </a>
293
- ` : ''}
294
- <a href="${diffUrl}" class="view-btn active">
295
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
296
- </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>
297
401
  </div>
298
402
  `;
299
403
 
404
+ // Use same structure as renderFile - with sidebar
300
405
  const html = `
301
406
  <!DOCTYPE html>
302
407
  <html data-theme="dark">
@@ -304,7 +409,23 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
304
409
  <title>gh-here: ${path.basename(filePath)} (diff)</title>
305
410
  <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
306
411
  <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
307
- <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>
308
429
  </head>
309
430
  <body>
310
431
  <header>
@@ -313,7 +434,9 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
313
434
  <h1>gh-here</h1>
314
435
  </div>
315
436
  <div class="header-right">
316
- ${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>
317
440
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
318
441
  ${octicons.moon.toSVG({ class: 'theme-icon' })}
319
442
  </button>
@@ -321,11 +444,32 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
321
444
  </div>
322
445
  </header>
323
446
  <main>
324
- <div class="main-content">
325
- <div class="breadcrumb-section">${breadcrumbs}</div>
326
- <div class="diff-container">
327
- <div class="diff-content">
328
- ${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>
329
473
  </div>
330
474
  </div>
331
475
  </div>
@@ -333,7 +477,7 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
333
477
  </body>
334
478
  </html>
335
479
  `;
336
-
480
+
337
481
  resolve(html);
338
482
  });
339
483
  });
@@ -343,51 +487,111 @@ function renderRawDiff(diffOutput, ext) {
343
487
  if (!diffOutput.trim()) {
344
488
  return '<div class="no-changes">No changes to display</div>';
345
489
  }
346
-
347
- const language = getLanguageFromExtension(ext);
348
-
349
- // Apply syntax highlighting to the entire diff
350
- let highlighted;
351
- try {
352
- // Use diff language for syntax highlighting if available, otherwise use the file's language
353
- highlighted = hljs.highlight(diffOutput, { language: 'diff' }).value;
354
- } catch {
355
- // Fallback to plain text if diff highlighting fails
356
- highlighted = diffOutput.replace(/&/g, '&amp;')
357
- .replace(/</g, '&lt;')
358
- .replace(/>/g, '&gt;');
359
- }
360
-
361
- // Split into lines and add line numbers
362
- const lines = highlighted.split('\n');
363
- let lineNumber = 1;
364
-
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
+
365
497
  const linesHtml = lines.map(line => {
366
- // 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);
367
537
  let lineType = 'context';
368
- let displayLine = line;
369
-
370
- 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
371
544
  lineType = 'removed';
372
- } 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
373
552
  lineType = 'added';
374
- } else if (line.startsWith('@@') || line.includes('hljs-meta')) {
375
- lineType = 'hunk';
376
- } else if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
377
- 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;
378
566
  }
379
-
380
- const currentLineNumber = (lineType === 'context' || lineType === 'removed' || lineType === 'added') ? lineNumber++ : '';
381
-
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
+
382
576
  return `<div class="diff-line diff-line-${lineType}">
383
- <span class="diff-line-number">${currentLineNumber}</span>
384
- <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>
385
580
  </div>`;
386
581
  }).join('');
387
-
582
+
388
583
  return `<div class="raw-diff-container">${linesHtml}</div>`;
389
584
  }
390
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
+
391
595
 
392
596
  async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStatus = null, workingDir = null) {
393
597
  const workingDirName = workingDir ? path.basename(workingDir) : null;
@@ -395,10 +599,21 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
395
599
  let displayContent;
396
600
  let viewToggle = '';
397
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
+
398
609
  // Check if file has git changes
399
610
  const absolutePath = path.resolve(path.join(workingDir, filePath));
400
611
  const hasGitChanges = gitStatus && gitStatus[absolutePath];
401
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
+
402
617
  // Determine file category and handle accordingly
403
618
  if (isImageFile(ext)) {
404
619
  // Handle image files
@@ -416,14 +631,16 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
416
631
  if (hasGitChanges) {
417
632
  const currentParams = new URLSearchParams({ path: filePath });
418
633
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
419
-
634
+
420
635
  viewToggle = `
421
636
  <div class="view-toggle">
422
637
  <a href="/?path=${encodeURIComponent(filePath)}" class="view-btn active">
423
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
638
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
639
+ <span>View</span>
424
640
  </a>
425
641
  <a href="${diffUrl}" class="view-btn">
426
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
642
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
643
+ <span>Diff</span>
427
644
  </a>
428
645
  </div>
429
646
  `;
@@ -444,35 +661,38 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
444
661
  } else if (ext === 'md') {
445
662
  if (viewMode === 'raw') {
446
663
  const highlighted = hljs.highlight(content, { language: 'markdown' }).value;
447
-
664
+
448
665
  // Add line numbers for raw markdown view
449
666
  const lines = highlighted.split('\n');
450
667
  const numberedLines = lines.map((line, index) => {
451
668
  const lineNum = index + 1;
452
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>`;
453
670
  }).join('');
454
-
671
+
455
672
  displayContent = `<pre><code class="hljs with-line-numbers">${numberedLines}</code></pre>`;
456
673
  } else {
457
674
  displayContent = `<div class="markdown">${marked.parse(content)}</div>`;
458
675
  }
459
-
676
+
460
677
  const currentParams = new URLSearchParams({ path: filePath });
461
- const rawUrl = `/?${currentParams.toString()}&view=raw`;
678
+ const rawUrlView = `/?${currentParams.toString()}&view=raw`;
462
679
  const renderedUrl = `/?${currentParams.toString()}&view=rendered`;
463
680
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
464
-
681
+
465
682
  viewToggle = `
466
683
  <div class="view-toggle">
467
684
  <a href="${renderedUrl}" class="view-btn ${viewMode === 'rendered' ? 'active' : ''}">
468
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
685
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
686
+ <span>View</span>
469
687
  </a>
470
- <a href="${rawUrl}" class="view-btn ${viewMode === 'raw' ? 'active' : ''}">
471
- ${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>
472
691
  </a>
473
692
  ${hasGitChanges ? `
474
693
  <a href="${diffUrl}" class="view-btn ${viewMode === 'diff' ? 'active' : ''}">
475
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
694
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
695
+ <span>Diff</span>
476
696
  </a>
477
697
  ` : ''}
478
698
  </div>
@@ -497,93 +717,95 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
497
717
  const currentParams = new URLSearchParams({ path: filePath });
498
718
  const viewUrl = `/?${currentParams.toString()}&view=rendered`;
499
719
  const diffUrl = `/?${currentParams.toString()}&view=diff`;
500
-
720
+
501
721
  viewToggle = `
502
722
  <div class="view-toggle">
503
723
  <a href="${viewUrl}" class="view-btn ${viewMode === 'rendered' ? 'active' : ''}">
504
- ${octicons.eye.toSVG({ class: 'view-icon' })} View
724
+ ${octicons.eye.toSVG({ class: 'view-icon' })}
725
+ <span>View</span>
505
726
  </a>
506
727
  <a href="${diffUrl}" class="view-btn ${viewMode === 'diff' ? 'active' : ''}">
507
- ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
728
+ ${octicons.diff.toSVG({ class: 'view-icon' })}
729
+ <span>Diff</span>
508
730
  </a>
509
731
  </div>
510
732
  `;
511
733
  }
512
734
  }
513
735
 
514
- return `
515
- <!DOCTYPE html>
516
- <html data-theme="dark">
517
- <head>
518
- <title>gh-here: ${path.basename(filePath)}</title>
519
- <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
520
- <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
521
- <script src="/static/app.js"></script>
522
- <script src="https://unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script>
523
- </head>
524
- <body>
525
- <header>
526
- <div class="header-content">
527
- <div class="header-left">
528
- <h1>gh-here</h1>
529
- </div>
530
- <div class="header-right">
531
- <div id="filename-input-container" class="filename-input-container" style="display: none;">
532
- <input type="text" id="filename-input" class="filename-input" placeholder="Name your file...">
533
- </div>
534
- ${isTextFile(ext) ? `
535
- <button id="edit-btn" class="edit-btn" aria-label="Edit file">
536
- ${octicons.pencil.toSVG({ class: 'edit-icon' })}
537
- </button>
538
- ` : ''}
539
- ${viewToggle}
540
- <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
541
- ${octicons.moon.toSVG({ class: 'theme-icon' })}
542
- </button>
543
- </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>
544
745
  </div>
545
- </header>
546
- <main>
547
- <div class="main-content">
548
- <div class="breadcrumb-section">${breadcrumbs}</div>
549
- <div class="file-content">
550
- ${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>
551
757
  </div>
758
+ `}
759
+ </div>
760
+ <div class="file-header-actions">
761
+ ${viewToggle}
552
762
  ${isTextFile(ext) ? `
553
- <div id="editor-container" class="editor-container" style="display: none;">
554
- <div class="editor-header">
555
- <div class="editor-title">Edit ${path.basename(filePath)}</div>
556
- <div class="editor-actions">
557
- <button id="word-wrap-btn" class="btn btn-secondary" title="Toggle word wrap (Alt+Z)">↩ Wrap</button>
558
- <button id="cancel-btn" class="btn btn-secondary">Cancel</button>
559
- <button id="save-btn" class="btn btn-primary">Save</button>
560
- </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>
561
773
  </div>
562
- <div class="monaco-editor-container">
563
- <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>
564
779
  </div>
565
- </div>
566
- ` : ''}
567
- </div>
568
- </main>
569
- </body>
570
- </html>
780
+ `}
781
+ </div>
782
+ </div>
571
783
  `;
572
- }
573
784
 
574
- function renderNewFile(currentPath, workingDir = null) {
575
- const workingDirName = workingDir ? path.basename(workingDir) : null;
576
- const breadcrumbs = generateBreadcrumbs(currentPath, null, workingDirName);
577
-
578
785
  return `
579
786
  <!DOCTYPE html>
580
787
  <html data-theme="dark">
581
788
  <head>
582
- <title>gh-here: Create new file</title>
789
+ <title>gh-here: ${path.basename(filePath)}</title>
583
790
  <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
584
791
  <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
585
- <script src="/static/app.js"></script>
586
- <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>
587
809
  </head>
588
810
  <body>
589
811
  <header>
@@ -592,6 +814,9 @@ function renderNewFile(currentPath, workingDir = null) {
592
814
  <h1>gh-here</h1>
593
815
  </div>
594
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>
595
820
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
596
821
  ${octicons.moon.toSVG({ class: 'theme-icon' })}
597
822
  </button>
@@ -599,26 +824,31 @@ function renderNewFile(currentPath, workingDir = null) {
599
824
  </div>
600
825
  </header>
601
826
  <main>
602
- <div class="main-content">
603
- <div class="breadcrumb-section">${breadcrumbs}</div>
604
- <div class="new-file-container">
605
- <div class="new-file-header">
606
- <div class="filename-section">
607
- <span class="filename-label">Name your file...</span>
608
- <input type="text" id="new-filename-input" class="new-filename-input" placeholder="README.md" autofocus>
609
- </div>
610
- <div class="new-file-actions">
611
- <button id="cancel-new-file" class="btn btn-secondary">Cancel</button>
612
- <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>
613
839
  </div>
614
840
  </div>
615
- <div class="new-file-editor">
616
- <div class="monaco-editor-container">
617
- <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}
618
849
  </div>
619
850
  </div>
620
851
  </div>
621
- </div>
622
852
  </main>
623
853
  </body>
624
854
  </html>
@@ -661,7 +891,6 @@ module.exports = {
661
891
  renderDirectory,
662
892
  renderFileDiff,
663
893
  renderFile,
664
- renderNewFile,
665
894
  generateBreadcrumbs,
666
895
  findReadmeFile,
667
896
  generateReadmePreview,