dsh-explorer 0.0.0-stage → 0.1.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 +22 -0
- package/README.md +106 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/lib/client.js +2814 -0
- package/lib/index.js +747 -0
- package/package.json +92 -4
- package/src/client/ExplorerContextMenu.tsx +159 -0
- package/src/client/ExplorerHeader.tsx +75 -0
- package/src/client/ExplorerOptionsModal.tsx +243 -0
- package/src/client/ExplorerRow.tsx +240 -0
- package/src/client/ExplorerSearch.tsx +42 -0
- package/src/client/ExplorerView.tsx +1139 -0
- package/src/client/api.ts +121 -0
- package/src/client/conversation-draft.ts +635 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag-drop.ts +209 -0
- package/src/client/explorer.module.css +628 -0
- package/src/client/index.tsx +160 -0
- package/src/client/options.ts +59 -0
- package/src/client/vscode-theme.tsx +250 -0
- package/src/config.ts +11 -0
- package/src/fs-operations.ts +116 -0
- package/src/fs-tree.ts +155 -0
- package/src/fs-watch.ts +179 -0
- package/src/git-status.ts +161 -0
- package/src/index.ts +165 -0
- package/src/path-security.ts +22 -0
- package/src/session-path.ts +35 -0
- package/src/wire.ts +63 -0
package/src/fs-tree.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { ExplorerError } from './wire.ts'
|
|
4
|
+
|
|
5
|
+
export interface CompactSegment {
|
|
6
|
+
name: string
|
|
7
|
+
path: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FsEntry {
|
|
11
|
+
name: string
|
|
12
|
+
path: string
|
|
13
|
+
isDir: boolean
|
|
14
|
+
hidden: boolean
|
|
15
|
+
isSymlink: boolean
|
|
16
|
+
broken: boolean
|
|
17
|
+
compact?: boolean
|
|
18
|
+
compactName?: string
|
|
19
|
+
compactPath?: string
|
|
20
|
+
compactChain?: CompactSegment[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FsListing {
|
|
24
|
+
path: string
|
|
25
|
+
entries: FsEntry[]
|
|
26
|
+
truncated: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function compareEntries(a: FsEntry, b: FsEntry): number {
|
|
30
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
31
|
+
const la = a.name.toLowerCase()
|
|
32
|
+
const lb = b.name.toLowerCase()
|
|
33
|
+
if (la !== lb) return la < lb ? -1 : 1
|
|
34
|
+
if (a.name === b.name) return 0
|
|
35
|
+
return a.name < b.name ? -1 : 1
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const directoryCache = new Map<string, { at: number; listing: FsListing }>()
|
|
39
|
+
const DIRECTORY_CACHE_TTL_MS = 300
|
|
40
|
+
|
|
41
|
+
export function invalidateDirectoryCache(_dir?: string): void {
|
|
42
|
+
directoryCache.clear()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listDirectory(dirPath: string, maxEntries = 2000): Promise<FsListing> {
|
|
46
|
+
const now = Date.now()
|
|
47
|
+
const key = `${dirPath}\0${maxEntries}`
|
|
48
|
+
const cached = directoryCache.get(key)
|
|
49
|
+
if (cached && now - cached.at < DIRECTORY_CACHE_TTL_MS) {
|
|
50
|
+
return cached.listing
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let dirEnts
|
|
54
|
+
try {
|
|
55
|
+
dirEnts = await readdir(dirPath, { withFileTypes: true })
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
58
|
+
throw new ExplorerError('not-found', `cannot read directory "${dirPath}": ${message}`, 404)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entries: FsEntry[] = []
|
|
62
|
+
const truncated = dirEnts.length > maxEntries
|
|
63
|
+
const slice = truncated ? dirEnts.slice(0, maxEntries) : dirEnts
|
|
64
|
+
|
|
65
|
+
for (const dirent of slice) {
|
|
66
|
+
const name = dirent.name
|
|
67
|
+
const fullPath = join(dirPath, name)
|
|
68
|
+
const isSymlink = dirent.isSymbolicLink()
|
|
69
|
+
let isDir = dirent.isDirectory()
|
|
70
|
+
let broken = false
|
|
71
|
+
|
|
72
|
+
if (isSymlink) {
|
|
73
|
+
try {
|
|
74
|
+
const targetStat = await stat(fullPath)
|
|
75
|
+
isDir = targetStat.isDirectory()
|
|
76
|
+
} catch {
|
|
77
|
+
broken = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hidden = name.startsWith('.')
|
|
82
|
+
let compact = false
|
|
83
|
+
let compactName: string | undefined = undefined
|
|
84
|
+
let compactPath: string | undefined = undefined
|
|
85
|
+
let compactChain: CompactSegment[] | undefined = undefined
|
|
86
|
+
|
|
87
|
+
// If it's a directory, resolve single-child directory chains for compact folding
|
|
88
|
+
if (isDir && !isSymlink) {
|
|
89
|
+
try {
|
|
90
|
+
let currPath = fullPath
|
|
91
|
+
const chain: CompactSegment[] = [{ name, path: fullPath }]
|
|
92
|
+
let depth = 0
|
|
93
|
+
while (depth < 6) {
|
|
94
|
+
const sub = await readdir(currPath, { withFileTypes: true })
|
|
95
|
+
const visibleSub = sub.filter((s) => !s.name.startsWith('.'))
|
|
96
|
+
if (visibleSub.length === 1 && visibleSub[0]!.isDirectory() && !visibleSub[0]!.isSymbolicLink()) {
|
|
97
|
+
const childName = visibleSub[0]!.name
|
|
98
|
+
currPath = join(currPath, childName)
|
|
99
|
+
chain.push({ name: childName, path: currPath })
|
|
100
|
+
depth++
|
|
101
|
+
} else {
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (chain.length > 1) {
|
|
106
|
+
compact = true
|
|
107
|
+
compactName = chain.map((c) => c.name).join('/')
|
|
108
|
+
compactPath = currPath
|
|
109
|
+
compactChain = chain
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore read errors for child probe
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
entries.push({
|
|
117
|
+
name,
|
|
118
|
+
path: fullPath,
|
|
119
|
+
isDir,
|
|
120
|
+
hidden,
|
|
121
|
+
isSymlink,
|
|
122
|
+
broken,
|
|
123
|
+
compact,
|
|
124
|
+
compactName,
|
|
125
|
+
compactPath,
|
|
126
|
+
compactChain,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
entries.sort(compareEntries)
|
|
131
|
+
|
|
132
|
+
const listing: FsListing = {
|
|
133
|
+
path: dirPath,
|
|
134
|
+
entries,
|
|
135
|
+
truncated,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
directoryCache.set(key, { at: now, listing })
|
|
139
|
+
return listing
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function listDirectoryBatch(paths: string[], maxEntriesPerDir = 1000): Promise<Record<string, FsListing>> {
|
|
143
|
+
const result: Record<string, FsListing> = {}
|
|
144
|
+
await Promise.all(
|
|
145
|
+
paths.map(async (p) => {
|
|
146
|
+
try {
|
|
147
|
+
result[p] = await listDirectory(p, maxEntriesPerDir)
|
|
148
|
+
} catch {
|
|
149
|
+
// Individual directory failures don't fail the whole batch
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
package/src/fs-watch.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'node:fs'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import type { Duplex } from 'node:stream'
|
|
4
|
+
import { invalidateDirectoryCache } from './fs-tree.ts'
|
|
5
|
+
|
|
6
|
+
interface Subscriber {
|
|
7
|
+
send: (data: unknown) => void
|
|
8
|
+
close: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface DirWatcher {
|
|
12
|
+
watcher: FSWatcher
|
|
13
|
+
subscribers: Set<Subscriber>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const dirWatchers = new Map<string, DirWatcher>()
|
|
17
|
+
|
|
18
|
+
function normalizeDirPath(d: string): string {
|
|
19
|
+
return d.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function subscribeToDir(dir: string, subscriber: Subscriber): () => void {
|
|
23
|
+
const norm = normalizeDirPath(dir)
|
|
24
|
+
let entry = dirWatchers.get(norm)
|
|
25
|
+
if (!entry) {
|
|
26
|
+
try {
|
|
27
|
+
const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
|
|
28
|
+
invalidateDirectoryCache()
|
|
29
|
+
const payload = {
|
|
30
|
+
event: eventType,
|
|
31
|
+
filename: filename ? String(filename) : undefined,
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
}
|
|
34
|
+
const current = dirWatchers.get(norm)
|
|
35
|
+
if (current) {
|
|
36
|
+
for (const sub of current.subscribers) {
|
|
37
|
+
try {
|
|
38
|
+
sub.send(payload)
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore individual send failures
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
entry = { watcher, subscribers: new Set() }
|
|
46
|
+
dirWatchers.set(norm, entry)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn('[dsh-explorer] recursive watch failed on', dir, err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (entry) {
|
|
53
|
+
entry.subscribers.add(subscriber)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
if (entry) {
|
|
58
|
+
entry.subscribers.delete(subscriber)
|
|
59
|
+
if (entry.subscribers.size === 0) {
|
|
60
|
+
try {
|
|
61
|
+
entry.watcher.close()
|
|
62
|
+
} catch {}
|
|
63
|
+
dirWatchers.delete(norm)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createFsWatcherServer(): {
|
|
70
|
+
handleSse: (req: IncomingMessage, res: ServerResponse, dir: string) => void
|
|
71
|
+
handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => void
|
|
72
|
+
close: () => void
|
|
73
|
+
} {
|
|
74
|
+
let wssPromise: Promise<any> | null = null
|
|
75
|
+
|
|
76
|
+
const getWss = async () => {
|
|
77
|
+
if (!wssPromise) {
|
|
78
|
+
wssPromise = (async () => {
|
|
79
|
+
try {
|
|
80
|
+
const wsModule: any = await import('ws')
|
|
81
|
+
const Server = wsModule.WebSocketServer ?? wsModule.default?.WebSocketServer
|
|
82
|
+
if (!Server) return null
|
|
83
|
+
|
|
84
|
+
const server = new Server({ noServer: true })
|
|
85
|
+
server.on('connection', (ws: any, req: IncomingMessage) => {
|
|
86
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
87
|
+
const dir = url.searchParams.get('dir')
|
|
88
|
+
if (!dir) {
|
|
89
|
+
ws.close(1008, 'missing dir parameter')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const subscriber: Subscriber = {
|
|
94
|
+
send: (data) => {
|
|
95
|
+
if (ws.readyState === 1) { // 1 = OPEN
|
|
96
|
+
ws.send(JSON.stringify(data))
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
close: () => {
|
|
100
|
+
try { ws.close() } catch {}
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const unsubscribe = subscribeToDir(dir, subscriber)
|
|
105
|
+
ws.on('close', () => {
|
|
106
|
+
unsubscribe()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
return server
|
|
110
|
+
} catch {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
})()
|
|
114
|
+
}
|
|
115
|
+
return wssPromise
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
handleSse: (req: IncomingMessage, res: ServerResponse, dir: string) => {
|
|
120
|
+
res.writeHead(200, {
|
|
121
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
122
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
123
|
+
'Connection': 'keep-alive',
|
|
124
|
+
'Access-Control-Allow-Origin': '*',
|
|
125
|
+
})
|
|
126
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', dir, timestamp: Date.now() })}\n\n`)
|
|
127
|
+
|
|
128
|
+
const subscriber: Subscriber = {
|
|
129
|
+
send: (data) => {
|
|
130
|
+
try {
|
|
131
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
132
|
+
} catch {
|
|
133
|
+
// Socket write failed
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
close: () => {
|
|
137
|
+
try { res.end() } catch {}
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const unsubscribe = subscribeToDir(dir, subscriber)
|
|
142
|
+
|
|
143
|
+
req.on('close', () => {
|
|
144
|
+
unsubscribe()
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
149
|
+
getWss().then((server) => {
|
|
150
|
+
if (server) {
|
|
151
|
+
server.handleUpgrade(req, socket, head, (ws: any) => {
|
|
152
|
+
server.emit('connection', ws, req)
|
|
153
|
+
})
|
|
154
|
+
} else {
|
|
155
|
+
socket.destroy()
|
|
156
|
+
}
|
|
157
|
+
}).catch(() => {
|
|
158
|
+
socket.destroy()
|
|
159
|
+
})
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
close: () => {
|
|
163
|
+
for (const entry of dirWatchers.values()) {
|
|
164
|
+
for (const sub of entry.subscribers) {
|
|
165
|
+
sub.close()
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
entry.watcher.close()
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
dirWatchers.clear()
|
|
172
|
+
if (wssPromise) {
|
|
173
|
+
wssPromise.then((server) => {
|
|
174
|
+
try { server?.close() } catch {}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
|
|
8
|
+
export interface GitStatusEntry {
|
|
9
|
+
path: string
|
|
10
|
+
xy: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GitStatusResult {
|
|
14
|
+
isRepo: boolean
|
|
15
|
+
branch?: string
|
|
16
|
+
entries: GitStatusEntry[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const gitCache = new Map<string, { at: number; data: GitStatusResult }>()
|
|
20
|
+
const GIT_CACHE_TTL_MS = 2000
|
|
21
|
+
|
|
22
|
+
const IGNORED_SCAN_DIRS = new Set([
|
|
23
|
+
'node_modules',
|
|
24
|
+
'.agent',
|
|
25
|
+
'.git',
|
|
26
|
+
'dist',
|
|
27
|
+
'target',
|
|
28
|
+
'build',
|
|
29
|
+
'.system_generated',
|
|
30
|
+
'.cache',
|
|
31
|
+
'.dsh',
|
|
32
|
+
'.gemini',
|
|
33
|
+
'.vscode',
|
|
34
|
+
'.idea',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
function parsePorcelainLines(stdout: string, prefix = ''): { branch?: string; entries: GitStatusEntry[] } {
|
|
38
|
+
let branch: string | undefined
|
|
39
|
+
const entries: GitStatusEntry[] = []
|
|
40
|
+
|
|
41
|
+
for (const rawLine of stdout.split('\n')) {
|
|
42
|
+
const line = rawLine.replace(/\r$/, '')
|
|
43
|
+
if (!line) continue
|
|
44
|
+
if (line.startsWith('## ')) {
|
|
45
|
+
const branchMatch = line.slice(3).match(/^([^.\s]+)/)
|
|
46
|
+
if (branchMatch) branch = branchMatch[1]
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (line.length < 4) continue
|
|
50
|
+
const xy = line.slice(0, 2)
|
|
51
|
+
let p = line.slice(3).trim()
|
|
52
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
53
|
+
p = p.slice(1, -1)
|
|
54
|
+
}
|
|
55
|
+
// Handle renamed files: "R old -> new"
|
|
56
|
+
if (xy.startsWith('R') || xy.endsWith('R')) {
|
|
57
|
+
const arrowIdx = p.indexOf(' -> ')
|
|
58
|
+
if (arrowIdx !== -1) {
|
|
59
|
+
p = p.slice(arrowIdx + 4).trim()
|
|
60
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
61
|
+
p = p.slice(1, -1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const cleanPath = p.replace(/\\/g, '/')
|
|
66
|
+
const finalPath = prefix ? `${prefix.replace(/\\/g, '/').replace(/\/+$/, '')}/${cleanPath}` : cleanPath
|
|
67
|
+
entries.push({ path: finalPath, xy })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { branch, entries }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findSubRepos(root: string, depth = 1, maxDepth = 3): string[] {
|
|
74
|
+
if (depth > maxDepth) return []
|
|
75
|
+
const repos: string[] = []
|
|
76
|
+
try {
|
|
77
|
+
const entries = readdirSync(root, { withFileTypes: true })
|
|
78
|
+
for (const ent of entries) {
|
|
79
|
+
if (!ent.isDirectory() || ent.name.startsWith('.') || IGNORED_SCAN_DIRS.has(ent.name)) {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const full = join(root, ent.name)
|
|
83
|
+
if (existsSync(join(full, '.git'))) {
|
|
84
|
+
repos.push(full)
|
|
85
|
+
} else {
|
|
86
|
+
repos.push(...findSubRepos(full, depth + 1, maxDepth))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Ignore read errors
|
|
91
|
+
}
|
|
92
|
+
return repos
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getGitStatus(cwd: string): Promise<GitStatusResult> {
|
|
96
|
+
const now = Date.now()
|
|
97
|
+
const cached = gitCache.get(cwd)
|
|
98
|
+
if (cached && now - cached.at < GIT_CACHE_TTL_MS) {
|
|
99
|
+
return cached.data
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 1. Try running git directly in cwd
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await execFileAsync(
|
|
105
|
+
'git',
|
|
106
|
+
['status', '--porcelain', '-b', '-unormal', '--ignored=matching'],
|
|
107
|
+
{ cwd, maxBuffer: 10 * 1024 * 1024, timeout: 3500 }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const parsed = parsePorcelainLines(stdout)
|
|
111
|
+
const data: GitStatusResult = {
|
|
112
|
+
isRepo: true,
|
|
113
|
+
branch: parsed.branch,
|
|
114
|
+
entries: parsed.entries,
|
|
115
|
+
}
|
|
116
|
+
gitCache.set(cwd, { at: now, data })
|
|
117
|
+
return data
|
|
118
|
+
} catch {
|
|
119
|
+
// cwd is not a git repo or direct command failed. Check for nested/sub repositories.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. Discover sub-repositories within cwd (for workspaces containing multiple repos)
|
|
123
|
+
try {
|
|
124
|
+
const subRepos = findSubRepos(cwd)
|
|
125
|
+
if (subRepos.length > 0) {
|
|
126
|
+
const allEntries: GitStatusEntry[] = []
|
|
127
|
+
const subResults = await Promise.all(
|
|
128
|
+
subRepos.map(async (repoDir) => {
|
|
129
|
+
const rel = relative(cwd, repoDir).replace(/\\/g, '/')
|
|
130
|
+
try {
|
|
131
|
+
const { stdout } = await execFileAsync(
|
|
132
|
+
'git',
|
|
133
|
+
['status', '--porcelain', '-b', '-unormal', '--ignored=matching'],
|
|
134
|
+
{ cwd: repoDir, maxBuffer: 5 * 1024 * 1024, timeout: 3000 }
|
|
135
|
+
)
|
|
136
|
+
return parsePorcelainLines(stdout, rel).entries
|
|
137
|
+
} catch {
|
|
138
|
+
return []
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
for (const res of subResults) {
|
|
144
|
+
allEntries.push(...res)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const data: GitStatusResult = {
|
|
148
|
+
isRepo: true,
|
|
149
|
+
entries: allEntries,
|
|
150
|
+
}
|
|
151
|
+
gitCache.set(cwd, { at: now, data })
|
|
152
|
+
return data
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Ignore
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const fallback: GitStatusResult = { isRepo: false, entries: [] }
|
|
159
|
+
gitCache.set(cwd, { at: now, data: fallback })
|
|
160
|
+
return fallback
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
import type { Duplex } from 'node:stream'
|
|
3
|
+
import { Config, type ExplorerConfig } from './config.ts'
|
|
4
|
+
import { createFsWatcherServer } from './fs-watch.ts'
|
|
5
|
+
import { listDirectory, listDirectoryBatch } from './fs-tree.ts'
|
|
6
|
+
import { createDirectory, createNewFile, readFileText, removeEntry, renameEntry, writeUpload } from './fs-operations.ts'
|
|
7
|
+
import { getGitStatus } from './git-status.ts'
|
|
8
|
+
import { resolveTarget } from './path-security.ts'
|
|
9
|
+
import { resolveSessionCwd } from './session-path.ts'
|
|
10
|
+
import { readJsonBody, writeError, writeOk, writeJson, ExplorerError } from './wire.ts'
|
|
11
|
+
|
|
12
|
+
export interface Context {
|
|
13
|
+
webServer: {
|
|
14
|
+
register: (options: {
|
|
15
|
+
path: string
|
|
16
|
+
kind?: 'exact' | 'prefix'
|
|
17
|
+
method?: string | string[]
|
|
18
|
+
prefix?: boolean
|
|
19
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean | void> | boolean | void
|
|
20
|
+
}) => () => void
|
|
21
|
+
registerUpgrade: (options: {
|
|
22
|
+
path: string
|
|
23
|
+
prefix?: boolean
|
|
24
|
+
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<boolean | void> | boolean | void
|
|
25
|
+
}) => () => void
|
|
26
|
+
}
|
|
27
|
+
effect: (fn: () => void | (() => void)) => void
|
|
28
|
+
get: (service: string) => unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const name = 'dsh-explorer'
|
|
32
|
+
export const inject = ['webServer', 'sessions']
|
|
33
|
+
export { Config }
|
|
34
|
+
|
|
35
|
+
export function apply(ctx: Context, config?: ExplorerConfig): void {
|
|
36
|
+
const watcher = createFsWatcherServer()
|
|
37
|
+
|
|
38
|
+
ctx.effect(() => {
|
|
39
|
+
return () => {
|
|
40
|
+
watcher.close()
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// WebSocket upgrade
|
|
45
|
+
ctx.effect(() => {
|
|
46
|
+
return ctx.webServer.registerUpgrade({
|
|
47
|
+
path: '/dsh-explorer/api/watch',
|
|
48
|
+
handler: (req, socket, head) => {
|
|
49
|
+
watcher.handleUpgrade(req, socket, head)
|
|
50
|
+
return true
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// HTTP Routes
|
|
56
|
+
ctx.effect(() => {
|
|
57
|
+
return ctx.webServer.register({
|
|
58
|
+
kind: 'prefix',
|
|
59
|
+
path: '/dsh-explorer/api',
|
|
60
|
+
handler: async (req, res) => {
|
|
61
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
62
|
+
const pathname = url.pathname
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (pathname === '/dsh-explorer/api/events') {
|
|
66
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined
|
|
67
|
+
const cwd = resolveSessionCwd(ctx, sessionId)
|
|
68
|
+
const dirParam = url.searchParams.get('dir')
|
|
69
|
+
const targetDir = resolveTarget(cwd, dirParam ?? cwd)
|
|
70
|
+
watcher.handleSse(req, res, targetDir)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (pathname === '/dsh-explorer/api/tree') {
|
|
75
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined
|
|
76
|
+
const cwd = resolveSessionCwd(ctx, sessionId)
|
|
77
|
+
const dirParam = url.searchParams.get('dir')
|
|
78
|
+
const targetDir = resolveTarget(cwd, dirParam ?? cwd)
|
|
79
|
+
const max = config?.maxListingEntries ?? 2000
|
|
80
|
+
const listing = await listDirectory(targetDir, max)
|
|
81
|
+
writeOk(res, { listing })
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (pathname === '/dsh-explorer/api/batch-tree' && req.method === 'POST') {
|
|
86
|
+
const body = await readJsonBody<{ paths: string[] }>(req)
|
|
87
|
+
if (!Array.isArray(body?.paths)) {
|
|
88
|
+
throw new ExplorerError('bad-request', 'paths must be an array', 400)
|
|
89
|
+
}
|
|
90
|
+
const results = await listDirectoryBatch(body.paths)
|
|
91
|
+
writeOk(res, { results })
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (pathname === '/dsh-explorer/api/git-status') {
|
|
96
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined
|
|
97
|
+
const cwd = resolveSessionCwd(ctx, sessionId)
|
|
98
|
+
const dirParam = url.searchParams.get('dir')
|
|
99
|
+
const targetDir = resolveTarget(cwd, dirParam ?? cwd)
|
|
100
|
+
const git = await getGitStatus(targetDir)
|
|
101
|
+
writeOk(res, { git })
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (pathname === '/dsh-explorer/api/read-file') {
|
|
106
|
+
const pathParam = url.searchParams.get('path')
|
|
107
|
+
if (!pathParam) {
|
|
108
|
+
throw new ExplorerError('bad-request', 'missing path parameter', 400)
|
|
109
|
+
}
|
|
110
|
+
const data = await readFileText(pathParam)
|
|
111
|
+
writeOk(res, data)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (pathname === '/dsh-explorer/api/new-file' && req.method === 'POST') {
|
|
116
|
+
const body = await readJsonBody<{ dir: string; name: string }>(req)
|
|
117
|
+
const created = await createNewFile(body.dir, body.name)
|
|
118
|
+
writeOk(res, created)
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (pathname === '/dsh-explorer/api/mkdir' && req.method === 'POST') {
|
|
123
|
+
const body = await readJsonBody<{ dir: string; name: string }>(req)
|
|
124
|
+
const created = await createDirectory(body.dir, body.name)
|
|
125
|
+
writeOk(res, created)
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (pathname === '/dsh-explorer/api/rename' && req.method === 'POST') {
|
|
130
|
+
const body = await readJsonBody<{ path: string; newName: string }>(req)
|
|
131
|
+
const renamed = await renameEntry(body.path, body.newName)
|
|
132
|
+
writeOk(res, renamed)
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (pathname === '/dsh-explorer/api/delete' && req.method === 'POST') {
|
|
137
|
+
const body = await readJsonBody<{ path: string }>(req)
|
|
138
|
+
await removeEntry(body.path)
|
|
139
|
+
writeOk(res)
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (pathname === '/dsh-explorer/api/upload' && req.method === 'POST') {
|
|
144
|
+
const dirParam = url.searchParams.get('dir')
|
|
145
|
+
const relParam = url.searchParams.get('relativePath')
|
|
146
|
+
if (!dirParam || !relParam) {
|
|
147
|
+
throw new ExplorerError('bad-request', 'dir and relativePath are required', 400)
|
|
148
|
+
}
|
|
149
|
+
const uploaded = await writeUpload(dirParam, relParam, req)
|
|
150
|
+
writeOk(res, uploaded)
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Unhandled
|
|
155
|
+
writeJson(res, { ok: false, message: 'Not found' }, 404)
|
|
156
|
+
return true
|
|
157
|
+
} catch (err) {
|
|
158
|
+
writeError(res, err)
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { isAbsolute, normalize, resolve } from 'node:path'
|
|
3
|
+
import { ExplorerError } from './wire.ts'
|
|
4
|
+
|
|
5
|
+
export function resolveTarget(cwd: string, targetPath: string): string {
|
|
6
|
+
if (!targetPath) return cwd
|
|
7
|
+
if (targetPath === '~' || targetPath.startsWith('~/') || targetPath.startsWith('~\\')) {
|
|
8
|
+
const home = homedir()
|
|
9
|
+
return normalize(resolve(home, targetPath.slice(targetPath.startsWith('~') ? (targetPath[1] ? 2 : 1) : 0)))
|
|
10
|
+
}
|
|
11
|
+
if (isAbsolute(targetPath)) {
|
|
12
|
+
return normalize(targetPath)
|
|
13
|
+
}
|
|
14
|
+
return normalize(resolve(cwd, targetPath))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function assertSingleSegmentName(name: string): void {
|
|
18
|
+
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
19
|
+
throw new ExplorerError('bad-request', 'name must be a single path segment without separators', 400)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|