gh-here 2.1.0 → 3.0.0
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/.claude/settings.local.json +20 -11
- package/README.md +30 -101
- package/lib/constants.js +38 -0
- package/lib/error-handler.js +55 -0
- package/lib/file-tree-builder.js +81 -0
- package/lib/file-utils.js +43 -12
- package/lib/renderers.js +423 -194
- package/lib/server.js +120 -32
- package/lib/validation.js +77 -0
- package/package.json +1 -1
- package/public/app.js +199 -1825
- package/public/app.js.backup +1902 -0
- package/public/js/clipboard-utils.js +45 -0
- package/public/js/constants.js +60 -0
- package/public/js/draft-manager.js +36 -0
- package/public/js/editor-manager.js +159 -0
- package/public/js/file-tree.js +321 -0
- package/public/js/keyboard-handler.js +41 -0
- package/public/js/modal-manager.js +70 -0
- package/public/js/navigation.js +254 -0
- package/public/js/notification.js +23 -0
- package/public/js/search-handler.js +238 -0
- package/public/js/theme-manager.js +108 -0
- package/public/js/utils.js +123 -0
- package/public/styles.css +874 -570
- package/.channels_cache_v2.json +0 -10882
- package/.users_cache.json +0 -16187
- package/blog-post.md +0 -100
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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()}
|
|
281
|
-
const rawUrl = `/?${currentParams.toString()}&view=raw`;
|
|
323
|
+
const viewUrl = `/?${currentParams.toString()}`;
|
|
282
324
|
const diffUrl = `/?${currentParams.toString()}&view=diff`;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
325
|
-
<div class="
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
let
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
highlighted = hljs.highlight(diffOutput, { language: 'diff' }).value;
|
|
354
|
-
} catch {
|
|
355
|
-
// Fallback to plain text if diff highlighting fails
|
|
356
|
-
highlighted = diffOutput.replace(/&/g, '&')
|
|
357
|
-
.replace(/</g, '<')
|
|
358
|
-
.replace(/>/g, '>');
|
|
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
|
-
//
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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">${
|
|
384
|
-
<span class="diff-line-
|
|
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, '&')
|
|
589
|
+
.replace(/</g, '<')
|
|
590
|
+
.replace(/>/g, '>')
|
|
591
|
+
.replace(/"/g, '"')
|
|
592
|
+
.replace(/'/g, ''');
|
|
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' })}
|
|
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' })}
|
|
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
|
|
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' })}
|
|
685
|
+
${octicons.eye.toSVG({ class: 'view-icon' })}
|
|
686
|
+
<span>View</span>
|
|
469
687
|
</a>
|
|
470
|
-
<a href="${
|
|
471
|
-
${octicons['file-code'].toSVG({ class: 'view-icon' })}
|
|
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' })}
|
|
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' })}
|
|
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' })}
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
<
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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:
|
|
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
|
|
586
|
-
|
|
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
|
-
<
|
|
603
|
-
<div class="
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
<div class="
|
|
611
|
-
|
|
612
|
-
<
|
|
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="
|
|
616
|
-
|
|
617
|
-
|
|
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,
|