gh-here 1.0.2 → 1.0.5

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/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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-here",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
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",