gh-here 1.0.5 → 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.
@@ -19,7 +19,8 @@
19
19
  "Bash(git push:*)",
20
20
  "Bash(git pull:*)",
21
21
  "Bash(npm publish:*)",
22
- "Bash(timeout:*)"
22
+ "Bash(timeout:*)",
23
+ "Bash(npm version:*)"
23
24
  ],
24
25
  "deny": [],
25
26
  "ask": []
package/README.md CHANGED
@@ -29,11 +29,12 @@ Navigate to any directory and run:
29
29
  ```bash
30
30
  gh-here # Start server on available port
31
31
  gh-here --open # Start server and open browser
32
+ gh-here --port=8080 # Start server on port 8080
32
33
  gh-here --open --browser=safari # Start server and open in Safari
33
34
  gh-here --open --browser=arc # Start server and open in Arc
34
35
  ```
35
36
 
36
- The app will automatically find an available port starting from 3000 and serve your current directory with a GitHub-like interface.
37
+ The app will automatically find an available port starting from 5555 and serve your current directory with a GitHub-like interface.
37
38
 
38
39
  ## Features
39
40
 
@@ -46,12 +47,13 @@ The app will automatically find an available port starting from 3000 and serve y
46
47
  - File and folder creation, editing, renaming, and deletion
47
48
 
48
49
  ### 🎨 Code Viewing & Editing
49
- - GitHub-accurate syntax highlighting for 25+ languages
50
- - Line numbers with GitHub-style selection (click, shift-click, ctrl-click)
51
- - In-browser file editing with auto-save to localStorage
50
+ - VS Code-quality Monaco Editor with advanced syntax highlighting for 30+ languages
51
+ - GitHub-style line numbers with selection (click, shift-click, ctrl-click)
52
+ - Professional in-browser file editing with auto-save to localStorage
53
+ - Draft management with persistence across sessions
52
54
  - Raw and rendered markdown views
53
55
  - Shareable URLs with line selections (`#L10-L20`)
54
- - Syntax highlighting and line numbers in editor
56
+ - Monaco Editor features: IntelliSense, bracket matching, folding
55
57
 
56
58
  ### 🔀 Git Integration
57
59
  - Automatic git repository detection
@@ -86,44 +88,9 @@ The app will automatically find an available port starting from 3000 and serve y
86
88
  - Error handling and loading states
87
89
  - Notification system for user feedback
88
90
 
89
- ## Supported File Types
90
-
91
- ### Programming Languages
92
- - JavaScript (.js, .mjs, .jsx) - React, Node.js
93
- - TypeScript (.ts, .tsx)
94
- - Python (.py)
95
- - Java (.java)
96
- - Go (.go)
97
- - Rust (.rs)
98
- - PHP (.php)
99
- - Ruby (.rb)
100
- - Swift (.swift)
101
- - Kotlin (.kt)
102
- - Dart (.dart)
103
-
104
- ### Web Technologies
105
- - HTML (.html)
106
- - CSS (.css, .scss, .sass, .less)
107
- - JSON (.json)
108
- - XML (.xml)
109
- - YAML (.yml, .yaml)
110
-
111
- ### Documentation & Config
112
- - Markdown (.md) - with beautiful rendering
113
- - Text files (.txt)
114
- - Configuration files (ESLint, Prettier, Webpack, etc.)
115
- - Docker files (Dockerfile, docker-compose.yml)
116
- - Environment files (.env)
117
-
118
- ### Media & Archives
119
- - Images (.png, .jpg, .gif, .svg, .webp)
120
- - Videos (.mp4, .mov, .avi)
121
- - Audio (.mp3, .wav, .flac)
122
- - Archives (.zip, .tar, .gz, .rar)
123
-
124
- ### Shell & Database
125
- - Shell scripts (.sh, .bash, .zsh)
126
- - SQL (.sql)
91
+ ## Language Support
92
+
93
+ Supports syntax highlighting for 30+ languages including JavaScript, TypeScript, Python, Go, Rust, Java, C/C++, and many more through Monaco Editor integration.
127
94
 
128
95
  ## Keyboard Shortcuts
129
96
 
@@ -161,7 +128,7 @@ npm install
161
128
  npm start
162
129
  ```
163
130
 
164
- Navigate to `http://localhost:3000` to view the interface.
131
+ Navigate to `http://localhost:5555` to view the interface.
165
132
 
166
133
  ## Dependencies
167
134
 
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
  /**
@@ -13,8 +13,9 @@ const { getGitStatusIcon, getGitStatusDescription } = require('./git');
13
13
  * Handles all HTML template generation for different views
14
14
  */
15
15
 
16
- function renderDirectory(currentPath, items, showGitignored = false, gitBranch = null, gitStatus = {}) {
17
- const breadcrumbs = generateBreadcrumbs(currentPath, gitBranch);
16
+ function renderDirectory(currentPath, items, showGitignored = false, gitBranch = null, gitStatus = {}, workingDir = null) {
17
+ const workingDirName = workingDir ? path.basename(workingDir) : null;
18
+ const breadcrumbs = generateBreadcrumbs(currentPath, gitBranch, workingDirName);
18
19
  const readmeFile = findReadmeFile(items);
19
20
  const readmePreview = readmeFile ? generateReadmePreview(currentPath, readmeFile) : '';
20
21
  const languageStats = generateLanguageStats(items);
@@ -22,7 +23,7 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
22
23
  const itemsHtml = items.map(item => `
23
24
  <tr class="file-row" data-name="${item.name.toLowerCase()}" data-type="${item.isDirectory ? 'dir' : 'file'}" data-path="${item.path}">
24
25
  <td class="icon">
25
- ${item.isDirectory ? octicons['file-directory'].toSVG({ class: 'octicon-directory' }) : getFileIcon(item.name)}
26
+ ${item.isDirectory ? octicons['file-directory-fill'].toSVG({ class: 'octicon-directory' }) : getFileIcon(item.name)}
26
27
  </td>
27
28
  <td class="git-status-col">
28
29
  ${item.gitStatus ? (item.gitStatus.status === '??' ? `<span class="git-status git-status-untracked" title="Untracked file">${require('./git').getGitStatusIcon('??')}</span>` : `<span class="git-status git-status-${item.gitStatus.status.replace(' ', '')}" title="Git Status: ${getGitStatusDescription(item.gitStatus.status)}">${getGitStatusIcon(item.gitStatus.status)}</span>`) : ''}
@@ -78,13 +79,9 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
78
79
  <header>
79
80
  <div class="header-content">
80
81
  <div class="header-left">
81
- <h1 class="header-path">${breadcrumbs}</h1>
82
+ <h1>gh-here</h1>
82
83
  </div>
83
84
  <div class="header-right">
84
- <div class="search-container">
85
- ${octicons.search.toSVG({ class: 'search-icon' })}
86
- <input type="text" id="file-search" placeholder="Find files..." class="search-input">
87
- </div>
88
85
  ${Object.keys(gitStatus).length > 0 ? `
89
86
  <button id="commit-btn" class="commit-btn" title="Commit changes">
90
87
  ${octicons['git-commit'].toSVG({ class: 'commit-icon' })}
@@ -100,15 +97,42 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
100
97
  </div>
101
98
  </div>
102
99
  </header>
103
- <main>
104
- ${languageStats}
105
- <div class="directory-actions">
106
- <button id="new-file-btn" class="btn btn-secondary">
107
- ${octicons['file-added'].toSVG({ class: 'btn-icon' })} New file
108
- </button>
100
+
101
+ <!-- White canvas section like GitHub -->
102
+ <div class="repo-canvas">
103
+ <div class="repo-canvas-content">
104
+ <div class="breadcrumb-section">${breadcrumbs}</div>
105
+ <hr class="repo-divider">
106
+
107
+ <div class="repo-controls">
108
+ <div class="repo-controls-left">
109
+ ${gitBranch ? `
110
+ <button class="branch-button">
111
+ ${octicons['git-branch'].toSVG({ class: 'octicon-branch' })}
112
+ <span class="branch-name">${gitBranch}</span>
113
+ ${octicons['chevron-down'].toSVG({ class: 'octicon-chevron' })}
114
+ </button>
115
+ ` : ''}
116
+ </div>
117
+ <div class="repo-controls-right">
118
+ <div class="search-container">
119
+ ${octicons.search.toSVG({ class: 'search-icon' })}
120
+ <input type="text" id="file-search" placeholder="Go to file" class="search-input">
121
+ <kbd class="search-hotkey">t</kbd>
122
+ </div>
123
+ <button id="new-file-btn" class="btn btn-outline">
124
+ <span class="btn-text">New file</span>
125
+ </button>
126
+ </div>
127
+ </div>
109
128
  </div>
110
- <div class="file-table-container">
111
- <table class="file-table" id="file-table">
129
+ </div>
130
+ <main>
131
+ <div class="repo-canvas">
132
+ <div class="repo-canvas-content">
133
+ ${languageStats}
134
+ <div class="file-table-container">
135
+ <table class="file-table" id="file-table">
112
136
  <thead>
113
137
  <tr>
114
138
  <th></th>
@@ -133,8 +157,10 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
133
157
  ${itemsHtml}
134
158
  </tbody>
135
159
  </table>
160
+ </div>
161
+ ${readmePreview}
162
+ </div>
136
163
  </div>
137
- ${readmePreview}
138
164
  </main>
139
165
  </body>
140
166
  </html>
@@ -265,7 +291,7 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot) {
265
291
  <header>
266
292
  <div class="header-content">
267
293
  <div class="header-left">
268
- <h1 class="header-path">${breadcrumbs}</h1>
294
+ <h1>gh-here</h1>
269
295
  </div>
270
296
  <div class="header-right">
271
297
  ${viewToggle}
@@ -276,9 +302,12 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot) {
276
302
  </div>
277
303
  </header>
278
304
  <main>
279
- <div class="diff-container">
280
- <div class="diff-content">
281
- ${diffContent}
305
+ <div class="main-content">
306
+ <div class="breadcrumb-section">${breadcrumbs}</div>
307
+ <div class="diff-container">
308
+ <div class="diff-content">
309
+ ${diffContent}
310
+ </div>
282
311
  </div>
283
312
  </div>
284
313
  </main>
@@ -340,6 +369,7 @@ function renderRawDiff(diffOutput, ext) {
340
369
  return `<div class="raw-diff-container">${linesHtml}</div>`;
341
370
  }
342
371
 
372
+
343
373
  async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStatus = null, workingDir) {
344
374
  const breadcrumbs = generateBreadcrumbs(filePath);
345
375
  let displayContent;
@@ -349,7 +379,49 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
349
379
  const absolutePath = path.resolve(path.join(workingDir, filePath));
350
380
  const hasGitChanges = gitStatus && gitStatus[absolutePath];
351
381
 
352
- 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') {
353
425
  if (viewMode === 'raw') {
354
426
  const highlighted = hljs.highlight(content, { language: 'markdown' }).value;
355
427
 
@@ -433,18 +505,17 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
433
505
  <header>
434
506
  <div class="header-content">
435
507
  <div class="header-left">
436
- <h1 class="header-path">
437
- ${breadcrumbs}
438
- ${hasGitChanges ? (hasGitChanges.status === '??' ? `<span class="git-status git-status-untracked" title="Untracked file">${getGitStatusIcon('??')}</span>` : `<span class="git-status git-status-${hasGitChanges.status.replace(' ', '')}" title="Git Status: ${getGitStatusDescription(hasGitChanges.status)}">${getGitStatusIcon(hasGitChanges.status)}</span>`) : ''}
439
- </h1>
508
+ <h1>gh-here</h1>
440
509
  </div>
441
510
  <div class="header-right">
442
511
  <div id="filename-input-container" class="filename-input-container" style="display: none;">
443
512
  <input type="text" id="filename-input" class="filename-input" placeholder="Name your file...">
444
513
  </div>
445
- <button id="edit-btn" class="edit-btn" aria-label="Edit file">
446
- ${octicons.pencil.toSVG({ class: 'edit-icon' })}
447
- </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
+ ` : ''}
448
519
  ${viewToggle}
449
520
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
450
521
  ${octicons.moon.toSVG({ class: 'theme-icon' })}
@@ -453,9 +524,12 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
453
524
  </div>
454
525
  </header>
455
526
  <main>
456
- <div class="file-content">
457
- ${displayContent}
458
- </div>
527
+ <div class="main-content">
528
+ <div class="breadcrumb-section">${breadcrumbs}</div>
529
+ <div class="file-content">
530
+ ${displayContent}
531
+ </div>
532
+ ${isTextFile(ext) ? `
459
533
  <div id="editor-container" class="editor-container" style="display: none;">
460
534
  <div class="editor-header">
461
535
  <div class="editor-title">Edit ${path.basename(filePath)}</div>
@@ -469,6 +543,8 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
469
543
  <div id="file-editor" class="monaco-editor"></div>
470
544
  </div>
471
545
  </div>
546
+ ` : ''}
547
+ </div>
472
548
  </main>
473
549
  </body>
474
550
  </html>
@@ -492,7 +568,7 @@ function renderNewFile(currentPath) {
492
568
  <header>
493
569
  <div class="header-content">
494
570
  <div class="header-left">
495
- <h1 class="header-path">${breadcrumbs}</h1>
571
+ <h1>gh-here</h1>
496
572
  </div>
497
573
  <div class="header-right">
498
574
  <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
@@ -502,8 +578,10 @@ function renderNewFile(currentPath) {
502
578
  </div>
503
579
  </header>
504
580
  <main>
505
- <div class="new-file-container">
506
- <div class="new-file-header">
581
+ <div class="main-content">
582
+ <div class="breadcrumb-section">${breadcrumbs}</div>
583
+ <div class="new-file-container">
584
+ <div class="new-file-header">
507
585
  <div class="filename-section">
508
586
  <span class="filename-label">Name your file...</span>
509
587
  <input type="text" id="new-filename-input" class="new-filename-input" placeholder="README.md" autofocus>
@@ -519,24 +597,26 @@ function renderNewFile(currentPath) {
519
597
  </div>
520
598
  </div>
521
599
  </div>
600
+ </div>
522
601
  </main>
523
602
  </body>
524
603
  </html>
525
604
  `;
526
605
  }
527
606
 
528
- function generateBreadcrumbs(currentPath, gitBranch = null) {
529
- // At root, show gh-here branding with git branch if available
607
+ function generateBreadcrumbs(currentPath, gitBranch = null, workingDirName = null) {
608
+ const rootDisplayName = workingDirName || 'repository';
609
+
610
+ // At root, show working directory name
530
611
  if (!currentPath || currentPath === '.') {
531
- const gitBranchDisplay = gitBranch ? `<span class="git-branch">${octicons['git-branch'].toSVG({ class: 'octicon-branch' })} ${gitBranch}</span>` : '';
532
- return `${octicons.home.toSVG({ class: 'octicon-home' })} gh-here ${gitBranchDisplay}`;
612
+ return `${octicons.home.toSVG({ class: 'octicon-home' })} ${rootDisplayName}`;
533
613
  }
534
614
 
535
615
  // In subdirectories, show clickable path
536
616
  const parts = currentPath.split('/').filter(p => p && p !== '.');
537
617
  let breadcrumbs = `
538
618
  <div class="breadcrumb-item">
539
- <a href="/">${octicons.home.toSVG({ class: 'octicon-home' })}</a>
619
+ <a href="/">${octicons.home.toSVG({ class: 'octicon-home' })} ${rootDisplayName}</a>
540
620
  </div>
541
621
  `;
542
622
  let buildPath = '';
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
  }
@@ -82,12 +92,17 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
82
92
  return a.name.localeCompare(b.name);
83
93
  });
84
94
 
85
- res.send(renderDirectory(currentPath, items, showGitignored, gitBranch, gitStatus));
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.0.5",
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
@@ -52,15 +52,15 @@ body {
52
52
  header {
53
53
  background-color: var(--bg-secondary);
54
54
  border-bottom: 1px solid var(--border-primary);
55
- padding: 16px 24px;
56
55
  }
57
56
 
58
57
  .header-content {
59
58
  display: flex;
60
59
  justify-content: space-between;
61
60
  align-items: flex-start;
62
- max-width: 1200px;
61
+ max-width: 900px;
63
62
  margin: 0 auto;
63
+ padding: 16px 24px;
64
64
  }
65
65
 
66
66
  .header-left h1 {
@@ -82,6 +82,203 @@ header {
82
82
  gap: 12px;
83
83
  }
84
84
 
85
+ /* GitHub-style White Canvas Section */
86
+ .repo-canvas {
87
+ background-color: var(--bg-primary);
88
+ }
89
+
90
+ .repo-canvas-content {
91
+ max-width: 900px;
92
+ margin: 0 auto;
93
+ padding: 16px 24px;
94
+ }
95
+
96
+ .breadcrumb-section {
97
+ font-size: 20px;
98
+ font-weight: 600;
99
+ color: var(--text-primary);
100
+ margin: 0 0 12px 0;
101
+ display: flex;
102
+ align-items: center;
103
+ flex-wrap: wrap;
104
+ }
105
+
106
+ .repo-name {
107
+ font-size: 20px;
108
+ font-weight: 600;
109
+ color: var(--text-primary);
110
+ margin: 0 0 12px 0;
111
+ }
112
+
113
+ .repo-divider {
114
+ border: none;
115
+ border-top: 1px solid var(--border-primary);
116
+ margin: 0 0 12px 0;
117
+ }
118
+
119
+ .repo-controls {
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: space-between;
123
+ }
124
+
125
+ .repo-controls-left {
126
+ display: flex;
127
+ align-items: center;
128
+ }
129
+
130
+ .repo-controls-right {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 12px;
134
+ }
135
+
136
+ .branch-button {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 6px;
140
+ padding: 5px 12px;
141
+ background: var(--bg-primary);
142
+ border: 1px solid var(--border-primary);
143
+ border-radius: 6px;
144
+ color: var(--text-primary);
145
+ font-size: 14px;
146
+ font-weight: 500;
147
+ cursor: pointer;
148
+ transition: all 0.2s ease;
149
+ }
150
+
151
+ .branch-button:hover {
152
+ background: var(--bg-secondary);
153
+ border-color: var(--text-secondary);
154
+ }
155
+
156
+ .branch-count,
157
+ .tag-count {
158
+ color: var(--text-secondary);
159
+ font-size: 14px;
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 6px;
163
+ }
164
+
165
+ .octicon-branch {
166
+ width: 16px;
167
+ height: 16px;
168
+ fill: var(--text-secondary);
169
+ }
170
+
171
+ .octicon-small {
172
+ width: 14px;
173
+ height: 14px;
174
+ fill: var(--text-secondary);
175
+ }
176
+
177
+ .branch-name {
178
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
179
+ font-weight: 600;
180
+ }
181
+
182
+ .octicon-chevron {
183
+ width: 12px;
184
+ height: 12px;
185
+ fill: var(--text-secondary);
186
+ }
187
+
188
+ .search-container {
189
+ position: relative;
190
+ display: flex;
191
+ align-items: center;
192
+ }
193
+
194
+ .search-input {
195
+ background: var(--bg-primary);
196
+ border: 1px solid var(--border-primary);
197
+ border-radius: 6px;
198
+ padding: 6px 40px 6px 32px;
199
+ color: var(--text-primary);
200
+ font-size: 14px;
201
+ width: 300px;
202
+ }
203
+
204
+ .search-input:focus {
205
+ outline: none;
206
+ border-color: #0969da;
207
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.3);
208
+ }
209
+
210
+ .search-icon {
211
+ position: absolute;
212
+ left: 8px;
213
+ width: 16px;
214
+ height: 16px;
215
+ fill: var(--text-secondary);
216
+ pointer-events: none;
217
+ }
218
+
219
+ .search-hotkey {
220
+ position: absolute;
221
+ right: 8px;
222
+ background: var(--bg-tertiary);
223
+ border: 1px solid var(--border-primary);
224
+ border-radius: 3px;
225
+ padding: 2px 6px;
226
+ font-size: 12px;
227
+ color: var(--text-secondary);
228
+ font-family: ui-monospace, SFMono-Regular, monospace;
229
+ }
230
+
231
+ /* GitHub-style Buttons */
232
+ .btn {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 6px;
236
+ padding: 6px 12px;
237
+ border-radius: 6px;
238
+ font-size: 14px;
239
+ font-weight: 500;
240
+ cursor: pointer;
241
+ transition: all 0.2s ease;
242
+ border: 1px solid;
243
+ }
244
+
245
+ .btn-primary {
246
+ background: var(--bg-primary);
247
+ border-color: var(--border-primary);
248
+ color: var(--text-primary);
249
+ }
250
+
251
+ .btn-primary:hover {
252
+ background: var(--bg-secondary);
253
+ }
254
+
255
+ .btn-outline {
256
+ background: var(--bg-primary);
257
+ border-color: var(--border-primary);
258
+ color: var(--text-primary);
259
+ }
260
+
261
+ .btn-outline:hover {
262
+ background: var(--bg-secondary);
263
+ }
264
+
265
+ .btn-success {
266
+ background: #238636;
267
+ border-color: rgba(240, 246, 252, 0.1);
268
+ color: #ffffff;
269
+ }
270
+
271
+ .btn-success:hover {
272
+ background: #2ea043;
273
+ }
274
+
275
+ .btn-icon,
276
+ .btn-chevron {
277
+ width: 16px;
278
+ height: 16px;
279
+ fill: currentColor;
280
+ }
281
+
85
282
  .theme-toggle,
86
283
  .gitignore-toggle,
87
284
  .edit-btn {
@@ -158,7 +355,7 @@ header {
158
355
  }
159
356
 
160
357
  .breadcrumb-item {
161
- display: flex;
358
+ display: inline-flex;
162
359
  align-items: center;
163
360
  }
164
361
 
@@ -184,9 +381,13 @@ header {
184
381
  }
185
382
 
186
383
  main {
187
- padding: 24px;
188
- max-width: 1200px;
384
+ padding: 0;
385
+ }
386
+
387
+ .main-content {
388
+ max-width: 900px;
189
389
  margin: 0 auto;
390
+ padding: 16px 24px;
190
391
  }
191
392
 
192
393
  .directory-actions {
@@ -261,13 +462,13 @@ main {
261
462
  vertical-align: text-bottom;
262
463
  }
263
464
 
264
- /* GitHub-style blue folder icons */
465
+ /* GitHub-style baby blue folder icons */
265
466
  .octicon-directory {
266
- fill: #58a6ff;
467
+ fill: #7dd3fc;
267
468
  }
268
469
 
269
470
  [data-theme="light"] .octicon-directory {
270
- fill: #0969da;
471
+ fill: #54adf5;
271
472
  }
272
473
 
273
474
  .octicon-home {
@@ -296,43 +497,7 @@ main {
296
497
  [data-theme="light"] .text-orange { color: #bc4c00; }
297
498
  [data-theme="light"] .text-purple { color: #6f42c1; }
298
499
 
299
- /* Search functionality */
300
- .search-container {
301
- position: relative;
302
- display: flex;
303
- align-items: center;
304
- background: var(--bg-primary);
305
- border: 1px solid var(--border-primary);
306
- border-radius: 6px;
307
- padding: 6px 8px;
308
- transition: all 0.2s ease;
309
- }
310
-
311
- .search-container:focus-within {
312
- border-color: var(--link-color);
313
- box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.1);
314
- }
315
-
316
- .search-icon {
317
- width: 16px;
318
- height: 16px;
319
- fill: var(--text-secondary);
320
- margin-right: 6px;
321
- }
322
-
323
- .search-input {
324
- background: none;
325
- border: none;
326
- outline: none;
327
- color: var(--text-primary);
328
- font-size: 14px;
329
- width: 200px;
330
- transition: all 0.2s ease;
331
- }
332
-
333
- .search-input::placeholder {
334
- color: var(--text-secondary);
335
- }
500
+ /* Old search styles removed - using new GitHub-style search */
336
501
 
337
502
  /* Language stats */
338
503
  .language-stats {
@@ -451,9 +616,6 @@ main {
451
616
 
452
617
 
453
618
  /* Loading states */
454
- .search-input:focus {
455
- width: 250px;
456
- }
457
619
 
458
620
  /* Quick actions */
459
621
  .name {
@@ -560,6 +722,83 @@ main {
560
722
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
561
723
  }
562
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
+
563
802
  /* Line numbers for code viewing */
564
803
  .with-line-numbers {
565
804
  counter-reset: line;
@@ -1109,8 +1348,18 @@ main {
1109
1348
 
1110
1349
 
1111
1350
  @media (max-width: 768px) {
1112
- main {
1113
- padding: 16px;
1351
+ .main-content {
1352
+ padding: 12px 16px;
1353
+ }
1354
+
1355
+ .repo-canvas-content {
1356
+ padding: 12px 16px;
1357
+ }
1358
+
1359
+ .header-content {
1360
+ padding: 12px 16px;
1361
+ flex-direction: column;
1362
+ gap: 12px;
1114
1363
  }
1115
1364
 
1116
1365
  .file-table .size,
@@ -1118,13 +1367,15 @@ main {
1118
1367
  display: none;
1119
1368
  }
1120
1369
 
1121
- header {
1122
- padding: 12px 16px;
1370
+ .repo-controls {
1371
+ flex-direction: column;
1372
+ gap: 12px;
1373
+ align-items: stretch;
1123
1374
  }
1124
1375
 
1125
- .header-content {
1376
+ .repo-controls-right {
1126
1377
  flex-direction: column;
1127
- gap: 12px;
1378
+ gap: 8px;
1128
1379
  }
1129
1380
 
1130
1381
  .header-right {
@@ -1331,22 +1582,8 @@ main {
1331
1582
  transform: translateY(0);
1332
1583
  }
1333
1584
  }
1334
- }
1335
- /* Git Integration */
1336
- .git-branch {
1337
- display: inline-flex;
1338
- align-items: center;
1339
- margin-left: 12px;
1340
- color: var(--text-secondary);
1341
- font-size: 14px;
1342
- font-weight: 500;
1343
- gap: 6px;
1344
- }
1345
1585
 
1346
- .octicon-branch {
1347
- width: 16px;
1348
- height: 16px;
1349
- }
1586
+ /* Git Integration */
1350
1587
 
1351
1588
  .git-status {
1352
1589
  display: inline;
@@ -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');