vimd 0.4.1 → 0.5.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/README.md +34 -14
- package/dist/cli/commands/dev.d.ts +1 -1
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +57 -5
- package/dist/core/folder-mode/assets/folder-mode.css +308 -0
- package/dist/core/folder-mode/assets/folder-mode.js +513 -0
- package/dist/core/folder-mode/folder-mode-server.d.ts +84 -0
- package/dist/core/folder-mode/folder-mode-server.d.ts.map +1 -0
- package/dist/core/folder-mode/folder-mode-server.js +375 -0
- package/dist/core/folder-mode/folder-scanner.d.ts +34 -0
- package/dist/core/folder-mode/folder-scanner.d.ts.map +1 -0
- package/dist/core/folder-mode/folder-scanner.js +105 -0
- package/dist/core/folder-mode/index.d.ts +8 -0
- package/dist/core/folder-mode/index.d.ts.map +1 -0
- package/dist/core/folder-mode/index.js +6 -0
- package/dist/core/folder-mode/types.d.ts +101 -0
- package/dist/core/folder-mode/types.d.ts.map +1 -0
- package/dist/core/folder-mode/types.js +15 -0
- package/dist/templates/folder-mode.html +86 -0
- package/package.json +1 -1
- package/templates/folder-mode.html +86 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import polka from 'polka';
|
|
6
|
+
import { WebSocketServer as WSServer, WebSocket } from 'ws';
|
|
7
|
+
import { Logger } from '../../utils/logger.js';
|
|
8
|
+
import { SessionManager } from '../../utils/session-manager.js';
|
|
9
|
+
import { ThemeManager } from '../../themes/index.js';
|
|
10
|
+
import { ParserFactory } from '../parser/index.js';
|
|
11
|
+
import { PandocDetector } from '../pandoc-detector.js';
|
|
12
|
+
import { FileWatcher } from '../watcher.js';
|
|
13
|
+
import { FolderScanner } from './folder-scanner.js';
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
/**
|
|
17
|
+
* Folder mode server for multi-file preview
|
|
18
|
+
*/
|
|
19
|
+
export class FolderModeServer {
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.httpServer = null;
|
|
22
|
+
this.wsServer = null;
|
|
23
|
+
this.clients = new Map();
|
|
24
|
+
this.fileTree = [];
|
|
25
|
+
this.renderedHtml = null;
|
|
26
|
+
this.options = options;
|
|
27
|
+
this._port = options.port;
|
|
28
|
+
this.scanner = new FolderScanner({ rootPath: options.rootPath });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Get the actual port the server is running on
|
|
32
|
+
*/
|
|
33
|
+
get port() {
|
|
34
|
+
return this._port;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get the root path
|
|
38
|
+
*/
|
|
39
|
+
get rootPath() {
|
|
40
|
+
return this.options.rootPath;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start the HTTP and WebSocket servers
|
|
44
|
+
*/
|
|
45
|
+
async start() {
|
|
46
|
+
const requestedPort = this.options.port;
|
|
47
|
+
let actualPort = requestedPort;
|
|
48
|
+
// Check if port is available
|
|
49
|
+
if (!(await SessionManager.isPortAvailable(requestedPort))) {
|
|
50
|
+
actualPort = await SessionManager.findAvailablePort(requestedPort + 1);
|
|
51
|
+
Logger.warn(`Port ${requestedPort} was unavailable, using port ${actualPort}`);
|
|
52
|
+
}
|
|
53
|
+
this._port = actualPort;
|
|
54
|
+
// Initial scan
|
|
55
|
+
this.fileTree = await this.scanner.scan();
|
|
56
|
+
Logger.info(`Found ${this.countFiles(this.fileTree)} files in folder`);
|
|
57
|
+
// Render template
|
|
58
|
+
this.renderedHtml = await this.renderTemplate();
|
|
59
|
+
// Create polka app
|
|
60
|
+
const app = polka();
|
|
61
|
+
// API: Get file tree
|
|
62
|
+
app.get('/api/tree', (_req, res) => {
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
64
|
+
res.end(JSON.stringify(this.fileTree));
|
|
65
|
+
});
|
|
66
|
+
// Serve folder mode HTML for all routes (SPA style)
|
|
67
|
+
app.get('*', (req, res) => {
|
|
68
|
+
this.serveFolderModeHtml(req, res);
|
|
69
|
+
});
|
|
70
|
+
// Create HTTP server
|
|
71
|
+
this.httpServer = http.createServer(app.handler);
|
|
72
|
+
// Create WebSocket server
|
|
73
|
+
this.wsServer = new WSServer({ server: this.httpServer });
|
|
74
|
+
// Handle WebSocket connections
|
|
75
|
+
this.wsServer.on('connection', (ws) => {
|
|
76
|
+
this.handleConnection(ws);
|
|
77
|
+
});
|
|
78
|
+
// Start listening
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
const timeout = setTimeout(() => {
|
|
81
|
+
reject(new Error('Server start timeout'));
|
|
82
|
+
}, 10000);
|
|
83
|
+
this.httpServer.listen(actualPort, 'localhost', () => {
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
resolve();
|
|
86
|
+
});
|
|
87
|
+
this.httpServer.on('error', (err) => {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
reject(err);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
const url = `http://localhost:${actualPort}`;
|
|
93
|
+
Logger.success(`Folder mode server started at ${url}`);
|
|
94
|
+
return {
|
|
95
|
+
actualPort,
|
|
96
|
+
requestedPort,
|
|
97
|
+
portChanged: actualPort !== requestedPort,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Stop the server
|
|
102
|
+
*/
|
|
103
|
+
async stop() {
|
|
104
|
+
// Terminate all WebSocket clients
|
|
105
|
+
for (const [client] of this.clients) {
|
|
106
|
+
try {
|
|
107
|
+
client.terminate();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Ignore termination errors
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.clients.clear();
|
|
114
|
+
// Close WebSocket server
|
|
115
|
+
if (this.wsServer) {
|
|
116
|
+
await new Promise((resolve) => {
|
|
117
|
+
this.wsServer.close(() => resolve());
|
|
118
|
+
});
|
|
119
|
+
this.wsServer = null;
|
|
120
|
+
}
|
|
121
|
+
// Close HTTP server
|
|
122
|
+
if (this.httpServer) {
|
|
123
|
+
this.httpServer.closeAllConnections();
|
|
124
|
+
await new Promise((resolve) => {
|
|
125
|
+
this.httpServer.close(() => resolve());
|
|
126
|
+
});
|
|
127
|
+
this.httpServer = null;
|
|
128
|
+
}
|
|
129
|
+
Logger.info('Folder mode server stopped');
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Handle new WebSocket connection
|
|
133
|
+
*/
|
|
134
|
+
handleConnection(ws) {
|
|
135
|
+
// Initialize client state
|
|
136
|
+
this.clients.set(ws, { currentFile: null, watcher: null });
|
|
137
|
+
Logger.info(`WebSocket client connected (${this.clients.size} total)`);
|
|
138
|
+
// Send file tree on connection
|
|
139
|
+
this.sendMessage(ws, { type: 'tree', data: this.fileTree });
|
|
140
|
+
// Handle messages from client
|
|
141
|
+
ws.on('message', (data) => {
|
|
142
|
+
try {
|
|
143
|
+
const message = JSON.parse(data.toString());
|
|
144
|
+
this.handleClientMessage(ws, message);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
Logger.warn(`Invalid message from client: ${error}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
ws.on('close', async () => {
|
|
151
|
+
// Stop watcher for this client
|
|
152
|
+
const state = this.clients.get(ws);
|
|
153
|
+
if (state?.watcher) {
|
|
154
|
+
await state.watcher.stop();
|
|
155
|
+
}
|
|
156
|
+
this.clients.delete(ws);
|
|
157
|
+
Logger.info(`WebSocket client disconnected (${this.clients.size} remaining)`);
|
|
158
|
+
});
|
|
159
|
+
ws.on('error', async (error) => {
|
|
160
|
+
Logger.warn(`WebSocket error: ${error.message}`);
|
|
161
|
+
// Stop watcher for this client
|
|
162
|
+
const state = this.clients.get(ws);
|
|
163
|
+
if (state?.watcher) {
|
|
164
|
+
await state.watcher.stop();
|
|
165
|
+
}
|
|
166
|
+
this.clients.delete(ws);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Handle message from client
|
|
171
|
+
*/
|
|
172
|
+
handleClientMessage(ws, message) {
|
|
173
|
+
switch (message.type) {
|
|
174
|
+
case 'selectFile':
|
|
175
|
+
this.handleSelectFile(ws, message.path);
|
|
176
|
+
break;
|
|
177
|
+
default:
|
|
178
|
+
Logger.warn(`Unknown message type: ${message.type}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Handle file selection
|
|
183
|
+
*/
|
|
184
|
+
async handleSelectFile(ws, relativePath) {
|
|
185
|
+
const state = this.clients.get(ws);
|
|
186
|
+
if (!state)
|
|
187
|
+
return;
|
|
188
|
+
// Validate path (security check)
|
|
189
|
+
const absolutePath = this.validatePath(relativePath);
|
|
190
|
+
if (!absolutePath) {
|
|
191
|
+
this.sendMessage(ws, {
|
|
192
|
+
type: 'error',
|
|
193
|
+
data: { type: 'invalid-path', message: 'Invalid file path' },
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Check file exists
|
|
198
|
+
if (!(await fs.pathExists(absolutePath))) {
|
|
199
|
+
this.sendMessage(ws, {
|
|
200
|
+
type: 'error',
|
|
201
|
+
data: { type: 'file-not-found', message: 'File not found' },
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Determine file type
|
|
206
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
207
|
+
const isLatex = ext === '.tex' || ext === '.latex';
|
|
208
|
+
// Check pandoc for LaTeX files
|
|
209
|
+
if (isLatex && !PandocDetector.check()) {
|
|
210
|
+
this.sendMessage(ws, {
|
|
211
|
+
type: 'error',
|
|
212
|
+
data: {
|
|
213
|
+
type: 'pandoc-not-found',
|
|
214
|
+
message: 'LaTeX files require pandoc. Please install pandoc.',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Stop previous watcher if exists
|
|
220
|
+
if (state.watcher) {
|
|
221
|
+
await state.watcher.stop();
|
|
222
|
+
state.watcher = null;
|
|
223
|
+
}
|
|
224
|
+
// Convert file
|
|
225
|
+
try {
|
|
226
|
+
const html = await this.convertFile(absolutePath, isLatex);
|
|
227
|
+
// Update client state
|
|
228
|
+
state.currentFile = relativePath;
|
|
229
|
+
// Send content
|
|
230
|
+
this.sendMessage(ws, {
|
|
231
|
+
type: 'content',
|
|
232
|
+
data: { path: relativePath, html },
|
|
233
|
+
});
|
|
234
|
+
// Start watching the file
|
|
235
|
+
state.watcher = new FileWatcher(absolutePath, { debounce: 300, ignored: [] });
|
|
236
|
+
state.watcher.onChange(async () => {
|
|
237
|
+
// Re-convert and send on file change
|
|
238
|
+
try {
|
|
239
|
+
const newHtml = await this.convertFile(absolutePath, isLatex);
|
|
240
|
+
this.sendMessage(ws, {
|
|
241
|
+
type: 'content',
|
|
242
|
+
data: { path: relativePath, html: newHtml },
|
|
243
|
+
});
|
|
244
|
+
Logger.info(`File updated: ${relativePath}`);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
const errMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
248
|
+
Logger.error(`Failed to reconvert ${relativePath}: ${errMsg}`);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
state.watcher.start();
|
|
252
|
+
Logger.info(`File converted and watching: ${relativePath}`);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
256
|
+
Logger.error(`Failed to convert ${relativePath}: ${message}`);
|
|
257
|
+
this.sendMessage(ws, {
|
|
258
|
+
type: 'error',
|
|
259
|
+
data: { type: 'conversion-error', message: `Failed to convert file: ${message}` },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Convert a file to HTML
|
|
265
|
+
*/
|
|
266
|
+
async convertFile(absolutePath, isLatex) {
|
|
267
|
+
const content = await fs.readFile(absolutePath, 'utf-8');
|
|
268
|
+
const fromFormat = isLatex ? 'latex' : 'markdown';
|
|
269
|
+
const parserType = isLatex ? 'pandoc' : 'markdown-it';
|
|
270
|
+
const parser = ParserFactory.create(parserType, {}, undefined, fromFormat);
|
|
271
|
+
return parser.parse(content);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Validate path and return absolute path if valid
|
|
275
|
+
*/
|
|
276
|
+
validatePath(relativePath) {
|
|
277
|
+
// Normalize path
|
|
278
|
+
const normalized = path.normalize(relativePath);
|
|
279
|
+
// Reject paths with '..'
|
|
280
|
+
if (normalized.includes('..')) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
// Create absolute path
|
|
284
|
+
const absolutePath = path.join(this.options.rootPath, normalized);
|
|
285
|
+
// Ensure path is within root
|
|
286
|
+
if (!absolutePath.startsWith(this.options.rootPath)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return absolutePath;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Send message to a specific client
|
|
293
|
+
*/
|
|
294
|
+
sendMessage(ws, message) {
|
|
295
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
296
|
+
ws.send(JSON.stringify(message));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Broadcast message to all clients
|
|
301
|
+
*/
|
|
302
|
+
broadcast(message) {
|
|
303
|
+
const data = JSON.stringify(message);
|
|
304
|
+
for (const [client] of this.clients) {
|
|
305
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
306
|
+
client.send(data);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Serve folder mode HTML
|
|
312
|
+
*/
|
|
313
|
+
serveFolderModeHtml(_req, res) {
|
|
314
|
+
res.writeHead(200, {
|
|
315
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
316
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
317
|
+
});
|
|
318
|
+
res.end(this.renderedHtml);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Render the folder mode template
|
|
322
|
+
*/
|
|
323
|
+
async renderTemplate() {
|
|
324
|
+
// Load template
|
|
325
|
+
const templatePath = path.join(__dirname, '../../../templates/folder-mode.html');
|
|
326
|
+
let template = await fs.readFile(templatePath, 'utf-8');
|
|
327
|
+
// Load CSS
|
|
328
|
+
const cssPath = path.join(__dirname, 'assets/folder-mode.css');
|
|
329
|
+
const folderModeCss = await fs.readFile(cssPath, 'utf-8');
|
|
330
|
+
// Load JS
|
|
331
|
+
const jsPath = path.join(__dirname, 'assets/folder-mode.js');
|
|
332
|
+
const folderModeJs = await fs.readFile(jsPath, 'utf-8');
|
|
333
|
+
// Load theme CSS
|
|
334
|
+
const themeCss = await ThemeManager.getCSS(this.options.theme);
|
|
335
|
+
// Get folder name for display
|
|
336
|
+
const folderName = path.basename(this.options.rootPath);
|
|
337
|
+
// Replace placeholders
|
|
338
|
+
template = template
|
|
339
|
+
.replace('{{folder_name}}', this.escapeHtml(folderName))
|
|
340
|
+
.replace('{{folder_mode_css}}', folderModeCss)
|
|
341
|
+
.replace('{{theme_css}}', themeCss)
|
|
342
|
+
.replace('{{folder_mode_js}}', folderModeJs);
|
|
343
|
+
// Handle math support (enabled by default)
|
|
344
|
+
template = template
|
|
345
|
+
.replace(/\{\{#if math_enabled\}\}/g, '')
|
|
346
|
+
.replace(/\{\{\/if\}\}/g, '');
|
|
347
|
+
return template;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Escape HTML special characters
|
|
351
|
+
*/
|
|
352
|
+
escapeHtml(text) {
|
|
353
|
+
return text
|
|
354
|
+
.replace(/&/g, '&')
|
|
355
|
+
.replace(/</g, '<')
|
|
356
|
+
.replace(/>/g, '>')
|
|
357
|
+
.replace(/"/g, '"')
|
|
358
|
+
.replace(/'/g, ''');
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Count total files in tree
|
|
362
|
+
*/
|
|
363
|
+
countFiles(nodes) {
|
|
364
|
+
let count = 0;
|
|
365
|
+
for (const node of nodes) {
|
|
366
|
+
if (node.type === 'file') {
|
|
367
|
+
count++;
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
count += this.countFiles(node.children);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return count;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TreeNode, FolderScannerOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Folder scanner for building file tree structure
|
|
4
|
+
*/
|
|
5
|
+
export declare class FolderScanner {
|
|
6
|
+
private rootPath;
|
|
7
|
+
private excludePatterns;
|
|
8
|
+
constructor(options: FolderScannerOptions);
|
|
9
|
+
/**
|
|
10
|
+
* Scan the folder and return tree structure
|
|
11
|
+
*/
|
|
12
|
+
scan(): Promise<TreeNode[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Recursively scan a directory
|
|
15
|
+
*/
|
|
16
|
+
private scanDirectory;
|
|
17
|
+
/**
|
|
18
|
+
* Check if a name is hidden (starts with .)
|
|
19
|
+
*/
|
|
20
|
+
private isHidden;
|
|
21
|
+
/**
|
|
22
|
+
* Check if a name matches exclude patterns
|
|
23
|
+
*/
|
|
24
|
+
private shouldExclude;
|
|
25
|
+
/**
|
|
26
|
+
* Check if extension is supported
|
|
27
|
+
*/
|
|
28
|
+
private isSupportedExtension;
|
|
29
|
+
/**
|
|
30
|
+
* Sort nodes: folders first, then files, both in alphabetical order
|
|
31
|
+
*/
|
|
32
|
+
private sortNodes;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=folder-scanner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"folder-scanner.d.ts","sourceRoot":"","sources":["../../../src/core/folder-mode/folder-scanner.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,QAAQ,EAIR,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAYpB;;GAEG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,eAAe,CAAW;gBAEtB,OAAO,EAAE,oBAAoB;IAQzC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;IAIjC;;OAEG;YACW,aAAa;IAyD3B;;OAEG;IACH,OAAO,CAAC,QAAQ;IAIhB;;OAEG;IACH,OAAO,CAAC,aAAa;IAIrB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAI5B;;OAEG;IACH,OAAO,CAAC,SAAS;CAUlB"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Default exclude patterns
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_EXCLUDE_PATTERNS = ['node_modules', '.git', 'dist', 'build'];
|
|
7
|
+
/**
|
|
8
|
+
* Supported file extensions
|
|
9
|
+
*/
|
|
10
|
+
const SUPPORTED_EXTENSIONS = ['.md', '.tex', '.latex'];
|
|
11
|
+
/**
|
|
12
|
+
* Folder scanner for building file tree structure
|
|
13
|
+
*/
|
|
14
|
+
export class FolderScanner {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.rootPath = options.rootPath;
|
|
17
|
+
this.excludePatterns = [
|
|
18
|
+
...DEFAULT_EXCLUDE_PATTERNS,
|
|
19
|
+
...(options.excludePatterns || []),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Scan the folder and return tree structure
|
|
24
|
+
*/
|
|
25
|
+
async scan() {
|
|
26
|
+
return this.scanDirectory(this.rootPath, '');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Recursively scan a directory
|
|
30
|
+
*/
|
|
31
|
+
async scanDirectory(dirPath, relativePath) {
|
|
32
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
33
|
+
const nodes = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const entryRelativePath = relativePath === '' ? entry.name : path.join(relativePath, entry.name);
|
|
36
|
+
// Skip hidden files/folders (except at root level when explicitly specified)
|
|
37
|
+
if (relativePath !== '' && this.isHidden(entry.name)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Skip excluded patterns
|
|
41
|
+
if (this.shouldExclude(entry.name)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
// Recursively scan subdirectory
|
|
46
|
+
const children = await this.scanDirectory(path.join(dirPath, entry.name), entryRelativePath);
|
|
47
|
+
// Only add folder if it has matching files
|
|
48
|
+
if (children.length > 0) {
|
|
49
|
+
const folderNode = {
|
|
50
|
+
name: entry.name,
|
|
51
|
+
path: entryRelativePath,
|
|
52
|
+
type: 'folder',
|
|
53
|
+
children,
|
|
54
|
+
};
|
|
55
|
+
nodes.push(folderNode);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (entry.isFile()) {
|
|
59
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
60
|
+
// Only include supported extensions
|
|
61
|
+
if (this.isSupportedExtension(ext)) {
|
|
62
|
+
const fileNode = {
|
|
63
|
+
name: entry.name,
|
|
64
|
+
path: entryRelativePath,
|
|
65
|
+
type: 'file',
|
|
66
|
+
extension: ext,
|
|
67
|
+
};
|
|
68
|
+
nodes.push(fileNode);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return this.sortNodes(nodes);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a name is hidden (starts with .)
|
|
76
|
+
*/
|
|
77
|
+
isHidden(name) {
|
|
78
|
+
return name.startsWith('.');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Check if a name matches exclude patterns
|
|
82
|
+
*/
|
|
83
|
+
shouldExclude(name) {
|
|
84
|
+
return this.excludePatterns.includes(name);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Check if extension is supported
|
|
88
|
+
*/
|
|
89
|
+
isSupportedExtension(ext) {
|
|
90
|
+
return SUPPORTED_EXTENSIONS.includes(ext);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sort nodes: folders first, then files, both in alphabetical order
|
|
94
|
+
*/
|
|
95
|
+
sortNodes(nodes) {
|
|
96
|
+
return nodes.sort((a, b) => {
|
|
97
|
+
// Folders come first
|
|
98
|
+
if (a.type !== b.type) {
|
|
99
|
+
return a.type === 'folder' ? -1 : 1;
|
|
100
|
+
}
|
|
101
|
+
// Alphabetical order (case-insensitive)
|
|
102
|
+
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder mode module exports
|
|
3
|
+
*/
|
|
4
|
+
export { FolderScanner } from './folder-scanner.js';
|
|
5
|
+
export { FolderModeServer } from './folder-mode-server.js';
|
|
6
|
+
export type { TreeNode, FileNode, FolderNode, SupportedExtension, FolderScannerOptions, FolderModeOptions, ServerMessage, ClientMessage, } from './types.js';
|
|
7
|
+
export { isFileNode, isFolderNode } from './types.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/folder-mode/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,kBAAkB,EAClB,oBAAoB,EACpB,iBAAiB,EACjB,aAAa,EACb,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder mode type definitions
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Supported file extensions for folder mode
|
|
6
|
+
*/
|
|
7
|
+
export type SupportedExtension = '.md' | '.tex' | '.latex';
|
|
8
|
+
/**
|
|
9
|
+
* File node in the tree structure
|
|
10
|
+
*/
|
|
11
|
+
export interface FileNode {
|
|
12
|
+
/** File name (e.g., "plan.md") */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Relative path from root (e.g., "docs/plan.md") */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Node type */
|
|
17
|
+
type: 'file';
|
|
18
|
+
/** File extension */
|
|
19
|
+
extension: SupportedExtension;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Folder node in the tree structure
|
|
23
|
+
*/
|
|
24
|
+
export interface FolderNode {
|
|
25
|
+
/** Folder name (e.g., "docs") */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Relative path from root (e.g., "docs") */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Node type */
|
|
30
|
+
type: 'folder';
|
|
31
|
+
/** Child nodes (sorted) */
|
|
32
|
+
children: TreeNode[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Tree node (file or folder)
|
|
36
|
+
*/
|
|
37
|
+
export type TreeNode = FileNode | FolderNode;
|
|
38
|
+
/**
|
|
39
|
+
* Type guard for FileNode
|
|
40
|
+
*/
|
|
41
|
+
export declare function isFileNode(node: TreeNode): node is FileNode;
|
|
42
|
+
/**
|
|
43
|
+
* Type guard for FolderNode
|
|
44
|
+
*/
|
|
45
|
+
export declare function isFolderNode(node: TreeNode): node is FolderNode;
|
|
46
|
+
/**
|
|
47
|
+
* Folder scanner options
|
|
48
|
+
*/
|
|
49
|
+
export interface FolderScannerOptions {
|
|
50
|
+
/** Root path to scan */
|
|
51
|
+
rootPath: string;
|
|
52
|
+
/** Additional exclude patterns (glob) */
|
|
53
|
+
excludePatterns?: string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Folder mode server options
|
|
57
|
+
*/
|
|
58
|
+
export interface FolderModeOptions {
|
|
59
|
+
/** Root path of the folder */
|
|
60
|
+
rootPath: string;
|
|
61
|
+
/** Server port */
|
|
62
|
+
port: number;
|
|
63
|
+
/** Theme name */
|
|
64
|
+
theme: string;
|
|
65
|
+
/** Open browser on start */
|
|
66
|
+
open: boolean;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* WebSocket message from server to client
|
|
70
|
+
*/
|
|
71
|
+
export type ServerMessage = {
|
|
72
|
+
type: 'tree';
|
|
73
|
+
data: TreeNode[];
|
|
74
|
+
} | {
|
|
75
|
+
type: 'content';
|
|
76
|
+
data: {
|
|
77
|
+
path: string;
|
|
78
|
+
html: string;
|
|
79
|
+
};
|
|
80
|
+
} | {
|
|
81
|
+
type: 'reload';
|
|
82
|
+
} | {
|
|
83
|
+
type: 'error';
|
|
84
|
+
data: {
|
|
85
|
+
type: string;
|
|
86
|
+
message: string;
|
|
87
|
+
};
|
|
88
|
+
} | {
|
|
89
|
+
type: 'fileDeleted';
|
|
90
|
+
data: {
|
|
91
|
+
path: string;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* WebSocket message from client to server
|
|
96
|
+
*/
|
|
97
|
+
export type ClientMessage = {
|
|
98
|
+
type: 'selectFile';
|
|
99
|
+
path: string;
|
|
100
|
+
};
|
|
101
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/folder-mode/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,SAAS,EAAE,kBAAkB,CAAC;CAC/B;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB;IAChB,IAAI,EAAE,QAAQ,CAAC;IACf,2BAA2B;IAC3B,QAAQ,EAAE,QAAQ,EAAE,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE7C;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,IAAI,QAAQ,CAE3D;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,IAAI,IAAI,UAAU,CAE/D;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,wBAAwB;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,iBAAiB;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,EAAE,OAAO,CAAC;CACf;AAED;;GAEG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,EAAE,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAEpD;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder mode type definitions
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Type guard for FileNode
|
|
6
|
+
*/
|
|
7
|
+
export function isFileNode(node) {
|
|
8
|
+
return node.type === 'file';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Type guard for FolderNode
|
|
12
|
+
*/
|
|
13
|
+
export function isFolderNode(node) {
|
|
14
|
+
return node.type === 'folder';
|
|
15
|
+
}
|