mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
#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.
250 lines
8.0 KiB
TypeScript
250 lines
8.0 KiB
TypeScript
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
import { dirname, join } from 'node:path'
|
|
import type { TopLevelView } from '../shared/ui-chrome-types'
|
|
import { isTopLevelView } from '../shared/top-level-view'
|
|
|
|
const ACTIVE_VIEW_FILE_NAME = 'active-view.json'
|
|
const SAVE_DEBOUNCE_MS = 100
|
|
|
|
type ActiveViewFile = {
|
|
activeView: TopLevelView
|
|
}
|
|
|
|
export function getActiveViewPreferenceFile(dataFile: string): string {
|
|
return join(dirname(dataFile), ACTIVE_VIEW_FILE_NAME)
|
|
}
|
|
|
|
function readActiveView(file: string): TopLevelView | null {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<ActiveViewFile>
|
|
return isTopLevelView(parsed.activeView) ? parsed.activeView : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function serializeActiveView(activeView: TopLevelView): string {
|
|
return `${JSON.stringify({ activeView } satisfies ActiveViewFile)}\n`
|
|
}
|
|
|
|
export class ActiveViewPreference {
|
|
private readonly file: string
|
|
private activeView: TopLevelView
|
|
private persistedActiveView: TopLevelView | null
|
|
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
|
private pendingWrite: Promise<void> | null = null
|
|
private pendingFlush: Promise<void> | null = null
|
|
private flushSignals = new Set<AbortSignal>()
|
|
private unabortableFlushConsumers = 0
|
|
private writeGeneration = 0
|
|
/** Temp path of the write awaiting its rename; flushOrThrow removes it to veto that rename. */
|
|
private inFlightTmpFile: string | null = null
|
|
/** Set by flushAsync so the quit flush is the final write; see scheduleSave. */
|
|
private quitFlushStarted = false
|
|
private quitFlushPromise: Promise<void> | null = null
|
|
|
|
constructor(dataFile: string, legacyActiveView: unknown) {
|
|
this.file = getActiveViewPreferenceFile(dataFile)
|
|
const storedActiveView = readActiveView(this.file)
|
|
const fallbackActiveView = isTopLevelView(legacyActiveView) ? legacyActiveView : 'terminal'
|
|
this.activeView = storedActiveView ?? fallbackActiveView
|
|
this.persistedActiveView = storedActiveView
|
|
}
|
|
|
|
get(): TopLevelView {
|
|
return this.activeView
|
|
}
|
|
|
|
set(value: unknown): boolean {
|
|
if (!isTopLevelView(value)) {
|
|
return false
|
|
}
|
|
const changed = value !== this.activeView
|
|
this.activeView = value
|
|
if (
|
|
value !== this.persistedActiveView ||
|
|
this.writeTimer !== null ||
|
|
this.pendingWrite !== null
|
|
) {
|
|
this.scheduleSave()
|
|
}
|
|
return changed
|
|
}
|
|
|
|
private scheduleSave(): void {
|
|
// Why: the quit flush is the final write; a later debounce would land unawaited mid-exit.
|
|
if (this.quitFlushStarted) {
|
|
return
|
|
}
|
|
this.writeGeneration += 1
|
|
if (this.writeTimer) {
|
|
clearTimeout(this.writeTimer)
|
|
}
|
|
this.writeTimer = setTimeout(() => {
|
|
this.writeTimer = null
|
|
void this.enqueueWrite(this.activeView, this.writeGeneration)
|
|
}, SAVE_DEBOUNCE_MS)
|
|
}
|
|
|
|
private enqueueWrite(activeView: TopLevelView, generation: number): Promise<void> {
|
|
const previousWrite = this.pendingWrite ?? Promise.resolve()
|
|
const nextWrite = previousWrite
|
|
.then(() => this.writeAsync(activeView, generation))
|
|
.catch((error) => {
|
|
console.error('[active-view] Failed to persist preference:', error)
|
|
})
|
|
.finally(() => {
|
|
if (this.pendingWrite === nextWrite) {
|
|
this.pendingWrite = null
|
|
}
|
|
})
|
|
this.pendingWrite = nextWrite
|
|
return nextWrite
|
|
}
|
|
|
|
private async writeAsync(activeView: TopLevelView, generation: number): Promise<void> {
|
|
const tmpFile = `${this.file}.${process.pid}.${generation}.tmp`
|
|
let renamed = false
|
|
try {
|
|
await mkdir(dirname(this.file), { recursive: true })
|
|
await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8')
|
|
// Every async writer chains on pendingWrite, so a stale generation can only lose
|
|
// here. Sync flushOrThrow skips that chain, which is what the temp-path claim below
|
|
// covers — past this point the generation guard is spent.
|
|
if (generation !== this.writeGeneration) {
|
|
return
|
|
}
|
|
this.inFlightTmpFile = tmpFile
|
|
try {
|
|
await rename(tmpFile, this.file)
|
|
renamed = true
|
|
if (generation === this.writeGeneration) {
|
|
this.persistedActiveView = activeView
|
|
}
|
|
} catch (err) {
|
|
// ENOENT: flushOrThrow removed this temp file because it already wrote a fresher view.
|
|
if (
|
|
(err as NodeJS.ErrnoException).code !== 'ENOENT' ||
|
|
generation === this.writeGeneration
|
|
) {
|
|
throw err
|
|
}
|
|
} finally {
|
|
if (this.inFlightTmpFile === tmpFile) {
|
|
this.inFlightTmpFile = null
|
|
}
|
|
}
|
|
} finally {
|
|
if (!renamed) {
|
|
await rm(tmpFile).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
flushOrThrow(): void {
|
|
if (this.quitFlushStarted) {
|
|
throw new Error('Cannot synchronously flush active view after final persistence has started')
|
|
}
|
|
if (this.writeTimer) {
|
|
clearTimeout(this.writeTimer)
|
|
this.writeTimer = null
|
|
}
|
|
const asyncWriteWasInFlight = this.pendingWrite !== null
|
|
this.writeGeneration += 1
|
|
if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) {
|
|
return
|
|
}
|
|
// Why remove it: an async writer parked on `await rename` has already cleared the
|
|
// generation guard, so deleting what it would rename is the only thing stopping a
|
|
// stale view from landing on top of the write below.
|
|
if (this.inFlightTmpFile) {
|
|
try {
|
|
unlinkSync(this.inFlightTmpFile)
|
|
this.inFlightTmpFile = null
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
void this.enqueueWrite(this.activeView, this.writeGeneration)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
mkdirSync(dirname(this.file), { recursive: true })
|
|
const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp`
|
|
writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8')
|
|
renameSync(tmpFile, this.file)
|
|
this.persistedActiveView = this.activeView
|
|
}
|
|
|
|
/** Async twin of flushOrThrow() for the quit path; never throws so it can join the teardown barrier. */
|
|
flushAsync(): Promise<void> {
|
|
if (this.quitFlushPromise) {
|
|
return this.quitFlushPromise
|
|
}
|
|
this.quitFlushStarted = true
|
|
this.quitFlushPromise = this.flushPendingAsync()
|
|
return this.quitFlushPromise
|
|
}
|
|
|
|
flushPendingAsync(signal?: AbortSignal): Promise<void> {
|
|
if (signal?.aborted) {
|
|
return Promise.reject(new Error('Active-view flush aborted'))
|
|
}
|
|
if (signal) {
|
|
this.flushSignals.add(signal)
|
|
} else {
|
|
this.unabortableFlushConsumers += 1
|
|
}
|
|
if (this.pendingFlush) {
|
|
return this.pendingFlush
|
|
}
|
|
const run = this.drainPendingWrites()
|
|
const tracked = run.finally(() => {
|
|
if (this.pendingFlush === tracked) {
|
|
this.pendingFlush = null
|
|
this.flushSignals.clear()
|
|
this.unabortableFlushConsumers = 0
|
|
}
|
|
})
|
|
this.pendingFlush = tracked
|
|
return tracked
|
|
}
|
|
|
|
private async drainPendingWrites(): Promise<void> {
|
|
for (;;) {
|
|
if (this.allFlushConsumersAborted()) {
|
|
throw new Error('Active-view flush aborted')
|
|
}
|
|
if (this.writeTimer) {
|
|
clearTimeout(this.writeTimer)
|
|
this.writeTimer = null
|
|
}
|
|
if (this.pendingWrite === null && this.activeView === this.persistedActiveView) {
|
|
return
|
|
}
|
|
const generation = ++this.writeGeneration
|
|
await this.enqueueWrite(this.activeView, generation)
|
|
if (this.allFlushConsumersAborted()) {
|
|
throw new Error('Active-view flush aborted')
|
|
}
|
|
if (generation === this.writeGeneration) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private allFlushConsumersAborted(): boolean {
|
|
return (
|
|
this.unabortableFlushConsumers === 0 &&
|
|
this.flushSignals.size > 0 &&
|
|
[...this.flushSignals].every((signal) => signal.aborted)
|
|
)
|
|
}
|
|
|
|
async waitForPendingWrite(): Promise<void> {
|
|
if (this.pendingWrite) {
|
|
await this.pendingWrite
|
|
}
|
|
}
|
|
}
|