Files
orca/src/shared/structural-value-equality.ts
T
Brennan Benson 9e5ee5ef8e feat(workspaces): rework cleanup discovery and dialog (#13413)
* fix(workspaces): support full cleanup scans

* feat(workspaces): persist cleanup snapshots

* feat(workspaces): add cleanup filter model

* refactor(workspaces): remove cleanup presets

* feat(workspaces): rework cleanup dialog

* fix(workspaces): keep cleanup row ordering render-pure

* refactor(workspaces): simplify cleanup browsing

* refactor(workspaces): show cleanup facts

* refactor(workspaces): surface cleanup row facts

* fix(workspaces): remove misleading cleanup count

* fix(workspaces): preserve full scan semantics

* fix(workspaces): scope snapshot persistence

* fix(workspaces): preserve cleanup browse compatibility

* fix(workspaces): reconcile cleanup dialog state

* test(workspaces): update snapshot store fixtures

* test(workspaces): preserve cleanup scan modes

* perf(workspace-cleanup): stream scan progress and size results

* fix(workspace-cleanup): address review feedback

* fix(workspace-cleanup): preserve host-scoped cleanup metadata

* fix(workspace-cleanup): declare review source dependencies

* fix(workspace-cleanup): align size scan banner

* fix(workspace-cleanup): shorten scan action

* perf(workspace-cleanup): avoid redundant scan IO

* perf(workspace-cleanup): bound restarted evidence scans

* fix(workspace-cleanup): satisfy scan queue lint

* perf(workspace-cleanup): bound scan and snapshot work

* perf(workspace-cleanup): serialize final enrichment

* test(workspace-cleanup): assert final enrichment drain

* fix(workspace-cleanup): stop progress after renderer teardown

* perf: batch workspace cleanup git evidence scans

* perf(workspace-cleanup): stop redundant snapshot and scan work

* fix(workspace-cleanup): resolve review findings across scan, store, and dialog

Correctness:
- Chunk git-evidence dispatches at the shared 500-target limit and exclude
  queued/in-flight ids from target selection, so fleets past the limit can no
  longer strand rows permanently mislabeled as checked-but-unknown.
- Key destructive selection pruning on the user's filter state instead of the
  per-tick matched-set identity; streaming reclassification no longer silently
  deselects rows.
- Clamp the facet clock to max(scannedAt, open time): a stale hydrated
  snapshot no longer misbuckets idle thresholds or keeps dead agents fresh;
  row labels use the same clock.
- Supersede and cancel the previous broad scan when a new one starts (renderer
  registry and same-sender guard in main) instead of racing two fleet scans.
- Gate snapshot persistence on hasTargetedWorkspaceCleanupScan so
  worktreeIds: [] can never persist an empty fleet snapshot.
- Re-apply dismissals at set-time in progress application so a dismissal
  landing mid-enrichment is not clobbered.
- Record a one-off local snapshot prune for single (unbatched) remote deletes
  so removed workspaces cannot resurrect from cache.
- Strip .exe when normalizing foreground process names so Windows agent
  processes match.

Performance:
- Cache per-candidate facet and review-info objects on candidate identity;
  no-op streaming ticks reuse the previous rows array and skip every
  downstream pass; matched-set identity is stable under equal membership.
- Compute facet counts/options only while the filter popover is open.
- Equality-bail git-evidence publishes; structural (non-stringify) facet-group
  comparison memoized in the toolbar.
- Identity-token fast path for the enrichment cache (cache hits skip both
  JSON.stringify signatures); prune viewed/dismissal records on removal and
  expiry; bound the superseded-scan-id set.
- Restore the no-op bail in removeWorkspaceSpaceWorktrees (regression).
- Abort main-side scans when the renderer is destroyed; module-scope
  controller maps survive handler re-registration.
- Batch removal preflight into one targeted scan (with refreshActivity) per
  500 ids instead of one scan per row.
- Scan repos at concurrency 2, report discovered counts upfront for honest
  progress, share fs-activity probes per path (folder workspaces), read only
  the reflog tail, and skip the snapshot read-before-write via a remembered
  scannedAt.

Split workspace-cleanup-worktree-listing, workspace-cleanup-facet-row-caches,
and workspace-cleanup-selection-model out of files that crossed max-lines.

* fix(workspace-cleanup): address verifier findings

- Fall back to a full reflog read when the newest record exceeds the 8KB
  tail window, so an oversized subject cannot hide recent ref activity.
- Bound the single-removal snapshot prune batch id with a UUID; embedding
  the unbounded worktreeId silently failed main's 128-char validation and
  skipped the prune for long remote ids.
- Key the main-side broad-scan supersession by sender AND scan mode so
  legacy suggestion-only and full-workspace scans stay isolated, matching
  the renderer registry.

* fix(workspace-cleanup): own facet caches with useMemo instead of render-time ref writes

React Doctor (CI changed-lines gate) correctly flagged the three cache refs
written during render. Each per-candidate cache now lives in one memo with
the derived context it is keyed on, so the memo deps are the invalidation
and interior fills stay content-addressed; the matched-set identity
stabilization is dropped since its only consumer reads through a
useEffectEvent and never keys on identity.
2026-08-13 23:47:20 -07:00

81 lines
2.8 KiB
TypeScript

// Why: catalog reconcilers compare rows that IPC structured-clone (and main's hydration) rebuild on
// every fetch, so a reference compare reports every row as changed and nothing ever reconciles.
// Only plain records and arrays are walked; anything exotic (Date, Map, class instance) falls back
// to reference equality rather than being mistaken for an empty record.
type ValueEqualityPolicy = {
// Why: `Object.is` makes NaN equal NaN but 0 unequal -0; `===` does the opposite.
readonly sameValueLeaves: boolean
readonly absentKeyEqualsUndefined: boolean
}
const STRICT_OWN_KEYS: ValueEqualityPolicy = {
sameValueLeaves: false,
absentKeyEqualsUndefined: false
}
const UNION_OF_KEYS: ValueEqualityPolicy = {
sameValueLeaves: true,
absentKeyEqualsUndefined: true
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function valuesEqual(a: unknown, b: unknown, policy: ValueEqualityPolicy): boolean {
if (policy.sameValueLeaves ? Object.is(a, b) : a === b) {
return true
}
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((item, index) => valuesEqual(item, b[index], policy))
)
}
if (!isPlainRecord(a) || !isPlainRecord(b)) {
return false
}
if (policy.absentKeyEqualsUndefined) {
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
if (!valuesEqual(a[key], b[key], policy)) {
return false
}
}
return true
}
const keys = Object.keys(a)
if (keys.length !== Object.keys(b).length) {
return false
}
return keys.every((key) => Object.hasOwn(b, key) && valuesEqual(a[key], b[key], policy))
}
/**
* Structural compare where an absent own key differs from a key that is present and holds
* `undefined`, and leaves compare with `===`.
*
* Why the strict key set: the repo/project merges branch on `'localWindowsRuntimePreference' in
* project`, so a key appearing or disappearing is a real change even when its value is `undefined`.
*/
export function structuralValuesEqual(a: unknown, b: unknown): boolean {
return valuesEqual(a, b, STRICT_OWN_KEYS)
}
/**
* Structural compare that treats an absent own key as equal to a key holding `undefined`, and
* compares leaves with `Object.is`.
*
* Why the loose key set: locally constructed worktree catalog rows carry explicit `undefined`
* fields that the host simply omits, and no consumer of those rows uses the `in` operator.
*/
export function structuralValuesEqualIgnoringUndefined(a: unknown, b: unknown): boolean {
return valuesEqual(a, b, UNION_OF_KEYS)
}