gh-here 1.1.0 → 1.1.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/file-utils.js CHANGED
@@ -256,9 +256,53 @@ function formatBytes(bytes) {
256
256
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
257
257
  }
258
258
 
259
+ /**
260
+ * File type detection functions
261
+ */
262
+ function isImageFile(filePathOrExt) {
263
+ const ext = getExtension(filePathOrExt);
264
+ const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'tiff', 'ico'];
265
+ return imageExtensions.includes(ext);
266
+ }
267
+
268
+ function isBinaryFile(filePathOrExt) {
269
+ const ext = getExtension(filePathOrExt);
270
+ const binaryExtensions = [
271
+ // Images (handled separately)
272
+ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'tiff', 'ico',
273
+ // Archives
274
+ 'zip', 'tar', 'gz', 'rar', '7z',
275
+ // Executables
276
+ 'exe', 'bin', 'app', 'deb', 'rpm',
277
+ // Documents
278
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
279
+ // Media
280
+ 'mp4', 'mov', 'avi', 'mkv', 'mp3', 'wav', 'flac',
281
+ // Other
282
+ 'class', 'so', 'dll', 'dylib'
283
+ ];
284
+ return binaryExtensions.includes(ext);
285
+ }
286
+
287
+ function isTextFile(filePathOrExt) {
288
+ return !isBinaryFile(filePathOrExt);
289
+ }
290
+
291
+ function getExtension(filePathOrExt) {
292
+ // If it's already just an extension (no dots), return as-is
293
+ if (!filePathOrExt.includes('.') && !filePathOrExt.includes('/') && !filePathOrExt.includes('\\')) {
294
+ return filePathOrExt.toLowerCase();
295
+ }
296
+ // Extract extension from file path
297
+ return path.extname(filePathOrExt).toLowerCase().slice(1);
298
+ }
299
+
259
300
  module.exports = {
260
301
  getFileIcon,
261
302
  getLanguageFromExtension,
262
303
  getLanguageColor,
263
- formatBytes
304
+ formatBytes,
305
+ isImageFile,
306
+ isBinaryFile,
307
+ isTextFile
264
308
  };
package/lib/renderers.js CHANGED
@@ -5,7 +5,7 @@ const marked = require('marked');
5
5
  const octicons = require('@primer/octicons');
6
6
  const { exec } = require('child_process');
7
7
 
8
- const { getFileIcon, getLanguageFromExtension, getLanguageColor, formatBytes } = require('./file-utils');
8
+ const { getFileIcon, getLanguageFromExtension, getLanguageColor, formatBytes, isImageFile, isBinaryFile, isTextFile } = require('./file-utils');
9
9
  const { getGitStatusIcon, getGitStatusDescription } = require('./git');
10
10
 
11
11
  /**
@@ -369,6 +369,7 @@ function renderRawDiff(diffOutput, ext) {
369
369
  return `<div class="raw-diff-container">${linesHtml}</div>`;
370
370
  }
371
371
 
372
+
372
373
  async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStatus = null, workingDir) {
373
374
  const breadcrumbs = generateBreadcrumbs(filePath);
374
375
  let displayContent;
@@ -378,7 +379,49 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
378
379
  const absolutePath = path.resolve(path.join(workingDir, filePath));
379
380
  const hasGitChanges = gitStatus && gitStatus[absolutePath];
380
381
 
381
- if (ext === 'md') {
382
+ // Determine file category and handle accordingly
383
+ if (isImageFile(ext)) {
384
+ // Handle image files
385
+ const imageUrl = `/download?path=${encodeURIComponent(filePath)}`;
386
+ displayContent = `
387
+ <div class="image-container">
388
+ <img src="${imageUrl}" alt="${path.basename(filePath)}" class="image-display">
389
+ <div class="image-info">
390
+ <span class="image-filename">${path.basename(filePath)}</span>
391
+ </div>
392
+ </div>
393
+ `;
394
+
395
+ // Add diff view for images with git changes
396
+ if (hasGitChanges) {
397
+ const currentParams = new URLSearchParams({ path: filePath });
398
+ const diffUrl = `/?${currentParams.toString()}&view=diff`;
399
+
400
+ viewToggle = `
401
+ <div class="view-toggle">
402
+ <a href="/?path=${encodeURIComponent(filePath)}" class="view-btn active">
403
+ ${octicons.eye.toSVG({ class: 'view-icon' })} View
404
+ </a>
405
+ <a href="${diffUrl}" class="view-btn">
406
+ ${octicons.diff.toSVG({ class: 'view-icon' })} Diff
407
+ </a>
408
+ </div>
409
+ `;
410
+ }
411
+ } else if (isBinaryFile(ext) && !isImageFile(ext)) {
412
+ // Handle other binary files - download only
413
+ displayContent = `
414
+ <div class="binary-file-container">
415
+ <div class="binary-file-info">
416
+ <h3>Binary File</h3>
417
+ <p>This is a binary file that cannot be displayed in the browser.</p>
418
+ <a href="/download?path=${encodeURIComponent(filePath)}" class="btn btn-primary" download="${path.basename(filePath)}">
419
+ ${octicons.download.toSVG({ class: 'download-icon' })} Download File
420
+ </a>
421
+ </div>
422
+ </div>
423
+ `;
424
+ } else if (ext === 'md') {
382
425
  if (viewMode === 'raw') {
383
426
  const highlighted = hljs.highlight(content, { language: 'markdown' }).value;
384
427
 
@@ -468,9 +511,11 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
468
511
  <div id="filename-input-container" class="filename-input-container" style="display: none;">
469
512
  <input type="text" id="filename-input" class="filename-input" placeholder="Name your file...">
470
513
  </div>
471
- <button id="edit-btn" class="edit-btn" aria-label="Edit file">
472
- ${octicons.pencil.toSVG({ class: 'edit-icon' })}
473
- </button>
514
+ ${isTextFile(ext) ? `
515
+ <button id="edit-btn" class="edit-btn" aria-label="Edit file">
516
+ ${octicons.pencil.toSVG({ class: 'edit-icon' })}
517
+ </button>
518
+ ` : ''}
474
519
  ${viewToggle}
475
520
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
476
521
  ${octicons.moon.toSVG({ class: 'theme-icon' })}
@@ -484,6 +529,7 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
484
529
  <div class="file-content">
485
530
  ${displayContent}
486
531
  </div>
532
+ ${isTextFile(ext) ? `
487
533
  <div id="editor-container" class="editor-container" style="display: none;">
488
534
  <div class="editor-header">
489
535
  <div class="editor-title">Edit ${path.basename(filePath)}</div>
@@ -497,6 +543,7 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
497
543
  <div id="file-editor" class="monaco-editor"></div>
498
544
  </div>
499
545
  </div>
546
+ ` : ''}
500
547
  </div>
501
548
  </main>
502
549
  </body>
package/lib/server.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { getGitStatus, getGitBranch, commitAllChanges, commitSelectedFiles, getGitDiff } = require('./git');
6
6
  const { isIgnoredByGitignore, getGitignoreRules } = require('./gitignore');
7
7
  const { renderDirectory, renderFileDiff, renderFile, renderNewFile } = require('./renderers');
8
+ const { isImageFile, isBinaryFile } = require('./file-utils');
8
9
 
9
10
  /**
10
11
  * Express server setup and route handlers
@@ -16,6 +17,7 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
16
17
  app.use('/static', express.static(path.join(__dirname, '..', 'public')));
17
18
  app.use('/octicons', express.static(path.join(__dirname, '..', 'node_modules', '@primer', 'octicons', 'build')));
18
19
 
20
+
19
21
  // Download route
20
22
  app.get('/download', (req, res) => {
21
23
  const filePath = req.query.path || '';
@@ -25,8 +27,16 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
25
27
  const stats = fs.statSync(fullPath);
26
28
  if (stats.isFile()) {
27
29
  const fileName = path.basename(fullPath);
28
- res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
29
- res.sendFile(fullPath);
30
+
31
+ // For images, serve inline for viewing, otherwise force download
32
+ if (isImageFile(fullPath)) {
33
+ // Serve image inline
34
+ res.sendFile(fullPath);
35
+ } else {
36
+ // Force download for non-images
37
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
38
+ res.sendFile(fullPath);
39
+ }
30
40
  } else {
31
41
  res.status(400).send('Cannot download directories');
32
42
  }
@@ -84,10 +94,15 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
84
94
 
85
95
  res.send(renderDirectory(currentPath, items, showGitignored, gitBranch, gitStatus, workingDir));
86
96
  } else {
87
- const content = fs.readFileSync(fullPath, 'utf8');
88
97
  const ext = path.extname(fullPath).slice(1);
89
98
  const viewMode = req.query.view || 'rendered';
90
99
 
100
+ // For image files, don't try to read as text
101
+ let content = '';
102
+ if (!isImageFile(fullPath)) {
103
+ content = fs.readFileSync(fullPath, 'utf8');
104
+ }
105
+
91
106
  if (viewMode === 'diff' && isGitRepo) {
92
107
  // Check if file has git status
93
108
  const absolutePath = path.resolve(fullPath);
@@ -122,6 +137,11 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
122
137
  return res.status(403).send('Access denied');
123
138
  }
124
139
 
140
+ // Check if it's a binary file - prevent editing
141
+ if (isBinaryFile(fullPath)) {
142
+ return res.status(400).json({ error: 'Cannot edit binary files' });
143
+ }
144
+
125
145
  const content = fs.readFileSync(fullPath, 'utf-8');
126
146
  res.send(content);
127
147
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-here",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "A local GitHub-like file browser for viewing code",
5
5
  "repository": "https://github.com/corywilkerson/gh-here",
6
6
  "main": "index.js",
package/public/styles.css CHANGED
@@ -722,6 +722,83 @@ main {
722
722
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
723
723
  }
724
724
 
725
+ /* Image viewing styles */
726
+ .image-container {
727
+ padding: 20px;
728
+ text-align: center;
729
+ background-color: var(--bg-primary);
730
+ border-radius: 6px;
731
+ }
732
+
733
+ .image-display {
734
+ max-width: 100%;
735
+ max-height: 80vh;
736
+ height: auto;
737
+ border-radius: 6px;
738
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
739
+ background: var(--bg-secondary);
740
+ }
741
+
742
+ [data-theme="light"] .image-display {
743
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
744
+ }
745
+
746
+ .image-info {
747
+ margin-top: 16px;
748
+ padding-top: 16px;
749
+ border-top: 1px solid var(--border-primary);
750
+ }
751
+
752
+ .image-filename {
753
+ font-size: 14px;
754
+ font-weight: 600;
755
+ color: var(--text-primary);
756
+ }
757
+
758
+ /* Binary file viewing styles */
759
+ .binary-file-container {
760
+ padding: 40px 20px;
761
+ text-align: center;
762
+ background-color: var(--bg-primary);
763
+ border-radius: 6px;
764
+ border: 1px solid var(--border-primary);
765
+ }
766
+
767
+ .binary-file-info h3 {
768
+ font-size: 18px;
769
+ font-weight: 600;
770
+ color: var(--text-primary);
771
+ margin-bottom: 12px;
772
+ }
773
+
774
+ .binary-file-info p {
775
+ font-size: 14px;
776
+ color: var(--text-secondary);
777
+ margin-bottom: 20px;
778
+ }
779
+
780
+ .binary-file-info .btn {
781
+ display: inline-flex;
782
+ align-items: center;
783
+ gap: 8px;
784
+ padding: 8px 16px;
785
+ border-radius: 6px;
786
+ font-size: 14px;
787
+ font-weight: 500;
788
+ text-decoration: none;
789
+ border: 1px solid transparent;
790
+ cursor: pointer;
791
+ }
792
+
793
+ .binary-file-info .btn-primary {
794
+ background-color: var(--link-color);
795
+ color: white;
796
+ }
797
+
798
+ .binary-file-info .btn-primary:hover {
799
+ opacity: 0.9;
800
+ }
801
+
725
802
  /* Line numbers for code viewing */
726
803
  .with-line-numbers {
727
804
  counter-reset: line;
@@ -0,0 +1,111 @@
1
+ // Simple unit tests for file type detection
2
+ // Run with: node tests/fileTypeDetection.test.js
3
+
4
+ // Import the functions from our file-utils module
5
+ const { isImageFile, isBinaryFile, isTextFile } = require('../lib/file-utils');
6
+
7
+ function test(description, testFn) {
8
+ try {
9
+ testFn();
10
+ console.log(`✅ ${description}`);
11
+ } catch (error) {
12
+ console.log(`❌ ${description}`);
13
+ console.log(` Error: ${error.message}`);
14
+ }
15
+ }
16
+
17
+ function assert(condition, message) {
18
+ if (!condition) {
19
+ throw new Error(message || 'Assertion failed');
20
+ }
21
+ }
22
+
23
+ // Test isImageFile function
24
+ test('should detect PNG files as images', () => {
25
+ assert(isImageFile('photo.png'), 'PNG should be detected as image');
26
+ assert(isImageFile('PNG'), 'Extension-only PNG should work');
27
+ });
28
+
29
+ test('should detect common image formats', () => {
30
+ const imageFiles = ['test.jpg', 'test.jpeg', 'test.gif', 'test.svg', 'test.webp', 'test.bmp', 'test.tiff', 'test.ico'];
31
+ imageFiles.forEach(file => {
32
+ assert(isImageFile(file), `${file} should be detected as image`);
33
+ });
34
+ });
35
+
36
+ test('should not detect text files as images', () => {
37
+ const textFiles = ['readme.md', 'script.js', 'style.css', 'config.json'];
38
+ textFiles.forEach(file => {
39
+ assert(!isImageFile(file), `${file} should not be detected as image`);
40
+ });
41
+ });
42
+
43
+ // Test isBinaryFile function
44
+ test('should detect images as binary files', () => {
45
+ assert(isBinaryFile('photo.png'), 'Images should be binary');
46
+ assert(isBinaryFile('animation.gif'), 'GIFs should be binary');
47
+ });
48
+
49
+ test('should detect archives as binary files', () => {
50
+ const archives = ['file.zip', 'backup.tar', 'compressed.gz', 'archive.rar', 'package.7z'];
51
+ archives.forEach(file => {
52
+ assert(isBinaryFile(file), `${file} should be detected as binary`);
53
+ });
54
+ });
55
+
56
+ test('should detect executables as binary files', () => {
57
+ const executables = ['program.exe', 'binary.bin', 'application.app'];
58
+ executables.forEach(file => {
59
+ assert(isBinaryFile(file), `${file} should be detected as binary`);
60
+ });
61
+ });
62
+
63
+ test('should detect documents as binary files', () => {
64
+ const docs = ['document.pdf', 'spreadsheet.xlsx', 'presentation.pptx'];
65
+ docs.forEach(file => {
66
+ assert(isBinaryFile(file), `${file} should be detected as binary`);
67
+ });
68
+ });
69
+
70
+ test('should not detect text files as binary', () => {
71
+ const textFiles = ['readme.md', 'script.js', 'style.css', 'data.json', 'config.yml', 'index.html'];
72
+ textFiles.forEach(file => {
73
+ assert(!isBinaryFile(file), `${file} should not be detected as binary`);
74
+ });
75
+ });
76
+
77
+ // Test isTextFile function
78
+ test('should detect common text files', () => {
79
+ const textFiles = ['readme.md', 'script.js', 'style.css', 'data.json', 'config.yml', 'index.html', 'app.py', 'main.go'];
80
+ textFiles.forEach(file => {
81
+ assert(isTextFile(file), `${file} should be detected as text`);
82
+ });
83
+ });
84
+
85
+ test('should not detect binary files as text', () => {
86
+ const binaryFiles = ['photo.png', 'archive.zip', 'program.exe', 'document.pdf'];
87
+ binaryFiles.forEach(file => {
88
+ assert(!isTextFile(file), `${file} should not be detected as text`);
89
+ });
90
+ });
91
+
92
+ // Test edge cases
93
+ test('should handle files without extensions', () => {
94
+ assert(isTextFile('README'), 'Files without extensions should default to text');
95
+ assert(isTextFile('Makefile'), 'Common text files without extensions should be text');
96
+ });
97
+
98
+ test('should handle extension-only input', () => {
99
+ assert(isImageFile('png'), 'Should handle bare extensions');
100
+ assert(isBinaryFile('exe'), 'Should handle bare extensions for binary');
101
+ assert(isTextFile('js'), 'Should handle bare extensions for text');
102
+ });
103
+
104
+ test('should be case insensitive', () => {
105
+ assert(isImageFile('PHOTO.PNG'), 'Should handle uppercase extensions');
106
+ assert(isBinaryFile('ARCHIVE.ZIP'), 'Should handle uppercase extensions');
107
+ assert(isTextFile('SCRIPT.JS'), 'Should handle uppercase extensions');
108
+ });
109
+
110
+ // Run all tests
111
+ console.log('Running file type detection tests...\n');