gh-here 1.0.2 → 1.0.4
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 +15 -10
- package/README.md +6 -3
- package/bin/gh-here.js +8 -1255
- package/lib/file-utils.js +264 -0
- package/lib/git.js +207 -0
- package/lib/gitignore.js +91 -0
- package/lib/renderers.js +569 -0
- package/lib/server.js +391 -0
- package/package.json +1 -1
- package/public/app.js +692 -129
- package/public/styles.css +414 -44
- package/tests/draftManager.test.js +241 -0
- package/tests/httpService.test.js +268 -0
- package/tests/languageDetection.test.js +145 -0
- package/tests/pathUtils.test.js +136 -0
package/lib/server.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const { getGitStatus, getGitBranch, commitAllChanges, commitSelectedFiles, getGitDiff } = require('./git');
|
|
6
|
+
const { isIgnoredByGitignore, getGitignoreRules } = require('./gitignore');
|
|
7
|
+
const { renderDirectory, renderFileDiff, renderFile, renderNewFile } = require('./renderers');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Express server setup and route handlers
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function setupRoutes(app, workingDir, isGitRepo, gitRepoRoot) {
|
|
14
|
+
|
|
15
|
+
// Serve static files
|
|
16
|
+
app.use('/static', express.static(path.join(__dirname, '..', 'public')));
|
|
17
|
+
app.use('/octicons', express.static(path.join(__dirname, '..', 'node_modules', '@primer', 'octicons', 'build')));
|
|
18
|
+
|
|
19
|
+
// Download route
|
|
20
|
+
app.get('/download', (req, res) => {
|
|
21
|
+
const filePath = req.query.path || '';
|
|
22
|
+
const fullPath = path.join(workingDir, filePath);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const stats = fs.statSync(fullPath);
|
|
26
|
+
if (stats.isFile()) {
|
|
27
|
+
const fileName = path.basename(fullPath);
|
|
28
|
+
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
29
|
+
res.sendFile(fullPath);
|
|
30
|
+
} else {
|
|
31
|
+
res.status(400).send('Cannot download directories');
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
res.status(404).send('File not found');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Main route - directory or file view
|
|
39
|
+
app.get('/', async (req, res) => {
|
|
40
|
+
const currentPath = req.query.path || '';
|
|
41
|
+
const showGitignored = req.query.gitignore === 'false';
|
|
42
|
+
const fullPath = path.join(workingDir, currentPath);
|
|
43
|
+
|
|
44
|
+
// Get git status and branch info
|
|
45
|
+
const gitStatus = await getGitStatus(gitRepoRoot);
|
|
46
|
+
const gitBranch = await getGitBranch(gitRepoRoot);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const stats = fs.statSync(fullPath);
|
|
50
|
+
|
|
51
|
+
if (stats.isDirectory()) {
|
|
52
|
+
const gitignoreRules = getGitignoreRules(workingDir);
|
|
53
|
+
|
|
54
|
+
let items = fs.readdirSync(fullPath).map(item => {
|
|
55
|
+
const itemPath = path.join(fullPath, item);
|
|
56
|
+
const itemStats = fs.statSync(itemPath);
|
|
57
|
+
const absoluteItemPath = path.resolve(itemPath);
|
|
58
|
+
const gitInfo = gitStatus[absoluteItemPath] || null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: item,
|
|
62
|
+
path: path.join(currentPath, item).replace(/\\/g, '/'),
|
|
63
|
+
isDirectory: itemStats.isDirectory(),
|
|
64
|
+
size: itemStats.size,
|
|
65
|
+
modified: itemStats.mtime,
|
|
66
|
+
gitStatus: gitInfo
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Filter out gitignored files unless explicitly requested to show them
|
|
71
|
+
if (!showGitignored) {
|
|
72
|
+
items = items.filter(item => {
|
|
73
|
+
const itemFullPath = path.join(fullPath, item.name);
|
|
74
|
+
return !isIgnoredByGitignore(itemFullPath, gitignoreRules, workingDir, item.isDirectory);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sort items (directories first, then alphabetically)
|
|
79
|
+
items.sort((a, b) => {
|
|
80
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
81
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
82
|
+
return a.name.localeCompare(b.name);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
res.send(renderDirectory(currentPath, items, showGitignored, gitBranch, gitStatus));
|
|
86
|
+
} else {
|
|
87
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
88
|
+
const ext = path.extname(fullPath).slice(1);
|
|
89
|
+
const viewMode = req.query.view || 'rendered';
|
|
90
|
+
|
|
91
|
+
if (viewMode === 'diff' && isGitRepo) {
|
|
92
|
+
// Check if file has git status
|
|
93
|
+
const absolutePath = path.resolve(fullPath);
|
|
94
|
+
const gitInfo = gitStatus[absolutePath];
|
|
95
|
+
if (gitInfo) {
|
|
96
|
+
const diffHtml = await renderFileDiff(currentPath, ext, gitInfo, gitRepoRoot);
|
|
97
|
+
return res.send(diffHtml);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
res.send(await renderFile(currentPath, content, ext, viewMode, gitStatus, workingDir));
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
res.status(404).send(`<h1>File not found</h1><p>${error.message}</p>`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Route for creating new files
|
|
109
|
+
app.get('/new', (req, res) => {
|
|
110
|
+
const currentPath = req.query.path || '';
|
|
111
|
+
res.send(renderNewFile(currentPath));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// API endpoint to get file content for editing
|
|
115
|
+
app.get('/api/file-content', (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const currentPath = req.query.path || '';
|
|
118
|
+
const fullPath = path.join(workingDir, currentPath);
|
|
119
|
+
|
|
120
|
+
// Security check - ensure we're not accessing files outside the current directory
|
|
121
|
+
if (!fullPath.startsWith(workingDir)) {
|
|
122
|
+
return res.status(403).send('Access denied');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
126
|
+
res.send(content);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
res.status(404).send(`File not found: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// API endpoint to save file changes
|
|
133
|
+
app.post('/api/save-file', express.json(), (req, res) => {
|
|
134
|
+
try {
|
|
135
|
+
const { path: filePath, content } = req.body;
|
|
136
|
+
const fullPath = path.join(workingDir, filePath || '');
|
|
137
|
+
|
|
138
|
+
// Security check - ensure we're not accessing files outside the current directory
|
|
139
|
+
if (!fullPath.startsWith(workingDir)) {
|
|
140
|
+
return res.status(403).json({ success: false, error: 'Access denied' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
144
|
+
res.json({ success: true });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
res.status(500).json({ success: false, error: error.message });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// API endpoint to create new file
|
|
151
|
+
app.post('/api/create-file', express.json(), (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
const { path: dirPath, filename } = req.body;
|
|
154
|
+
const fullDirPath = path.join(workingDir, dirPath || '');
|
|
155
|
+
const fullFilePath = path.join(fullDirPath, filename);
|
|
156
|
+
|
|
157
|
+
// Security checks
|
|
158
|
+
if (!fullDirPath.startsWith(workingDir) || !fullFilePath.startsWith(workingDir)) {
|
|
159
|
+
return res.status(403).json({ success: false, error: 'Access denied' });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if file already exists
|
|
163
|
+
if (fs.existsSync(fullFilePath)) {
|
|
164
|
+
return res.status(400).json({ success: false, error: 'File already exists' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Create the file with empty content
|
|
168
|
+
fs.writeFileSync(fullFilePath, '', 'utf-8');
|
|
169
|
+
res.json({ success: true });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
res.status(500).json({ success: false, error: error.message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// API endpoint to create new folder
|
|
176
|
+
app.post('/api/create-folder', express.json(), (req, res) => {
|
|
177
|
+
try {
|
|
178
|
+
const { path: dirPath, foldername } = req.body;
|
|
179
|
+
const fullDirPath = path.join(workingDir, dirPath || '');
|
|
180
|
+
const fullFolderPath = path.join(fullDirPath, foldername);
|
|
181
|
+
|
|
182
|
+
// Security checks
|
|
183
|
+
if (!fullDirPath.startsWith(workingDir) || !fullFolderPath.startsWith(workingDir)) {
|
|
184
|
+
return res.status(403).json({ success: false, error: 'Access denied' });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check if folder already exists
|
|
188
|
+
if (fs.existsSync(fullFolderPath)) {
|
|
189
|
+
return res.status(400).json({ success: false, error: 'Folder already exists' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Create the folder
|
|
193
|
+
fs.mkdirSync(fullFolderPath);
|
|
194
|
+
res.json({ success: true });
|
|
195
|
+
} catch (error) {
|
|
196
|
+
res.status(500).json({ success: false, error: error.message });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// API endpoint to delete file or folder
|
|
201
|
+
app.post('/api/delete', express.json(), (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const { path: itemPath } = req.body;
|
|
204
|
+
const fullPath = path.join(workingDir, itemPath);
|
|
205
|
+
|
|
206
|
+
// Security check
|
|
207
|
+
if (!fullPath.startsWith(workingDir)) {
|
|
208
|
+
return res.status(403).json({ success: false, error: 'Access denied' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check if item exists
|
|
212
|
+
if (!fs.existsSync(fullPath)) {
|
|
213
|
+
return res.status(404).json({ success: false, error: 'Item not found' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Delete the item
|
|
217
|
+
const stats = fs.statSync(fullPath);
|
|
218
|
+
if (stats.isDirectory()) {
|
|
219
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
220
|
+
} else {
|
|
221
|
+
fs.unlinkSync(fullPath);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
res.json({ success: true });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
res.status(500).json({ success: false, error: error.message });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// API endpoint to rename file or folder
|
|
231
|
+
app.post('/api/rename', express.json(), (req, res) => {
|
|
232
|
+
try {
|
|
233
|
+
const { path: oldPath, newName } = req.body;
|
|
234
|
+
const fullOldPath = path.join(workingDir, oldPath);
|
|
235
|
+
const dirPath = path.dirname(fullOldPath);
|
|
236
|
+
const fullNewPath = path.join(dirPath, newName);
|
|
237
|
+
|
|
238
|
+
// Security checks
|
|
239
|
+
if (!fullOldPath.startsWith(workingDir) || !fullNewPath.startsWith(workingDir)) {
|
|
240
|
+
return res.status(403).json({ success: false, error: 'Access denied' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check if old item exists
|
|
244
|
+
if (!fs.existsSync(fullOldPath)) {
|
|
245
|
+
return res.status(404).json({ success: false, error: 'Item not found' });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check if new name already exists
|
|
249
|
+
if (fs.existsSync(fullNewPath)) {
|
|
250
|
+
return res.status(400).json({ success: false, error: 'Name already exists' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Rename the item
|
|
254
|
+
fs.renameSync(fullOldPath, fullNewPath);
|
|
255
|
+
res.json({ success: true });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
res.status(500).json({ success: false, error: error.message });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Git commit all endpoint
|
|
262
|
+
app.post('/api/git-commit-all', express.json(), async (req, res) => {
|
|
263
|
+
try {
|
|
264
|
+
if (!isGitRepo) {
|
|
265
|
+
return res.status(404).json({ success: false, error: 'Not a git repository' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { message } = req.body;
|
|
269
|
+
if (!message || !message.trim()) {
|
|
270
|
+
return res.status(400).json({ success: false, error: 'Commit message is required' });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const result = await commitAllChanges(gitRepoRoot, message);
|
|
275
|
+
res.json(result);
|
|
276
|
+
} catch (gitError) {
|
|
277
|
+
console.error('Git commit error:', gitError);
|
|
278
|
+
res.status(500).json({ success: false, error: gitError.message });
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error('Commit endpoint error:', error);
|
|
282
|
+
res.status(500).json({ success: false, error: error.message });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Git commit selected files endpoint
|
|
287
|
+
app.post('/api/git-commit-selected', express.json(), async (req, res) => {
|
|
288
|
+
try {
|
|
289
|
+
if (!isGitRepo) {
|
|
290
|
+
return res.status(404).json({ success: false, error: 'Not a git repository' });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { message, files } = req.body;
|
|
294
|
+
if (!message || !message.trim()) {
|
|
295
|
+
return res.status(400).json({ success: false, error: 'Commit message is required' });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!files || files.length === 0) {
|
|
299
|
+
return res.status(400).json({ success: false, error: 'No files selected' });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const result = await commitSelectedFiles(gitRepoRoot, message, files);
|
|
304
|
+
res.json(result);
|
|
305
|
+
} catch (gitError) {
|
|
306
|
+
console.error('Git commit error:', gitError);
|
|
307
|
+
res.status(500).json({ success: false, error: gitError.message });
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error('Commit selected endpoint error:', error);
|
|
311
|
+
res.status(500).json({ success: false, error: error.message });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Get git changes endpoint (with directory filtering)
|
|
316
|
+
app.get('/api/git-status', async (req, res) => {
|
|
317
|
+
try {
|
|
318
|
+
if (!isGitRepo) {
|
|
319
|
+
return res.status(404).json({ success: false, error: 'Not a git repository' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const currentPath = req.query.currentPath || '';
|
|
323
|
+
const currentDir = currentPath ? path.resolve(workingDir, currentPath) : workingDir;
|
|
324
|
+
|
|
325
|
+
const gitStatus = await getGitStatus(gitRepoRoot);
|
|
326
|
+
let changes = Object.entries(gitStatus).map(([filePath, info]) => {
|
|
327
|
+
// Convert absolute path to relative path from repo root
|
|
328
|
+
const relativePath = path.relative(gitRepoRoot, filePath);
|
|
329
|
+
return {
|
|
330
|
+
name: relativePath,
|
|
331
|
+
status: require('./git').getGitStatusDescription(info.status),
|
|
332
|
+
fullPath: filePath
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Filter to only show files in current directory and subdirectories
|
|
337
|
+
if (currentPath) {
|
|
338
|
+
const currentRelativePath = path.relative(gitRepoRoot, currentDir);
|
|
339
|
+
changes = changes.filter(change => {
|
|
340
|
+
// Include files that are in the current directory or its subdirectories
|
|
341
|
+
return change.name.startsWith(currentRelativePath + path.sep) || change.name === currentRelativePath;
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
// If at root, show files in working directory and below
|
|
345
|
+
const workingRelativePath = path.relative(gitRepoRoot, workingDir);
|
|
346
|
+
if (workingRelativePath && workingRelativePath !== '.') {
|
|
347
|
+
changes = changes.filter(change => {
|
|
348
|
+
return change.name.startsWith(workingRelativePath + path.sep) || change.name === workingRelativePath;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Sort changes alphabetically
|
|
354
|
+
changes.sort((a, b) => a.name.localeCompare(b.name));
|
|
355
|
+
|
|
356
|
+
res.json({ success: true, changes });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error('Git status endpoint error:', error);
|
|
359
|
+
res.status(500).json({ success: false, error: error.message });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Git diff endpoint
|
|
364
|
+
app.get('/api/git-diff', async (req, res) => {
|
|
365
|
+
try {
|
|
366
|
+
if (!isGitRepo) {
|
|
367
|
+
return res.status(404).json({ success: false, error: 'Not a git repository' });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const filePath = req.query.path;
|
|
371
|
+
const staged = req.query.staged === 'true';
|
|
372
|
+
|
|
373
|
+
if (!filePath) {
|
|
374
|
+
return res.status(400).json({ success: false, error: 'File path is required' });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const result = await getGitDiff(gitRepoRoot, filePath, staged);
|
|
379
|
+
res.json(result);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
res.status(500).json({ success: false, error: error.message });
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
res.status(500).json({ success: false, error: error.message });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
module.exports = {
|
|
390
|
+
setupRoutes
|
|
391
|
+
};
|