@elefunc/send 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 +21 -0
- package/README.md +57 -0
- package/package.json +52 -0
- package/patches/@rezi-ui%2Fcore@0.1.0-alpha.60.patch +227 -0
- package/patches/werift@0.22.9.patch +31 -0
- package/src/core/files.ts +79 -0
- package/src/core/paths.ts +19 -0
- package/src/core/protocol.ts +241 -0
- package/src/core/session.ts +1435 -0
- package/src/core/targeting.ts +39 -0
- package/src/index.ts +283 -0
- package/src/tui/app.ts +1442 -0
- package/src/tui/file-search-protocol.ts +48 -0
- package/src/tui/file-search.ts +282 -0
- package/src/tui/file-search.worker.ts +127 -0
- package/src/tui/rezi-checkbox-click.ts +63 -0
- package/src/types/bun-runtime.d.ts +5 -0
- package/src/types/bun-test.d.ts +9 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface FileSearchMatch {
|
|
2
|
+
relativePath: string
|
|
3
|
+
absolutePath: string
|
|
4
|
+
fileName: string
|
|
5
|
+
kind: "file" | "directory"
|
|
6
|
+
score: number
|
|
7
|
+
indices: number[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type FileSearchRequest =
|
|
11
|
+
| {
|
|
12
|
+
type: "create-session"
|
|
13
|
+
sessionId: string
|
|
14
|
+
workspaceRoot: string
|
|
15
|
+
resultLimit?: number
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: "update-query"
|
|
19
|
+
sessionId: string
|
|
20
|
+
query: string
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "dispose-session"
|
|
24
|
+
sessionId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FileSearchEvent =
|
|
28
|
+
| {
|
|
29
|
+
type: "update"
|
|
30
|
+
sessionId: string
|
|
31
|
+
query: string
|
|
32
|
+
matches: FileSearchMatch[]
|
|
33
|
+
walkComplete: boolean
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
type: "complete"
|
|
37
|
+
sessionId: string
|
|
38
|
+
query: string
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
type: "error"
|
|
42
|
+
sessionId: string
|
|
43
|
+
query: string
|
|
44
|
+
message: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const FILE_SEARCH_RESULT_LIMIT = 20
|
|
48
|
+
export const FILE_SEARCH_VISIBLE_ROWS = 8
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises"
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path"
|
|
3
|
+
import { expandHomePath, isHomeDirectoryPath } from "../core/paths"
|
|
4
|
+
import type { FileSearchMatch } from "./file-search-protocol"
|
|
5
|
+
|
|
6
|
+
export interface IndexedEntry {
|
|
7
|
+
absolutePath: string
|
|
8
|
+
relativePath: string
|
|
9
|
+
fileName: string
|
|
10
|
+
kind: "file" | "directory"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FileSearchScope {
|
|
14
|
+
normalizedInput: string
|
|
15
|
+
workspaceRoot: string
|
|
16
|
+
displayPrefix: string
|
|
17
|
+
query: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MountInfoEntry {
|
|
21
|
+
mountPoint: string
|
|
22
|
+
fsType: string
|
|
23
|
+
source: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MountProbeResult {
|
|
27
|
+
mount: MountInfoEntry
|
|
28
|
+
probeMs: number
|
|
29
|
+
slow: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CrawlWorkspaceOptions {
|
|
33
|
+
mountTable?: readonly MountInfoEntry[]
|
|
34
|
+
probeThresholdMs?: number
|
|
35
|
+
probeMount?: (mount: MountInfoEntry) => Promise<number>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SlowSearchMountError extends Error {
|
|
39
|
+
readonly mountPoint: string
|
|
40
|
+
readonly probeMs: number
|
|
41
|
+
|
|
42
|
+
constructor(mountPoint: string, probeMs: number) {
|
|
43
|
+
super(`Preview search disabled for slow mount ${mountPoint} (${Math.round(probeMs)}ms probe).`)
|
|
44
|
+
this.name = "SlowSearchMountError"
|
|
45
|
+
this.mountPoint = mountPoint
|
|
46
|
+
this.probeMs = probeMs
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"])
|
|
51
|
+
const BOUNDARY_CHARS = new Set(["/", "_", "-", "."])
|
|
52
|
+
export const FILE_SEARCH_SLOW_MOUNT_MS = 250
|
|
53
|
+
|
|
54
|
+
const trimTrailingCrLf = (value: string) => value.replace(/[\r\n]+$/u, "")
|
|
55
|
+
const normalizeSeparators = (value: string) => value.replace(/\\/gu, "/")
|
|
56
|
+
const pathChars = (value: string) => Array.from(value)
|
|
57
|
+
const lower = (value: string) => value.toLocaleLowerCase("en-US")
|
|
58
|
+
const renderedDisplayPrefix = (displayPrefix: string) => displayPrefix === "~" ? "~/" : displayPrefix
|
|
59
|
+
const MOUNTINFO_PATH = "/proc/self/mountinfo"
|
|
60
|
+
|
|
61
|
+
export const normalizeSearchQuery = (value: string) => normalizeSeparators(trimTrailingCrLf(value))
|
|
62
|
+
export const normalizeRelativePath = (value: string) => normalizeSeparators(value.split(sep).join("/"))
|
|
63
|
+
export const shouldSkipSearchDirectory = (name: string) => SKIPPED_DIRECTORIES.has(name)
|
|
64
|
+
export const isCaseSensitiveQuery = (query: string) => /[A-Z]/u.test(query)
|
|
65
|
+
export const isSlowMountProbe = (probeMs: number, thresholdMs = FILE_SEARCH_SLOW_MOUNT_MS) => probeMs >= thresholdMs
|
|
66
|
+
|
|
67
|
+
const isBoundaryIndex = (chars: string[], index: number) => index === 0 || BOUNDARY_CHARS.has(chars[index - 1] || "")
|
|
68
|
+
const decodeMountInfoValue = (value: string) => value.replace(/\\([0-7]{3})/gu, (_, octal: string) => String.fromCharCode(Number.parseInt(octal, 8)))
|
|
69
|
+
const pathOnMount = (path: string, mountPoint: string) => mountPoint === "/" || path === mountPoint || path.startsWith(`${mountPoint}/`)
|
|
70
|
+
const sortMountTable = (mounts: readonly MountInfoEntry[]) => [...mounts].sort((left, right) => right.mountPoint.length - left.mountPoint.length || left.mountPoint.localeCompare(right.mountPoint))
|
|
71
|
+
|
|
72
|
+
const basenameStartIndex = (chars: string[]) => {
|
|
73
|
+
for (let index = chars.length - 1; index >= 0; index -= 1) if (chars[index] === "/") return index + 1
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const parseMountInfo = (text: string): MountInfoEntry[] => sortMountTable(text
|
|
78
|
+
.split(/\r?\n/u)
|
|
79
|
+
.map(line => line.trim())
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.flatMap(line => {
|
|
82
|
+
const parts = line.split(" - ", 2)
|
|
83
|
+
if (parts.length !== 2) return []
|
|
84
|
+
const left = parts[0]!.split(" ")
|
|
85
|
+
const right = parts[1]!.split(" ")
|
|
86
|
+
if (left.length < 5 || right.length < 2) return []
|
|
87
|
+
return [{
|
|
88
|
+
mountPoint: decodeMountInfoValue(left[4]!),
|
|
89
|
+
fsType: right[0]!,
|
|
90
|
+
source: right[1]!,
|
|
91
|
+
} satisfies MountInfoEntry]
|
|
92
|
+
}))
|
|
93
|
+
|
|
94
|
+
export const findMountForPath = (mounts: readonly MountInfoEntry[], path: string) => {
|
|
95
|
+
const absolute = resolve(path)
|
|
96
|
+
return sortMountTable(mounts).find(mount => pathOnMount(absolute, mount.mountPoint)) ?? null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const loadMountTable = async () => {
|
|
100
|
+
try {
|
|
101
|
+
return parseMountInfo(await readFile(MOUNTINFO_PATH, "utf8"))
|
|
102
|
+
} catch {
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const measureMountProbe = async (mount: MountInfoEntry) => {
|
|
108
|
+
const startedAt = performance.now()
|
|
109
|
+
await readdir(mount.mountPoint, { withFileTypes: true })
|
|
110
|
+
return performance.now() - startedAt
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const deriveFileSearchScope = (value: string, cwd = process.cwd()): FileSearchScope | null => {
|
|
114
|
+
const normalizedInput = normalizeSearchQuery(value)
|
|
115
|
+
if (!normalizedInput) return null
|
|
116
|
+
if (normalizedInput === "~") {
|
|
117
|
+
return {
|
|
118
|
+
normalizedInput,
|
|
119
|
+
workspaceRoot: expandHomePath(normalizedInput)!,
|
|
120
|
+
displayPrefix: "~",
|
|
121
|
+
query: "",
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const slashIndex = normalizedInput.endsWith("/")
|
|
125
|
+
? normalizedInput.length - 1
|
|
126
|
+
: normalizedInput.lastIndexOf("/")
|
|
127
|
+
const displayPrefix = slashIndex >= 0 ? normalizedInput.slice(0, slashIndex + 1) : ""
|
|
128
|
+
return {
|
|
129
|
+
normalizedInput,
|
|
130
|
+
workspaceRoot: isHomeDirectoryPath(displayPrefix || ".")
|
|
131
|
+
? expandHomePath(displayPrefix || ".")!
|
|
132
|
+
: resolve(cwd, displayPrefix || "."),
|
|
133
|
+
displayPrefix,
|
|
134
|
+
query: normalizedInput.slice(displayPrefix.length),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const formatFileSearchDisplayPath = (displayPrefix: string, relativePath: string) =>
|
|
139
|
+
!displayPrefix ? relativePath : renderedDisplayPrefix(displayPrefix) === "/" ? `/${relativePath}` : `${renderedDisplayPrefix(displayPrefix)}${relativePath}`
|
|
140
|
+
|
|
141
|
+
export const offsetFileSearchMatchIndices = (displayPrefix: string, indices: number[]) => {
|
|
142
|
+
const offset = pathChars(renderedDisplayPrefix(displayPrefix)).length
|
|
143
|
+
return offset ? indices.map(index => index + offset) : indices
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const matchPathQuery = (relativePath: string, query: string) => {
|
|
147
|
+
const normalizedQuery = normalizeSearchQuery(query)
|
|
148
|
+
if (!normalizedQuery) return null
|
|
149
|
+
const chars = pathChars(relativePath)
|
|
150
|
+
const queryChars = pathChars(normalizedQuery)
|
|
151
|
+
const caseSensitive = isCaseSensitiveQuery(normalizedQuery)
|
|
152
|
+
const haystack = caseSensitive ? chars : chars.map(lower)
|
|
153
|
+
const needle = caseSensitive ? queryChars : queryChars.map(lower)
|
|
154
|
+
const indices: number[] = []
|
|
155
|
+
let cursor = 0
|
|
156
|
+
for (const token of needle) {
|
|
157
|
+
let found = -1
|
|
158
|
+
for (let index = cursor; index < haystack.length; index += 1) {
|
|
159
|
+
if (haystack[index] === token) {
|
|
160
|
+
found = index
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (found < 0) return null
|
|
165
|
+
indices.push(found)
|
|
166
|
+
cursor = found + 1
|
|
167
|
+
}
|
|
168
|
+
const basenameStart = basenameStartIndex(chars)
|
|
169
|
+
const contiguousBasenamePrefix = indices[0] === basenameStart && indices.every((value, index) => value === basenameStart + index)
|
|
170
|
+
let score = indices.length
|
|
171
|
+
if (indices[0] === basenameStart) score += 80
|
|
172
|
+
if (contiguousBasenamePrefix) score += 120
|
|
173
|
+
for (let index = 0; index < indices.length; index += 1) {
|
|
174
|
+
if (isBoundaryIndex(chars, indices[index]!)) score += 12
|
|
175
|
+
if (index > 0 && indices[index] === indices[index - 1]! + 1) score += 8
|
|
176
|
+
}
|
|
177
|
+
score -= Math.floor(chars.length / 16)
|
|
178
|
+
return { indices, score }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const browseEntries = (entries: readonly IndexedEntry[], resultLimit: number) => entries
|
|
182
|
+
.filter(entry => !entry.relativePath.includes("/"))
|
|
183
|
+
.sort((left, right) => (left.kind === right.kind ? 0 : left.kind === "directory" ? -1 : 1) || left.relativePath.localeCompare(right.relativePath))
|
|
184
|
+
.slice(0, resultLimit)
|
|
185
|
+
.map(entry => ({
|
|
186
|
+
relativePath: entry.relativePath,
|
|
187
|
+
absolutePath: entry.absolutePath,
|
|
188
|
+
fileName: entry.fileName,
|
|
189
|
+
kind: entry.kind,
|
|
190
|
+
score: 0,
|
|
191
|
+
indices: [],
|
|
192
|
+
} satisfies FileSearchMatch))
|
|
193
|
+
|
|
194
|
+
export const searchEntries = (entries: readonly IndexedEntry[], query: string, resultLimit: number) => {
|
|
195
|
+
const normalizedQuery = normalizeSearchQuery(query)
|
|
196
|
+
if (!normalizedQuery) return browseEntries(entries, resultLimit)
|
|
197
|
+
const matches: FileSearchMatch[] = []
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
const match = matchPathQuery(entry.relativePath, normalizedQuery)
|
|
200
|
+
if (!match) continue
|
|
201
|
+
matches.push({
|
|
202
|
+
relativePath: entry.relativePath,
|
|
203
|
+
absolutePath: entry.absolutePath,
|
|
204
|
+
fileName: entry.fileName,
|
|
205
|
+
kind: entry.kind,
|
|
206
|
+
score: match.score,
|
|
207
|
+
indices: match.indices,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
matches.sort((left, right) => right.score - left.score || left.relativePath.localeCompare(right.relativePath))
|
|
211
|
+
return matches.slice(0, resultLimit)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const searchResultSignature = (query: string, matches: readonly FileSearchMatch[], walkComplete: boolean) =>
|
|
215
|
+
`${normalizeSearchQuery(query)}|${walkComplete ? 1 : 0}|${matches.map(match => `${match.relativePath}:${match.kind}:${match.score}:${match.indices.join(",")}`).join("|")}`
|
|
216
|
+
|
|
217
|
+
export const crawlWorkspaceEntries = async (workspaceRoot: string, onEntry: (entry: IndexedEntry) => void, options: CrawlWorkspaceOptions = {}) => {
|
|
218
|
+
const root = resolve(workspaceRoot)
|
|
219
|
+
const rootReal = await realpath(root)
|
|
220
|
+
const mounts = options.mountTable ? sortMountTable(options.mountTable) : await loadMountTable()
|
|
221
|
+
const rootMount = findMountForPath(mounts, rootReal) ?? findMountForPath(mounts, root)
|
|
222
|
+
const probeThresholdMs = options.probeThresholdMs ?? FILE_SEARCH_SLOW_MOUNT_MS
|
|
223
|
+
const probeMount = options.probeMount ?? measureMountProbe
|
|
224
|
+
const probeCache = new Map<string, MountProbeResult>()
|
|
225
|
+
const classifyMount = async (mount: MountInfoEntry | null) => {
|
|
226
|
+
if (!mount) return null
|
|
227
|
+
const cached = probeCache.get(mount.mountPoint)
|
|
228
|
+
if (cached) return cached
|
|
229
|
+
const probeMs = await probeMount(mount)
|
|
230
|
+
const result = { mount, probeMs, slow: isSlowMountProbe(probeMs, probeThresholdMs) } satisfies MountProbeResult
|
|
231
|
+
probeCache.set(mount.mountPoint, result)
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const rootProbe = await classifyMount(rootMount)
|
|
236
|
+
if (rootProbe?.slow) throw new SlowSearchMountError(rootProbe.mount.mountPoint, rootProbe.probeMs)
|
|
237
|
+
|
|
238
|
+
const stack: Array<{ absolutePath: string; ancestorReals: Set<string>; mount: MountInfoEntry | null }> = [{ absolutePath: root, ancestorReals: new Set([rootReal]), mount: rootMount }]
|
|
239
|
+
|
|
240
|
+
while (stack.length) {
|
|
241
|
+
const current = stack.pop()!
|
|
242
|
+
const children = await readdir(current.absolutePath, { withFileTypes: true })
|
|
243
|
+
for (const child of children) {
|
|
244
|
+
if (shouldSkipSearchDirectory(child.name) && child.isDirectory()) continue
|
|
245
|
+
const absolutePath = join(current.absolutePath, child.name)
|
|
246
|
+
try {
|
|
247
|
+
const info = await stat(absolutePath)
|
|
248
|
+
if (info.isDirectory()) {
|
|
249
|
+
if (shouldSkipSearchDirectory(child.name)) continue
|
|
250
|
+
const resolvedPath = await realpath(absolutePath)
|
|
251
|
+
if (current.ancestorReals.has(resolvedPath)) continue
|
|
252
|
+
const childMount = findMountForPath(mounts, resolvedPath) ?? current.mount
|
|
253
|
+
if (childMount && childMount.mountPoint !== current.mount?.mountPoint) {
|
|
254
|
+
const probe = await classifyMount(childMount)
|
|
255
|
+
if (probe?.slow) continue
|
|
256
|
+
}
|
|
257
|
+
const entry: IndexedEntry = {
|
|
258
|
+
absolutePath,
|
|
259
|
+
relativePath: normalizeRelativePath(relative(root, absolutePath)),
|
|
260
|
+
fileName: child.name,
|
|
261
|
+
kind: "directory",
|
|
262
|
+
}
|
|
263
|
+
onEntry(entry)
|
|
264
|
+
const ancestorReals = new Set(current.ancestorReals)
|
|
265
|
+
ancestorReals.add(resolvedPath)
|
|
266
|
+
stack.push({ absolutePath, ancestorReals, mount: childMount })
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (info.isFile()) {
|
|
270
|
+
onEntry({
|
|
271
|
+
absolutePath,
|
|
272
|
+
relativePath: normalizeRelativePath(relative(root, absolutePath)),
|
|
273
|
+
fileName: child.name,
|
|
274
|
+
kind: "file",
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { crawlWorkspaceEntries, searchEntries, searchResultSignature, type IndexedEntry } from "./file-search"
|
|
3
|
+
import { FILE_SEARCH_RESULT_LIMIT, type FileSearchEvent, type FileSearchRequest } from "./file-search-protocol"
|
|
4
|
+
|
|
5
|
+
type WorkerSession = {
|
|
6
|
+
sessionId: string
|
|
7
|
+
workspaceRoot: string
|
|
8
|
+
resultLimit: number
|
|
9
|
+
latestQuery: string
|
|
10
|
+
queryActive: boolean
|
|
11
|
+
walkComplete: boolean
|
|
12
|
+
disposed: boolean
|
|
13
|
+
entries: IndexedEntry[]
|
|
14
|
+
pendingNotify: ReturnType<typeof setTimeout> | null
|
|
15
|
+
lastSignature: string | null
|
|
16
|
+
completedQuery: string | null
|
|
17
|
+
rootError: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sessions = new Map<string, WorkerSession>()
|
|
21
|
+
|
|
22
|
+
const emit = (event: FileSearchEvent) => postMessage(event)
|
|
23
|
+
const emitError = (session: WorkerSession, message: string) => emit({
|
|
24
|
+
type: "error",
|
|
25
|
+
sessionId: session.sessionId,
|
|
26
|
+
query: session.latestQuery,
|
|
27
|
+
message,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emitCompleteIfNeeded = (session: WorkerSession) => {
|
|
31
|
+
if (!session.walkComplete || !session.queryActive || session.completedQuery === session.latestQuery) return
|
|
32
|
+
session.completedQuery = session.latestQuery
|
|
33
|
+
emit({ type: "complete", sessionId: session.sessionId, query: session.latestQuery })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const recompute = (session: WorkerSession) => {
|
|
37
|
+
if (session.disposed || !session.queryActive) return
|
|
38
|
+
const matches = searchEntries(session.entries, session.latestQuery, session.resultLimit)
|
|
39
|
+
const signature = searchResultSignature(session.latestQuery, matches, session.walkComplete)
|
|
40
|
+
if (signature !== session.lastSignature) {
|
|
41
|
+
session.lastSignature = signature
|
|
42
|
+
emit({
|
|
43
|
+
type: "update",
|
|
44
|
+
sessionId: session.sessionId,
|
|
45
|
+
query: session.latestQuery,
|
|
46
|
+
matches,
|
|
47
|
+
walkComplete: session.walkComplete,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
emitCompleteIfNeeded(session)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const scheduleRecompute = (session: WorkerSession) => {
|
|
54
|
+
if (session.disposed || !session.queryActive || session.pendingNotify !== null) return
|
|
55
|
+
session.pendingNotify = setTimeout(() => {
|
|
56
|
+
session.pendingNotify = null
|
|
57
|
+
recompute(session)
|
|
58
|
+
}, 12)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const createSession = async (request: Extract<FileSearchRequest, { type: "create-session" }>) => {
|
|
62
|
+
const previous = sessions.get(request.sessionId)
|
|
63
|
+
if (previous) {
|
|
64
|
+
previous.disposed = true
|
|
65
|
+
if (previous.pendingNotify) clearTimeout(previous.pendingNotify)
|
|
66
|
+
sessions.delete(request.sessionId)
|
|
67
|
+
}
|
|
68
|
+
const session: WorkerSession = {
|
|
69
|
+
sessionId: request.sessionId,
|
|
70
|
+
workspaceRoot: request.workspaceRoot,
|
|
71
|
+
resultLimit: request.resultLimit ?? FILE_SEARCH_RESULT_LIMIT,
|
|
72
|
+
latestQuery: "",
|
|
73
|
+
queryActive: false,
|
|
74
|
+
walkComplete: false,
|
|
75
|
+
disposed: false,
|
|
76
|
+
entries: [],
|
|
77
|
+
pendingNotify: null,
|
|
78
|
+
lastSignature: null,
|
|
79
|
+
completedQuery: null,
|
|
80
|
+
rootError: null,
|
|
81
|
+
}
|
|
82
|
+
sessions.set(session.sessionId, session)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await crawlWorkspaceEntries(session.workspaceRoot, entry => {
|
|
86
|
+
if (session.disposed) return
|
|
87
|
+
session.entries.push(entry)
|
|
88
|
+
scheduleRecompute(session)
|
|
89
|
+
})
|
|
90
|
+
session.walkComplete = true
|
|
91
|
+
recompute(session)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
session.rootError = error instanceof Error ? error.message : `${error}`
|
|
94
|
+
session.walkComplete = true
|
|
95
|
+
emitError(session, session.rootError)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const updateQuery = (request: Extract<FileSearchRequest, { type: "update-query" }>) => {
|
|
100
|
+
const session = sessions.get(request.sessionId)
|
|
101
|
+
if (!session || session.disposed) return
|
|
102
|
+
session.latestQuery = request.query
|
|
103
|
+
session.queryActive = true
|
|
104
|
+
session.completedQuery = null
|
|
105
|
+
session.lastSignature = null
|
|
106
|
+
if (session.rootError) {
|
|
107
|
+
emitError(session, session.rootError)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
recompute(session)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const disposeSession = (request: Extract<FileSearchRequest, { type: "dispose-session" }>) => {
|
|
114
|
+
const session = sessions.get(request.sessionId)
|
|
115
|
+
if (!session) return
|
|
116
|
+
session.disposed = true
|
|
117
|
+
if (session.pendingNotify) clearTimeout(session.pendingNotify)
|
|
118
|
+
sessions.delete(request.sessionId)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
self.onmessage = (event: MessageEvent<FileSearchRequest>) => {
|
|
122
|
+
const request = event.data
|
|
123
|
+
if (!request || typeof request !== "object") return
|
|
124
|
+
if (request.type === "create-session") void createSession(request)
|
|
125
|
+
if (request.type === "update-query") updateQuery(request)
|
|
126
|
+
if (request.type === "dispose-session") disposeSession(request)
|
|
127
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { WidgetRenderer } from "../../node_modules/@rezi-ui/core/dist/app/widgetRenderer.js"
|
|
2
|
+
|
|
3
|
+
const PATCH_FLAG = Symbol.for("send.rezi.checkboxClickPatchInstalled")
|
|
4
|
+
const CLICKABLE_CHECKBOX_IDS = Symbol.for("send.rezi.checkboxClickPressableIds")
|
|
5
|
+
|
|
6
|
+
type CheckboxProps = {
|
|
7
|
+
checked: boolean
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
onChange?: (checked: boolean) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type CheckboxRuntime = {
|
|
13
|
+
checkboxById: Map<string, CheckboxProps>
|
|
14
|
+
pressableIds: Set<string>
|
|
15
|
+
[CLICKABLE_CHECKBOX_IDS]?: Set<string>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PatchedWidgetRendererClass = typeof WidgetRenderer & {
|
|
19
|
+
[PATCH_FLAG]?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const syncClickableCheckboxIds = (renderer: CheckboxRuntime) => {
|
|
23
|
+
const nextIds = new Set<string>()
|
|
24
|
+
for (const [id, checkbox] of renderer.checkboxById) {
|
|
25
|
+
if (checkbox.disabled === true || typeof checkbox.onChange !== "function") continue
|
|
26
|
+
nextIds.add(id)
|
|
27
|
+
}
|
|
28
|
+
const previousIds = renderer[CLICKABLE_CHECKBOX_IDS] ?? new Set<string>()
|
|
29
|
+
for (const id of previousIds) {
|
|
30
|
+
if (!nextIds.has(id)) renderer.pressableIds.delete(id)
|
|
31
|
+
}
|
|
32
|
+
for (const id of nextIds) renderer.pressableIds.add(id)
|
|
33
|
+
renderer[CLICKABLE_CHECKBOX_IDS] = nextIds
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const installCheckboxClickPatch = () => {
|
|
37
|
+
const WidgetRendererClass = WidgetRenderer as PatchedWidgetRendererClass
|
|
38
|
+
if (WidgetRendererClass[PATCH_FLAG]) return
|
|
39
|
+
WidgetRendererClass[PATCH_FLAG] = true
|
|
40
|
+
|
|
41
|
+
const originalRouteEngineEvent = WidgetRenderer.prototype.routeEngineEvent as any
|
|
42
|
+
|
|
43
|
+
WidgetRenderer.prototype.routeEngineEvent = function (this: unknown, event: any) {
|
|
44
|
+
const renderer = this as CheckboxRuntime
|
|
45
|
+
syncClickableCheckboxIds(renderer)
|
|
46
|
+
const result = originalRouteEngineEvent.call(this, event) as any
|
|
47
|
+
const action = result?.action
|
|
48
|
+
if (event.kind !== "mouse" || !action || action.action !== "press") return result
|
|
49
|
+
const checkbox = renderer.checkboxById.get(action.id)
|
|
50
|
+
if (!checkbox || checkbox.disabled === true || typeof checkbox.onChange !== "function") return result
|
|
51
|
+
const nextChecked = !checkbox.checked
|
|
52
|
+
checkbox.onChange(nextChecked)
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
...result,
|
|
55
|
+
needsRender: true,
|
|
56
|
+
action: Object.freeze({
|
|
57
|
+
id: action.id,
|
|
58
|
+
action: "toggle",
|
|
59
|
+
checked: nextChecked,
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
} as any
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare module "bun:test" {
|
|
2
|
+
export const describe: (label: string, fn: () => void) => void
|
|
3
|
+
export const test: (label: string, fn: () => void | Promise<void>) => void
|
|
4
|
+
export const expect: (value: unknown) => {
|
|
5
|
+
toBe(expected: unknown): void
|
|
6
|
+
toEqual(expected: unknown): void
|
|
7
|
+
toContain(expected: unknown): void
|
|
8
|
+
}
|
|
9
|
+
}
|