@civilization/dsh-workspace-files 0.2.0
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/LICENSE +201 -0
- package/README.md +59 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1633 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +196 -0
- package/package.json +61 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/index.tsx +1545 -0
- package/src/files.ts +126 -0
- package/src/index.ts +121 -0
package/src/files.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface FileItem {
|
|
5
|
+
name: string
|
|
6
|
+
path: string // relative to workspace root, e.g. "src/client/index.tsx"
|
|
7
|
+
isDirectory: boolean
|
|
8
|
+
size?: number
|
|
9
|
+
mtime?: number
|
|
10
|
+
children?: FileItem[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WorkspaceFilesResult {
|
|
14
|
+
root: string
|
|
15
|
+
items: FileItem[]
|
|
16
|
+
totalFiles: number
|
|
17
|
+
totalDirs: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_IGNORED_DIRS = new Set([
|
|
21
|
+
'.git',
|
|
22
|
+
'.svn',
|
|
23
|
+
'.hg',
|
|
24
|
+
'node_modules',
|
|
25
|
+
'.turbo',
|
|
26
|
+
'.next',
|
|
27
|
+
'dist',
|
|
28
|
+
'build',
|
|
29
|
+
'.pnpm-store',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
function isWithin(parent: string, child: string): boolean {
|
|
33
|
+
const rel = relative(parent, child)
|
|
34
|
+
return !rel.startsWith('..') && !rel.includes(':')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function scanWorkspaceTree(
|
|
38
|
+
cwd: string,
|
|
39
|
+
options: { maxDepth?: number; showHidden?: boolean; showIgnored?: boolean } = {},
|
|
40
|
+
): Promise<WorkspaceFilesResult> {
|
|
41
|
+
const root = resolve(cwd)
|
|
42
|
+
const maxDepth = options.maxDepth ?? 8
|
|
43
|
+
const showHidden = options.showHidden ?? false
|
|
44
|
+
const showIgnored = options.showIgnored ?? false
|
|
45
|
+
|
|
46
|
+
let totalFiles = 0
|
|
47
|
+
let totalDirs = 0
|
|
48
|
+
|
|
49
|
+
async function walk(dirPath: string, currentDepth: number): Promise<FileItem[]> {
|
|
50
|
+
if (currentDepth > maxDepth) return []
|
|
51
|
+
|
|
52
|
+
let dirents: import('node:fs').Dirent[]
|
|
53
|
+
try {
|
|
54
|
+
dirents = await fs.readdir(dirPath, { withFileTypes: true })
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
|
|
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[] = []
|
|
67
|
+
|
|
68
|
+
for (const ent of dirents) {
|
|
69
|
+
const name = ent.name
|
|
70
|
+
|
|
71
|
+
// Hidden file filter (starts with .)
|
|
72
|
+
if (!showHidden && name.startsWith('.') && name !== '.gitignore' && name !== '.env') {
|
|
73
|
+
if (!showIgnored) continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Ignored directory filter
|
|
77
|
+
if (!showIgnored && ent.isDirectory() && DEFAULT_IGNORED_DIRS.has(name)) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fullPath = join(dirPath, name)
|
|
82
|
+
if (!isWithin(root, fullPath)) continue
|
|
83
|
+
|
|
84
|
+
const relPath = relative(root, fullPath).replace(/\\/g, '/')
|
|
85
|
+
|
|
86
|
+
if (ent.isDirectory()) {
|
|
87
|
+
totalDirs += 1
|
|
88
|
+
const children = await walk(fullPath, currentDepth + 1)
|
|
89
|
+
items.push({
|
|
90
|
+
name,
|
|
91
|
+
path: relPath,
|
|
92
|
+
isDirectory: true,
|
|
93
|
+
children,
|
|
94
|
+
})
|
|
95
|
+
} else {
|
|
96
|
+
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({
|
|
106
|
+
name,
|
|
107
|
+
path: relPath,
|
|
108
|
+
isDirectory: false,
|
|
109
|
+
size,
|
|
110
|
+
mtime,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return items
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const items = await walk(root, 1)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
root,
|
|
122
|
+
items,
|
|
123
|
+
totalFiles,
|
|
124
|
+
totalDirs,
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { scanWorkspaceTree } from './files.js'
|
|
3
|
+
|
|
4
|
+
export const name = '@civilization/dsh-workspace-files'
|
|
5
|
+
export const inject = ['webServer']
|
|
6
|
+
|
|
7
|
+
async function readJsonBody(req: any): Promise<Record<string, any>> {
|
|
8
|
+
if (req.body && typeof req.body === 'object') {
|
|
9
|
+
return req.body
|
|
10
|
+
}
|
|
11
|
+
if (typeof req.body === 'string' && req.body.trim()) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(req.body)
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
if (req.readableEnded || req.complete) {
|
|
17
|
+
return {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let raw = ''
|
|
22
|
+
let timer: any = null
|
|
23
|
+
|
|
24
|
+
const cleanup = () => {
|
|
25
|
+
if (timer) clearTimeout(timer)
|
|
26
|
+
req.off?.('data', onData)
|
|
27
|
+
req.off?.('end', onEnd)
|
|
28
|
+
req.off?.('error', onError)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const onData = (chunk: Buffer) => { raw += chunk.toString('utf8') }
|
|
32
|
+
const onEnd = () => {
|
|
33
|
+
cleanup()
|
|
34
|
+
try {
|
|
35
|
+
resolve(raw ? JSON.parse(raw) : {})
|
|
36
|
+
} catch (err) {
|
|
37
|
+
reject(err)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const onError = (err: any) => {
|
|
41
|
+
cleanup()
|
|
42
|
+
reject(err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
req.on('data', onData)
|
|
46
|
+
req.on('end', onEnd)
|
|
47
|
+
req.on('error', onError)
|
|
48
|
+
|
|
49
|
+
timer = setTimeout(() => {
|
|
50
|
+
cleanup()
|
|
51
|
+
try {
|
|
52
|
+
resolve(raw ? JSON.parse(raw) : {})
|
|
53
|
+
} catch {
|
|
54
|
+
resolve({})
|
|
55
|
+
}
|
|
56
|
+
}, 1500)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeJson(res: any, status: number, body: any): void {
|
|
61
|
+
const data = JSON.stringify(body)
|
|
62
|
+
res.writeHead(status, {
|
|
63
|
+
'content-type': 'application/json; charset=utf-8',
|
|
64
|
+
'content-length': Buffer.byteLength(data),
|
|
65
|
+
})
|
|
66
|
+
res.end(data)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveCwd(ctx: Context, body: Record<string, any>): string {
|
|
70
|
+
if (body.cwd?.trim()) return body.cwd.trim()
|
|
71
|
+
if (body.root?.trim()) return body.root.trim()
|
|
72
|
+
if (body.sessionId) {
|
|
73
|
+
try {
|
|
74
|
+
const sessions = ctx.get('sessions' as any) as any
|
|
75
|
+
const session = sessions?.byId?.[body.sessionId]
|
|
76
|
+
if (session?.cwd) return session.cwd
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
return process.cwd()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function apply(ctx: Context): void {
|
|
83
|
+
const webServer = (ctx as any).webServer
|
|
84
|
+
if (!webServer) return
|
|
85
|
+
|
|
86
|
+
const handleRpc = async (req: any, res: any, prefix: string) => {
|
|
87
|
+
if (req.method !== 'POST') {
|
|
88
|
+
writeJson(res, 405, { ok: false, error: { message: 'Method Not Allowed' } })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
93
|
+
const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined
|
|
94
|
+
|
|
95
|
+
if (method !== 'files.tree') {
|
|
96
|
+
writeJson(res, 404, { ok: false, error: { message: `Unknown method: ${method}` } })
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const body = await readJsonBody(req)
|
|
102
|
+
const cwd = resolveCwd(ctx, body)
|
|
103
|
+
const result = await scanWorkspaceTree(cwd, {
|
|
104
|
+
showHidden: Boolean(body.showHidden),
|
|
105
|
+
showIgnored: Boolean(body.showIgnored),
|
|
106
|
+
maxDepth: typeof body.maxDepth === 'number' ? body.maxDepth : 8,
|
|
107
|
+
})
|
|
108
|
+
writeJson(res, 200, { ok: true, value: result })
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
writeJson(res, 500, { ok: false, error: { message: err?.message || String(err) } })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 注册 /dsh-workspace-files/api/ 前缀
|
|
115
|
+
ctx.effect(() => webServer.register({
|
|
116
|
+
kind: 'prefix',
|
|
117
|
+
path: '/dsh-workspace-files/api',
|
|
118
|
+
handler: (req: any, res: any) => handleRpc(req, res, '/dsh-workspace-files/api/'),
|
|
119
|
+
}), 'dsh-workspace-files: /dsh-workspace-files/api routes')
|
|
120
|
+
}
|
|
121
|
+
|