@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/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,20 +55,42 @@ 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 } = {},
40
79
  ): Promise<WorkspaceFilesResult> {
41
80
  const root = resolve(cwd)
42
- const maxDepth = options.maxDepth ?? 8
81
+ const maxDepth = options.maxDepth ?? 16
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,9 +1,22 @@
1
+ import { exec, spawn } from 'node:child_process'
2
+ import { isAbsolute, resolve } from 'node:path'
1
3
  import type { Context } from '@deepseek-ai/cordis'
4
+ import Schema from '@deepseek-ai/schemastery'
2
5
  import { scanWorkspaceTree } from './files.js'
3
6
 
4
7
  export const name = '@civilization/dsh-workspace-files'
5
8
  export const inject = ['webServer']
6
9
 
10
+ export interface Config {
11
+ maxDepth?: number
12
+ showHidden?: boolean
13
+ }
14
+
15
+ export const Config: Schema<Config> = Schema.object({
16
+ maxDepth: Schema.natural().min(1).max(100).default(16).description('工作区文件树扫描的最大文件夹深度(层级)'),
17
+ showHidden: Schema.boolean().default(false).description('默认是否显示以点开头的隐藏文件'),
18
+ }).description('工作区文件:浏览与检索工作区文件树')
19
+
7
20
  async function readJsonBody(req: any): Promise<Record<string, any>> {
8
21
  if (req.body && typeof req.body === 'object') {
9
22
  return req.body
@@ -79,7 +92,41 @@ function resolveCwd(ctx: Context, body: Record<string, any>): string {
79
92
  return process.cwd()
80
93
  }
81
94
 
82
- export function apply(ctx: Context): void {
95
+ export const SETTINGS_NS = 'workspace-files'
96
+
97
+ interface CacheEntry {
98
+ timestamp: number
99
+ result: any
100
+ }
101
+ const scanCache = new Map<string, CacheEntry>()
102
+ const CACHE_TTL_MS = 20_000 // 20 秒热缓存
103
+
104
+ export function apply(ctx: Context, config: Config = {}): void {
105
+ const liveConfig = {
106
+ maxDepth: typeof config?.maxDepth === 'number' && config.maxDepth > 0 ? config.maxDepth : 16,
107
+ showHidden: Boolean(config?.showHidden),
108
+ }
109
+
110
+ // 关键:向 Host 的 settings 服务注册 settings namespace,使设置面板的前端控制器能够枚举到此插件卡片
111
+ if ((ctx as any).inject) {
112
+ (ctx as any).inject(['settings'], (scopedCtx: any) => {
113
+ try {
114
+ const scope = scopedCtx.settings?.register(SETTINGS_NS, Config, {
115
+ base: liveConfig,
116
+ })
117
+ const applyChange = () => {
118
+ const current = scope?.get?.()
119
+ if (typeof current?.maxDepth === 'number') liveConfig.maxDepth = current.maxDepth
120
+ if (typeof current?.showHidden === 'boolean') liveConfig.showHidden = current.showHidden
121
+ }
122
+ applyChange()
123
+ scope?.watch?.(applyChange)
124
+ } catch (err) {
125
+ console.warn('[dsh-workspace-files] failed to register settings scope:', err)
126
+ }
127
+ })
128
+ }
129
+
83
130
  const webServer = (ctx as any).webServer
84
131
  if (!webServer) return
85
132
 
@@ -92,6 +139,69 @@ export function apply(ctx: Context): void {
92
139
  const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
93
140
  const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined
94
141
 
142
+ if (method === 'config.get') {
143
+ writeJson(res, 200, { ok: true, value: liveConfig })
144
+ return
145
+ }
146
+
147
+ if (method === 'config.set') {
148
+ try {
149
+ const body = await readJsonBody(req)
150
+ if (typeof body.maxDepth === 'number' && body.maxDepth > 0) liveConfig.maxDepth = body.maxDepth
151
+ if (typeof body.showHidden === 'boolean') liveConfig.showHidden = body.showHidden
152
+ writeJson(res, 200, { ok: true, value: liveConfig })
153
+ } catch (err: any) {
154
+ writeJson(res, 500, { ok: false, error: { message: err?.message || String(err) } })
155
+ }
156
+ return
157
+ }
158
+
159
+ if (method === 'files.open') {
160
+ try {
161
+ const body = await readJsonBody(req)
162
+ const cwd = resolveCwd(ctx, body)
163
+ const rawPath = String(body.path || '')
164
+ if (!rawPath) {
165
+ writeJson(res, 400, { ok: false, error: { message: 'Missing path parameter' } })
166
+ return
167
+ }
168
+
169
+ const fullPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
170
+ const action = body.action || 'reveal'
171
+ const isDirectory = Boolean(body.isDirectory)
172
+
173
+ if (process.platform === 'win32') {
174
+ const winPath = fullPath.replace(/\//g, '\\')
175
+ if (action === 'reveal') {
176
+ if (isDirectory) {
177
+ spawn('explorer.exe', [winPath], { detached: true, stdio: 'ignore' }).unref()
178
+ } else {
179
+ spawn('explorer.exe', [`/select,${winPath}`], { detached: true, stdio: 'ignore' }).unref()
180
+ }
181
+ } else {
182
+ exec(`start "" "${winPath.replace(/"/g, '\\"')}"`, { windowsHide: true })
183
+ }
184
+ } else if (process.platform === 'darwin') {
185
+ if (action === 'reveal') {
186
+ spawn('open', [isDirectory ? fullPath : '-R', fullPath], { detached: true, stdio: 'ignore' }).unref()
187
+ } else {
188
+ spawn('open', [fullPath], { detached: true, stdio: 'ignore' }).unref()
189
+ }
190
+ } else {
191
+ if (action === 'reveal') {
192
+ spawn('xdg-open', [isDirectory ? fullPath : resolve(fullPath, '..')], { detached: true, stdio: 'ignore' }).unref()
193
+ } else {
194
+ spawn('xdg-open', [fullPath], { detached: true, stdio: 'ignore' }).unref()
195
+ }
196
+ }
197
+
198
+ writeJson(res, 200, { ok: true })
199
+ } catch (err: any) {
200
+ writeJson(res, 500, { ok: false, error: { message: err?.message || String(err) } })
201
+ }
202
+ return
203
+ }
204
+
95
205
  if (method !== 'files.tree') {
96
206
  writeJson(res, 404, { ok: false, error: { message: `Unknown method: ${method}` } })
97
207
  return
@@ -100,11 +210,29 @@ export function apply(ctx: Context): void {
100
210
  try {
101
211
  const body = await readJsonBody(req)
102
212
  const cwd = resolveCwd(ctx, body)
213
+ const showHidden = typeof body.showHidden === 'boolean' ? body.showHidden : liveConfig.showHidden
214
+ const showIgnored = Boolean(body.showIgnored)
215
+ const maxDepth = typeof body.maxDepth === 'number' && body.maxDepth > 0 ? body.maxDepth : liveConfig.maxDepth
216
+ const forceRefresh = Boolean(body.forceRefresh)
217
+
218
+ const cacheKey = `${cwd}::${showHidden}::${showIgnored}::${maxDepth}`
219
+ const now = Date.now()
220
+
221
+ if (!forceRefresh) {
222
+ const cached = scanCache.get(cacheKey)
223
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) {
224
+ writeJson(res, 200, { ok: true, value: cached.result, cached: true })
225
+ return
226
+ }
227
+ }
228
+
103
229
  const result = await scanWorkspaceTree(cwd, {
104
- showHidden: Boolean(body.showHidden),
105
- showIgnored: Boolean(body.showIgnored),
106
- maxDepth: typeof body.maxDepth === 'number' ? body.maxDepth : 8,
230
+ showHidden,
231
+ showIgnored,
232
+ maxDepth,
107
233
  })
234
+
235
+ scanCache.set(cacheKey, { timestamp: now, result })
108
236
  writeJson(res, 200, { ok: true, value: result })
109
237
  } catch (err: any) {
110
238
  writeJson(res, 500, { ok: false, error: { message: err?.message || String(err) } })