@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/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
+