@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/README.md +6 -1
- package/lib/client.js +211 -77
- package/lib/client.js.map +1 -1
- package/lib/index.js +151 -47
- package/package.json +1 -1
- package/src/client/icons.tsx +19 -0
- package/src/client/index.tsx +149 -27
- package/src/files.ts +74 -33
- package/src/index.ts +98 -22
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)
|
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
160
|
-
showIgnored
|
|
161
|
-
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) } })
|