@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/README.md +22 -1
- package/lib/client.js +655 -60
- package/lib/client.js.map +1 -1
- package/lib/index.js +198 -36
- package/package.json +8 -2
- package/src/client/icons.tsx +27 -0
- package/src/client/index.tsx +587 -8
- package/src/files.ts +75 -34
- package/src/index.ts +132 -4
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,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 ??
|
|
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
|
-
|
|
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)
|
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
|
|
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
|
|
105
|
-
showIgnored
|
|
106
|
-
maxDepth
|
|
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) } })
|