gh-here 2.0.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/SAMPLE.md +287 -0
- 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 +440 -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/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const { getGitStatus, getGitBranch, commitAllChanges, commitSelectedFiles, getGi
|
|
|
6
6
|
const { isIgnoredByGitignore, getGitignoreRules } = require('./gitignore');
|
|
7
7
|
const { renderDirectory, renderFileDiff, renderFile, renderNewFile } = require('./renderers');
|
|
8
8
|
const { isImageFile, isBinaryFile } = require('./file-utils');
|
|
9
|
+
const { buildFileTree } = require('./file-tree-builder');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Express server setup and route handlers
|
|
@@ -18,6 +19,84 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
|
|
|
18
19
|
app.use('/octicons', express.static(path.join(__dirname, '..', 'node_modules', '@primer', 'octicons', 'build')));
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
// API endpoint for file tree
|
|
23
|
+
app.get('/api/file-tree', (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const showGitignored = req.query.showGitignored === 'true';
|
|
26
|
+
const gitignoreRules = getGitignoreRules(workingDir);
|
|
27
|
+
const tree = buildFileTree(workingDir, '', gitignoreRules, workingDir, showGitignored);
|
|
28
|
+
res.json({ success: true, tree });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
res.status(500).json({ success: false, error: error.message });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// API endpoint for global file search
|
|
35
|
+
app.get('/api/search', (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const query = req.query.q || '';
|
|
38
|
+
if (!query.trim()) {
|
|
39
|
+
return res.json({ success: true, results: [] });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const gitignoreRules = getGitignoreRules(workingDir);
|
|
43
|
+
const results = [];
|
|
44
|
+
const lowerQuery = query.toLowerCase();
|
|
45
|
+
|
|
46
|
+
function searchDirectory(dir, relativePath = '') {
|
|
47
|
+
const entries = fs.readdirSync(dir);
|
|
48
|
+
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const fullPath = path.join(dir, entry);
|
|
51
|
+
const relPath = path.join(relativePath, entry).replace(/\\/g, '/');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const stats = fs.statSync(fullPath);
|
|
55
|
+
|
|
56
|
+
// Skip gitignored files
|
|
57
|
+
if (!isIgnoredByGitignore(fullPath, gitignoreRules, workingDir, stats.isDirectory())) {
|
|
58
|
+
// Check if filename matches
|
|
59
|
+
if (entry.toLowerCase().includes(lowerQuery)) {
|
|
60
|
+
results.push({
|
|
61
|
+
path: relPath,
|
|
62
|
+
name: entry,
|
|
63
|
+
isDirectory: stats.isDirectory(),
|
|
64
|
+
modified: stats.mtime
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Recurse into directories
|
|
69
|
+
if (stats.isDirectory()) {
|
|
70
|
+
searchDirectory(fullPath, relPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Skip files we can't access
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
searchDirectory(workingDir);
|
|
81
|
+
|
|
82
|
+
// Sort results: exact matches first, then directories, then alphabetically
|
|
83
|
+
results.sort((a, b) => {
|
|
84
|
+
const aExact = a.name.toLowerCase() === lowerQuery ? 1 : 0;
|
|
85
|
+
const bExact = b.name.toLowerCase() === lowerQuery ? 1 : 0;
|
|
86
|
+
if (aExact !== bExact) return bExact - aExact;
|
|
87
|
+
|
|
88
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
89
|
+
return a.isDirectory ? -1 : 1;
|
|
90
|
+
}
|
|
91
|
+
return a.path.localeCompare(b.path);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
res.json({ success: true, results, query });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
res.status(500).json({ success: false, error: error.message });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
21
100
|
// Download route
|
|
22
101
|
app.get('/download', (req, res) => {
|
|
23
102
|
const filePath = req.query.path || '';
|
|
@@ -48,7 +127,7 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
|
|
|
48
127
|
// Main route - directory or file view
|
|
49
128
|
app.get('/', async (req, res) => {
|
|
50
129
|
const currentPath = req.query.path || '';
|
|
51
|
-
const showGitignored = req.query.
|
|
130
|
+
const showGitignored = req.query.showGitignored === 'true';
|
|
52
131
|
const fullPath = path.join(workingDir, currentPath);
|
|
53
132
|
|
|
54
133
|
// Get git status and branch info
|
|
@@ -61,36 +140,39 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
|
|
|
61
140
|
if (stats.isDirectory()) {
|
|
62
141
|
const gitignoreRules = getGitignoreRules(workingDir);
|
|
63
142
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
143
|
+
// Optimize: Batch file system operations
|
|
144
|
+
const dirEntries = fs.readdirSync(fullPath);
|
|
145
|
+
const items = dirEntries
|
|
146
|
+
.map(item => {
|
|
147
|
+
const itemPath = path.join(fullPath, item);
|
|
148
|
+
const itemStats = fs.statSync(itemPath);
|
|
149
|
+
const absoluteItemPath = path.resolve(itemPath);
|
|
150
|
+
const gitInfo = gitStatus[absoluteItemPath] || null;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
name: item,
|
|
154
|
+
path: path.join(currentPath, item).replace(/\\/g, '/'),
|
|
155
|
+
isDirectory: itemStats.isDirectory(),
|
|
156
|
+
size: itemStats.size,
|
|
157
|
+
modified: itemStats.mtime,
|
|
158
|
+
gitStatus: gitInfo,
|
|
159
|
+
fullPath: itemPath
|
|
160
|
+
};
|
|
161
|
+
})
|
|
162
|
+
.filter(item => {
|
|
163
|
+
// Filter gitignored files unless explicitly requested
|
|
164
|
+
if (!showGitignored) {
|
|
165
|
+
return !isIgnoredByGitignore(item.fullPath, gitignoreRules, workingDir, item.isDirectory);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
})
|
|
169
|
+
.sort((a, b) => {
|
|
170
|
+
// Sort: directories first, then alphabetically
|
|
171
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
172
|
+
return a.isDirectory ? -1 : 1;
|
|
173
|
+
}
|
|
174
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
85
175
|
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Sort items (directories first, then alphabetically)
|
|
89
|
-
items.sort((a, b) => {
|
|
90
|
-
if (a.isDirectory && !b.isDirectory) return -1;
|
|
91
|
-
if (!a.isDirectory && b.isDirectory) return 1;
|
|
92
|
-
return a.name.localeCompare(b.name);
|
|
93
|
-
});
|
|
94
176
|
|
|
95
177
|
res.send(renderDirectory(currentPath, items, showGitignored, gitBranch, gitStatus, workingDir));
|
|
96
178
|
} else {
|
|
@@ -108,11 +190,11 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
|
|
|
108
190
|
const absolutePath = path.resolve(fullPath);
|
|
109
191
|
const gitInfo = gitStatus[absolutePath];
|
|
110
192
|
if (gitInfo) {
|
|
111
|
-
const diffHtml = await renderFileDiff(currentPath, ext, gitInfo, gitRepoRoot, workingDir);
|
|
193
|
+
const diffHtml = await renderFileDiff(currentPath, ext, gitInfo, gitRepoRoot, workingDir, gitBranch);
|
|
112
194
|
return res.send(diffHtml);
|
|
113
195
|
}
|
|
114
196
|
}
|
|
115
|
-
|
|
197
|
+
|
|
116
198
|
res.send(await renderFile(currentPath, content, ext, viewMode, gitStatus, workingDir));
|
|
117
199
|
}
|
|
118
200
|
} catch (error) {
|
|
@@ -143,6 +225,12 @@ function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
|
|
|
143
225
|
}
|
|
144
226
|
|
|
145
227
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
228
|
+
|
|
229
|
+
// Set proper headers for raw file view (like GitHub)
|
|
230
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
231
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
232
|
+
res.setHeader('Content-Disposition', `inline; filename="${path.basename(fullPath)}"`);
|
|
233
|
+
|
|
146
234
|
res.send(content);
|
|
147
235
|
} catch (error) {
|
|
148
236
|
res.status(404).send(`File not found: ${error.message}`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation and security utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validates that a path is within the allowed working directory
|
|
9
|
+
* Prevents path traversal attacks
|
|
10
|
+
*/
|
|
11
|
+
function validatePath(requestPath, workingDir) {
|
|
12
|
+
const fullPath = path.resolve(path.join(workingDir, requestPath || ''));
|
|
13
|
+
return fullPath.startsWith(workingDir);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sanitizes file paths to prevent injection
|
|
18
|
+
*/
|
|
19
|
+
function sanitizePath(filePath) {
|
|
20
|
+
if (!filePath) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
return filePath.replace(/\.\./g, '').replace(/[<>:"|?*]/g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validates commit message
|
|
28
|
+
*/
|
|
29
|
+
function validateCommitMessage(message) {
|
|
30
|
+
if (!message || typeof message !== 'string') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return message.trim().length > 0 && message.trim().length <= 5000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validates filename
|
|
38
|
+
*/
|
|
39
|
+
function validateFilename(filename) {
|
|
40
|
+
if (!filename || typeof filename !== 'string') {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sanitized = filename.trim();
|
|
45
|
+
|
|
46
|
+
if (sanitized.length === 0 || sanitized.length > 255) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const invalidChars = /[<>:"|?*\x00-\x1F]/;
|
|
51
|
+
if (invalidChars.test(sanitized)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validates an array of file paths
|
|
60
|
+
*/
|
|
61
|
+
function validateFilePaths(files) {
|
|
62
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return files.every(file => {
|
|
67
|
+
return typeof file === 'string' && file.trim().length > 0;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
validatePath,
|
|
73
|
+
sanitizePath,
|
|
74
|
+
validateCommitMessage,
|
|
75
|
+
validateFilename,
|
|
76
|
+
validateFilePaths
|
|
77
|
+
};
|