Files
orca/src/relay/fs-handler-utils.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00

235 lines
7.6 KiB
TypeScript

/**
* Pure helpers and child-process search utilities extracted from fs-handler.ts.
*
* Why: oxlint max-lines requires .ts files to stay under 300 lines.
* These functions depend only on their arguments (plus `rg` being on PATH),
* so they are straightforward to test independently.
*/
import { spawn } from 'node:child_process'
import { open } from 'node:fs/promises'
import {
buildRgArgs,
createAccumulator,
finalize,
ingestRgJsonLine,
SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS
} from '../shared/text-search'
import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions'
import type { SearchResult as SharedSearchResult } from '../shared/code-search-types'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableAfterLaunchFailure,
isRipgrepUnavailableExit,
killSpawnedRipgrepProcess,
RipgrepUnavailableError
} from '../shared/ripgrep-process-availability'
// ─── Constants ───────────────────────────────────────────────────────
// Why: remote reads still travel through bounded JSON-RPC frames, but matching
// the old 5MB search cap would block common JSON/log files before Monaco's
// large-file optimizations can handle them.
export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
// Why: matches the local cap (src/main/ipc/filesystem.ts MAX_PREVIEWABLE_BINARY_SIZE).
// Reads above the legacy 16MB single-frame budget go through fs.readFileStream,
// which chunks at STREAM_CHUNK_SIZE; see docs/relay-file-stream-design.md.
export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024
export const BINARY_PROBE_BYTES = 8192
export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS
export const DEFAULT_MAX_RESULTS = 2000
export const IMAGE_MIME_TYPES: Record<string, string> = {
...IMAGE_FILE_MIME_TYPES,
'.pdf': 'application/pdf'
}
// ─── Binary detection ────────────────────────────────────────────────
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i++) {
if (buffer[i] === 0) {
return true
}
}
return false
}
export async function isBinaryFilePrefix(filePath: string): Promise<boolean> {
const handle = await open(filePath, 'r')
try {
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
return isBinaryBuffer(probe.subarray(0, bytesRead))
} finally {
await handle.close()
}
}
// ─── Search types ────────────────────────────────────────────────────
export type SearchOptions = {
caseSensitive?: boolean
wholeWord?: boolean
useRegex?: boolean
includePattern?: string
excludePattern?: string
maxResults: number
}
export type SearchResult = SharedSearchResult
// ─── rg-based search ─────────────────────────────────────────────────
/**
* Run ripgrep (`rg`) with JSON output to collect text matches.
*
* Why `spawn` and not `execFile`: `execFile` buffers stdout internally and
* kills the child when `maxBuffer` is exceeded, even when 'data' listeners
* are attached. Under rg's verbose `--json` output, a 50MB buffer fills
* well before the match cap in large folders, and `execFile`'s silent
* buffer-exceeded error resolves the result as `truncated: false` despite
* dropping matches. See docs/design/share-text-search.md.
*/
export function searchWithRg(
rootPath: string,
query: string,
opts: SearchOptions
): Promise<SearchResult> {
return new Promise((resolve, reject) => {
const rgArgs = buildRgArgs(query, rootPath, opts)
const acc = createAccumulator()
let buffer = ''
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
let launchFailureCheck: Promise<void> | null = null
// Why: spawn can throw synchronously on invalid options (e.g. bad cwd),
// which would leak out of the `new Promise` executor and leave the
// promise forever pending. Treat a synchronous throw as a clean
// "no results" fallback, the same way an async 'error' event is handled.
let child: ReturnType<typeof spawn>
try {
child = spawn('rg', rgArgs, {
cwd: rootPath,
stdio: ['ignore', 'pipe', 'pipe']
})
} catch {
resolve(finalize(acc))
return
}
let killTimeout: ReturnType<typeof setTimeout>
function resolveOnce(): void {
if (resolved) {
return
}
resolved = true
clearTimeout(killTimeout)
// Why: child.kill() is advisory over SSH; detach listeners if the
// process ignores timeout kill so old searches cannot retain closures.
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
resolve(finalize(acc))
}
function rejectUnavailable(): void {
if (resolved) {
return
}
resolved = true
clearTimeout(killTimeout)
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
reject(new RipgrepUnavailableError())
}
function settleLaunchFailure(): void {
if (launchFailureCheck) {
return
}
launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => {
if (unavailable) {
rejectUnavailable()
} else {
resolveOnce()
}
})
}
function processLine(line: string): void {
const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults)
if (verdict === 'stop') {
killSpawnedRipgrepProcess(child)
}
}
function handleStdoutData(chunk: string): void {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
processLine(line)
}
}
function handleStderrData(): void {
/* drain */
}
function handleError(): void {
processErrorObserved = true
if (isRipgrepUnavailableExit(child, null, null)) {
settleLaunchFailure()
return
}
resolveOnce()
}
function handleClose(code: number | null, signal: NodeJS.Signals | null): void {
if (
isRipgrepUnavailableExit(child, code, signal, {
classifyNativeLauncherExit: true
})
) {
unavailableExitObserved = true
settleLaunchFailure()
return
}
if (buffer) {
processLine(buffer)
}
resolveOnce()
}
child.stdout!.setEncoding('utf-8')
child.stdout!.on('data', handleStdoutData)
child.stderr!.on('data', handleStderrData)
child.once('error', handleError)
child.once('close', handleClose)
killTimeout = setTimeout(() => {
acc.truncated = true
killSpawnedRipgrepProcess(child)
resolveOnce()
}, SEARCH_TIMEOUT_MS)
})
}
// Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint)
export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files'