@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/README.md +6 -1
- package/lib/client.js +203 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +147 -33
- package/package.json +1 -1
- package/src/client/icons.tsx +19 -0
- package/src/client/index.tsx +136 -5
- package/src/files.ts +74 -33
- package/src/index.ts +76 -3
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
|
-
"
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/src/client/icons.tsx
CHANGED
|
@@ -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
|
+
|
package/src/client/index.tsx
CHANGED
|
@@ -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"
|
|
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 {
|
|
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
|
-
'
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
110
|
+
// 隐藏文件/文件夹过滤(以 . 开头)
|
|
72
111
|
if (!showHidden && name.startsWith('.') && name !== '.gitignore' && name !== '.env') {
|
|
73
112
|
if (!showIgnored) continue
|
|
74
113
|
}
|
|
75
114
|
|
|
76
|
-
//
|
|
77
|
-
if (!showIgnored
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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)
|