dsh-explorer 0.0.0-stage → 0.1.1

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.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +106 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +7 -0
  5. package/lib/client.js +859 -0
  6. package/lib/index.js +854 -0
  7. package/package.json +91 -4
  8. package/src/client/ExplorerView.tsx +981 -0
  9. package/src/client/components/ExplorerContextMenu.tsx +157 -0
  10. package/src/client/components/ExplorerDeleteModal.tsx +124 -0
  11. package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
  12. package/src/client/components/ExplorerHeader.tsx +81 -0
  13. package/src/client/components/ExplorerOptionsModal.tsx +230 -0
  14. package/src/client/components/ExplorerRow.tsx +258 -0
  15. package/src/client/components/ExplorerSearch.tsx +55 -0
  16. package/src/client/components/TreeNode.tsx +384 -0
  17. package/src/client/css-modules.d.ts +5 -0
  18. package/src/client/explorer.module.css +833 -0
  19. package/src/client/hooks/useKeyboardNav.ts +89 -0
  20. package/src/client/hooks/useLiveSync.ts +81 -0
  21. package/src/client/index.tsx +238 -0
  22. package/src/client/utils/api.ts +251 -0
  23. package/src/client/utils/conversation-draft.ts +644 -0
  24. package/src/client/utils/drag-drop.ts +209 -0
  25. package/src/client/utils/open-tab.ts +69 -0
  26. package/src/client/utils/options.ts +59 -0
  27. package/src/client/utils/vscode-theme.tsx +250 -0
  28. package/src/config.ts +11 -0
  29. package/src/fs-operations.ts +116 -0
  30. package/src/fs-search.ts +116 -0
  31. package/src/fs-tree.ts +155 -0
  32. package/src/fs-watch.ts +179 -0
  33. package/src/git-status.ts +167 -0
  34. package/src/index.ts +177 -0
  35. package/src/path-security.ts +22 -0
  36. package/src/session-path.ts +35 -0
  37. package/src/wire.ts +63 -0
package/lib/index.js ADDED
@@ -0,0 +1,854 @@
1
+ // src/config.ts
2
+ import z from "@deepseek-ai/schemastery";
3
+ var Config = z.object({
4
+ maxListingEntries: z.natural().default(2e3),
5
+ defaultCompactFolders: z.boolean().default(true)
6
+ });
7
+
8
+ // src/fs-watch.ts
9
+ import { watch } from "node:fs";
10
+
11
+ // src/fs-tree.ts
12
+ import { readdir, stat } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+
15
+ // src/wire.ts
16
+ var ExplorerError = class extends Error {
17
+ code;
18
+ status;
19
+ constructor(code, message, status = 400) {
20
+ super(message);
21
+ this.name = "ExplorerError";
22
+ this.code = code;
23
+ this.status = status;
24
+ }
25
+ };
26
+ function writeJson(res, data, status = 200) {
27
+ const payload = JSON.stringify(data);
28
+ res.writeHead(status, {
29
+ "Content-Type": "application/json; charset=utf-8",
30
+ "Content-Length": Buffer.byteLength(payload),
31
+ "Cache-Control": "no-store"
32
+ });
33
+ res.end(payload);
34
+ }
35
+ function writeOk(res, data = {}) {
36
+ writeJson(res, { ok: true, ...data });
37
+ }
38
+ function writeError(res, error) {
39
+ if (error instanceof ExplorerError) {
40
+ writeJson(res, { ok: false, code: error.code, message: error.message }, error.status);
41
+ return;
42
+ }
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ writeJson(res, { ok: false, code: "internal-error", message }, 500);
45
+ }
46
+ async function readJsonBody(req, limitBytes = 10 * 1024 * 1024) {
47
+ const text = await readTextBody(req, limitBytes);
48
+ if (!text.trim()) {
49
+ throw new ExplorerError("bad-request", "empty request body", 400);
50
+ }
51
+ try {
52
+ return JSON.parse(text);
53
+ } catch {
54
+ throw new ExplorerError("bad-request", "invalid json body", 400);
55
+ }
56
+ }
57
+ async function readTextBody(req, limitBytes = 10 * 1024 * 1024) {
58
+ let size = 0;
59
+ const chunks = [];
60
+ for await (const chunk of req) {
61
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
62
+ size += buf.length;
63
+ if (size > limitBytes) {
64
+ throw new ExplorerError("payload-too-large", `request body exceeded ${limitBytes} bytes`, 413);
65
+ }
66
+ chunks.push(buf);
67
+ }
68
+ return Buffer.concat(chunks).toString("utf-8");
69
+ }
70
+
71
+ // src/fs-tree.ts
72
+ function compareEntries(a, b) {
73
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
74
+ const la = a.name.toLowerCase();
75
+ const lb = b.name.toLowerCase();
76
+ if (la !== lb) return la < lb ? -1 : 1;
77
+ if (a.name === b.name) return 0;
78
+ return a.name < b.name ? -1 : 1;
79
+ }
80
+ var directoryCache = /* @__PURE__ */ new Map();
81
+ var DIRECTORY_CACHE_TTL_MS = 300;
82
+ function invalidateDirectoryCache(_dir) {
83
+ directoryCache.clear();
84
+ }
85
+ async function listDirectory(dirPath, maxEntries = 2e3) {
86
+ const now = Date.now();
87
+ const key = `${dirPath}\0${maxEntries}`;
88
+ const cached = directoryCache.get(key);
89
+ if (cached && now - cached.at < DIRECTORY_CACHE_TTL_MS) {
90
+ return cached.listing;
91
+ }
92
+ let dirEnts;
93
+ try {
94
+ dirEnts = await readdir(dirPath, { withFileTypes: true });
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ throw new ExplorerError("not-found", `cannot read directory "${dirPath}": ${message}`, 404);
98
+ }
99
+ const entries = [];
100
+ const truncated = dirEnts.length > maxEntries;
101
+ const slice = truncated ? dirEnts.slice(0, maxEntries) : dirEnts;
102
+ for (const dirent of slice) {
103
+ const name2 = dirent.name;
104
+ const fullPath = join(dirPath, name2);
105
+ const isSymlink = dirent.isSymbolicLink();
106
+ let isDir = dirent.isDirectory();
107
+ let broken = false;
108
+ if (isSymlink) {
109
+ try {
110
+ const targetStat = await stat(fullPath);
111
+ isDir = targetStat.isDirectory();
112
+ } catch {
113
+ broken = true;
114
+ }
115
+ }
116
+ const hidden = name2.startsWith(".");
117
+ let compact = false;
118
+ let compactName = void 0;
119
+ let compactPath = void 0;
120
+ let compactChain = void 0;
121
+ if (isDir && !isSymlink) {
122
+ try {
123
+ let currPath = fullPath;
124
+ const chain = [{ name: name2, path: fullPath }];
125
+ let depth = 0;
126
+ while (depth < 6) {
127
+ const sub = await readdir(currPath, { withFileTypes: true });
128
+ const visibleSub = sub.filter((s) => !s.name.startsWith("."));
129
+ if (visibleSub.length === 1 && visibleSub[0].isDirectory() && !visibleSub[0].isSymbolicLink()) {
130
+ const childName = visibleSub[0].name;
131
+ currPath = join(currPath, childName);
132
+ chain.push({ name: childName, path: currPath });
133
+ depth++;
134
+ } else {
135
+ break;
136
+ }
137
+ }
138
+ if (chain.length > 1) {
139
+ compact = true;
140
+ compactName = chain.map((c) => c.name).join("/");
141
+ compactPath = currPath;
142
+ compactChain = chain;
143
+ }
144
+ } catch {
145
+ }
146
+ }
147
+ entries.push({
148
+ name: name2,
149
+ path: fullPath,
150
+ isDir,
151
+ hidden,
152
+ isSymlink,
153
+ broken,
154
+ compact,
155
+ compactName,
156
+ compactPath,
157
+ compactChain
158
+ });
159
+ }
160
+ entries.sort(compareEntries);
161
+ const listing = {
162
+ path: dirPath,
163
+ entries,
164
+ truncated
165
+ };
166
+ directoryCache.set(key, { at: now, listing });
167
+ return listing;
168
+ }
169
+ async function listDirectoryBatch(paths, maxEntriesPerDir = 1e3) {
170
+ const result = {};
171
+ await Promise.all(
172
+ paths.map(async (p) => {
173
+ try {
174
+ result[p] = await listDirectory(p, maxEntriesPerDir);
175
+ } catch {
176
+ }
177
+ })
178
+ );
179
+ return result;
180
+ }
181
+
182
+ // src/fs-watch.ts
183
+ var dirWatchers = /* @__PURE__ */ new Map();
184
+ function normalizeDirPath(d) {
185
+ return d.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
186
+ }
187
+ function subscribeToDir(dir, subscriber) {
188
+ const norm = normalizeDirPath(dir);
189
+ let entry = dirWatchers.get(norm);
190
+ if (!entry) {
191
+ try {
192
+ const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
193
+ invalidateDirectoryCache();
194
+ const payload = {
195
+ event: eventType,
196
+ filename: filename ? String(filename) : void 0,
197
+ timestamp: Date.now()
198
+ };
199
+ const current = dirWatchers.get(norm);
200
+ if (current) {
201
+ for (const sub of current.subscribers) {
202
+ try {
203
+ sub.send(payload);
204
+ } catch {
205
+ }
206
+ }
207
+ }
208
+ });
209
+ entry = { watcher, subscribers: /* @__PURE__ */ new Set() };
210
+ dirWatchers.set(norm, entry);
211
+ } catch (err) {
212
+ console.warn("[dsh-explorer] recursive watch failed on", dir, err);
213
+ }
214
+ }
215
+ if (entry) {
216
+ entry.subscribers.add(subscriber);
217
+ }
218
+ return () => {
219
+ if (entry) {
220
+ entry.subscribers.delete(subscriber);
221
+ if (entry.subscribers.size === 0) {
222
+ try {
223
+ entry.watcher.close();
224
+ } catch {
225
+ }
226
+ dirWatchers.delete(norm);
227
+ }
228
+ }
229
+ };
230
+ }
231
+ function createFsWatcherServer() {
232
+ let wssPromise = null;
233
+ const getWss = async () => {
234
+ if (!wssPromise) {
235
+ wssPromise = (async () => {
236
+ try {
237
+ const wsModule = await import("ws");
238
+ const Server = wsModule.WebSocketServer ?? wsModule.default?.WebSocketServer;
239
+ if (!Server) return null;
240
+ const server = new Server({ noServer: true });
241
+ server.on("connection", (ws, req) => {
242
+ const url = new URL(req.url ?? "/", "http://localhost");
243
+ const dir = url.searchParams.get("dir");
244
+ if (!dir) {
245
+ ws.close(1008, "missing dir parameter");
246
+ return;
247
+ }
248
+ const subscriber = {
249
+ send: (data) => {
250
+ if (ws.readyState === 1) {
251
+ ws.send(JSON.stringify(data));
252
+ }
253
+ },
254
+ close: () => {
255
+ try {
256
+ ws.close();
257
+ } catch {
258
+ }
259
+ }
260
+ };
261
+ const unsubscribe = subscribeToDir(dir, subscriber);
262
+ ws.on("close", () => {
263
+ unsubscribe();
264
+ });
265
+ });
266
+ return server;
267
+ } catch {
268
+ return null;
269
+ }
270
+ })();
271
+ }
272
+ return wssPromise;
273
+ };
274
+ return {
275
+ handleSse: (req, res, dir) => {
276
+ res.writeHead(200, {
277
+ "Content-Type": "text/event-stream; charset=utf-8",
278
+ "Cache-Control": "no-cache, no-transform",
279
+ "Connection": "keep-alive",
280
+ "Access-Control-Allow-Origin": "*"
281
+ });
282
+ res.write(`data: ${JSON.stringify({ type: "connected", dir, timestamp: Date.now() })}
283
+
284
+ `);
285
+ const subscriber = {
286
+ send: (data) => {
287
+ try {
288
+ res.write(`data: ${JSON.stringify(data)}
289
+
290
+ `);
291
+ } catch {
292
+ }
293
+ },
294
+ close: () => {
295
+ try {
296
+ res.end();
297
+ } catch {
298
+ }
299
+ }
300
+ };
301
+ const unsubscribe = subscribeToDir(dir, subscriber);
302
+ req.on("close", () => {
303
+ unsubscribe();
304
+ });
305
+ },
306
+ handleUpgrade: (req, socket, head) => {
307
+ getWss().then((server) => {
308
+ if (server) {
309
+ server.handleUpgrade(req, socket, head, (ws) => {
310
+ server.emit("connection", ws, req);
311
+ });
312
+ } else {
313
+ socket.destroy();
314
+ }
315
+ }).catch(() => {
316
+ socket.destroy();
317
+ });
318
+ },
319
+ close: () => {
320
+ for (const entry of dirWatchers.values()) {
321
+ for (const sub of entry.subscribers) {
322
+ sub.close();
323
+ }
324
+ try {
325
+ entry.watcher.close();
326
+ } catch {
327
+ }
328
+ }
329
+ dirWatchers.clear();
330
+ if (wssPromise) {
331
+ wssPromise.then((server) => {
332
+ try {
333
+ server?.close();
334
+ } catch {
335
+ }
336
+ });
337
+ }
338
+ }
339
+ };
340
+ }
341
+
342
+ // src/fs-search.ts
343
+ import { readdir as readdir2 } from "node:fs/promises";
344
+ import { join as join2, relative } from "node:path";
345
+ var IGNORED_SEARCH_DIRS = /* @__PURE__ */ new Set([
346
+ "node_modules",
347
+ ".agent",
348
+ ".git",
349
+ "dist",
350
+ "target",
351
+ "build",
352
+ ".system_generated",
353
+ ".cache",
354
+ ".dsh",
355
+ ".gemini",
356
+ ".vscode",
357
+ ".idea"
358
+ ]);
359
+ async function searchDirectory(rootDir, query, maxResults = 250, includeHidden = false) {
360
+ const cleanQuery = query.trim().toLowerCase();
361
+ if (!cleanQuery) {
362
+ return { query, matches: [], totalMatches: 0, truncated: false };
363
+ }
364
+ const isExtensionFilter = cleanQuery.startsWith("*.") && cleanQuery.length > 2;
365
+ const extensionTarget = isExtensionFilter ? cleanQuery.slice(1) : "";
366
+ const matches = [];
367
+ let truncated = false;
368
+ let scannedCount = 0;
369
+ const maxScanned = 3e4;
370
+ const queue = [rootDir];
371
+ while (queue.length > 0 && matches.length < maxResults && scannedCount < maxScanned) {
372
+ const currentDir = queue.shift();
373
+ let entries;
374
+ try {
375
+ entries = await readdir2(currentDir, { withFileTypes: true });
376
+ } catch {
377
+ continue;
378
+ }
379
+ for (const ent of entries) {
380
+ scannedCount++;
381
+ if (scannedCount >= maxScanned) {
382
+ truncated = true;
383
+ break;
384
+ }
385
+ if (!includeHidden && ent.name.startsWith(".")) {
386
+ continue;
387
+ }
388
+ const isDirectory = ent.isDirectory();
389
+ if (isDirectory && IGNORED_SEARCH_DIRS.has(ent.name)) {
390
+ continue;
391
+ }
392
+ const fullPath = join2(currentDir, ent.name);
393
+ const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
394
+ const nameLower = ent.name.toLowerCase();
395
+ const relLower = relPath.toLowerCase();
396
+ let isMatch = false;
397
+ if (isExtensionFilter) {
398
+ isMatch = !isDirectory && nameLower.endsWith(extensionTarget);
399
+ } else {
400
+ isMatch = nameLower.includes(cleanQuery) || relLower.includes(cleanQuery);
401
+ }
402
+ if (isMatch) {
403
+ matches.push({
404
+ name: ent.name,
405
+ path: fullPath,
406
+ relativePath: relPath,
407
+ isDir: isDirectory
408
+ });
409
+ if (matches.length >= maxResults) {
410
+ truncated = true;
411
+ break;
412
+ }
413
+ }
414
+ if (isDirectory) {
415
+ queue.push(fullPath);
416
+ }
417
+ }
418
+ }
419
+ return {
420
+ query,
421
+ matches,
422
+ totalMatches: matches.length,
423
+ truncated
424
+ };
425
+ }
426
+
427
+ // src/fs-operations.ts
428
+ import { randomUUID } from "node:crypto";
429
+ import { once } from "node:events";
430
+ import { createWriteStream } from "node:fs";
431
+ import { access, lstat, mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
432
+ import { basename, dirname, join as join3 } from "node:path";
433
+
434
+ // src/path-security.ts
435
+ import { homedir } from "node:os";
436
+ import { isAbsolute, normalize, resolve } from "node:path";
437
+ function resolveTarget(cwd, targetPath) {
438
+ if (!targetPath) return cwd;
439
+ if (targetPath === "~" || targetPath.startsWith("~/") || targetPath.startsWith("~\\")) {
440
+ const home = homedir();
441
+ return normalize(resolve(home, targetPath.slice(targetPath.startsWith("~") ? targetPath[1] ? 2 : 1 : 0)));
442
+ }
443
+ if (isAbsolute(targetPath)) {
444
+ return normalize(targetPath);
445
+ }
446
+ return normalize(resolve(cwd, targetPath));
447
+ }
448
+ function assertSingleSegmentName(name2) {
449
+ if (!name2 || name2 === "." || name2 === ".." || name2.includes("/") || name2.includes("\\")) {
450
+ throw new ExplorerError("bad-request", "name must be a single path segment without separators", 400);
451
+ }
452
+ }
453
+
454
+ // src/fs-operations.ts
455
+ async function createNewFile(dirPath, name2) {
456
+ assertSingleSegmentName(name2);
457
+ const fullPath = join3(dirPath, name2);
458
+ try {
459
+ await access(fullPath);
460
+ throw new ExplorerError("already-exists", `file "${name2}" already exists`, 409);
461
+ } catch (err) {
462
+ if (err instanceof ExplorerError) throw err;
463
+ }
464
+ await writeFile(fullPath, "", { flag: "wx" });
465
+ invalidateDirectoryCache(dirPath);
466
+ return { path: fullPath };
467
+ }
468
+ async function createDirectory(dirPath, name2) {
469
+ assertSingleSegmentName(name2);
470
+ const fullPath = join3(dirPath, name2);
471
+ try {
472
+ await access(fullPath);
473
+ throw new ExplorerError("already-exists", `directory "${name2}" already exists`, 409);
474
+ } catch (err) {
475
+ if (err instanceof ExplorerError) throw err;
476
+ }
477
+ await mkdir(fullPath, { recursive: true });
478
+ invalidateDirectoryCache(dirPath);
479
+ return { path: fullPath };
480
+ }
481
+ async function renameEntry(targetPath, newName) {
482
+ assertSingleSegmentName(newName);
483
+ const parent = dirname(targetPath);
484
+ const newPath = join3(parent, newName);
485
+ if (targetPath === newPath) return { oldPath: targetPath, newPath };
486
+ try {
487
+ await access(newPath);
488
+ throw new ExplorerError("already-exists", `destination "${newName}" already exists`, 409);
489
+ } catch (err) {
490
+ if (err instanceof ExplorerError) throw err;
491
+ }
492
+ await rename(targetPath, newPath);
493
+ invalidateDirectoryCache(parent);
494
+ return { oldPath: targetPath, newPath };
495
+ }
496
+ async function removeEntry(targetPath) {
497
+ const parent = dirname(targetPath);
498
+ const st = await lstat(targetPath);
499
+ if (st.isDirectory()) {
500
+ await rm(targetPath, { recursive: true, force: true });
501
+ } else {
502
+ await unlink(targetPath);
503
+ }
504
+ invalidateDirectoryCache(parent);
505
+ }
506
+ async function readFileText(filePath, maxBytes = 1024 * 1024 * 2) {
507
+ const content = await readFile(filePath);
508
+ if (content.length > maxBytes) {
509
+ throw new ExplorerError("payload-too-large", `file exceeds max preview limit of ${maxBytes} bytes`, 413);
510
+ }
511
+ return {
512
+ text: content.toString("utf-8"),
513
+ size: content.length
514
+ };
515
+ }
516
+ async function writeUpload(baseDir, relativePath, streamChunks) {
517
+ if (!relativePath || relativePath.startsWith("/") || relativePath.startsWith("\\")) {
518
+ throw new ExplorerError("bad-request", "relativePath must stay within upload target", 400);
519
+ }
520
+ const segments = relativePath.split(/[\\/]/);
521
+ if (segments.some((p) => !p || p === "." || p === "..")) {
522
+ throw new ExplorerError("bad-request", "relativePath contains invalid path traversal segments", 400);
523
+ }
524
+ const target = join3(baseDir, ...segments);
525
+ const tmp = join3(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`);
526
+ await mkdir(dirname(target), { recursive: true });
527
+ const writeStream = createWriteStream(tmp, { flags: "wx" });
528
+ let bytes = 0;
529
+ try {
530
+ for await (const chunk of streamChunks) {
531
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
532
+ bytes += buf.length;
533
+ if (!writeStream.write(buf)) {
534
+ await once(writeStream, "drain");
535
+ }
536
+ }
537
+ writeStream.end();
538
+ await once(writeStream, "finish");
539
+ await rename(tmp, target);
540
+ invalidateDirectoryCache(dirname(target));
541
+ return { path: target, size: bytes };
542
+ } catch (err) {
543
+ writeStream.destroy();
544
+ try {
545
+ await unlink(tmp);
546
+ } catch {
547
+ }
548
+ throw err;
549
+ }
550
+ }
551
+
552
+ // src/git-status.ts
553
+ import { execFile } from "node:child_process";
554
+ import { existsSync, readdirSync } from "node:fs";
555
+ import { join as join4, relative as relative2 } from "node:path";
556
+ import { promisify } from "node:util";
557
+ var execFileAsync = promisify(execFile);
558
+ var gitCache = /* @__PURE__ */ new Map();
559
+ var GIT_CACHE_TTL_MS = 2e3;
560
+ var IGNORED_SCAN_DIRS = /* @__PURE__ */ new Set([
561
+ "node_modules",
562
+ ".agent",
563
+ ".git",
564
+ "dist",
565
+ "target",
566
+ "build",
567
+ ".system_generated",
568
+ ".cache",
569
+ ".dsh",
570
+ ".gemini",
571
+ ".vscode",
572
+ ".idea"
573
+ ]);
574
+ function parsePorcelainLines(stdout, prefix = "") {
575
+ let branch;
576
+ const entries = [];
577
+ for (const rawLine of stdout.split("\n")) {
578
+ const line = rawLine.replace(/\r$/, "");
579
+ if (!line) continue;
580
+ if (line.startsWith("## ")) {
581
+ const branchMatch = line.slice(3).match(/^([^.\s]+)/);
582
+ if (branchMatch) branch = branchMatch[1];
583
+ continue;
584
+ }
585
+ if (line.length < 4) continue;
586
+ const xy = line.slice(0, 2);
587
+ let p = line.slice(3).trim();
588
+ if (p.startsWith('"') && p.endsWith('"')) {
589
+ p = p.slice(1, -1);
590
+ }
591
+ if (xy.startsWith("R") || xy.endsWith("R")) {
592
+ const arrowIdx = p.indexOf(" -> ");
593
+ if (arrowIdx !== -1) {
594
+ p = p.slice(arrowIdx + 4).trim();
595
+ if (p.startsWith('"') && p.endsWith('"')) {
596
+ p = p.slice(1, -1);
597
+ }
598
+ }
599
+ }
600
+ const cleanPath = p.replace(/\\/g, "/").replace(/^\.\//, "");
601
+ const cleanPrefix = prefix ? prefix.replace(/\\/g, "/").replace(/\/+$/, "").replace(/^\.\//, "") : "";
602
+ const finalPath = cleanPrefix ? `${cleanPrefix}/${cleanPath}` : cleanPath;
603
+ entries.push({ path: finalPath, xy });
604
+ }
605
+ return { branch, entries };
606
+ }
607
+ function findSubRepos(root, depth = 1, maxDepth = 3) {
608
+ if (depth > maxDepth) return [];
609
+ const repos = [];
610
+ try {
611
+ const entries = readdirSync(root, { withFileTypes: true });
612
+ for (const ent of entries) {
613
+ if (!ent.isDirectory() || ent.name.startsWith(".") || IGNORED_SCAN_DIRS.has(ent.name)) {
614
+ continue;
615
+ }
616
+ const full = join4(root, ent.name);
617
+ if (existsSync(join4(full, ".git"))) {
618
+ repos.push(full);
619
+ } else {
620
+ repos.push(...findSubRepos(full, depth + 1, maxDepth));
621
+ }
622
+ }
623
+ } catch {
624
+ }
625
+ return repos;
626
+ }
627
+ async function getGitStatus(cwd) {
628
+ const now = Date.now();
629
+ const cached = gitCache.get(cwd);
630
+ if (cached && now - cached.at < GIT_CACHE_TTL_MS) {
631
+ return cached.data;
632
+ }
633
+ try {
634
+ let stdout = "";
635
+ try {
636
+ const res = await execFileAsync(
637
+ "git",
638
+ ["status", "--porcelain", "-b", "-unormal", "--ignored=matching"],
639
+ { cwd, maxBuffer: 10 * 1024 * 1024, timeout: 3500 }
640
+ );
641
+ stdout = res.stdout;
642
+ } catch {
643
+ const res = await execFileAsync(
644
+ "git",
645
+ ["status", "--porcelain", "-b", "-unormal"],
646
+ { cwd, maxBuffer: 10 * 1024 * 1024, timeout: 3500 }
647
+ );
648
+ stdout = res.stdout;
649
+ }
650
+ const parsed = parsePorcelainLines(stdout);
651
+ const data = {
652
+ isRepo: true,
653
+ branch: parsed.branch,
654
+ entries: parsed.entries
655
+ };
656
+ gitCache.set(cwd, { at: now, data });
657
+ return data;
658
+ } catch {
659
+ }
660
+ try {
661
+ const subRepos = findSubRepos(cwd);
662
+ if (subRepos.length > 0) {
663
+ const allEntries = [];
664
+ const subResults = await Promise.all(
665
+ subRepos.map(async (repoDir) => {
666
+ const rel = relative2(cwd, repoDir).replace(/\\/g, "/");
667
+ try {
668
+ const { stdout } = await execFileAsync(
669
+ "git",
670
+ ["status", "--porcelain", "-b", "-unormal", "--ignored=matching"],
671
+ { cwd: repoDir, maxBuffer: 5 * 1024 * 1024, timeout: 3e3 }
672
+ );
673
+ return parsePorcelainLines(stdout, rel).entries;
674
+ } catch {
675
+ return [];
676
+ }
677
+ })
678
+ );
679
+ for (const res of subResults) {
680
+ allEntries.push(...res);
681
+ }
682
+ const data = {
683
+ isRepo: true,
684
+ entries: allEntries
685
+ };
686
+ gitCache.set(cwd, { at: now, data });
687
+ return data;
688
+ }
689
+ } catch {
690
+ }
691
+ const fallback = { isRepo: false, entries: [] };
692
+ gitCache.set(cwd, { at: now, data: fallback });
693
+ return fallback;
694
+ }
695
+
696
+ // src/session-path.ts
697
+ import { homedir as homedir2 } from "node:os";
698
+ function resolveSessionCwd(ctx, sessionId) {
699
+ try {
700
+ const sessionService = ctx.get("sessions") ?? ctx.sessions;
701
+ if (sessionService) {
702
+ if (sessionId) {
703
+ const direct = sessionService.get?.(sessionId);
704
+ if (direct?.header?.cwd) return direct.header.cwd;
705
+ if (direct?.cwd) return direct.cwd;
706
+ if (direct?.workingDirectory) return direct.workingDirectory;
707
+ }
708
+ if (typeof sessionService.list === "function") {
709
+ const all = sessionService.list();
710
+ for (let i = all.length - 1; i >= 0; i--) {
711
+ const s = all[i];
712
+ if (s?.header?.cwd) return s.header.cwd;
713
+ if (s?.cwd) return s.cwd;
714
+ if (s?.workingDirectory) return s.workingDirectory;
715
+ }
716
+ }
717
+ }
718
+ } catch {
719
+ }
720
+ return homedir2();
721
+ }
722
+
723
+ // src/index.ts
724
+ var name = "dsh-explorer";
725
+ var inject = ["webServer", "sessions"];
726
+ function apply(ctx, config) {
727
+ const watcher = createFsWatcherServer();
728
+ ctx.effect(() => {
729
+ return () => {
730
+ watcher.close();
731
+ };
732
+ });
733
+ ctx.effect(() => {
734
+ return ctx.webServer.registerUpgrade({
735
+ path: "/dsh-explorer/api/watch",
736
+ handler: (req, socket, head) => {
737
+ watcher.handleUpgrade(req, socket, head);
738
+ return true;
739
+ }
740
+ });
741
+ });
742
+ ctx.effect(() => {
743
+ return ctx.webServer.register({
744
+ kind: "prefix",
745
+ path: "/dsh-explorer/api",
746
+ handler: async (req, res) => {
747
+ const url = new URL(req.url ?? "/", "http://localhost");
748
+ const pathname = url.pathname;
749
+ try {
750
+ if (pathname === "/dsh-explorer/api/events") {
751
+ const sessionId = url.searchParams.get("sessionId") ?? void 0;
752
+ const cwd = resolveSessionCwd(ctx, sessionId);
753
+ const dirParam = url.searchParams.get("dir");
754
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd);
755
+ watcher.handleSse(req, res, targetDir);
756
+ return true;
757
+ }
758
+ if (pathname === "/dsh-explorer/api/tree") {
759
+ const sessionId = url.searchParams.get("sessionId") ?? void 0;
760
+ const cwd = resolveSessionCwd(ctx, sessionId);
761
+ const dirParam = url.searchParams.get("dir");
762
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd);
763
+ const max = config?.maxListingEntries ?? 2e3;
764
+ const listing = await listDirectory(targetDir, max);
765
+ writeOk(res, { listing });
766
+ return true;
767
+ }
768
+ if (pathname === "/dsh-explorer/api/batch-tree" && req.method === "POST") {
769
+ const body = await readJsonBody(req);
770
+ if (!Array.isArray(body?.paths)) {
771
+ throw new ExplorerError("bad-request", "paths must be an array", 400);
772
+ }
773
+ const results = await listDirectoryBatch(body.paths);
774
+ writeOk(res, { results });
775
+ return true;
776
+ }
777
+ if (pathname === "/dsh-explorer/api/git-status") {
778
+ const sessionId = url.searchParams.get("sessionId") ?? void 0;
779
+ const cwd = resolveSessionCwd(ctx, sessionId);
780
+ const dirParam = url.searchParams.get("dir");
781
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd);
782
+ const git = await getGitStatus(targetDir);
783
+ writeOk(res, { git });
784
+ return true;
785
+ }
786
+ if (pathname === "/dsh-explorer/api/search") {
787
+ const sessionId = url.searchParams.get("sessionId") ?? void 0;
788
+ const cwd = resolveSessionCwd(ctx, sessionId);
789
+ const dirParam = url.searchParams.get("dir");
790
+ const q = url.searchParams.get("q") ?? "";
791
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd);
792
+ const result = await searchDirectory(targetDir, q);
793
+ writeOk(res, { result });
794
+ return true;
795
+ }
796
+ if (pathname === "/dsh-explorer/api/read-file") {
797
+ const pathParam = url.searchParams.get("path");
798
+ if (!pathParam) {
799
+ throw new ExplorerError("bad-request", "missing path parameter", 400);
800
+ }
801
+ const data = await readFileText(pathParam);
802
+ writeOk(res, data);
803
+ return true;
804
+ }
805
+ if (pathname === "/dsh-explorer/api/new-file" && req.method === "POST") {
806
+ const body = await readJsonBody(req);
807
+ const created = await createNewFile(body.dir, body.name);
808
+ writeOk(res, created);
809
+ return true;
810
+ }
811
+ if (pathname === "/dsh-explorer/api/mkdir" && req.method === "POST") {
812
+ const body = await readJsonBody(req);
813
+ const created = await createDirectory(body.dir, body.name);
814
+ writeOk(res, created);
815
+ return true;
816
+ }
817
+ if (pathname === "/dsh-explorer/api/rename" && req.method === "POST") {
818
+ const body = await readJsonBody(req);
819
+ const renamed = await renameEntry(body.path, body.newName);
820
+ writeOk(res, renamed);
821
+ return true;
822
+ }
823
+ if (pathname === "/dsh-explorer/api/delete" && req.method === "POST") {
824
+ const body = await readJsonBody(req);
825
+ await removeEntry(body.path);
826
+ writeOk(res);
827
+ return true;
828
+ }
829
+ if (pathname === "/dsh-explorer/api/upload" && req.method === "POST") {
830
+ const dirParam = url.searchParams.get("dir");
831
+ const relParam = url.searchParams.get("relativePath");
832
+ if (!dirParam || !relParam) {
833
+ throw new ExplorerError("bad-request", "dir and relativePath are required", 400);
834
+ }
835
+ const uploaded = await writeUpload(dirParam, relParam, req);
836
+ writeOk(res, uploaded);
837
+ return true;
838
+ }
839
+ writeJson(res, { ok: false, message: "Not found" }, 404);
840
+ return true;
841
+ } catch (err) {
842
+ writeError(res, err);
843
+ return true;
844
+ }
845
+ }
846
+ });
847
+ });
848
+ }
849
+ export {
850
+ Config,
851
+ apply,
852
+ inject,
853
+ name
854
+ };