@civilization/dsh-workspace-files 0.3.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,83 +1,119 @@
1
+ import { exec, spawn } from "node:child_process";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
1
3
  import Schema from "@deepseek-ai/schemastery";
2
4
  import { promises } from "node:fs";
3
- import { join, relative, resolve } from "node:path";
4
5
  //#region src/files.ts
5
6
  const DEFAULT_IGNORED_DIRS = /* @__PURE__ */ new Set([
6
7
  ".git",
7
8
  ".svn",
8
9
  ".hg",
9
10
  "node_modules",
11
+ ".pnpm-store",
10
12
  ".turbo",
11
13
  ".next",
14
+ ".nuxt",
15
+ ".output",
12
16
  "dist",
13
17
  "build",
14
- ".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"
15
36
  ]);
16
37
  function isWithin(parent, child) {
17
38
  const rel = relative(parent, child);
18
39
  return !rel.startsWith("..") && !rel.includes(":");
19
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
+ }
20
55
  async function scanWorkspaceTree(cwd, options = {}) {
21
56
  const root = resolve(cwd);
22
57
  const maxDepth = options.maxDepth ?? 16;
23
58
  const showHidden = options.showHidden ?? false;
24
59
  const showIgnored = options.showIgnored ?? false;
60
+ const gitIgnoredNames = showIgnored ? /* @__PURE__ */ new Set() : await loadGitIgnoreNames(root);
25
61
  let totalFiles = 0;
26
62
  let totalDirs = 0;
63
+ let totalNodes = 0;
64
+ const MAX_NODES_SAFETY_LIMIT = 3e4;
27
65
  async function walk(dirPath, currentDepth) {
28
- if (currentDepth > maxDepth) return [];
66
+ if (currentDepth > maxDepth || totalNodes >= MAX_NODES_SAFETY_LIMIT) return [];
29
67
  let dirents;
30
68
  try {
31
69
  dirents = await promises.readdir(dirPath, { withFileTypes: true });
32
70
  } catch {
33
71
  return [];
34
72
  }
35
- dirents.sort((a, b) => {
36
- if (a.isDirectory() && !b.isDirectory()) return -1;
37
- if (!a.isDirectory() && b.isDirectory()) return 1;
38
- return a.name.localeCompare(b.name, void 0, {
39
- numeric: true,
40
- sensitivity: "base"
41
- });
42
- });
43
- const items = [];
73
+ const dirTasks = [];
74
+ const fileItems = [];
44
75
  for (const ent of dirents) {
76
+ if (totalNodes >= MAX_NODES_SAFETY_LIMIT) break;
45
77
  const name = ent.name;
46
78
  if (!showHidden && name.startsWith(".") && name !== ".gitignore" && name !== ".env") {
47
79
  if (!showIgnored) continue;
48
80
  }
49
- 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
+ }
50
85
  const fullPath = join(dirPath, name);
51
86
  if (!isWithin(root, fullPath)) continue;
52
87
  const relPath = relative(root, fullPath).replace(/\\/g, "/");
53
88
  if (ent.isDirectory()) {
54
89
  totalDirs += 1;
55
- const children = await walk(fullPath, currentDepth + 1);
56
- items.push({
90
+ totalNodes += 1;
91
+ dirTasks.push(walk(fullPath, currentDepth + 1).then((children) => ({
57
92
  name,
58
93
  path: relPath,
59
94
  isDirectory: true,
60
95
  children
61
- });
96
+ })));
62
97
  } else {
63
98
  totalFiles += 1;
64
- let size;
65
- let mtime;
66
- try {
67
- const st = await promises.stat(fullPath);
68
- size = st.size;
69
- mtime = st.mtimeMs;
70
- } catch {}
71
- items.push({
99
+ totalNodes += 1;
100
+ fileItems.push({
72
101
  name,
73
102
  path: relPath,
74
- isDirectory: false,
75
- size,
76
- mtime
103
+ isDirectory: false
77
104
  });
78
105
  }
79
106
  }
80
- 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];
81
117
  }
82
118
  return {
83
119
  root,
@@ -155,6 +191,8 @@ function resolveCwd(ctx, body) {
155
191
  return process.cwd();
156
192
  }
157
193
  const SETTINGS_NS = "workspace-files";
194
+ const scanCache = /* @__PURE__ */ new Map();
195
+ const CACHE_TTL_MS = 2e4;
158
196
  function apply(ctx, config = {}) {
159
197
  const liveConfig = {
160
198
  maxDepth: typeof config?.maxDepth === "number" && config.maxDepth > 0 ? config.maxDepth : 16,
@@ -210,6 +248,59 @@ function apply(ctx, config = {}) {
210
248
  }
211
249
  return;
212
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
+ }
213
304
  if (method !== "files.tree") {
214
305
  writeJson(res, 404, {
215
306
  ok: false,
@@ -219,13 +310,36 @@ function apply(ctx, config = {}) {
219
310
  }
220
311
  try {
221
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
+ });
222
340
  writeJson(res, 200, {
223
341
  ok: true,
224
- value: await scanWorkspaceTree(resolveCwd(ctx, body), {
225
- showHidden: typeof body.showHidden === "boolean" ? body.showHidden : liveConfig.showHidden,
226
- showIgnored: Boolean(body.showIgnored),
227
- maxDepth: typeof body.maxDepth === "number" && body.maxDepth > 0 ? body.maxDepth : liveConfig.maxDepth
228
- })
342
+ value: result
229
343
  });
230
344
  } catch (err) {
231
345
  writeJson(res, 500, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civilization/dsh-workspace-files",
3
- "version": "0.3.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",
@@ -167,3 +167,22 @@ export function IconChevronDown({ size = 14 }: { size?: number }): JSX.Element {
167
167
  )
168
168
  }
169
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
+
@@ -13,6 +13,8 @@ import {
13
13
  IconCollapseAll,
14
14
  IconCopy,
15
15
  IconExpandAll,
16
+ IconExplorer,
17
+ IconExternalApp,
16
18
  IconFolder,
17
19
  IconList,
18
20
  IconRefresh,
@@ -582,7 +584,7 @@ function WorkspaceFilesView({
582
584
  return () => window.removeEventListener('dsh-workspace-files:config-change', handleCfgChange)
583
585
  }, [])
584
586
 
585
- const loadTree = useCallback(async (quiet = false) => {
587
+ const loadTree = useCallback(async (quiet = false, forceRefresh = false) => {
586
588
  if (!quiet) setLoading(true)
587
589
  setError(null)
588
590
  try {
@@ -591,6 +593,7 @@ function WorkspaceFilesView({
591
593
  cwd,
592
594
  showHidden,
593
595
  maxDepth: activeConfig.maxDepth,
596
+ forceRefresh,
594
597
  })
595
598
  setData(res)
596
599
 
@@ -725,6 +728,60 @@ function WorkspaceFilesView({
725
728
  })
726
729
  }
727
730
 
731
+ // 在文件资源管理器中定位/打开
732
+ const handleRevealInExplorer = (e: React.MouseEvent, item: FileItem) => {
733
+ e.stopPropagation()
734
+ fetch('/dsh-workspace-files/api/files.open', {
735
+ method: 'POST',
736
+ headers: { 'content-type': 'application/json' },
737
+ body: JSON.stringify({
738
+ action: 'reveal',
739
+ path: item.path,
740
+ isDirectory: Boolean(item.isDirectory),
741
+ sessionId,
742
+ cwd,
743
+ }),
744
+ })
745
+ .then(res => res.json())
746
+ .then(data => {
747
+ if (data.ok) {
748
+ showToastMsg(`已在资源管理器中显示: ${item.name}`)
749
+ } else {
750
+ showToastMsg(`打开失败: ${data.error?.message || '未知错误'}`)
751
+ }
752
+ })
753
+ .catch(err => {
754
+ showToastMsg(`请求失败: ${err?.message || String(err)}`)
755
+ })
756
+ }
757
+
758
+ // 用系统默认应用打开
759
+ const handleOpenDefaultApp = (e: React.MouseEvent, item: FileItem) => {
760
+ e.stopPropagation()
761
+ fetch('/dsh-workspace-files/api/files.open', {
762
+ method: 'POST',
763
+ headers: { 'content-type': 'application/json' },
764
+ body: JSON.stringify({
765
+ action: 'openDefault',
766
+ path: item.path,
767
+ isDirectory: Boolean(item.isDirectory),
768
+ sessionId,
769
+ cwd,
770
+ }),
771
+ })
772
+ .then(res => res.json())
773
+ .then(data => {
774
+ if (data.ok) {
775
+ showToastMsg(`已启动系统应用打开: ${item.name}`)
776
+ } else {
777
+ showToastMsg(`打开失败: ${data.error?.message || '未知错误'}`)
778
+ }
779
+ })
780
+ .catch(err => {
781
+ showToastMsg(`请求失败: ${err?.message || String(err)}`)
782
+ })
783
+ }
784
+
728
785
  const [searchViewMode, setSearchViewMode] = useState<'flat' | 'tree'>('flat')
729
786
  const [searchExpandedPaths, setSearchExpandedPaths] = useState<Set<string>>(() => new Set())
730
787
 
@@ -885,7 +942,7 @@ function WorkspaceFilesView({
885
942
  <button
886
943
  type="button"
887
944
  className="dsh-files-tool-btn"
888
- onClick={() => void loadTree(true)}
945
+ onClick={() => void loadTree(false, true)}
889
946
  disabled={loading}
890
947
  title="刷新文件树"
891
948
  >
@@ -927,7 +984,10 @@ function WorkspaceFilesView({
927
984
  /* ── 扁平搜索结果列表 ── */
928
985
  <div className="dsh-files-search-container">
929
986
  <div className="dsh-files-search-header">
930
- <span className="dsh-files-search-count">找到 {flatSearchResults.length} 个匹配项</span>
987
+ <span className="dsh-files-search-count">
988
+ 找到 {flatSearchResults.length} 个匹配项
989
+ {flatSearchResults.length > 150 ? '(展示前 150 项)' : ''}
990
+ </span>
931
991
  <button
932
992
  type="button"
933
993
  className="dsh-files-view-toggle-btn"
@@ -939,7 +999,7 @@ function WorkspaceFilesView({
939
999
  </button>
940
1000
  </div>
941
1001
  <div className="dsh-files-flat-list">
942
- {flatSearchResults.map(({ item, parentDir }) => (
1002
+ {flatSearchResults.slice(0, 150).map(({ item, parentDir }) => (
943
1003
  <div
944
1004
  key={item.path}
945
1005
  className={`dsh-files-flat-row ${item.isDirectory ? 'is-directory' : ''}`}
@@ -984,6 +1044,22 @@ function WorkspaceFilesView({
984
1044
  <IconAt size={13} />
985
1045
  <span className="dsh-files-action-at-text">@引用</span>
986
1046
  </button>
1047
+ <button
1048
+ type="button"
1049
+ className="dsh-files-action-btn"
1050
+ onClick={e => handleRevealInExplorer(e, item)}
1051
+ title={`在文件资源管理器中显示此${item.isDirectory ? '目录' : '文件'}`}
1052
+ >
1053
+ <IconExplorer size={12} />
1054
+ </button>
1055
+ <button
1056
+ type="button"
1057
+ className="dsh-files-action-btn"
1058
+ onClick={e => handleOpenDefaultApp(e, item)}
1059
+ title={`用系统默认应用打开此${item.isDirectory ? '目录' : '文件'}`}
1060
+ >
1061
+ <IconExternalApp size={12} />
1062
+ </button>
987
1063
  <button
988
1064
  type="button"
989
1065
  className="dsh-files-action-btn"
@@ -995,6 +1071,11 @@ function WorkspaceFilesView({
995
1071
  </div>
996
1072
  </div>
997
1073
  ))}
1074
+ {flatSearchResults.length > 150 && (
1075
+ <div style={{ padding: '10px 14px', fontSize: 11, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #8b93a1)', borderTop: '1px dashed var(--dsw-alias-border-l2, #e5e7eb)' }}>
1076
+ 匹配项较多,已限制展示前 150 项以保证流畅度,请输入更详细关键字
1077
+ </div>
1078
+ )}
998
1079
  </div>
999
1080
  </div>
1000
1081
  ) : (
@@ -1037,6 +1118,8 @@ function WorkspaceFilesView({
1037
1118
  onOpenFile={onOpenFile}
1038
1119
  onMention={handleMention}
1039
1120
  onCopyPath={handleCopyPath}
1121
+ onRevealInExplorer={handleRevealInExplorer}
1122
+ onOpenDefaultApp={handleOpenDefaultApp}
1040
1123
  />
1041
1124
  ))}
1042
1125
  </div>
@@ -1065,8 +1148,22 @@ function FileTreeNode(props: {
1065
1148
  onOpenFile: (path: string) => void
1066
1149
  onMention: (e: React.MouseEvent, item: FileItem) => void
1067
1150
  onCopyPath: (e: React.MouseEvent, path: string) => void
1151
+ onRevealInExplorer: (e: React.MouseEvent, item: FileItem) => void
1152
+ onOpenDefaultApp: (e: React.MouseEvent, item: FileItem) => void
1068
1153
  }): JSX.Element {
1069
- const { item, depth, query, isSearchMode, expandedPaths, onToggleExpand, onOpenFile, onMention, onCopyPath } = props
1154
+ const {
1155
+ item,
1156
+ depth,
1157
+ query,
1158
+ isSearchMode,
1159
+ expandedPaths,
1160
+ onToggleExpand,
1161
+ onOpenFile,
1162
+ onMention,
1163
+ onCopyPath,
1164
+ onRevealInExplorer,
1165
+ onOpenDefaultApp,
1166
+ } = props
1070
1167
  const isExpanded = expandedPaths.has(item.path)
1071
1168
 
1072
1169
  if (item.isDirectory) {
@@ -1098,6 +1195,22 @@ function FileTreeNode(props: {
1098
1195
  <IconAt size={13} />
1099
1196
  <span className="dsh-files-action-at-text">@引用</span>
1100
1197
  </button>
1198
+ <button
1199
+ type="button"
1200
+ className="dsh-files-action-btn"
1201
+ onClick={e => onRevealInExplorer(e, item)}
1202
+ title="在文件资源管理器中打开此目录"
1203
+ >
1204
+ <IconExplorer size={12} />
1205
+ </button>
1206
+ <button
1207
+ type="button"
1208
+ className="dsh-files-action-btn"
1209
+ onClick={e => onOpenDefaultApp(e, item)}
1210
+ title="用系统默认应用打开此目录"
1211
+ >
1212
+ <IconExternalApp size={12} />
1213
+ </button>
1101
1214
  <button
1102
1215
  type="button"
1103
1216
  className="dsh-files-action-btn"
@@ -1122,6 +1235,8 @@ function FileTreeNode(props: {
1122
1235
  onOpenFile={onOpenFile}
1123
1236
  onMention={onMention}
1124
1237
  onCopyPath={onCopyPath}
1238
+ onRevealInExplorer={onRevealInExplorer}
1239
+ onOpenDefaultApp={onOpenDefaultApp}
1125
1240
  />
1126
1241
  ))}
1127
1242
  </div>
@@ -1161,6 +1276,22 @@ function FileTreeNode(props: {
1161
1276
  <IconAt size={13} />
1162
1277
  <span className="dsh-files-action-at-text">@引用</span>
1163
1278
  </button>
1279
+ <button
1280
+ type="button"
1281
+ className="dsh-files-action-btn"
1282
+ onClick={e => onRevealInExplorer(e, item)}
1283
+ title="在文件资源管理器中显示此文件"
1284
+ >
1285
+ <IconExplorer size={12} />
1286
+ </button>
1287
+ <button
1288
+ type="button"
1289
+ className="dsh-files-action-btn"
1290
+ onClick={e => onOpenDefaultApp(e, item)}
1291
+ title="用系统默认应用打开此文件"
1292
+ >
1293
+ <IconExternalApp size={12} />
1294
+ </button>
1164
1295
  <button
1165
1296
  type="button"
1166
1297
  className="dsh-files-action-btn"
package/src/files.ts CHANGED
@@ -17,16 +17,37 @@ export interface WorkspaceFilesResult {
17
17
  totalDirs: number
18
18
  }
19
19
 
20
+ // 常见大型构建产物、虚拟环境、开发缓存与三方依赖黑名单目录
20
21
  const DEFAULT_IGNORED_DIRS = new Set([
21
22
  '.git',
22
23
  '.svn',
23
24
  '.hg',
24
25
  'node_modules',
26
+ '.pnpm-store',
25
27
  '.turbo',
26
28
  '.next',
29
+ '.nuxt',
30
+ '.output',
27
31
  'dist',
28
32
  'build',
29
- '.pnpm-store',
33
+ 'out',
34
+ '.cache',
35
+ '.idea',
36
+ '.vscode',
37
+ '.husky',
38
+ 'venv',
39
+ '.venv',
40
+ 'env',
41
+ '__pycache__',
42
+ 'target', // Rust / Cargo / Maven
43
+ '.gradle',
44
+ '.cargo',
45
+ 'vendor', // PHP / Go
46
+ 'coverage',
47
+ 'tmp',
48
+ 'temp',
49
+ 'logs',
50
+ '.dsh',
30
51
  ])
31
52
 
32
53
  function isWithin(parent: string, child: string): boolean {
@@ -34,6 +55,24 @@ function isWithin(parent: string, child: string): boolean {
34
55
  return !rel.startsWith('..') && !rel.includes(':')
35
56
  }
36
57
 
58
+ // 快速解析工作区根目录的 .gitignore,避免深入被 git 忽略的庞大目录
59
+ async function loadGitIgnoreNames(rootDir: string): Promise<Set<string>> {
60
+ const names = new Set<string>()
61
+ try {
62
+ const gitignorePath = join(rootDir, '.gitignore')
63
+ const content = await fs.readFile(gitignorePath, 'utf8')
64
+ for (const rawLine of content.split(/\r?\n/)) {
65
+ const line = rawLine.trim()
66
+ if (!line || line.startsWith('#') || line.startsWith('!')) continue
67
+ const clean = line.replace(/^\/+/, '').replace(/\/+$/, '')
68
+ if (clean && !clean.includes('*') && !clean.includes('?') && !clean.includes('/')) {
69
+ names.add(clean)
70
+ }
71
+ }
72
+ } catch {}
73
+ return names
74
+ }
75
+
37
76
  export async function scanWorkspaceTree(
38
77
  cwd: string,
39
78
  options: { maxDepth?: number; showHidden?: boolean; showIgnored?: boolean } = {},
@@ -43,11 +82,15 @@ export async function scanWorkspaceTree(
43
82
  const showHidden = options.showHidden ?? false
44
83
  const showIgnored = options.showIgnored ?? false
45
84
 
85
+ const gitIgnoredNames = showIgnored ? new Set<string>() : await loadGitIgnoreNames(root)
86
+
46
87
  let totalFiles = 0
47
88
  let totalDirs = 0
89
+ let totalNodes = 0
90
+ const MAX_NODES_SAFETY_LIMIT = 30000 // 防爆保护阈值
48
91
 
49
92
  async function walk(dirPath: string, currentDepth: number): Promise<FileItem[]> {
50
- if (currentDepth > maxDepth) return []
93
+ if (currentDepth > maxDepth || totalNodes >= MAX_NODES_SAFETY_LIMIT) return []
51
94
 
52
95
  let dirents: import('node:fs').Dirent[]
53
96
  try {
@@ -56,26 +99,23 @@ export async function scanWorkspaceTree(
56
99
  return []
57
100
  }
58
101
 
59
- // Sort: directories first, then files alphabetically
60
- dirents.sort((a, b) => {
61
- if (a.isDirectory() && !b.isDirectory()) return -1
62
- if (!a.isDirectory() && b.isDirectory()) return 1
63
- return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
64
- })
65
-
66
- const items: FileItem[] = []
102
+ const dirTasks: Array<Promise<FileItem | null>> = []
103
+ const fileItems: FileItem[] = []
67
104
 
68
105
  for (const ent of dirents) {
106
+ if (totalNodes >= MAX_NODES_SAFETY_LIMIT) break
107
+
69
108
  const name = ent.name
70
109
 
71
- // Hidden file filter (starts with .)
110
+ // 隐藏文件/文件夹过滤(以 . 开头)
72
111
  if (!showHidden && name.startsWith('.') && name !== '.gitignore' && name !== '.env') {
73
112
  if (!showIgnored) continue
74
113
  }
75
114
 
76
- // Ignored directory filter
77
- if (!showIgnored && ent.isDirectory() && DEFAULT_IGNORED_DIRS.has(name)) {
78
- continue
115
+ // 忽略目录及 .gitignore 规则过滤
116
+ if (!showIgnored) {
117
+ if (ent.isDirectory() && DEFAULT_IGNORED_DIRS.has(name)) continue
118
+ if (gitIgnoredNames.has(name)) continue
79
119
  }
80
120
 
81
121
  const fullPath = join(dirPath, name)
@@ -85,34 +125,35 @@ export async function scanWorkspaceTree(
85
125
 
86
126
  if (ent.isDirectory()) {
87
127
  totalDirs += 1
88
- const children = await walk(fullPath, currentDepth + 1)
89
- items.push({
90
- name,
91
- path: relPath,
92
- isDirectory: true,
93
- children,
94
- })
128
+ totalNodes += 1
129
+ dirTasks.push(
130
+ walk(fullPath, currentDepth + 1).then(children => ({
131
+ name,
132
+ path: relPath,
133
+ isDirectory: true,
134
+ children,
135
+ })),
136
+ )
95
137
  } else {
96
138
  totalFiles += 1
97
- let size: number | undefined
98
- let mtime: number | undefined
99
- try {
100
- const st = await fs.stat(fullPath)
101
- size = st.size
102
- mtime = st.mtimeMs
103
- } catch {}
104
-
105
- items.push({
139
+ totalNodes += 1
140
+ fileItems.push({
106
141
  name,
107
142
  path: relPath,
108
143
  isDirectory: false,
109
- size,
110
- mtime,
111
144
  })
112
145
  }
113
146
  }
114
147
 
115
- return items
148
+ // 并行处理子目录
149
+ const dirResults = await Promise.all(dirTasks)
150
+ const validDirs = dirResults.filter((d): d is FileItem => d !== null)
151
+
152
+ // 目录优先,按自然字母排序
153
+ validDirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
154
+ fileItems.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
155
+
156
+ return [...validDirs, ...fileItems]
116
157
  }
117
158
 
118
159
  const items = await walk(root, 1)