@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.
@@ -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,5 @@
1
+ declare const Bun: {
2
+ file(path: string): Blob & { type: string }
3
+ write(path: string, data: string | Blob | ArrayBuffer | ArrayBufferView): Promise<number>
4
+ sleep(ms: number): Promise<void>
5
+ }
@@ -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
+ }