gh-here 3.0.1 → 3.0.3

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/README.md CHANGED
@@ -62,6 +62,20 @@ npm install
62
62
  npm start
63
63
  ```
64
64
 
65
+ ### Running Tests (Optional)
66
+
67
+ Tests use Playwright for smoke testing but are optional for development:
68
+
69
+ ```bash
70
+ # First time only - install Chromium for testing (~210MB)
71
+ npx playwright install chromium
72
+
73
+ # Run tests
74
+ npm test
75
+ ```
76
+
77
+ **Note**: End users don't need Playwright - it's only installed when you clone the repo and run `npm install` (devDependency).
78
+
65
79
  ## Dependencies
66
80
 
67
81
  - express - Web server
package/lib/renderers.js CHANGED
@@ -103,7 +103,7 @@ function renderDirectory(currentPath, items, showGitignored = false, gitBranch =
103
103
  <html data-theme="dark">
104
104
  <head>
105
105
  <title>gh-here: ${currentPath || 'Root'}</title>
106
- <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
106
+ <link rel="stylesheet" href="/static/styles.css?v=3.0.5">
107
107
  <script>
108
108
  // Check localStorage and add showGitignored param if needed (before page renders)
109
109
  (function() {
@@ -306,18 +306,18 @@ function generateLanguageStats(items) {
306
306
  async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir = null, gitBranch = null) {
307
307
  const workingDirName = workingDir ? path.basename(workingDir) : null;
308
308
  const breadcrumbs = generateBreadcrumbs(filePath, null, workingDirName);
309
-
309
+
310
310
  // Get git diff for the file
311
311
  return new Promise((resolve, reject) => {
312
- const diffCommand = gitInfo.staged ?
313
- `git diff --cached "${filePath}"` :
312
+ const diffCommand = gitInfo.staged ?
313
+ `git diff --cached "${filePath}"` :
314
314
  `git diff "${filePath}"`;
315
-
316
- exec(diffCommand, { cwd: gitRepoRoot }, (error, stdout) => {
315
+
316
+ exec(diffCommand, { cwd: gitRepoRoot }, (error, stdout, stderr) => {
317
317
  if (error) {
318
318
  return reject(error);
319
319
  }
320
-
320
+
321
321
  const diffContent = renderRawDiff(stdout, ext);
322
322
  const currentParams = new URLSearchParams({ path: filePath });
323
323
  const viewUrl = `/?${currentParams.toString()}`;
@@ -407,7 +407,7 @@ async function renderFileDiff(filePath, ext, gitInfo, gitRepoRoot, workingDir =
407
407
  <html data-theme="dark">
408
408
  <head>
409
409
  <title>gh-here: ${path.basename(filePath)} (diff)</title>
410
- <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
410
+ <link rel="stylesheet" href="/static/styles.css?v=3.0.5">
411
411
  <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
412
412
  <script>
413
413
  // Check localStorage and add showGitignored param if needed (before page renders)
@@ -787,7 +787,7 @@ async function renderFile(filePath, content, ext, viewMode = 'rendered', gitStat
787
787
  <html data-theme="dark">
788
788
  <head>
789
789
  <title>gh-here: ${path.basename(filePath)}</title>
790
- <link rel="stylesheet" href="/static/styles.css?v=${Date.now()}">
790
+ <link rel="stylesheet" href="/static/styles.css?v=3.0.5">
791
791
  <link rel="stylesheet" href="/static/highlight.css?v=${Date.now()}">
792
792
  <script>
793
793
  // Check localStorage and add showGitignored param if needed (before page renders)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-here",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "A local GitHub-like file browser for viewing code",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "scripts": {
14
14
  "start": "node bin/gh-here.js",
15
- "test": "echo yolo no tests yet"
15
+ "test": "node test.js"
16
16
  },
17
17
  "keywords": [
18
18
  "github",
@@ -23,9 +23,12 @@
23
23
  "author": "",
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
+ "@primer/octicons": "^19.8.0",
26
27
  "express": "^4.18.2",
27
28
  "highlight.js": "^11.9.0",
28
- "marked": "^12.0.0",
29
- "@primer/octicons": "^19.8.0"
29
+ "marked": "^12.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "playwright": "^1.56.1"
30
33
  }
31
34
  }
package/public/app.js CHANGED
@@ -19,6 +19,7 @@ class Application {
19
19
  this.keyboardHandler = null;
20
20
  this.fileTree = null;
21
21
  this.navigationHandler = null;
22
+ this.lastSelectedLine = null;
22
23
  }
23
24
 
24
25
  init() {
@@ -35,15 +36,15 @@ class Application {
35
36
  if (this.themeManager) {
36
37
  this.themeManager.setupListeners();
37
38
  }
38
-
39
+
39
40
  // Re-initialize components that need fresh DOM references
40
41
  this.searchHandler = new SearchHandler();
41
42
  this.keyboardHandler = new KeyboardHandler(this.searchHandler);
42
-
43
+
43
44
  // Re-initialize file tree when sidebar becomes visible
44
45
  const sidebar = document.querySelector('.file-tree-sidebar');
45
46
  const treeContainer = document.getElementById('file-tree');
46
-
47
+
47
48
  if (sidebar && treeContainer && !sidebar.classList.contains('hidden')) {
48
49
  // Sidebar is visible - initialize or re-initialize file tree
49
50
  if (!this.fileTree || !this.fileTree.isInitialized || this.fileTree.treeContainer !== treeContainer) {
@@ -53,10 +54,11 @@ class Application {
53
54
  // Sidebar is hidden - don't initialize but keep reference for when it becomes visible
54
55
  this.fileTree = null;
55
56
  }
56
-
57
+
57
58
  this.setupGlobalEventListeners();
58
59
  this.setupGitignoreToggle();
59
60
  this.setupFileOperations();
61
+ this.highlightLinesFromHash();
60
62
  } catch (error) {
61
63
  console.error('Error re-initializing components:', error);
62
64
  }
@@ -72,7 +74,7 @@ class Application {
72
74
  // Initialize components
73
75
  this.searchHandler = new SearchHandler();
74
76
  this.keyboardHandler = new KeyboardHandler(this.searchHandler);
75
-
77
+
76
78
  // Initialize file tree if sidebar is visible (not hidden)
77
79
  const sidebar = document.querySelector('.file-tree-sidebar');
78
80
  const treeContainer = document.getElementById('file-tree');
@@ -83,6 +85,7 @@ class Application {
83
85
  this.setupGlobalEventListeners();
84
86
  this.setupGitignoreToggle();
85
87
  this.setupFileOperations();
88
+ this.highlightLinesFromHash();
86
89
  }
87
90
 
88
91
  setupGlobalEventListeners() {
@@ -104,6 +107,16 @@ class Application {
104
107
  }
105
108
 
106
109
  handleGlobalClick(e) {
110
+ // Line number selection (like GitHub)
111
+ const lineNumber = e.target.closest('.line-number');
112
+ if (lineNumber) {
113
+ e.preventDefault();
114
+ e.stopPropagation();
115
+ const lineNum = parseInt(lineNumber.textContent.trim(), 10);
116
+ this.handleLineSelection(lineNum, e.shiftKey);
117
+ return;
118
+ }
119
+
107
120
  // Copy path button
108
121
  const copyPathBtn = e.target.closest('.copy-path-btn, .file-path-copy-btn');
109
122
  if (copyPathBtn) {
@@ -264,6 +277,61 @@ class Application {
264
277
  showNotification('Failed to copy raw content', 'error');
265
278
  }
266
279
  }
280
+
281
+ handleLineSelection(lineNum, shiftKey) {
282
+ // If shift is held and we have a previous selection, select range
283
+ if (shiftKey && this.lastSelectedLine) {
284
+ const start = Math.min(this.lastSelectedLine, lineNum);
285
+ const end = Math.max(this.lastSelectedLine, lineNum);
286
+ this.highlightLines(start, end);
287
+ this.updateUrlHash(start, end);
288
+ } else {
289
+ // Single line selection
290
+ this.highlightLines(lineNum, lineNum);
291
+ this.updateUrlHash(lineNum, lineNum);
292
+ this.lastSelectedLine = lineNum;
293
+ }
294
+ }
295
+
296
+ highlightLines(start, end) {
297
+ // Clear all existing selections and highlight new range in one pass
298
+ document.querySelectorAll('.line-container').forEach(el => {
299
+ const lineNum = parseInt(el.dataset.line, 10);
300
+ if (lineNum >= start && lineNum <= end) {
301
+ el.classList.add('selected');
302
+ } else {
303
+ el.classList.remove('selected');
304
+ }
305
+ });
306
+ }
307
+
308
+ updateUrlHash(start, end) {
309
+ const hash = start === end ? `L${start}` : `L${start}-L${end}`;
310
+ // Use history API to update URL without scrolling - preserve path and query params
311
+ const url = new URL(window.location);
312
+ url.hash = hash;
313
+ history.replaceState(null, null, url);
314
+ }
315
+
316
+ highlightLinesFromHash() {
317
+ const hash = window.location.hash.slice(1); // Remove #
318
+ if (!hash.startsWith('L')) return;
319
+
320
+ const match = hash.match(/^L(\d+)(?:-L(\d+))?$/);
321
+ if (!match) return;
322
+
323
+ const start = parseInt(match[1], 10);
324
+ const end = match[2] ? parseInt(match[2], 10) : start;
325
+
326
+ this.highlightLines(start, end);
327
+ this.lastSelectedLine = start;
328
+
329
+ // Scroll to the first selected line
330
+ const firstLine = document.querySelector(`.line-container[data-line="${start}"]`);
331
+ if (firstLine) {
332
+ firstLine.scrollIntoView({ behavior: 'smooth', block: 'center' });
333
+ }
334
+ }
267
335
  }
268
336
 
269
337
  const app = new Application();
@@ -35,6 +35,11 @@ export class NavigationHandler {
35
35
  const link = e.target.closest('a');
36
36
  if (!link) return;
37
37
 
38
+ // Skip line number links (hash-only navigation like #L10)
39
+ if (link.classList.contains('line-number')) {
40
+ return;
41
+ }
42
+
38
43
  // Skip if clicking inside quick actions or other interactive elements
39
44
  if (e.target.closest('.quick-actions, button, .file-action-btn')) {
40
45
  return;
package/public/styles.css CHANGED
@@ -120,7 +120,16 @@ header {
120
120
  font-weight: 600;
121
121
  margin: 0;
122
122
  color: var(--text-primary);
123
- letter-spacing: -0.01em;
123
+ letter-spacing: 0.02em;
124
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
125
+ }
126
+
127
+ .header-left h1::before {
128
+ content: '$';
129
+ color: var(--link-color);
130
+ margin-right: 8px;
131
+ opacity: 0.8;
132
+ font-weight: 400;
124
133
  }
125
134
 
126
135
  .header-path {
@@ -1623,7 +1632,7 @@ main {
1623
1632
 
1624
1633
  .line-content {
1625
1634
  white-space: pre;
1626
- display: inline-block;
1635
+ display: inline;
1627
1636
  padding: 0 10px;
1628
1637
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1629
1638
  font-size: 12px;
package/test.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Lightweight smoke tests for gh-here
3
+ * Run with: node test.js
4
+ */
5
+
6
+ const { chromium } = require('playwright');
7
+ const { spawn } = require('child_process');
8
+
9
+ const TEST_PORT = 5556; // Use different port to avoid conflicts
10
+ const BASE_URL = `http://localhost:${TEST_PORT}`;
11
+
12
+ async function sleep(ms) {
13
+ return new Promise(resolve => setTimeout(resolve, ms));
14
+ }
15
+
16
+ async function runTests() {
17
+ console.log('Starting gh-here server...');
18
+
19
+ // Start server
20
+ const server = spawn('node', ['bin/gh-here.js', `--port=${TEST_PORT}`], {
21
+ cwd: __dirname,
22
+ stdio: 'pipe'
23
+ });
24
+
25
+ // Wait for server to start
26
+ await sleep(2000);
27
+
28
+ let browser;
29
+ let failures = 0;
30
+
31
+ try {
32
+ console.log('Launching browser...');
33
+ browser = await chromium.launch({ headless: true });
34
+ const context = await browser.newContext();
35
+ const page = await context.newPage();
36
+
37
+ // Test 1: Root page loads
38
+ console.log('\n✓ Test 1: Root page loads');
39
+ await page.goto(BASE_URL);
40
+ const title = await page.title();
41
+ if (!title.includes('gh-here')) {
42
+ console.error(' ✗ FAILED: Page title incorrect');
43
+ failures++;
44
+ }
45
+
46
+ // Test 2: File tree exists (may be hidden on root)
47
+ console.log('✓ Test 2: File tree element exists');
48
+ const fileTreeExists = await page.$('#file-tree');
49
+ if (!fileTreeExists) {
50
+ console.error(' ✗ FAILED: File tree element not found');
51
+ failures++;
52
+ }
53
+
54
+ // Test 3: Navigate to a file and check line numbers display correctly
55
+ console.log('✓ Test 3: File view with line numbers');
56
+ await page.goto(`${BASE_URL}/?path=lib/renderers.js`);
57
+ await page.waitForSelector('.line-container', { timeout: 5000 });
58
+
59
+ // Check that line numbers are in a vertical column (not nested)
60
+ const lineContainers = await page.$$('.line-container');
61
+ if (lineContainers.length < 10) {
62
+ console.error(' ✗ FAILED: Not enough line containers found');
63
+ failures++;
64
+ }
65
+
66
+ // Check line 1 and line 2 have sequential numbers
67
+ const line1 = await page.$('.line-container[data-line="1"]');
68
+ const line2 = await page.$('.line-container[data-line="2"]');
69
+ if (!line1 || !line2) {
70
+ console.error(' ✗ FAILED: Line containers missing data-line attributes');
71
+ failures++;
72
+ }
73
+
74
+ // Verify line numbers are not nested (check display property)
75
+ const line1Display = await page.$eval('.line-container[data-line="1"]',
76
+ el => window.getComputedStyle(el).display
77
+ );
78
+ if (line1Display !== 'block') {
79
+ console.error(` ✗ FAILED: Line containers should have display:block, got ${line1Display}`);
80
+ failures++;
81
+ }
82
+
83
+ // Test 4: Check if modified files show diff button
84
+ console.log('✓ Test 4: Modified files show diff button');
85
+ await page.goto(BASE_URL);
86
+ const diffButtons = await page.$$('.diff-btn');
87
+ // Should have at least one diff button if there are modified files
88
+ console.log(` Found ${diffButtons.length} diff buttons`);
89
+
90
+ // Test 5: Gitignore toggle exists and is clickable
91
+ console.log('✓ Test 5: Gitignore toggle button');
92
+ const gitignoreToggle = await page.$('#gitignore-toggle');
93
+ if (!gitignoreToggle) {
94
+ console.error(' ✗ FAILED: Gitignore toggle button not found');
95
+ failures++;
96
+ }
97
+
98
+ // Test 6: Theme toggle works
99
+ console.log('✓ Test 6: Theme toggle');
100
+ const themeToggle = await page.$('#theme-toggle');
101
+ if (!themeToggle) {
102
+ console.error(' ✗ FAILED: Theme toggle button not found');
103
+ failures++;
104
+ }
105
+
106
+ // Test 7: Search functionality
107
+ console.log('✓ Test 7: Search input exists');
108
+ const searchInput = await page.$('#file-search, #root-file-search');
109
+ if (!searchInput) {
110
+ console.error(' ✗ FAILED: Search input not found');
111
+ failures++;
112
+ }
113
+
114
+ console.log('\n' + '='.repeat(50));
115
+ if (failures === 0) {
116
+ console.log('✓ All tests passed!');
117
+ } else {
118
+ console.log(`✗ ${failures} test(s) failed`);
119
+ }
120
+ console.log('='.repeat(50) + '\n');
121
+
122
+ } catch (error) {
123
+ console.error('\n✗ Test failed with error:', error.message);
124
+ failures++;
125
+ } finally {
126
+ if (browser) {
127
+ await browser.close();
128
+ }
129
+
130
+ // Kill server
131
+ server.kill();
132
+
133
+ // Exit with appropriate code
134
+ process.exit(failures > 0 ? 1 : 0);
135
+ }
136
+ }
137
+
138
+ runTests();