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 +45 -1
- package/lib/renderers.js +52 -5
- package/lib/server.js +23 -3
- package/package.json +1 -1
- package/public/styles.css +77 -0
- package/tests/fileTypeDetection.test.js +111 -0
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
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');
|