@civilization/dsh-workspace-files 0.2.0 → 0.3.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.
package/lib/index.js CHANGED
@@ -1,82 +1,119 @@
1
+ import { exec, spawn } from "node:child_process";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import Schema from "@deepseek-ai/schemastery";
1
4
  import { promises } from "node:fs";
2
- import { join, relative, resolve } from "node:path";
3
5
  //#region src/files.ts
4
6
  const DEFAULT_IGNORED_DIRS = /* @__PURE__ */ new Set([
5
7
  ".git",
6
8
  ".svn",
7
9
  ".hg",
8
10
  "node_modules",
11
+ ".pnpm-store",
9
12
  ".turbo",
10
13
  ".next",
14
+ ".nuxt",
15
+ ".output",
11
16
  "dist",
12
17
  "build",
13
- ".pnpm-store"
18
+ "out",
19
+ ".cache",
20
+ ".idea",
21
+ ".vscode",
22
+ ".husky",
23
+ "venv",
24
+ ".venv",
25
+ "env",
26
+ "__pycache__",
27
+ "target",
28
+ ".gradle",
29
+ ".cargo",
30
+ "vendor",
31
+ "coverage",
32
+ "tmp",
33
+ "temp",
34
+ "logs",
35
+ ".dsh"
14
36
  ]);
15
37
  function isWithin(parent, child) {
16
38
  const rel = relative(parent, child);
17
39
  return !rel.startsWith("..") && !rel.includes(":");
18
40
  }
41
+ async function loadGitIgnoreNames(rootDir) {
42
+ const names = /* @__PURE__ */ new Set();
43
+ try {
44
+ const gitignorePath = join(rootDir, ".gitignore");
45
+ const content = await promises.readFile(gitignorePath, "utf8");
46
+ for (const rawLine of content.split(/\r?\n/)) {
47
+ const line = rawLine.trim();
48
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
49
+ const clean = line.replace(/^\/+/, "").replace(/\/+$/, "");
50
+ if (clean && !clean.includes("*") && !clean.includes("?") && !clean.includes("/")) names.add(clean);
51
+ }
52
+ } catch {}
53
+ return names;
54
+ }
19
55
  async function scanWorkspaceTree(cwd, options = {}) {
20
56
  const root = resolve(cwd);
21
- const maxDepth = options.maxDepth ?? 8;
57
+ const maxDepth = options.maxDepth ?? 16;
22
58
  const showHidden = options.showHidden ?? false;
23
59
  const showIgnored = options.showIgnored ?? false;
60
+ const gitIgnoredNames = showIgnored ? /* @__PURE__ */ new Set() : await loadGitIgnoreNames(root);
24
61
  let totalFiles = 0;
25
62
  let totalDirs = 0;
63
+ let totalNodes = 0;
64
+ const MAX_NODES_SAFETY_LIMIT = 3e4;
26
65
  async function walk(dirPath, currentDepth) {
27
- if (currentDepth > maxDepth) return [];
66
+ if (currentDepth > maxDepth || totalNodes >= MAX_NODES_SAFETY_LIMIT) return [];
28
67
  let dirents;
29
68
  try {
30
69
  dirents = await promises.readdir(dirPath, { withFileTypes: true });
31
70
  } catch {
32
71
  return [];
33
72
  }
34
- dirents.sort((a, b) => {
35
- if (a.isDirectory() && !b.isDirectory()) return -1;
36
- if (!a.isDirectory() && b.isDirectory()) return 1;
37
- return a.name.localeCompare(b.name, void 0, {
38
- numeric: true,
39
- sensitivity: "base"
40
- });
41
- });
42
- const items = [];
73
+ const dirTasks = [];
74
+ const fileItems = [];
43
75
  for (const ent of dirents) {
76
+ if (totalNodes >= MAX_NODES_SAFETY_LIMIT) break;
44
77
  const name = ent.name;
45
78
  if (!showHidden && name.startsWith(".") && name !== ".gitignore" && name !== ".env") {
46
79
  if (!showIgnored) continue;
47
80
  }
48
- if (!showIgnored && ent.isDirectory() && DEFAULT_IGNORED_DIRS.has(name)) continue;
81
+ if (!showIgnored) {
82
+ if (ent.isDirectory() && DEFAULT_IGNORED_DIRS.has(name)) continue;
83
+ if (gitIgnoredNames.has(name)) continue;
84
+ }
49
85
  const fullPath = join(dirPath, name);
50
86
  if (!isWithin(root, fullPath)) continue;
51
87
  const relPath = relative(root, fullPath).replace(/\\/g, "/");
52
88
  if (ent.isDirectory()) {
53
89
  totalDirs += 1;
54
- const children = await walk(fullPath, currentDepth + 1);
55
- items.push({
90
+ totalNodes += 1;
91
+ dirTasks.push(walk(fullPath, currentDepth + 1).then((children) => ({
56
92
  name,
57
93
  path: relPath,
58
94
  isDirectory: true,
59
95
  children
60
- });
96
+ })));
61
97
  } else {
62
98
  totalFiles += 1;
63
- let size;
64
- let mtime;
65
- try {
66
- const st = await promises.stat(fullPath);
67
- size = st.size;
68
- mtime = st.mtimeMs;
69
- } catch {}
70
- items.push({
99
+ totalNodes += 1;
100
+ fileItems.push({
71
101
  name,
72
102
  path: relPath,
73
- isDirectory: false,
74
- size,
75
- mtime
103
+ isDirectory: false
76
104
  });
77
105
  }
78
106
  }
79
- return items;
107
+ const validDirs = (await Promise.all(dirTasks)).filter((d) => d !== null);
108
+ validDirs.sort((a, b) => a.name.localeCompare(b.name, void 0, {
109
+ numeric: true,
110
+ sensitivity: "base"
111
+ }));
112
+ fileItems.sort((a, b) => a.name.localeCompare(b.name, void 0, {
113
+ numeric: true,
114
+ sensitivity: "base"
115
+ }));
116
+ return [...validDirs, ...fileItems];
80
117
  }
81
118
  return {
82
119
  root,
@@ -89,6 +126,10 @@ async function scanWorkspaceTree(cwd, options = {}) {
89
126
  //#region src/index.ts
90
127
  const name = "@civilization/dsh-workspace-files";
91
128
  const inject = ["webServer"];
129
+ const Config = Schema.object({
130
+ maxDepth: Schema.natural().min(1).max(100).default(16).description("工作区文件树扫描的最大文件夹深度(层级)"),
131
+ showHidden: Schema.boolean().default(false).description("默认是否显示以点开头的隐藏文件")
132
+ }).description("工作区文件:浏览与检索工作区文件树");
92
133
  async function readJsonBody(req) {
93
134
  if (req.body && typeof req.body === "object") return req.body;
94
135
  if (typeof req.body === "string" && req.body.trim()) try {
@@ -149,7 +190,28 @@ function resolveCwd(ctx, body) {
149
190
  } catch {}
150
191
  return process.cwd();
151
192
  }
152
- function apply(ctx) {
193
+ const SETTINGS_NS = "workspace-files";
194
+ const scanCache = /* @__PURE__ */ new Map();
195
+ const CACHE_TTL_MS = 2e4;
196
+ function apply(ctx, config = {}) {
197
+ const liveConfig = {
198
+ maxDepth: typeof config?.maxDepth === "number" && config.maxDepth > 0 ? config.maxDepth : 16,
199
+ showHidden: Boolean(config?.showHidden)
200
+ };
201
+ if (ctx.inject) ctx.inject(["settings"], (scopedCtx) => {
202
+ try {
203
+ const scope = scopedCtx.settings?.register(SETTINGS_NS, Config, { base: liveConfig });
204
+ const applyChange = () => {
205
+ const current = scope?.get?.();
206
+ if (typeof current?.maxDepth === "number") liveConfig.maxDepth = current.maxDepth;
207
+ if (typeof current?.showHidden === "boolean") liveConfig.showHidden = current.showHidden;
208
+ };
209
+ applyChange();
210
+ scope?.watch?.(applyChange);
211
+ } catch (err) {
212
+ console.warn("[dsh-workspace-files] failed to register settings scope:", err);
213
+ }
214
+ });
153
215
  const webServer = ctx.webServer;
154
216
  if (!webServer) return;
155
217
  const handleRpc = async (req, res, prefix) => {
@@ -162,6 +224,83 @@ function apply(ctx) {
162
224
  }
163
225
  const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
164
226
  const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : void 0;
227
+ if (method === "config.get") {
228
+ writeJson(res, 200, {
229
+ ok: true,
230
+ value: liveConfig
231
+ });
232
+ return;
233
+ }
234
+ if (method === "config.set") {
235
+ try {
236
+ const body = await readJsonBody(req);
237
+ if (typeof body.maxDepth === "number" && body.maxDepth > 0) liveConfig.maxDepth = body.maxDepth;
238
+ if (typeof body.showHidden === "boolean") liveConfig.showHidden = body.showHidden;
239
+ writeJson(res, 200, {
240
+ ok: true,
241
+ value: liveConfig
242
+ });
243
+ } catch (err) {
244
+ writeJson(res, 500, {
245
+ ok: false,
246
+ error: { message: err?.message || String(err) }
247
+ });
248
+ }
249
+ return;
250
+ }
251
+ if (method === "files.open") {
252
+ try {
253
+ const body = await readJsonBody(req);
254
+ const cwd = resolveCwd(ctx, body);
255
+ const rawPath = String(body.path || "");
256
+ if (!rawPath) {
257
+ writeJson(res, 400, {
258
+ ok: false,
259
+ error: { message: "Missing path parameter" }
260
+ });
261
+ return;
262
+ }
263
+ const fullPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
264
+ const action = body.action || "reveal";
265
+ const isDirectory = Boolean(body.isDirectory);
266
+ if (process.platform === "win32") {
267
+ const winPath = fullPath.replace(/\//g, "\\");
268
+ if (action === "reveal") {
269
+ if (isDirectory) spawn("explorer.exe", [winPath], {
270
+ detached: true,
271
+ stdio: "ignore"
272
+ }).unref();
273
+ else spawn("explorer.exe", [`/select,${winPath}`], {
274
+ detached: true,
275
+ stdio: "ignore"
276
+ }).unref();
277
+ } else exec(`start "" "${winPath.replace(/"/g, "\\\"")}"`, { windowsHide: true });
278
+ } else if (process.platform === "darwin") {
279
+ if (action === "reveal") spawn("open", [isDirectory ? fullPath : "-R", fullPath], {
280
+ detached: true,
281
+ stdio: "ignore"
282
+ }).unref();
283
+ else spawn("open", [fullPath], {
284
+ detached: true,
285
+ stdio: "ignore"
286
+ }).unref();
287
+ } else if (action === "reveal") spawn("xdg-open", [isDirectory ? fullPath : resolve(fullPath, "..")], {
288
+ detached: true,
289
+ stdio: "ignore"
290
+ }).unref();
291
+ else spawn("xdg-open", [fullPath], {
292
+ detached: true,
293
+ stdio: "ignore"
294
+ }).unref();
295
+ writeJson(res, 200, { ok: true });
296
+ } catch (err) {
297
+ writeJson(res, 500, {
298
+ ok: false,
299
+ error: { message: err?.message || String(err) }
300
+ });
301
+ }
302
+ return;
303
+ }
165
304
  if (method !== "files.tree") {
166
305
  writeJson(res, 404, {
167
306
  ok: false,
@@ -171,13 +310,36 @@ function apply(ctx) {
171
310
  }
172
311
  try {
173
312
  const body = await readJsonBody(req);
313
+ const cwd = resolveCwd(ctx, body);
314
+ const showHidden = typeof body.showHidden === "boolean" ? body.showHidden : liveConfig.showHidden;
315
+ const showIgnored = Boolean(body.showIgnored);
316
+ const maxDepth = typeof body.maxDepth === "number" && body.maxDepth > 0 ? body.maxDepth : liveConfig.maxDepth;
317
+ const forceRefresh = Boolean(body.forceRefresh);
318
+ const cacheKey = `${cwd}::${showHidden}::${showIgnored}::${maxDepth}`;
319
+ const now = Date.now();
320
+ if (!forceRefresh) {
321
+ const cached = scanCache.get(cacheKey);
322
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
323
+ writeJson(res, 200, {
324
+ ok: true,
325
+ value: cached.result,
326
+ cached: true
327
+ });
328
+ return;
329
+ }
330
+ }
331
+ const result = await scanWorkspaceTree(cwd, {
332
+ showHidden,
333
+ showIgnored,
334
+ maxDepth
335
+ });
336
+ scanCache.set(cacheKey, {
337
+ timestamp: now,
338
+ result
339
+ });
174
340
  writeJson(res, 200, {
175
341
  ok: true,
176
- value: await scanWorkspaceTree(resolveCwd(ctx, body), {
177
- showHidden: Boolean(body.showHidden),
178
- showIgnored: Boolean(body.showIgnored),
179
- maxDepth: typeof body.maxDepth === "number" ? body.maxDepth : 8
180
- })
342
+ value: result
181
343
  });
182
344
  } catch (err) {
183
345
  writeJson(res, 500, {
@@ -193,4 +355,4 @@ function apply(ctx) {
193
355
  }), "dsh-workspace-files: /dsh-workspace-files/api routes");
194
356
  }
195
357
  //#endregion
196
- export { apply, inject, name };
358
+ export { Config, SETTINGS_NS, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civilization/dsh-workspace-files",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Enhanced workspace file tree explorer with fuzzy search and @-mention for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -25,7 +25,10 @@
25
25
  },
26
26
  "client": {
27
27
  "platform": "web",
28
- "inject": []
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-ui-settings",
30
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
31
+ ]
29
32
  }
30
33
  },
31
34
  "scripts": {
@@ -33,6 +36,9 @@
33
36
  "dev": "tsdown --watch",
34
37
  "typecheck": "tsc --noEmit"
35
38
  },
39
+ "dependencies": {
40
+ "@deepseek-ai/schemastery": "3.18.2"
41
+ },
36
42
  "peerDependencies": {
37
43
  "@deepseek-ai/cordis": ">=4.0.0",
38
44
  "react": ">=18.0.0"
@@ -159,3 +159,30 @@ export function IconTree({ size = 13 }: { size?: number }): JSX.Element {
159
159
  )
160
160
  }
161
161
 
162
+ export function IconChevronDown({ size = 14 }: { size?: number }): JSX.Element {
163
+ return (
164
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 }}>
165
+ <polyline points="4 6 8 10 12 6" />
166
+ </svg>
167
+ )
168
+ }
169
+
170
+ export function IconExplorer({ size = 12 }: { size?: number }): JSX.Element {
171
+ return (
172
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" style={{ display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 }}>
173
+ <path d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 2.26 5.55 2 5 2H1.75zM1.5 5.5h13v6.75a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25V5.5z" />
174
+ </svg>
175
+ )
176
+ }
177
+
178
+ export function IconExternalApp({ size = 12 }: { size?: number }): JSX.Element {
179
+ return (
180
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.35" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 }}>
181
+ <path d="M12.5 9.5v3.25a1.25 1.25 0 01-1.25 1.25H3.25A1.25 1.25 0 012 12.75V4.75A1.25 1.25 0 013.25 3.5H6.5" />
182
+ <polyline points="10 2 14 2 14 6" />
183
+ <line x1="6.5" y1="9.5" x2="13.5" y2="2.5" />
184
+ </svg>
185
+ )
186
+ }
187
+
188
+