@civilization/dsh-workspace-files 0.3.0 → 0.3.2

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 { spawn } from "node:child_process";
2
+ import { isAbsolute, join, relative, resolve, sep } 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,
@@ -145,6 +181,30 @@ function writeJson(res, status, body) {
145
181
  });
146
182
  res.end(data);
147
183
  }
184
+ /**
185
+ * 拒绝工作区之外的目标:files.open 的 path 来自请求体,必须是工作区内的相对/绝对路径,
186
+ * 否则模型或页面脚本可以借宿主进程去打开任意本地路径。
187
+ */
188
+ function assertInsideWorkspace(cwd, fullPath) {
189
+ const root = resolve(cwd);
190
+ const target = resolve(fullPath);
191
+ const rel = relative(root, target);
192
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("Path must stay inside the workspace.");
193
+ }
194
+ /**
195
+ * 启动系统文件管理器/默认应用。spawn 在命令不存在时会 emit 'error',
196
+ * 没有监听器就会变成未捕获异常拖垮宿主进程,因此这里必须兜住。
197
+ */
198
+ function launch(command, args) {
199
+ const child = spawn(command, args, {
200
+ detached: true,
201
+ stdio: "ignore"
202
+ });
203
+ child.on("error", (err) => {
204
+ console.warn(`[dsh-workspace-files] failed to launch ${command}:`, err?.message || String(err));
205
+ });
206
+ child.unref();
207
+ }
148
208
  function resolveCwd(ctx, body) {
149
209
  if (body.cwd?.trim()) return body.cwd.trim();
150
210
  if (body.root?.trim()) return body.root.trim();
@@ -155,25 +215,13 @@ function resolveCwd(ctx, body) {
155
215
  return process.cwd();
156
216
  }
157
217
  const SETTINGS_NS = "workspace-files";
218
+ const scanCache = /* @__PURE__ */ new Map();
219
+ const CACHE_TTL_MS = 2e4;
158
220
  function apply(ctx, config = {}) {
159
221
  const liveConfig = {
160
222
  maxDepth: typeof config?.maxDepth === "number" && config.maxDepth > 0 ? config.maxDepth : 16,
161
223
  showHidden: Boolean(config?.showHidden)
162
224
  };
163
- if (ctx.inject) ctx.inject(["settings"], (scopedCtx) => {
164
- try {
165
- const scope = scopedCtx.settings?.register(SETTINGS_NS, Config, { base: liveConfig });
166
- const applyChange = () => {
167
- const current = scope?.get?.();
168
- if (typeof current?.maxDepth === "number") liveConfig.maxDepth = current.maxDepth;
169
- if (typeof current?.showHidden === "boolean") liveConfig.showHidden = current.showHidden;
170
- };
171
- applyChange();
172
- scope?.watch?.(applyChange);
173
- } catch (err) {
174
- console.warn("[dsh-workspace-files] failed to register settings scope:", err);
175
- }
176
- });
177
225
  const webServer = ctx.webServer;
178
226
  if (!webServer) return;
179
227
  const handleRpc = async (req, res, prefix) => {
@@ -210,6 +258,39 @@ function apply(ctx, config = {}) {
210
258
  }
211
259
  return;
212
260
  }
261
+ if (method === "files.open") {
262
+ try {
263
+ const body = await readJsonBody(req);
264
+ const cwd = resolveCwd(ctx, body);
265
+ const rawPath = String(body.path || "");
266
+ if (!rawPath) {
267
+ writeJson(res, 400, {
268
+ ok: false,
269
+ error: { message: "Missing path parameter" }
270
+ });
271
+ return;
272
+ }
273
+ const fullPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
274
+ assertInsideWorkspace(cwd, fullPath);
275
+ const action = body.action || "reveal";
276
+ const isDirectory = Boolean(body.isDirectory);
277
+ if (process.platform === "win32") {
278
+ const winPath = fullPath.replace(/\//g, "\\");
279
+ launch("explorer.exe", action === "reveal" && !isDirectory ? [`/select,${winPath}`] : [winPath]);
280
+ } else if (process.platform === "darwin") {
281
+ if (action === "reveal") launch("open", [isDirectory ? fullPath : "-R", fullPath]);
282
+ else launch("open", [fullPath]);
283
+ } else if (action === "reveal") launch("xdg-open", [isDirectory ? fullPath : resolve(fullPath, "..")]);
284
+ else launch("xdg-open", [fullPath]);
285
+ writeJson(res, 200, { ok: true });
286
+ } catch (err) {
287
+ writeJson(res, 500, {
288
+ ok: false,
289
+ error: { message: err?.message || String(err) }
290
+ });
291
+ }
292
+ return;
293
+ }
213
294
  if (method !== "files.tree") {
214
295
  writeJson(res, 404, {
215
296
  ok: false,
@@ -219,13 +300,36 @@ function apply(ctx, config = {}) {
219
300
  }
220
301
  try {
221
302
  const body = await readJsonBody(req);
303
+ const cwd = resolveCwd(ctx, body);
304
+ const showHidden = typeof body.showHidden === "boolean" ? body.showHidden : liveConfig.showHidden;
305
+ const showIgnored = Boolean(body.showIgnored);
306
+ const maxDepth = typeof body.maxDepth === "number" && body.maxDepth > 0 ? body.maxDepth : liveConfig.maxDepth;
307
+ const forceRefresh = Boolean(body.forceRefresh);
308
+ const cacheKey = `${cwd}::${showHidden}::${showIgnored}::${maxDepth}`;
309
+ const now = Date.now();
310
+ if (!forceRefresh) {
311
+ const cached = scanCache.get(cacheKey);
312
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
313
+ writeJson(res, 200, {
314
+ ok: true,
315
+ value: cached.result,
316
+ cached: true
317
+ });
318
+ return;
319
+ }
320
+ }
321
+ const result = await scanWorkspaceTree(cwd, {
322
+ showHidden,
323
+ showIgnored,
324
+ maxDepth
325
+ });
326
+ scanCache.set(cacheKey, {
327
+ timestamp: now,
328
+ result
329
+ });
222
330
  writeJson(res, 200, {
223
331
  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
- })
332
+ value: result
229
333
  });
230
334
  } catch (err) {
231
335
  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.2",
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,
@@ -22,6 +24,9 @@ import {
22
24
 
23
25
  export const inject = ['slots', 'sidebarRightTabs']
24
26
 
27
+ /** 构建时由 tsdown define 注入,避免卡片上的版本号与 package.json 漂移。 */
28
+ declare const __PLUGIN_VERSION__: string
29
+
25
30
  let globalCtx: Context | null = null
26
31
 
27
32
  const OFFICIAL_FILES_TAB_ID = '@civilization/dsh-workspace-files'
@@ -113,28 +118,16 @@ export function apply(ctx: Context): void {
113
118
  }, WorkspaceFilesTabTitle)), 'dsh-workspace-files: official tab title')
114
119
  }
115
120
 
116
- // 注入设置中心“插件配置”卡片(命名空间必须为 workspace-files,与 Host settings 服务注册一致)
117
- const registerSettingsCard = (targetCtx: any) => {
118
- if (!targetCtx?.slots) return
119
- targetCtx.effect?.(() => targetCtx.slots.inject('settings.plugin.item', function* () {
120
- yield targetCtx.slots.register({
121
- name: 'settings.plugin.item',
122
- key: 'workspace-files',
123
- }, WorkspaceFilesSettingsCard)
124
- yield targetCtx.slots.register({
125
- name: 'settings.plugin.item',
126
- key: '@civilization/dsh-workspace-files',
127
- }, WorkspaceFilesSettingsCard)
128
- }), 'dsh-workspace-files: settings card')
129
- }
130
-
121
+ // 设置中心“插件”页的自定义卡片:DSH 0.2.x 用 settings.plugins.tab(list 槽)承载
122
+ // 各插件自己的配置视图,注册需要 id/order/label;旧版的 settings.plugin.item 槽与
123
+ // settingsScope 服务在 0.2.0 已不存在。
131
124
  if (slots) {
132
- registerSettingsCard(ctx)
133
- }
134
- if ((ctx as any).inject) {
135
- (ctx as any).inject(['settingsScope'], (scoped: any) => {
136
- registerSettingsCard(scoped)
137
- })
125
+ ctx.effect(() => slots.inject('settings.plugins.tab', () => slots.register({
126
+ name: 'settings.plugins.tab',
127
+ id: 'workspace-files',
128
+ order: 20,
129
+ label: () => '工作区文件',
130
+ }, WorkspaceFilesSettingsCard)), 'dsh-workspace-files: settings card')
138
131
  }
139
132
  }
140
133
 
@@ -451,7 +444,7 @@ export function WorkspaceFilesSettingsCard(): JSX.Element {
451
444
  <div className="dsh-wf-head-text">
452
445
  <div className="dsh-wf-name">
453
446
  <span>工作区文件</span>
454
- <span className="dsh-wf-version">v0.3.0</span>
447
+ <span className="dsh-wf-version">v{__PLUGIN_VERSION__}</span>
455
448
  </div>
456
449
  <div className="dsh-wf-desc">
457
450
  浏览与检索工作区文件树,支持递归目录扫描与一键引用。
@@ -582,7 +575,7 @@ function WorkspaceFilesView({
582
575
  return () => window.removeEventListener('dsh-workspace-files:config-change', handleCfgChange)
583
576
  }, [])
584
577
 
585
- const loadTree = useCallback(async (quiet = false) => {
578
+ const loadTree = useCallback(async (quiet = false, forceRefresh = false) => {
586
579
  if (!quiet) setLoading(true)
587
580
  setError(null)
588
581
  try {
@@ -591,6 +584,7 @@ function WorkspaceFilesView({
591
584
  cwd,
592
585
  showHidden,
593
586
  maxDepth: activeConfig.maxDepth,
587
+ forceRefresh,
594
588
  })
595
589
  setData(res)
596
590
 
@@ -725,6 +719,60 @@ function WorkspaceFilesView({
725
719
  })
726
720
  }
727
721
 
722
+ // 在文件资源管理器中定位/打开
723
+ const handleRevealInExplorer = (e: React.MouseEvent, item: FileItem) => {
724
+ e.stopPropagation()
725
+ fetch('/dsh-workspace-files/api/files.open', {
726
+ method: 'POST',
727
+ headers: { 'content-type': 'application/json' },
728
+ body: JSON.stringify({
729
+ action: 'reveal',
730
+ path: item.path,
731
+ isDirectory: Boolean(item.isDirectory),
732
+ sessionId,
733
+ cwd,
734
+ }),
735
+ })
736
+ .then(res => res.json())
737
+ .then(data => {
738
+ if (data.ok) {
739
+ showToastMsg(`已在资源管理器中显示: ${item.name}`)
740
+ } else {
741
+ showToastMsg(`打开失败: ${data.error?.message || '未知错误'}`)
742
+ }
743
+ })
744
+ .catch(err => {
745
+ showToastMsg(`请求失败: ${err?.message || String(err)}`)
746
+ })
747
+ }
748
+
749
+ // 用系统默认应用打开
750
+ const handleOpenDefaultApp = (e: React.MouseEvent, item: FileItem) => {
751
+ e.stopPropagation()
752
+ fetch('/dsh-workspace-files/api/files.open', {
753
+ method: 'POST',
754
+ headers: { 'content-type': 'application/json' },
755
+ body: JSON.stringify({
756
+ action: 'openDefault',
757
+ path: item.path,
758
+ isDirectory: Boolean(item.isDirectory),
759
+ sessionId,
760
+ cwd,
761
+ }),
762
+ })
763
+ .then(res => res.json())
764
+ .then(data => {
765
+ if (data.ok) {
766
+ showToastMsg(`已启动系统应用打开: ${item.name}`)
767
+ } else {
768
+ showToastMsg(`打开失败: ${data.error?.message || '未知错误'}`)
769
+ }
770
+ })
771
+ .catch(err => {
772
+ showToastMsg(`请求失败: ${err?.message || String(err)}`)
773
+ })
774
+ }
775
+
728
776
  const [searchViewMode, setSearchViewMode] = useState<'flat' | 'tree'>('flat')
729
777
  const [searchExpandedPaths, setSearchExpandedPaths] = useState<Set<string>>(() => new Set())
730
778
 
@@ -885,7 +933,7 @@ function WorkspaceFilesView({
885
933
  <button
886
934
  type="button"
887
935
  className="dsh-files-tool-btn"
888
- onClick={() => void loadTree(true)}
936
+ onClick={() => void loadTree(false, true)}
889
937
  disabled={loading}
890
938
  title="刷新文件树"
891
939
  >
@@ -927,7 +975,10 @@ function WorkspaceFilesView({
927
975
  /* ── 扁平搜索结果列表 ── */
928
976
  <div className="dsh-files-search-container">
929
977
  <div className="dsh-files-search-header">
930
- <span className="dsh-files-search-count">找到 {flatSearchResults.length} 个匹配项</span>
978
+ <span className="dsh-files-search-count">
979
+ 找到 {flatSearchResults.length} 个匹配项
980
+ {flatSearchResults.length > 150 ? '(展示前 150 项)' : ''}
981
+ </span>
931
982
  <button
932
983
  type="button"
933
984
  className="dsh-files-view-toggle-btn"
@@ -939,7 +990,7 @@ function WorkspaceFilesView({
939
990
  </button>
940
991
  </div>
941
992
  <div className="dsh-files-flat-list">
942
- {flatSearchResults.map(({ item, parentDir }) => (
993
+ {flatSearchResults.slice(0, 150).map(({ item, parentDir }) => (
943
994
  <div
944
995
  key={item.path}
945
996
  className={`dsh-files-flat-row ${item.isDirectory ? 'is-directory' : ''}`}
@@ -984,6 +1035,22 @@ function WorkspaceFilesView({
984
1035
  <IconAt size={13} />
985
1036
  <span className="dsh-files-action-at-text">@引用</span>
986
1037
  </button>
1038
+ <button
1039
+ type="button"
1040
+ className="dsh-files-action-btn"
1041
+ onClick={e => handleRevealInExplorer(e, item)}
1042
+ title={`在文件资源管理器中显示此${item.isDirectory ? '目录' : '文件'}`}
1043
+ >
1044
+ <IconExplorer size={12} />
1045
+ </button>
1046
+ <button
1047
+ type="button"
1048
+ className="dsh-files-action-btn"
1049
+ onClick={e => handleOpenDefaultApp(e, item)}
1050
+ title={`用系统默认应用打开此${item.isDirectory ? '目录' : '文件'}`}
1051
+ >
1052
+ <IconExternalApp size={12} />
1053
+ </button>
987
1054
  <button
988
1055
  type="button"
989
1056
  className="dsh-files-action-btn"
@@ -995,6 +1062,11 @@ function WorkspaceFilesView({
995
1062
  </div>
996
1063
  </div>
997
1064
  ))}
1065
+ {flatSearchResults.length > 150 && (
1066
+ <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)' }}>
1067
+ 匹配项较多,已限制展示前 150 项以保证流畅度,请输入更详细关键字
1068
+ </div>
1069
+ )}
998
1070
  </div>
999
1071
  </div>
1000
1072
  ) : (
@@ -1037,6 +1109,8 @@ function WorkspaceFilesView({
1037
1109
  onOpenFile={onOpenFile}
1038
1110
  onMention={handleMention}
1039
1111
  onCopyPath={handleCopyPath}
1112
+ onRevealInExplorer={handleRevealInExplorer}
1113
+ onOpenDefaultApp={handleOpenDefaultApp}
1040
1114
  />
1041
1115
  ))}
1042
1116
  </div>
@@ -1065,8 +1139,22 @@ function FileTreeNode(props: {
1065
1139
  onOpenFile: (path: string) => void
1066
1140
  onMention: (e: React.MouseEvent, item: FileItem) => void
1067
1141
  onCopyPath: (e: React.MouseEvent, path: string) => void
1142
+ onRevealInExplorer: (e: React.MouseEvent, item: FileItem) => void
1143
+ onOpenDefaultApp: (e: React.MouseEvent, item: FileItem) => void
1068
1144
  }): JSX.Element {
1069
- const { item, depth, query, isSearchMode, expandedPaths, onToggleExpand, onOpenFile, onMention, onCopyPath } = props
1145
+ const {
1146
+ item,
1147
+ depth,
1148
+ query,
1149
+ isSearchMode,
1150
+ expandedPaths,
1151
+ onToggleExpand,
1152
+ onOpenFile,
1153
+ onMention,
1154
+ onCopyPath,
1155
+ onRevealInExplorer,
1156
+ onOpenDefaultApp,
1157
+ } = props
1070
1158
  const isExpanded = expandedPaths.has(item.path)
1071
1159
 
1072
1160
  if (item.isDirectory) {
@@ -1098,6 +1186,22 @@ function FileTreeNode(props: {
1098
1186
  <IconAt size={13} />
1099
1187
  <span className="dsh-files-action-at-text">@引用</span>
1100
1188
  </button>
1189
+ <button
1190
+ type="button"
1191
+ className="dsh-files-action-btn"
1192
+ onClick={e => onRevealInExplorer(e, item)}
1193
+ title="在文件资源管理器中打开此目录"
1194
+ >
1195
+ <IconExplorer size={12} />
1196
+ </button>
1197
+ <button
1198
+ type="button"
1199
+ className="dsh-files-action-btn"
1200
+ onClick={e => onOpenDefaultApp(e, item)}
1201
+ title="用系统默认应用打开此目录"
1202
+ >
1203
+ <IconExternalApp size={12} />
1204
+ </button>
1101
1205
  <button
1102
1206
  type="button"
1103
1207
  className="dsh-files-action-btn"
@@ -1122,6 +1226,8 @@ function FileTreeNode(props: {
1122
1226
  onOpenFile={onOpenFile}
1123
1227
  onMention={onMention}
1124
1228
  onCopyPath={onCopyPath}
1229
+ onRevealInExplorer={onRevealInExplorer}
1230
+ onOpenDefaultApp={onOpenDefaultApp}
1125
1231
  />
1126
1232
  ))}
1127
1233
  </div>
@@ -1161,6 +1267,22 @@ function FileTreeNode(props: {
1161
1267
  <IconAt size={13} />
1162
1268
  <span className="dsh-files-action-at-text">@引用</span>
1163
1269
  </button>
1270
+ <button
1271
+ type="button"
1272
+ className="dsh-files-action-btn"
1273
+ onClick={e => onRevealInExplorer(e, item)}
1274
+ title="在文件资源管理器中显示此文件"
1275
+ >
1276
+ <IconExplorer size={12} />
1277
+ </button>
1278
+ <button
1279
+ type="button"
1280
+ className="dsh-files-action-btn"
1281
+ onClick={e => onOpenDefaultApp(e, item)}
1282
+ title="用系统默认应用打开此文件"
1283
+ >
1284
+ <IconExternalApp size={12} />
1285
+ </button>
1164
1286
  <button
1165
1287
  type="button"
1166
1288
  className="dsh-files-action-btn"