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.
- package/LICENSE +22 -0
- package/README.md +106 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/lib/client.js +859 -0
- package/lib/index.js +854 -0
- package/package.json +91 -4
- package/src/client/ExplorerView.tsx +981 -0
- package/src/client/components/ExplorerContextMenu.tsx +157 -0
- package/src/client/components/ExplorerDeleteModal.tsx +124 -0
- package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
- package/src/client/components/ExplorerHeader.tsx +81 -0
- package/src/client/components/ExplorerOptionsModal.tsx +230 -0
- package/src/client/components/ExplorerRow.tsx +258 -0
- package/src/client/components/ExplorerSearch.tsx +55 -0
- package/src/client/components/TreeNode.tsx +384 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/explorer.module.css +833 -0
- package/src/client/hooks/useKeyboardNav.ts +89 -0
- package/src/client/hooks/useLiveSync.ts +81 -0
- package/src/client/index.tsx +238 -0
- package/src/client/utils/api.ts +251 -0
- package/src/client/utils/conversation-draft.ts +644 -0
- package/src/client/utils/drag-drop.ts +209 -0
- package/src/client/utils/open-tab.ts +69 -0
- package/src/client/utils/options.ts +59 -0
- package/src/client/utils/vscode-theme.tsx +250 -0
- package/src/config.ts +11 -0
- package/src/fs-operations.ts +116 -0
- package/src/fs-search.ts +116 -0
- package/src/fs-tree.ts +155 -0
- package/src/fs-watch.ts +179 -0
- package/src/git-status.ts +167 -0
- package/src/index.ts +177 -0
- package/src/path-security.ts +22 -0
- package/src/session-path.ts +35 -0
- 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
|
+
|
package/src/fs-search.ts
ADDED
|
@@ -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
|
+
|
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
|
+
}
|