dsh-explorer 0.0.0-stage → 0.1.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.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +106 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +7 -0
  5. package/lib/client.js +859 -0
  6. package/lib/index.js +854 -0
  7. package/package.json +91 -4
  8. package/src/client/ExplorerView.tsx +981 -0
  9. package/src/client/components/ExplorerContextMenu.tsx +157 -0
  10. package/src/client/components/ExplorerDeleteModal.tsx +124 -0
  11. package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
  12. package/src/client/components/ExplorerHeader.tsx +81 -0
  13. package/src/client/components/ExplorerOptionsModal.tsx +230 -0
  14. package/src/client/components/ExplorerRow.tsx +258 -0
  15. package/src/client/components/ExplorerSearch.tsx +55 -0
  16. package/src/client/components/TreeNode.tsx +384 -0
  17. package/src/client/css-modules.d.ts +5 -0
  18. package/src/client/explorer.module.css +833 -0
  19. package/src/client/hooks/useKeyboardNav.ts +89 -0
  20. package/src/client/hooks/useLiveSync.ts +81 -0
  21. package/src/client/index.tsx +238 -0
  22. package/src/client/utils/api.ts +251 -0
  23. package/src/client/utils/conversation-draft.ts +644 -0
  24. package/src/client/utils/drag-drop.ts +209 -0
  25. package/src/client/utils/open-tab.ts +69 -0
  26. package/src/client/utils/options.ts +59 -0
  27. package/src/client/utils/vscode-theme.tsx +250 -0
  28. package/src/config.ts +11 -0
  29. package/src/fs-operations.ts +116 -0
  30. package/src/fs-search.ts +116 -0
  31. package/src/fs-tree.ts +155 -0
  32. package/src/fs-watch.ts +179 -0
  33. package/src/git-status.ts +167 -0
  34. package/src/index.ts +177 -0
  35. package/src/path-security.ts +22 -0
  36. package/src/session-path.ts +35 -0
  37. package/src/wire.ts +63 -0
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { once } from 'node:events'
3
+ import { createWriteStream } from 'node:fs'
4
+ import { access, lstat, mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, join } from 'node:path'
6
+ import { invalidateDirectoryCache } from './fs-tree.ts'
7
+ import { assertSingleSegmentName, resolveTarget } from './path-security.ts'
8
+ import { ExplorerError } from './wire.ts'
9
+
10
+ export async function createNewFile(dirPath: string, name: string): Promise<{ path: string }> {
11
+ assertSingleSegmentName(name)
12
+ const fullPath = join(dirPath, name)
13
+ try {
14
+ await access(fullPath)
15
+ throw new ExplorerError('already-exists', `file "${name}" already exists`, 409)
16
+ } catch (err) {
17
+ if (err instanceof ExplorerError) throw err
18
+ }
19
+ await writeFile(fullPath, '', { flag: 'wx' })
20
+ invalidateDirectoryCache(dirPath)
21
+ return { path: fullPath }
22
+ }
23
+
24
+ export async function createDirectory(dirPath: string, name: string): Promise<{ path: string }> {
25
+ assertSingleSegmentName(name)
26
+ const fullPath = join(dirPath, name)
27
+ try {
28
+ await access(fullPath)
29
+ throw new ExplorerError('already-exists', `directory "${name}" already exists`, 409)
30
+ } catch (err) {
31
+ if (err instanceof ExplorerError) throw err
32
+ }
33
+ await mkdir(fullPath, { recursive: true })
34
+ invalidateDirectoryCache(dirPath)
35
+ return { path: fullPath }
36
+ }
37
+
38
+ export async function renameEntry(targetPath: string, newName: string): Promise<{ oldPath: string; newPath: string }> {
39
+ assertSingleSegmentName(newName)
40
+ const parent = dirname(targetPath)
41
+ const newPath = join(parent, newName)
42
+ if (targetPath === newPath) return { oldPath: targetPath, newPath }
43
+
44
+ try {
45
+ await access(newPath)
46
+ throw new ExplorerError('already-exists', `destination "${newName}" already exists`, 409)
47
+ } catch (err) {
48
+ if (err instanceof ExplorerError) throw err
49
+ }
50
+
51
+ await rename(targetPath, newPath)
52
+ invalidateDirectoryCache(parent)
53
+ return { oldPath: targetPath, newPath }
54
+ }
55
+
56
+ export async function removeEntry(targetPath: string): Promise<void> {
57
+ const parent = dirname(targetPath)
58
+ const st = await lstat(targetPath)
59
+ if (st.isDirectory()) {
60
+ await rm(targetPath, { recursive: true, force: true })
61
+ } else {
62
+ await unlink(targetPath)
63
+ }
64
+ invalidateDirectoryCache(parent)
65
+ }
66
+
67
+ export async function readFileText(filePath: string, maxBytes = 1024 * 1024 * 2): Promise<{ text: string; size: number }> {
68
+ const content = await readFile(filePath)
69
+ if (content.length > maxBytes) {
70
+ throw new ExplorerError('payload-too-large', `file exceeds max preview limit of ${maxBytes} bytes`, 413)
71
+ }
72
+ return {
73
+ text: content.toString('utf-8'),
74
+ size: content.length,
75
+ }
76
+ }
77
+
78
+ export async function writeUpload(
79
+ baseDir: string,
80
+ relativePath: string,
81
+ streamChunks: AsyncIterable<Buffer | Uint8Array>,
82
+ ): Promise<{ path: string; size: number }> {
83
+ if (!relativePath || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
84
+ throw new ExplorerError('bad-request', 'relativePath must stay within upload target', 400)
85
+ }
86
+ const segments = relativePath.split(/[\\/]/)
87
+ if (segments.some(p => !p || p === '.' || p === '..')) {
88
+ throw new ExplorerError('bad-request', 'relativePath contains invalid path traversal segments', 400)
89
+ }
90
+ const target = join(baseDir, ...segments)
91
+ const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`)
92
+ await mkdir(dirname(target), { recursive: true })
93
+
94
+ const writeStream = createWriteStream(tmp, { flags: 'wx' })
95
+ let bytes = 0
96
+
97
+ try {
98
+ for await (const chunk of streamChunks) {
99
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
100
+ bytes += buf.length
101
+ if (!writeStream.write(buf)) {
102
+ await once(writeStream, 'drain')
103
+ }
104
+ }
105
+ writeStream.end()
106
+ await once(writeStream, 'finish')
107
+ await rename(tmp, target)
108
+ invalidateDirectoryCache(dirname(target))
109
+ return { path: target, size: bytes }
110
+ } catch (err) {
111
+ writeStream.destroy()
112
+ try { await unlink(tmp) } catch {}
113
+ throw err
114
+ }
115
+ }
116
+
@@ -0,0 +1,116 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { join, relative } from 'node:path'
3
+
4
+ export interface SearchMatch {
5
+ name: string
6
+ path: string
7
+ relativePath: string
8
+ isDir: boolean
9
+ }
10
+
11
+ export interface SearchResult {
12
+ query: string
13
+ matches: SearchMatch[]
14
+ totalMatches: number
15
+ truncated: boolean
16
+ }
17
+
18
+ const IGNORED_SEARCH_DIRS = new Set([
19
+ 'node_modules',
20
+ '.agent',
21
+ '.git',
22
+ 'dist',
23
+ 'target',
24
+ 'build',
25
+ '.system_generated',
26
+ '.cache',
27
+ '.dsh',
28
+ '.gemini',
29
+ '.vscode',
30
+ '.idea',
31
+ ])
32
+
33
+ export async function searchDirectory(
34
+ rootDir: string,
35
+ query: string,
36
+ maxResults = 250,
37
+ includeHidden = false
38
+ ): Promise<SearchResult> {
39
+ const cleanQuery = query.trim().toLowerCase()
40
+ if (!cleanQuery) {
41
+ return { query, matches: [], totalMatches: 0, truncated: false }
42
+ }
43
+
44
+ const isExtensionFilter = cleanQuery.startsWith('*.') && cleanQuery.length > 2
45
+ const extensionTarget = isExtensionFilter ? cleanQuery.slice(1) : ''
46
+
47
+ const matches: SearchMatch[] = []
48
+ let truncated = false
49
+ let scannedCount = 0
50
+ const maxScanned = 30000
51
+
52
+ const queue: string[] = [rootDir]
53
+
54
+ while (queue.length > 0 && matches.length < maxResults && scannedCount < maxScanned) {
55
+ const currentDir = queue.shift()!
56
+ let entries
57
+ try {
58
+ entries = await readdir(currentDir, { withFileTypes: true })
59
+ } catch {
60
+ continue
61
+ }
62
+
63
+ for (const ent of entries) {
64
+ scannedCount++
65
+ if (scannedCount >= maxScanned) {
66
+ truncated = true
67
+ break
68
+ }
69
+
70
+ if (!includeHidden && ent.name.startsWith('.')) {
71
+ continue
72
+ }
73
+
74
+ const isDirectory = ent.isDirectory()
75
+ if (isDirectory && IGNORED_SEARCH_DIRS.has(ent.name)) {
76
+ continue
77
+ }
78
+
79
+ const fullPath = join(currentDir, ent.name)
80
+ const relPath = relative(rootDir, fullPath).replace(/\\/g, '/')
81
+ const nameLower = ent.name.toLowerCase()
82
+ const relLower = relPath.toLowerCase()
83
+
84
+ let isMatch = false
85
+ if (isExtensionFilter) {
86
+ isMatch = !isDirectory && nameLower.endsWith(extensionTarget)
87
+ } else {
88
+ isMatch = nameLower.includes(cleanQuery) || relLower.includes(cleanQuery)
89
+ }
90
+
91
+ if (isMatch) {
92
+ matches.push({
93
+ name: ent.name,
94
+ path: fullPath,
95
+ relativePath: relPath,
96
+ isDir: isDirectory,
97
+ })
98
+ if (matches.length >= maxResults) {
99
+ truncated = true
100
+ break
101
+ }
102
+ }
103
+
104
+ if (isDirectory) {
105
+ queue.push(fullPath)
106
+ }
107
+ }
108
+ }
109
+
110
+ return {
111
+ query,
112
+ matches,
113
+ totalMatches: matches.length,
114
+ truncated,
115
+ }
116
+ }
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
+
@@ -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
+ }