@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/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)
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
1
3
  import type { Context } from '@deepseek-ai/cordis'
2
4
  import Schema from '@deepseek-ai/schemastery'
3
5
  import { scanWorkspaceTree } from './files.js'
@@ -77,6 +79,30 @@ function writeJson(res: any, status: number, body: any): void {
77
79
  res.end(data)
78
80
  }
79
81
 
82
+ /**
83
+ * 拒绝工作区之外的目标:files.open 的 path 来自请求体,必须是工作区内的相对/绝对路径,
84
+ * 否则模型或页面脚本可以借宿主进程去打开任意本地路径。
85
+ */
86
+ function assertInsideWorkspace(cwd: string, fullPath: string): void {
87
+ const root = resolve(cwd)
88
+ const target = resolve(fullPath)
89
+ const rel = relative(root, target)
90
+ const escaped = rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
91
+ if (escaped) throw new Error('Path must stay inside the workspace.')
92
+ }
93
+
94
+ /**
95
+ * 启动系统文件管理器/默认应用。spawn 在命令不存在时会 emit 'error',
96
+ * 没有监听器就会变成未捕获异常拖垮宿主进程,因此这里必须兜住。
97
+ */
98
+ function launch(command: string, args: string[]): void {
99
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' })
100
+ child.on('error', (err: any) => {
101
+ console.warn(`[dsh-workspace-files] failed to launch ${command}:`, err?.message || String(err))
102
+ })
103
+ child.unref()
104
+ }
105
+
80
106
  function resolveCwd(ctx: Context, body: Record<string, any>): string {
81
107
  if (body.cwd?.trim()) return body.cwd.trim()
82
108
  if (body.root?.trim()) return body.root.trim()
@@ -92,31 +118,22 @@ function resolveCwd(ctx: Context, body: Record<string, any>): string {
92
118
 
93
119
  export const SETTINGS_NS = 'workspace-files'
94
120
 
121
+ interface CacheEntry {
122
+ timestamp: number
123
+ result: any
124
+ }
125
+ const scanCache = new Map<string, CacheEntry>()
126
+ const CACHE_TTL_MS = 20_000 // 20 秒热缓存
127
+
95
128
  export function apply(ctx: Context, config: Config = {}): void {
96
129
  const liveConfig = {
97
130
  maxDepth: typeof config?.maxDepth === 'number' && config.maxDepth > 0 ? config.maxDepth : 16,
98
131
  showHidden: Boolean(config?.showHidden),
99
132
  }
100
133
 
101
- // 关键:向 Host 的 settings 服务注册 settings namespace,使设置面板的前端控制器能够枚举到此插件卡片
102
- if ((ctx as any).inject) {
103
- (ctx as any).inject(['settings'], (scopedCtx: any) => {
104
- try {
105
- const scope = scopedCtx.settings?.register(SETTINGS_NS, Config, {
106
- base: liveConfig,
107
- })
108
- const applyChange = () => {
109
- const current = scope?.get?.()
110
- if (typeof current?.maxDepth === 'number') liveConfig.maxDepth = current.maxDepth
111
- if (typeof current?.showHidden === 'boolean') liveConfig.showHidden = current.showHidden
112
- }
113
- applyChange()
114
- scope?.watch?.(applyChange)
115
- } catch (err) {
116
- console.warn('[dsh-workspace-files] failed to register settings scope:', err)
117
- }
118
- })
119
- }
134
+ // 设置表单由 DSH 从本插件导出的 Config schema 自动生成(Host 侧 settings.describe()),
135
+ // 无需也不存在插件侧的 settings.register():DSH 的 settings 服务只提供
136
+ // describe/update/replace/mutate/configure,namespace 就是 profile 里的 entry id。
120
137
 
121
138
  const webServer = (ctx as any).webServer
122
139
  if (!webServer) return
@@ -147,6 +164,47 @@ export function apply(ctx: Context, config: Config = {}): void {
147
164
  return
148
165
  }
149
166
 
167
+ if (method === 'files.open') {
168
+ try {
169
+ const body = await readJsonBody(req)
170
+ const cwd = resolveCwd(ctx, body)
171
+ const rawPath = String(body.path || '')
172
+ if (!rawPath) {
173
+ writeJson(res, 400, { ok: false, error: { message: 'Missing path parameter' } })
174
+ return
175
+ }
176
+
177
+ const fullPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
178
+ assertInsideWorkspace(cwd, fullPath)
179
+ const action = body.action || 'reveal'
180
+ const isDirectory = Boolean(body.isDirectory)
181
+
182
+ if (process.platform === 'win32') {
183
+ const winPath = fullPath.replace(/\//g, '\\')
184
+ // explorer.exe 自己解析路径,不再经过 cmd.exe 的字符串拼接,避免 & | ^ 等注入
185
+ const args = action === 'reveal' && !isDirectory ? [`/select,${winPath}`] : [winPath]
186
+ launch('explorer.exe', args)
187
+ } else if (process.platform === 'darwin') {
188
+ if (action === 'reveal') {
189
+ launch('open', [isDirectory ? fullPath : '-R', fullPath])
190
+ } else {
191
+ launch('open', [fullPath])
192
+ }
193
+ } else {
194
+ if (action === 'reveal') {
195
+ launch('xdg-open', [isDirectory ? fullPath : resolve(fullPath, '..')])
196
+ } else {
197
+ launch('xdg-open', [fullPath])
198
+ }
199
+ }
200
+
201
+ writeJson(res, 200, { ok: true })
202
+ } catch (err: any) {
203
+ writeJson(res, 500, { ok: false, error: { message: err?.message || String(err) } })
204
+ }
205
+ return
206
+ }
207
+
150
208
  if (method !== 'files.tree') {
151
209
  writeJson(res, 404, { ok: false, error: { message: `Unknown method: ${method}` } })
152
210
  return
@@ -155,11 +213,29 @@ export function apply(ctx: Context, config: Config = {}): void {
155
213
  try {
156
214
  const body = await readJsonBody(req)
157
215
  const cwd = resolveCwd(ctx, body)
216
+ const showHidden = typeof body.showHidden === 'boolean' ? body.showHidden : liveConfig.showHidden
217
+ const showIgnored = Boolean(body.showIgnored)
218
+ const maxDepth = typeof body.maxDepth === 'number' && body.maxDepth > 0 ? body.maxDepth : liveConfig.maxDepth
219
+ const forceRefresh = Boolean(body.forceRefresh)
220
+
221
+ const cacheKey = `${cwd}::${showHidden}::${showIgnored}::${maxDepth}`
222
+ const now = Date.now()
223
+
224
+ if (!forceRefresh) {
225
+ const cached = scanCache.get(cacheKey)
226
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
227
+ writeJson(res, 200, { ok: true, value: cached.result, cached: true })
228
+ return
229
+ }
230
+ }
231
+
158
232
  const result = await scanWorkspaceTree(cwd, {
159
- showHidden: typeof body.showHidden === 'boolean' ? body.showHidden : liveConfig.showHidden,
160
- showIgnored: Boolean(body.showIgnored),
161
- maxDepth: typeof body.maxDepth === 'number' && body.maxDepth > 0 ? body.maxDepth : liveConfig.maxDepth,
233
+ showHidden,
234
+ showIgnored,
235
+ maxDepth,
162
236
  })
237
+
238
+ scanCache.set(cacheKey, { timestamp: now, result })
163
239
  writeJson(res, 200, { ok: true, value: result })
164
240
  } catch (err: any) {
165
241
  writeJson(res, 500, { ok: false, error: { message: err?.message || String(err) } })