diff --git a/AGENTS.md b/AGENTS.md index c7664b45..26efb577 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ When adding new features, you **MUST** follow these rules: 5. **Swift Language Mode**: All packages use `swiftLanguageModes: [.v5]` 6. **Singletons go through `@Dependency`**:每个项目 singleton 都声明为 `fileprivate static let shared`,并通过 `extension DependencyValues` 中的 `@DependencyEntry` 暴露。调用方统一使用 `@Dependency(\.xxx)`;禁止 `public static let shared`,也禁止在定义文件外调用 `Foo.shared.bar()`。详见 Code Style 下的 **Singletons & Dependency Injection**。 7. **AppDelegate stays thin**: AppDelegate is a dispatch shell, not a service container. Every non-trivial lifecycle responsibility (appearance, debug menu, update checking, version probes, etc.) lives in its own `@MainActor` controller class under `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/`, registered via `@Dependency` per rule #6. See **AppDelegate Convention** under Code Style. +8. **Single-object interface fetches go through the document's interface cache**: fetch one object's interface via `documentState.interfaceCache.interface(for:options:)` with `ViewModel.currentMergedGenerationOptions` as the options — never bare `appDefaults.options` and never `runtimeEngine.interface(...)` directly — so cache keys line up with the content pane and exported text matches what it displays. Bulk consumers (interface export, MCP tools) deliberately bypass the cache and call the engine directly; do not route them through it. See `Documentations/Plans/2026-08-04-navigation-interface-cache.md`. ## Code Style diff --git a/Documentations/Evolutions/0005-cellvm-appearance-single-observed.md b/Documentations/Evolutions/0005-cellvm-appearance-single-observed.md new file mode 100644 index 00000000..d31d61b4 --- /dev/null +++ b/Documentations/Evolutions/0005-cellvm-appearance-single-observed.md @@ -0,0 +1,132 @@ +# 0005 - 高基数 Cell ViewModel 的 Appearance 单流化 + +- **状态**: Implemented +- **作者**: JH +- **日期**: 2026-08-09 +- **关联**: [0004](0004-differentiable-box-lazy-cellvm.md)(本提案与其互补:0004 处理「短命 static cellVM」,本提案处理「长寿命 stateful cellVM」) + +## 摘要 + +把高基数(N >= ~1k)长寿命 cell ViewModel 上的多个分立 `@Observed` 外观属性(`primaryIcon` / `secondaryIcon` / `tertiaryIcon` / `title` / `subtitle`)合并为**单个** `@Observed appearance: RuntimeObjectCellAppearance` 结构体属性,`RuntimeObjectCellDisplayable` 协议相应从 4 个分立 driver 收敛为 1 个 `appearanceDriver`。每行的 Rx 固定成本从 5 套 subject + lock 降到 1 套,行为与渲染结果不变。 + +首批适用对象:`SidebarRuntimeObjectCellViewModel`(浏览路径,每镜像数千行)与 `SidebarRootCellViewModel`(镜像列表,常驻 ~13k 行)。 + +## 动机 + +三轮库侧内存优化(MachOSwiftSection 0001/0002/0003 + swift-demangling 0010/0011)落地后,五镜像稳态从 470-480 MB 降到 262 MB,库侧六个堆簇在全量浏览压力下全部横住。此时唯一仍随浏览量线性增长的成本只剩 RV 自己的 UI 管线,2026-08-09 实测(用户拖拽浏览 SwiftUI 全部 RuntimeObject 后): + +- `NSRecursiveLock`:**125,225 把 / 28.1 MiB**(干净基线 54k),全进程第一大 ObjC 类; +- UI/Rx 簇 46.7 MiB(基线 21.4),占浏览堆增量 ~60%; +- 增量来源:+6,895 个 `SidebarRuntimeObjectCellViewModel`,每个带 5 个 `@Observed`。 + +`@Observed`(RxSwiftPlus)的存储是 `BehaviorRelay`,每个实例内含 `BehaviorSubject` + `NSRecursiveLock`(224 B)+ 同步追踪器,单属性全套 ~450-500 B。5 个属性 × 每行 ≈ 2.2-2.5 KB 的纯管线开销,而这些属性**只在 filter 变化与 specialization splice 时才更新**——事件频率完全撑不起每属性一条流。 + +lazy 路线走不通:[0004](0004-differentiable-box-lazy-cellvm.md) 的「反模式案例 #1」已裁定 Sidebar cellVM 必须保持 eager(filter 感知 attributed name 的订阅身份、树结构、splice 复用)。本提案是与其正交的另一条路:**不动 eager 树、不动任何行为语义,只减每行的流数量**。 + +### 非目标 + +- **不**改 `@Observed` 本身或 RxSwiftPlus 的锁实现——那是上游改动,影响全项目每一个 `@Observed`,风险面完全不同(可作为后续独立提案)。 +- **不**动 cellVM 的树结构、filter pipeline、splice 逻辑、`StableID` / `Differentiable` 身份——全部保持原样。 +- **不**处理低基数 cellVM(Inspector 各 tab、popover 等 N < 数百的场景)——eager 多流在那些量级下无感,改了徒增 churn。 +- **不**触碰 `SpecializationTypePickerCellViewModel`——它走 0004 的 lazy 短命路线,构造后即弃,无长寿命流成本。 + +## 提议方案 + +### 1. `RuntimeObjectCellAppearance` 结构体(RuntimeViewerApplication) + +```swift +public struct RuntimeObjectCellAppearance: Equatable { + public var primaryIcon: NSUIImage + public var secondaryIcon: NSUIImage? + public var tertiaryIcon: NSUIImage? + public var title: NSAttributedString + public var subtitle: NSAttributedString? +} +``` + +成员均为引用类型或 Optional 引用,struct 拷贝只是引用拷贝;`Equatable` 用于 `didSet` 去重(等值不发事件)。 + +### 2. cellVM 改造 + +```swift +@Observed +public private(set) var appearance: RuntimeObjectCellAppearance +``` + +`refreshAppearance()` / `rebuildTitleForFilterResult()` 改为组装完整 struct 后一次赋值——顺带把「一次刷新发 5 个事件」的既有冗余消掉,发布变为原子。 + +### 3. `RuntimeObjectCellDisplayable` 协议收敛 + +```swift +public protocol RuntimeObjectCellDisplayable { + var appearanceDriver: Driver { get } +} +``` + +cell view 的 `bind(to:)` 单订阅、单闭包内更新全部 outlet。订阅数从每可视行 4 条降到 1 条(可视行仅 ~12 行,此处收益次要,主要收益在 cellVM 侧的存储)。 + +## 影响(App 型) + +- **用户可见变化**:无。渲染结果要求逐字节一致(filter 高亮、图标、Open Quickly 均不变)。 +- **可发现性**:不适用。 +- **数据与配置兼容**:无持久化数据涉及。 +- **平台与最低版本**:不变。 +- **发布影响**:纯内部重构,无发布注意事项。 + +## 预期收益与验收标准 + +同款负载(五镜像索引 + SwiftUI 全量拖拽浏览)对照 2026-08-09 基线: + +| 指标 | 基线 | 验收线 | 实测(2026-08-09 落地后) | +|---|---|---|---| +| `NSRecursiveLock` 实例数(全量浏览后) | 125,225 | **≤ 45,000** | **42,218** ✓(−66%;与 6,895 行 × 1 流的推算 ≈ 41k 吻合。稳态为 27,341) | +| UI/Rx 堆簇(全量浏览后) | 46.7 MiB | **≤ 32 MiB** | **17.7 MiB** ✓(−62%) | +| 五镜像稳态堆存活(含 13k 行镜像列表) | 210 MiB | **≤ 205 MiB** | **196.7 MiB** ✓(含 0006 的 NIO 回收;footprint 262 → 239 MB,索引峰值 613 → 546 MB) | +| 行为回归 | — | 现有测试全绿 + filter 高亮 / splice / Open Quickly 手测无回归 | 包内 14 测试全绿(filter 基线的精确发射计数 100/10000/10000 保持不变);用户以同款负载全量拖拽浏览完成复测,无异常反馈 | + +**稳态锁减半的构成**:镜像列表 13,159 行 × 每行 2 流(icon + name)→ 1 流,每流约 2 把锁(`BehaviorRelay` + `BehaviorSubject` 各一),恰好对应 54k → 27.3k。heap 中 `BehaviorSubject` 计数 13,159,与行数一一对应。 + +## 风险与假设 + +1. **事件粒度变粗**:原来 title 单独变化只重设 title,现在整个 struct 重发、5 个 outlet 全重设。事件频率低(filter 键入节流后 / splice 一次性),单事件多 4 次赋值可忽略;`SidebarFilterPerformanceBaselineTests` 把关键路径(nil→nil 跳过)钉住,保持不动。 +2. **`Equatable` 去重的比较成本**:`NSAttributedString` 的 `==` 在 title 确实变化时才走全比较;等值路径(占绝对多数)由既有 `oldValue == nil, filterResult == nil` 早退挡住,不经过 struct 比较。 +3. **协议收敛波及面**:`RuntimeObjectCellDisplayable` 的全部 conformer 与消费 cell view 需同批改;grep 确认后列入落地清单。 + +## 替代方案考量 + +### A. Lazy cellVM(DifferentiableBox) + +被 0004 明确列为反模式:filter 感知 attributed name 依赖订阅身份,lazy 重建即失效。不重议。 + +### B. 上游改 RxSwiftPlus:`@Observed` 换 `os_unfair_lock` / lock-free + +收益量级相近(224 B 锁 → 8 B),且惠及全项目。但改动在上游仓库、影响所有 `@Observed` 调用点的并发语义(NSRecursiveLock 可重入,unfair lock 不可),需要独立评审与全量回归。作为后续候选提案,不与本提案捆绑。 + +### C. cellVM 不持有外观,cell 渲染时从模型现算 + +即「半 lazy」:外观退化为纯函数。filter 高亮需要 cellVM 持有 `filterResult` 并在变化时通知 cell——通知机制绕一圈还是一条流,复杂度不降反升,且打破 0004 划定的两范式边界。 + +## 测试策略 + +- 现有 `SidebarFilterPerformanceBaselineTests`、`OpenQuicklyLazyConstructionTests` 全绿(行为契约不变的机器证明)。 +- 新增单测:appearance 原子性(一次 `refreshAppearance()` 恰好一个事件)、等值不发事件。 +- 验收数字用与基线同款的 `heap -sortBySize` 流程复测(agent 侧已有成套脚本)。 + +## 落地步骤 + +1. `RuntimeObjectCellAppearance` + `RuntimeObjectCellDisplayable` 收敛 + 两个 Sidebar cellVM 改造 + cell view 适配,单 commit; +2. 回归测试 + heap 验收复测,数字回填本提案; +3. 状态 `Accepted` → `In Progress` → `Implemented` 随批次原地更新。 + +## 落地记录(2026-08-09) + +改动清单(与提案方案一致,另含协议收敛的连带面): + +- 新增 `RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift`(跨平台,不带 `#if` gate;协议仍仅 AppKit)。 +- `SidebarRuntimeObjectCellViewModel`:5 `@Observed` → 1;`refreshAppearance()` 原子组装(顺带消除旧实现「tertiaryIcon 不清零」的隐性残留);`rebuildTitleForFilterResult()` 只换 title;新增 `publishAppearance(_:)` 等值去重。 +- `SidebarRootCellViewModel`:2 `@Observed` → 1(嵌套 `Appearance` struct,形态与 5 字段共享结构体不同故单独建型)。 +- 协议 conformer 连带单流化:`InspectorSwiftSpecializationCellViewModel`、`InspectorRelationshipsCellViewModel`、`SpecializationTypePickerCellViewModel`(均为 init 一次组装)。 +- 消费面:`RuntimeObjectCellView`(单订阅 + `apply(_:)`)、`SidebarRootTableCellView`、两处 `typeSelectStringFor` 直读、UIKit 侧 3 处直读属性。 +- 测试:`TitleRebuildCounter` 改观察 `$appearance`(去重语义下计数含义不变,精确计数全部保持);新增 `SidebarCellAppearanceTests`(一次过渡恰一事件、等值重放零事件、display-neutral splice 零事件——最后一项还消除了旧实现的冗余重绘)。 + +**验收复测(2026-08-09,用户实例,同款负载:五镜像索引 + SwiftUI 全量拖拽浏览)**:浏览增量与基线完全同构(`SidebarRuntimeObjectCellViewModel` 恰为 6,895 个),两条浏览验收线全部达标——`NSRecursiveLock` 125,225 → **42,218**(9.5 MiB,验收线 ≤45k),UI/Rx 簇 46.7 → **17.7 MiB**(验收线 ≤32)。全量浏览后堆存活 242 MiB、footprint 346 MB(含 59 MB 待回收页)、索引峰值 789 MB。heap 中 `BehaviorSubject` 13,189 / `BehaviorSubject` 6,896,与行数一一对应,单流化按设计生效。 diff --git a/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md b/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md new file mode 100644 index 00000000..4da00816 --- /dev/null +++ b/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md @@ -0,0 +1,60 @@ +# PR #88(perf/pipeline-optimizations)审查发现裁决 — 2026-08-09 + +对 PR #88 的 xhigh 级 code review(15 条发现,逐条完成「四问」)经跨会话双向复核后的最终裁决。 +审查基线:分叉点 `8e72b6c` vs 分支 `70733b9`。审查报告由发起会话持有;本文件是**留档的裁决结论**: +误报与暂不修的记录在此,已修的登记修复 commit。ID 形式 `PR88.`,编号与原报告一致。 + +## 已修(本批次,2026-08-09) + +| ID | 严重度 | 摘要 | 修复 commit | +|---|---|---|---| +| PR88.2 | Blocker | UIKit 侧 `.just(false)` 在引擎语义如实化后使 iOS 搜索区分大小写(本 PR 引入的回归) | `a2770de`(含引擎层语义契约测试 `FilterEngineCaseSensitivityTests`) | +| PR88.3 | Major | fetch/render 拆分后 render 半段不在 `trackActivity` 内:主题/字号变更全程无加载指示;缓存命中时用户全部等待落在无指示半段 | `30d6fef`(新测试修复前红、修复后绿) | +| PR88.4 | Major | 空查询快路径先建整棵 snapshot forest 再发现不需要(reload 后 haystack 缓存冷,O(N) 白做) | `dc5df85`(新增 snapshot-free `resetToUnfiltered`,等价性测试 `SidebarFilterFastPathTests`) | +| PR88.5 | Major | `FilterEngine.filter` 先跑 `match` 再被空查询 guard 丢弃结果 | `b7eea71` | +| PR88.6 | Major | render 半段每次发射在闭包内新建 `ConcurrentDispatchQueueScheduler`(= 每次一个新 DispatchQueue) | `30d6fef`(与 PR88.3 同 commit,同一代码区域) | +| PR88.8 | Minor | `SidebarRootFilterPipeline.verdicts` 空查询会清空整棵树,仅靠唯一调用方守约 | `337ccd9`(加契约 assert;行为不变) | +| PR88.11 | Minor | cellVM 上 `appDefaults` 自 `cbb589c` 起零引用(`@Dependency` 不触发未使用告警) | `337ccd9`(删除死属性) | + +## False positive(误报,留档防止重查) + +### PR88.1 — 「macOS 侧边栏过滤框在首次点击 Case Insensitive 按钮前完全不生效」 + +**裁决:误报,整条撤销**(发起会话已确认撤销)。运行时最小 probe + 源码双证: + +- 原推演认为 `NSButton().rx.state` 落到 RxAppKit `HasTargeAction+Rx.swift` 的 + `@dynamicMemberLookup` 转发(该实现确实无订阅初值),于是 + `Driver.combineLatest(searchString, isSearchCaseInsensitive)` 被闸死。 +- 实际决议:**RxCocoa 在 macOS 上自带具体成员 `NSButton.rx.state`** + (`RxCocoa/macOS/NSButton+Rx.swift:21`),具体成员在 Swift 重载决议中永远压过 + `@dynamicMemberLookup`;RxCocoa 的 `controlProperty` 在 `Observable.create` 体内 + **先 `observer.on(.next(()))` 再装 `ControlTarget`**(`NSControl+Rx.swift:61`)—— + 订阅瞬间即发一次当前值。 +- 运行时验证(RxAppKit 0.5.4,与仓库 pin 同版):订阅即得初值;程序赋值 + `state = .on` 不发射(这点原推演正确);combineLatest 从未被闸。 + `searchCaseInsensitiveButton.state = .on` 在视图构造期执行、先于 `setupBindings` + 订阅,因此初值捕获 `.on` → 默认大小写不敏感在 macOS 上真实生效。 + +**连带撤销**:本仓其余 4 处 `rx.state` 调用点同样解析到 RxCocoa,无需排查。 + +**留档的真实陷阱(这是这条误报里值得记住的部分)**:RxAppKit 的 +dynamicMemberLookup / 自有 ControlProperty **确实不发初值**(同文件 +`click(with:isStartWithDefaultValue:)` 的显式开关反证这是既定设计)。分界反直觉: +**同一个 NSButton 上,`rx.state`(RxCocoa)有初值,`rx.isCheck` / +`rx.stateBoolValue`(RxAppKit 自有实现)没有。** 对没有 RxCocoa 具体成员兜底的属性, +`combineLatest` 闸死的风险是真实存在的。判此类问题的排查顺序:先查 RxCocoa `macOS/` +有没有该控件的具体成员,再查 RxAppKit `Components/`,都没有才轮到 dynamicMemberLookup。 + +## 暂不修(backlog,后续拾起) + +| ID | 严重度 | 摘要 | 状态与理由 | +|---|---|---|---| +| PR88.7 | Minor | `SemanticString+ThemeProfile` 出口处 `.copy()` 对大接口多一次深拷贝 + 瞬时 2× 峰值 | 保留拷贝(跨线程不可变性契约,已有注释);用现成 `content.attributedStringBuild` signpost 实测大接口占比后再裁决是否优化 | +| PR88.9 | Minor | `StatefulOutlineView` 展开状态合并持久化在窗口期内被 `beginFiltering` 打断时静默丢弃、不重排。**另有第二条丢弃路径**(第三轮补注,2026-08-13):`scheduledExpansionPersistStructureVersion == dataStructureVersion` 守卫失配时同样静默丢弃且不重排(`StatefulOutlineView.swift:309`)——该守卫是第二轮修 `PR88R2.1` 时(`3a99ca68`)才加入的,本条初次登记时并不存在。修法相同(失败时重排一次),但**两条路径都要修**,只修 `filteringState` 那一半等于没修 | 后果限于「重启后恢复不到最新展开状态」;改失败重排属小改动,随下一轮 outline 工作拾起 | +| PR88.10 | Minor | `RuntimeInterfaceCache` 仅按条数封顶(16),无字节预算/内存压力驱逐 | 稳态基线已降至 239 MB,大接口常驻敏感度上升;建议补字节预算或改 `NSCache`,需要作者对 16 的窗口做实测后定 | +| PR88.12 | Minor | `SharedLocalEngineTestLock` 启动屏障无 deadline,沙盒环境下整个 target 静默挂死 | 补 deadline + `Issue.record`;测试基建项,随下一轮测试工作拾起 | +| PR88.13 | Minor | 测试直写进程级 `Settings` / `AppDefaults`(UserDefaults 支撑),跨 suite 可见且崩溃时污染真实偏好 | 正解是注入 `UserDefaults(suiteName:)`;改动面涉及 Settings 依赖注入,单独立项 | +| PR88.14 | Minor | 测试接缝进入生产类型(`expansionAutosavePersistCount`、为测试去 `final`) | 与「成员默认 private」约定相抵触;重构为注入回调的成本与收益需权衡,暂记 | +| PR88.15 | Minor | 4 份新文档落在已归档的 `Plans/`、两处索引未更新;AGENTS.md 规则 #8 链接指向 Plans | 分叉点早于归档规则确立,属时序问题而非违规;**rebase 到最新 main 后**按新约定归并成 Evolutions 提案 / Internal 说明并补索引,届时更新本行 | + +> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。 diff --git a/Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md b/Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md new file mode 100644 index 00000000..5c2fbb87 --- /dev/null +++ b/Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md @@ -0,0 +1,85 @@ +# PR #88(perf/pipeline-optimizations)第二轮 max 级审查发现裁决 — 2026-08-10 + +对 PR #88 的第二轮 max 级 code review(15 条发现 F1–F15)经跨会话独立复核后的最终裁决。 +发起会话产出发现;复核会话逐条读代码验证(含 RxSwiftPlus / RxConcurrency 依赖源码实测),修正了 +其中 4 条的结论或严重度;裁决按复核后的结论落档。ID 形式 `PR88R2.`,编号对应原发现 F。 + +## 已修(本批次,2026-08-10) + +| ID | 严重度 | 摘要 | 修复 commit | +|---|---|---|---| +| PR88R2.1 | Major | 展开状态持久化:coalesced flush 在树被整体重建后仍执行,收集空结果覆写用户的展开状态(新引入:旧版在通知回调内同步 persist,无此窗口) | `3a99ca68`(outline 结构版本号防护 + 3 条测试) | +| PR88R2.2 | Major | 根侧栏过滤作废动作经 `subscribeOnNextMainActor` 异步 hop,晚于同步的新树装载,被作废的过滤结果可覆盖新树且无自动恢复 | `920c2aa3`(合并为同步 `installRebuiltNodes(_:)` + 2 条测试) | +| PR88R2.3 | Major | 类型链接跳转缓存键错位:resolution fetch 以点击合成对象为键,display fetch 以引擎重建的权威对象为键——Swift 支全部 miss(不止跨 image)、ObjC 支跨 image miss,双倍生成且 resolution 键占 LRU | `9f32e85e`(缓存按 `interface.object` 回填索引 + 1 条测试) | +| PR88R2.9 | Minor | 被作废的过滤趟丢弃已完成的 haystack 构建(构建只依赖对象列表、与查询无关),构建慢于查询间隔时缓存永不建立且被丢弃的构建照跑 | `523d98dd`(对象列表版本号守卫下提前安装 + gated-builder 测试) | +| PR88R2.10 | Minor | 新 materialize 的 cell 被标高亮时触发 `composedTitle()`,在主线程重建与后台 pass 逐字节相同的子树 haystack(parity 契约明示两串相同) | `b3c65095`(materialize 时用 pass 的 haystack 播种 cell 缓存 + SeedMarker 测试) | +| PR88R2.11 | Major | Open Quickly 宽查询(单字符 fuzzy 命中近全部行)在一个 main-actor turn 内 materialize 全部行,重现被删除路径的 O(N) 主线程成本且每次 reload 后首个宽查询重付 | `11238751`(按 fuzzy 分数截断前 500 行 + 行数上限测试) | + +复核对首批三条的严重度维持原判;PR88R2.11 由第三梯队提为 Major(它是 15 条中唯一在本 PR +自己的目标场景——打字路径主线程卡顿——上复现被删除问题的发现)。 + +## False positive / 不修(留档防止重查) + +### PR88R2.5 — transformer 合并选项的一个 tick 时序偏差(基本误报) + +`currentMergedGenerationOptions` 同步读 `settings.transformer`,内容管线的 `Observable.tracking` +re-arm 晚一个 main-queue tick。机制属实,但构造不出用户可见后果: + +1. 新 push 的 `ContentTextViewModel` 订阅 tracking 时**首发射同步读当前值**(re-arm 延迟只影响 + 已订阅链的后续发射)——「目标页渲染改动前的文本」不成立; +2. 写 settings(设置面板事件)与读 options(保存 / 链接点击事件)分属不同用户事件,间隔远超 + 一个 tick,人类操作凑不出重叠; +3. 即便撞上窗口,后果是多一次 fetch(键分裂但各自正确)或保存文本比屏幕早一个 tick 更新,最终一致。 + +**裁决:不修。** `MainViewModel` 保存 / 分享路径的同一引用同理。 + +### PR88R2.12 — 过滤管线 `applyNodes` 非事务改写(降为加固建议) + +`applyNodes` 深度优先边走边写,第 k 个 cell shape 失配时前缀已改写,且 `applyFilterOutcome` +直写 `filterContextStorage` 使等值守卫短路。两管线同形,均属实。但触发路径当前不可达: + +- 所有树变形源(reload / splice / 查询变更)与代际递增在同一个 main-actor 同步临界区内完成, + apply 与其代际检查之间无 await; +- root 侧唯一的异步作废窗口(PR88R2.2,已修)发生时,apply 作用于 Task 捕获的旧 cells 数组, + 其内部 shape 自洽——走的是成功路径而非 mismatch,两个发现互斥; +- 「永久失联」不成立:下一次成功 refilter 的 `applyFilterOutcome` 无条件全量覆盖,残留窗口 + 只到下一次过滤触发。 + +**裁决:作为加固建议挂起**(若做:apply 先只读校验全树 shape,通过后再第二遍写入),不排期。 + +### PR88R2.14(正确性段)— 「特化后必 miss + 死条目占 16 格」(误报,撤销) + +`.specializationAdded` 与 `.fullReload` 走同一个 `dataChangePublisher`,而 +`RuntimeInterfaceCache` 的订阅 `.map { _ in () }` **不分 case 全量 flush**——特化事件当场清空 +整个缓存,不存在跨特化的死条目。(副作用是特化一次全缓存清零,属过度失效,另行讨论。) +性能段(Key 内嵌整棵 `children` 的 hash/== 常数成本)属实,见 backlog。 + +## 暂不修(backlog,后续拾起) + +| ID | 严重度 | 摘要 | 状态与理由 | +|---|---|---|---| +| PR88R2.4 | Minor | `invalidateAll` 经 `subscribeOnNextMainActor` 异步 hop 晚一个 main-actor turn,窗口内 hit 返回旧源文本;类文档承诺「绝不返回过期接口」未被实现兑现 | 触发窗口极窄、后果一次性陈旧渲染。注意 `RuntimeEngine` 是 actor、`dataChangePublisher` 从非主线程发出,不能简单改同步订阅;需 `observe(on:)` + 同步 main 路径设计,随下一轮缓存工作拾起 | +| PR88R2.6 | Minor | 缓存 fetch 的 `Task { fetcher }` 脱离结构化取消:`flatMapLatest` 释放只取消 awaiting 侧,在飞引擎生成照跑(改前 `Observable.async` 的 dispose 直达 engine 调用);`.inFlight` joiner 同样不响应取消 | 正确性由代际守卫兜住,纯资源/延迟问题。joiner 需 `withTaskCancellationHandler` + 引用计数取消才能保住 dedup 语义,非一行改;`invalidateAll` 不取消在飞 fetch 是注释明示的设计(已 await 的 caller 要拿到值) | +| PR88R2.7 | Minor | reload 后 scope 激活时快路径被跳过,`filteredNodes` 只在异步 Task 里赋值:loaded 界面短暂绑着旧 cell,点击会 push 过期对象;书签侧栏订阅整个书签字典,触发频率高 | 低成本修法:`shouldFilter` 分支也先同步装未过滤新树再异步 refine(与 root 侧行为对齐);随下一轮 sidebar 工作拾起 | +| PR88R2.8 | Minor | specialization splice 的 `reloadRow` 同步展开时,新 child cell 高亮缺失、其子树未过滤(parent 层过滤实际同步完成;瞬时视觉,pipeline 完成后自愈) | 与 PR88R2.7 同一批处理 | +| PR88R2.13 | Minor | object 侧 plain-contains 从 `localizedCaseInsensitiveContains` 改为无 locale 的 `range(of:options:)` 后,与 root 管线(保留 localized)折叠规则不一致;无 locale 语义测试钉住 | **修复方向与原发现相反**:非 localized 折叠对符号搜索更正确(tr/az locale 下 localized 版查询 "i" 匹配不了 "Image"),应把 root 管线统一到非 localized 并补 locale 对测试,而非恢复 localized | +| PR88R2.14(性能段) | Minor | 缓存 Key 内嵌整个 `RuntimeObject`(含递归 children)的 hash/== 常数成本;`markRecentlyUsed` 线性扫描至多 16 次递归 ==;链接合成对象把源类 children 带进 key 加剧 | 多数对象 children=[] 时微秒级;等缓存键结构再演进时一并考虑(注意不能裸换 `RuntimeObjectKey`——children 变化影响生成文本,现靠全量 flush 兜底) | +| PR88R2.15 | Minor | root 过滤管线每键三笔开销:(a) 后台重建全树聚合串(cell 侧 lazy 缓存已删);(b) snapshot + apply 两遍 O(N) 主线程遍历(~1.3 万节点 ms 级/键);(c) 祖先命中后子树先逐个匹配再被 `unfilterSubtree` 覆盖 | (a)(c) 在后台执行、(b) 主线程 ms 级——与 main 是不同 tradeoff 而非纯回归。修法:递归传 `ancestorMatched` 短路 + 聚合缓存(配合既有代际);随下一轮 sidebar 性能工作拾起 | + +### 复核中提级 / 新增的条目 + +- **`SidebarRootCellViewModel.lazy _children` 后台/主线程竞争**(次要项提级):后台 + `indexedNodes` 迭代与主线程管线遍历可并发**首次**触碰同一 cell 的 lazy var(Swift lazy 非 + 原子),理论上双构建 / 撕裂(crash 级,低概率)。建议随 PR88R2.15 一并处理。 +- **`RuntimeImageNode` weak-parent 生命周期陷阱**(落地测试时新发现):`parent` 是 weak、 + `absolutePath` 是 lazy 且靠 parent 链推导——持有裸叶子而不锚定 root 时祖先链释放, + `path` 坍缩为 `"/"`(`absolutePath` 的 decode 注释早已记录同款坑)。测试脚手架已用 + `withExtendedLifetime(root) { _ = leaf.absolutePath }` 固化;生产侧 root 恒由 engine/VM + 持有,暂无实害。同目录 `OpenQuicklyLazyConstructionTests` 的同形代码靠 -Onone 下局部 + 变量活到作用域尾才幸免,属未承诺的 ARC 行为,后续测试基建工作时一并加锚。 +- 其余次要项(`resetToUnfiltered` 存 scope 不应用、filterMode 切换不重过滤、`children` + setter 不设 parent、`ResolvedThemeStream` 永久捕获 Settings、`MainViewModel` sharing + 回调在任意线程读 `@MainActor` 属性、`SidebarRootFilterPipeline.verdicts` 的 assert 在 + -O 下编译掉、三处代际令牌等重复簇)复核均属实,维持次要级,随各自区域的后续工作拾起。 + +> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。 diff --git a/Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md b/Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md new file mode 100644 index 00000000..795785e1 --- /dev/null +++ b/Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md @@ -0,0 +1,190 @@ +# PR #88(perf/pipeline-optimizations)第三轮 max 级审查发现裁决 — 2026-08-13 + +对 PR #88 的第三轮 max 级 code review(15 条发现)经跨会话独立复核后的最终裁决。 +审查基线:分叉点 `8e72b6c5` vs 分支 `9e6ca6a3`。发起会话产出发现并逐条完成「四问」; +复核会话独立验证,**修正了 5 条结论**(1 条提级、1 条降级、2 条归并错误、1 条误报改判)。 +裁决按复核后的结论落档。ID 形式 `PR88R3.`。 + +前两轮裁决见 [2026-08-09](2026-08-09-pr88-review-findings.md)(`PR88.`)与 +[2026-08-10](2026-08-10-pr88-max-review-findings.md)(`PR88R2.`)。 + +**本轮的总体判断:15 条中没有一条是本 PR 引入的回归。** 每一处 PR 分支都严格优于 main; +问题一律是「优化只覆盖了一半」。这与前两轮不同(前两轮各有真回归,如 `PR88.2` 的 iOS +搜索大小写、`PR88R2.1` 的展开状态覆写),是这条性能线趋于收敛的信号。 + +## 已修(本批次,2026-08-13) + +| ID | 严重度 | 摘要 | 修复 commit | +|---|---|---|---| +| PR88R3.1 | Major | 链接跳转的缓存对自己的请求键永远 miss:结果只按 `interface.object` 归档,请求键被 `entries[key] = nil` 清掉且不回填,「Back 之后再点同一个 token」每次全量重算(且本 PR 把该路径的选项从空改为完整选项,抬高了每次 miss 的代价) | ``(请求键→存储键重定向表 + 回归测试 `repeatedResolutionOfTheSameTokenHitsCache`) | +| PR88R3.2 | Major | 模糊搜索模式下侧边栏树每个命中行都在主线程重建富文本标题:`composedTitle()` 用 `ranges(of:)` 在整个子树聚合串里搜自己的 displayName,而该串按构造恒以 displayName 开头——每行一次全串扫描(`ranges(of:)` 收集全部匹配,连第一个命中都不短路),去重复现了本 PR 目标场景(打字路径主线程卡顿)的成本 | ``(常量 `NSRange(location: 0, length:)` 替换搜索) | +| PR88R3.3 | Minor | Open Quickly 索引(haystack)构建不去重:`cachedHaystacks` 在调度时同步快照、任务体内不重读,缓存冷时(每次 reload 后)落在构建窗口内的每个键击各开一份全量 O(N) 构建;`defaultHaystackBuilder` 无取消点,取消被取代的 pass 不释放任何东西 | ``(`inFlightOpenQuicklyHaystackBuild` 共享在飞构建 + 改写既有测试同时钉住去重与不丢弃) | +| PR88R3.4 | Minor | Open Quickly 每次键击遍历整个「暖缓存」字典来取消高亮,而该字典按设计跨查询保留,于是每键主线程成本随会话时长上涨 | ``(`highlightedOpenQuicklyRowIndices` 差集,保留暖缓存设计不变) | +| PR88R3.5 | Minor | Open Quickly 的 reload 作废(取消任务、代际递增、对象列表替换、缓存清空)在 `await super.reloadData()` **之后**的第二个 `MainActor.run` 里执行,而基类的 5 个 `MainActor.run` 中最后一个才安装 `nodes`;两者之间的 main-actor hop 是一个窗口 | ``(基类新增 `didInstallReloadedNodes()` hook,作废与安装合并进同一同步临界区) | + +### 关于 PR88R3.5 的测试缺口(诚实登记) + +**这一条没有配回归测试,因为写不出确定性的复现。** 触发要求在飞的 pass 恰好在 +「`nodes` 已安装、作废尚未执行」这一次 main-actor hop 内恢复并跑完全部剩余工作。而 +`matchOffMain` 必然挂起,挂起就把 main actor 让给了作废块,其后的 `Task.isCancelled` / +代际检查就会拦住它。用 gated builder 精确编排时序也命中不了:pass 的 continuation 与 +作废块的入队顺序无法在测试里固定。 + +修复本身仍然值得做:它把窗口**消除**而不是缩窄,形状与 `PR88R2.2`(根侧边栏 +`installRebuiltNodes(_:)`)一致,是同一个 bug 在 Open Quickly 分支上未修的那一半。 +但严重度按复核结论定为 Minor 而非 Major:即便命中,后果是 Open Quickly 面板停在 +重载前的结果,**用户再敲一个字符即恢复**(`quickActionBar(_:itemsForSearchTermTask:)` +会重新武装 `currentSearchTask`),不是永久卡死,也不越界——264 行代际守卫到 289 行 +赋值之间没有 await,`openQuicklyRuntimeObjects` 在该窗口内尚未被替换,索引与捕获的 +haystacks 仍然对齐。 + +main 侧同一位置是裸 `Task.detached`,无取消无代际守卫,失效顺序一样(77 行 +`super.reloadData()` 之后才在 82-83 行重建),故非回归成立。 + +## 已被前两轮覆盖(对照后跳过,不重走四问) + +| 本轮发现 | 归入 | 说明 | +|---|---|---| +| `SidebarRuntimeObjectViewModel.swift:452` 快照/应用时序不对称 | `PR88R2.12` | 复核确认:446 行按值快照 `nodes`、452 行读 `self.nodes`,正是该条分析的 mismatch 条件,且「所有树变形源与代际递增在同一 main-actor 同步临界区」的论证在此同样成立(`nodes` 赋值与 `scheduleRefilter()` 同在一个 `MainActor.run` 内)。维持「加固建议挂起」 | +| `FilterEngine.swift:25` 大小写默认值只活在视图层 | `PR88.1` + `PR88.2` | 两条合起来盖住两个平台臂。**补充**:`FilterContext.isCaseInsensitive = false` 这个模型层默认值本身没被正面记过,它与 `PR88.8`(空查询清空整棵树,仅靠唯一调用方守约)是同一类隐形契约,照 `PR88.8` 加注释即可,不必排期 | + +## 需要独立登记 / 补注(复核推翻了本轮的归并) + +### PR88R3.6 — 根过滤管线的取消检查形同虚设(`PR88R2.15` 的第 (d) 项) + +`SidebarRootFilterPipeline.verdicts` 的 `Task.isCancelled` 只在顶层 forest 条目之间检查, +而根 forest 恰好只有 **2** 个条目(`RuntimeEngine.swift:471` +`setImageNodes([dyldSharedCacheImageRootNode, otherImageRootNode])`,已复核)。两次检查都在 +实质工作之前;`verdictNode` 递归内与 `unfilterSubtree` 零检查。于是每一趟被取代的过滤 +都会把 ~1.3 万节点的聚合串构建与逐节点 `localizedCaseInsensitiveContains` 跑到底, +`currentRootFilterTask?.cancel()` 什么也释放不了。 + +**不能并入 `PR88R2.15` 了事**:那条登记的是三笔开销 (a)(b)(c),且它写的修法 +(`ancestorMatched` 短路 + 聚合缓存)**不解决取消问题**——修完 (a)(c) 之后,被取代的 +那一趟照样跑到底。作为 **(d)** 登记,随该批一并处理。 + +两个必须一并记住的点: +- **同形代码不同结论**:`SidebarRuntimeObjectFilterPipeline.swift:69-72` 写法一模一样, + 但对象树顶层是数千个对象,**那边的取消检查是有效的**。不要写成「两条管线同形所以同结论」。 +- 两份文件的注释都写着 "Checks for cooperative cancellation between top-level nodes", + root 那份字面为真但实际等于没有;即便不修,也应把注释改准。 + +### PR88R3.7 — 对象过滤管线的快照进入被 scope 裁剪的子树(独立于 `PR88R2.7`) + +`SidebarRuntimeObjectFilterPipeline.snapshot(of:scope:)` 无条件递归进 +`cell.unfilteredChildren`(第 55 行),即使该节点已被 scope 裁掉;同时对每个节点调 +`cell.matchesScopeRecursively(scope)`(第 54 行),而该函数自身要走完整个子树——scope +激活时是 sum-over-nodes 的子树大小而非 O(N)。第 53 行读 `currentAndChildrenNames` 还会在 +缓存冷时**在主线程**为注定被丢弃的子树构建聚合串。删掉的旧 cascade 只进入 scope 幸存者, +所以这是严格多于基线的主线程工作;scope-only 过滤(空查询 + 激活 scope)路径下,真正 +移出主线程的只有一次空查询 `FilterEngine.match`,即恒等函数。 + +**主题与 `PR88R2.7` 对不上**:那条讲的是时序/陈旧(reload 后 scope 激活时快路径被跳过、 +loaded 界面短暂绑着旧 cell、点击 push 过期对象),本条讲的是开销。更接近 +`PR88R2.15(c)` 的对象侧同胞。独立登记,随下一轮 sidebar 性能工作拾起。 + +### 对 PR88.9 的补注(必须补,否则将来只会修掉一半) + +`PR88.9` 描述的展开状态持久化丢弃路径是 `filteringState == .idle` 守卫被 `beginFiltering` +打断。本轮发现的是另一条:`scheduledExpansionPersistStructureVersion == dataStructureVersion` +守卫失配时静默丢弃且不重排(`StatefulOutlineView.swift:309`)。**那个守卫是第二轮修 +`PR88R2.1` 时(`3a99ca68`)才加进来的,写 `PR88.9` 时并不存在。** 后果与修法相同 +(失败时重排一次),并入可以,但必须在 `PR88.9` 行内注明这条新增路径。 + +> 已在 [2026-08-09](2026-08-09-pr88-review-findings.md) 的 PR88.9 行补注。 + +## False positive / 不修(留档防止重查) + +### PR88R3.8 — 「外层 `catchAndReturn` 被删导致 `bind(to:)` 无保护」(误报,且方向相反) + +`ContentTextViewModel.swift:135-141` 的注释已写明内层 catch 是有意设计。复核对了 +merge-base 与 PR HEAD 的 diff:**旧代码的 `.catchAndReturn(nil)` 挂在 `flatMapLatest` +之后**,而 RxSwift 中内层 error 会穿透 `flatMapLatest` 终止外层序列——旧写法就是 +「首次错误 → 发一个 nil → complete 整条链」,正是注释警告的永久冻结该 tab。新写法 +严格更好。 + +补充正面证据:新外层链上**没有任何 error 源**——`themeObservable` 是 +`ResolvedThemeStream.observable`(tracking + `distinctUntilChanged` + `share`,无错误 +路径),render 半段是 `just(()).observe(on:).map { }`,`map` 非 throwing, +`trackActivity` 不注入错误。`bind(to:)` 拿不到 error。 + +### PR88R3.9 — 缓存「幽灵 LRU 键」(**可达**,但最坏后果=一次冗余取数) + +原发现主张的路径不可达:`ContentTextViewModel.swift:229-231` 的 push 发生在解析取数 +**完成之后**(`.emit` 里才 `trigger(.push(interface.object))`),所以目标页的显示取数 +必然命中已写好的条目,不会创建在飞条目。 + +**但复核构造出了另一条可达路径**,本轮据此改判:两笔取数请求键不同、存储键相同—— +tab A 正在显示对象 O(请求键 K_O),tab B 点了指向 O 的链接(请求键 K_合成)。若 B 先完成, +它把 `.ready` 写到 K_O 上,**覆盖 A 的 `.inFlight`**(原第 130-134 行「只有这个创建者路径 +会改下面的条目」的注释在该交错下不成立);随后 A 恢复,无条件 `entries[key] = nil` 删掉 +刚写好的条目。A 正常返回非 nil 时同一同步块内立刻重写回去而**自愈**——这正是它一直没被 +撞见的原因;A 返回 nil 或抛错(XPC 断链、文档关闭时 fetcher 抛 `CancellationError`)才 +留下幽灵:K_O 在 `readyKeysByRecency` 里而 entries 中没有它。 + +后果比原发现描述的轻得多:幽灵要么被下一次同键 store 经 `markRecentlyUsed` 去重吃掉, +要么被 `evictBeyondCapacity` 弹出;最坏是弹出时该键上恰好有活的 `.inFlight`——等待方 +持有的是 Task 对象本身,照样拿到值,代价只是丢一次 dedup、多一趟引擎往返。不返回陈旧 +数据、不崩、不无界增长。 + +**裁决:随 PR88R3.1 一并消除**(`clearInFlight(_:ifStillOwnedBy:)` 只删自己那一笔的 +在飞条目),因为—— + +> ⚠️ **PR88R3.1 与本条是耦合的,两轮裁决都没记过这一点。** PR88R3.1 的修法引入 +> 请求键与存储键的别名关系,会让本条的交错**更容易**形成、也更容易撞上活条目。 +> 「不可达」这类结论必须连同「什么改动会让它变可达」一起记,否则一条被判死的发现 +> 会在修另一条时悄悄复活。回归测试 +> `failingFetchLeavesAConvergedEntryIntact` 钉住了这个交错。 + +### PR88R3.10 — 「漏掉 `dataSource` 重设 / `noteNumberOfRowsChanged()`」(不可达) + +`StatefulOutlineView` 的 5 个 override(`reloadData` / `insertItems` / `removeItems` / +`moveItem` / `reloadItem(_:reloadChildren:)`)**完整覆盖** RxAppKit outline adapter 的全部 +树变形入口(`NSOutlineView+StagedChangeset.swift:56,63,76,80,87`)。`dataSource` 由 +DelegateProxy 设置,而 `SidebarRuntimeObjectCoordinator.swift:25` 只在 `.initial` 路由调 +一次 `setupBindings`,`.objects` / `.bookmarks` 只是 `.select(index:)`;切换 image 是整个 +新建 coordinator + ViewController + outline view,不存在重设 `dataSource`。 +`noteNumberOfRowsChanged()` 全仓库只有 UIFoundation 自己的 override,无应用侧调用点。 + +**但值得记住的部分**:展开状态持久化在本仓已经修过至少三次(`997f4737` 改进过滤与 +展开状态恢复、`69c4f38a` 修可靠恢复、`3a99ca68` 加结构版本号防护)。原发现提出的 +「改用通知 `userInfo["NSObject"]` 增量维护展开集合」比继续枚举 AppKit 入口更根本, +且能同时去掉 O(rows) 全表走查与版本计数器。随 outline 那批工作一并考虑。 + +### PR88R3.11 — 「并发 scheduler 让被取代的渲染并行跑」(取舍,非缺陷) + +`ConcurrentDispatchQueueScheduler` 下被取代的构建确实真并行跑(`flatMapLatest` 丢弃的是 +emission,不是已开始的计算)。但串行会让新构建排在旧的后面等,用户看到结果更慢; +并发是用内存峰值换响应速度。 + +**不单独排期,但挂到 `PR88.10` 那一行**:每一份并行构建都带着 `PR88.7` 记过的 `.copy()` +瞬时 2× 峰值,而 `PR88.10` 已写「稳态基线 239 MB,大接口常驻敏感度上升」。连点字号在 +`UIView.h` 量级接口上会把峰值乘几倍——它是那场讨论的输入,不是独立问题。 + +### PR88R3.12 — 「每个 ViewModel 一个 DispatchQueue」(属实,成本微小) + +`PR88.6`(`30d6fef`)修的是「每次发射新建一个」,剩下「每个 ViewModel 一个」。 +`ContentCoordinator.rebindTextViewController` 每次导航都构造新的 `ContentTextViewModel`, +所以确实是每次 push / next / back / tab 切换一个 DispatchQueue。改成 +`private static let` 是安全的(`renderAttributedString` 是 `nonisolated static`、 +无实例状态),**可顺手做,不值得单独排期**。 + +## 结构观察(不作为缺陷登记) + +`SidebarRootFilterPipeline`(160 行)与 `SidebarRuntimeObjectFilterPipeline`(188 行) +约 55 行同构:`ForestVerdict` + `.empty`、`snapshot(of:)`、`apply(_:to:)`、`applyNodes` +(两者都是:count 守卫、`zip`、递归进 `cell.unfilteredChildren`、 +`applyFilterOutcome(... indices.map { unfilteredChildren[$0] })`)、`resetToUnfiltered`。 +真正不同的只有 `verdictNode`。 + +本轮的 PR88R3.6、PR88R3.7 各是「一条管线有、另一条没有」的缺陷,`PR88R2.13` +(localized 折叠不一致)也是——这就是拆分的具体代价。两种 cell 都已暴露泛型管线需要的 +两个成员(`unfilteredChildren` + `applyFilterOutcome`),扁平版本的抽象 +(`FilterableItem` + `FilterEngine.filter(context:items:)`)已在 `FilterEngine.swift`。 +泛型树形版本应放在它旁边,每条管线只保留自己的 `verdictNode`。 + +注:根侧边栏不走 `FilterMode` 早于本 PR(基线 `SidebarRootCellViewModel.filter` 的 didSet +同样硬编码 `localizedCaseInsensitiveContains`),本 PR 是把它固化进一个新的 160 行文件, +而非引入。 + +> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。 diff --git a/Documentations/KnownIssues/README.md b/Documentations/KnownIssues/README.md index 4be1a0c6..557fb376 100644 --- a/Documentations/KnownIssues/README.md +++ b/Documentations/KnownIssues/README.md @@ -41,3 +41,31 @@ when picking up follow-up work. full re-render + autosave per frame; TS.2 editor's Light/Dark variant selector hardcoded to `.dark`; TS.3 duplicate-preset names not deduped; TS.4 toolbar font-size +/- read-modify-write not coalesced on auto-repeat. +- [2026-08-09-pr88-review-findings.md](2026-08-09-pr88-review-findings.md) — + adjudications for the first (xhigh) review pass on PR #88 + (`perf/pipeline-optimizations`), IDs `PR88.`: 7 fixed in-branch, 1 false + positive (the RxAppKit `rx.state` no-initial-value premise, refuted at + runtime), 7 backlogged (byte-budget-less interface cache, test + infrastructure seams, docs placement). +- [2026-08-10-pr88-max-review-findings.md](2026-08-10-pr88-max-review-findings.md) — + adjudications for the second (max) review pass on PR #88 after cross-session + re-verification, IDs `PR88R2.` mapping to findings F1–F15: 6 fixed + in-branch (expansion-autosave wipe, root-filter stale overwrite, link-jump + cache key mismatch, Open Quickly haystack discard / main-thread rebuild / + unbounded materialization), 2 downgraded on re-verification (one-tick + transformer skew, unreachable applyNodes guard), 1 claim refuted + (specialization flushes the whole interface cache, so no dead entries), + 6 backlogged. +- [2026-08-13-pr88-max-review-findings.md](2026-08-13-pr88-max-review-findings.md) — + adjudications for the third (max) review pass on PR #88, IDs `PR88R3.`. + No finding was a regression this round — every one is an optimization that + covered only half its ground. 5 fixed in-branch (link-jump cache misses its + own request key; fuzzy-mode title rebuild scanning the whole subtree + haystack; Open Quickly haystack builds not deduplicated, per-keystroke sweep + of the warm cell cache, and reload invalidation landing outside the turn + that installs the nodes), 2 归并被推翻并独立登记 (root pipeline's + cancellation check covers nothing — `PR88R2.15(d)`; object pipeline's + snapshot descends into scope-pruned subtrees), 1 补注 on `PR88.9` (a second + discard path introduced by the `PR88R2.1` fix), 5 false positive / no-fix — + including the phantom-LRU-key claim **改判为可达但无害**, whose write-up + records that fixing the cache-key finding makes it easier to reach. diff --git a/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md b/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md index a38e3c8d..d88eb05b 100644 --- a/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md +++ b/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md @@ -1,10 +1,18 @@ # ContentTextViewController AttributedString 性能优化 **Date**: 2026-05-17 -**Status**: ⏳ Pending Approval +**Status**: ✅ PR0 + PR1 已落地;PR2 / PR3 按度量门控,未启动 **Branch (proposed)**: 待人工指定 **Author**: ralplan 共识规划(Planner → Architect → Critic) +> **落地记录(2026-08-04)**:PR0(工具栏字号 throttle)已随 TS.4 修复先行落地 +> (`MainViewModel.fontSizeThrottleMilliseconds`)。PR1(管线拆分 + 后台构建) +> 的实际落地与本计划的差异、三个 Open Questions 的答案、以及顺带修复的 +> 管线一错即死 / tracking 依赖上下文两个 bug,见 +> [2026-08-04-content-text-pipeline-pr1.md](2026-08-04-content-text-pipeline-pr1.md)。 +> PR2(`.semanticType` 增量重涂)与 PR3 维持度量门控,signpost +> `Content.TextPipeline` 已就位。 + --- ## 1. 问题陈述 diff --git a/Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md b/Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md new file mode 100644 index 00000000..df4a12ae --- /dev/null +++ b/Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md @@ -0,0 +1,71 @@ +# 内容区渲染管线拆分(PR1:主题变化不再重拉接口 + 后台构建富文本) + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md`(原始三阶段计划;本文是其 PR1 的落地记录,PR0 throttle 已先行落地)、`Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md`(同一"流畅度卖点"主线的侧边栏部分) +- **Regression suite**: `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift` + +## 1. 动机(为什么做) + +`ContentTextViewModel` 原先是一条单管线: + +``` +combineLatest($runtimeObject, options, theme, transformer) + → flatMapLatest { XPC 拉接口 } + → 主线程构建 NSAttributedString + → setAttributedString 全文档重排 +``` + +两个结构性浪费: + +1. **主题/字号变化重新跨 XPC 拉接口**。接口内容(`SemanticString`)与主题完全无关,但 theme 挂在同一个 `combineLatest` 上,任何字号 ± 都触发整条链,其中 XPC 往返是最贵的一段。 +2. **整份富文本在主线程从零构建**。大接口(UIView.h 量级、数万 token)每次几十到几百毫秒的主线程冻结。 + +另有两个排查中确认的正确性 bug,一并修复: + +3. **管线一错即死**:`.catchAndReturn(nil)` 挂在最外层。RxSwift 语义是"上游 error → 发补偿值 → complete 整条链",所以任何一次接口拉取失败后,该 tab 的内容管线永久停摆——之后切主题、改生成选项都不再刷新,直到导航换绑新 ViewModel。 +4. **`Observable.tracking` 内解析 `@Dependency` 的隐患**(测试中暴露):tracking 桥的 re-arm 跑在裸 `DispatchQueue.main.async` 上,task-local 依赖上下文丢失,闭包内的 `@Dependency(\.settings)` 会按环境默认上下文重新解析。App 进程默认 `.live` 所以线上无症状;但任何非 live 默认上下文(测试进程为 `.test`)下,第一次 re-arm 就会解析出另一个 `Settings` 实例,tracking 从此追踪错对象、链路静默死亡。`ResolvedThemeStream` 与 `ContentTextViewModel` 的 transformer 流都踩在这个模式上。 + +## 2. 范围(改了哪些部分) + +| 文件 | 改动 | +|---|---| +| `RuntimeViewerApplication/Content/ContentTextViewModel.swift` | 管线拆两截:**fetch 半程**(`$runtimeObject` × `$options.distinctUntilChanged()` × `transformer.distinctUntilChanged()` → XPC,theme 不再参与)+ **render 半程**(`combineLatest(interfaceStream, themeObservable)` → 后台调度器构建 → 主线程 bind)。`catchAndReturn(nil)` 移进 `flatMapLatest` 内层。新增可注入的 `InterfaceProvider`(internal,默认走 engine,测试用来计数/模拟失败)与 `nonisolated static renderAttributedString(for:theme:)`。两个 signpost 区间:`content.interfaceFetch` / `content.attributedStringBuild`(subsystem `com.RuntimeViewer.RuntimeViewerApplication`,category `Content.TextPipeline`)。 | +| `RuntimeViewerApplication/Theme/ResolvedThemeStream.swift` | `@Dependency(\.settings)` 解析移出 tracking 闭包,arm 时捕获实例(修 §1.4)。 | +| `RuntimeViewerArchitectures/Observable+Tracking.swift` | 文档新增 `- Important:` 契约:**绝不在 `access` 闭包内解析 `@Dependency`**,解析一次、捕获实例。 | +| `RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift` | builder 出口 `attributedString.copy()` 固化 immutable——跨线程交接契约(后台构建、主线程消费),可变工作副本不再逃逸。 | +| `RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift` | `GenerationOptions` 补 `Equatable`(三个成员本就 Equatable),供 `distinctUntilChanged()` 使用。 | +| `RuntimeViewerCore/Common/RuntimeObjectInterface.swift` | 补 public memberwise init(测试构造 stub 需要;原先只有 internal 合成 init)。 | + +## 3. 关键设计与取舍 + +- **theme 移出 fetch 流是本次的全部要点**。fetch 半程只对"真正需要重新生成接口"的输入(对象、生成选项、transformer)敏感;render 半程消费 `share(replay: 1)` 的最新接口 + 最新主题。字号连点(工具栏已有 120ms throttle)只走 render 半程:零 XPC、零主线程构建,主线程只剩 `setAttributedString` 本身。 +- **后台构建的线程安全前提已逐项核实**:`ResolvedTheme` 的 color/font 查表是 init 后只读字典(`d6c8a12e` 预解析);builder 只分配 immutable 的 NSFont/NSColor/NSAttributedString;出口 `.copy()` 保证跨线程传递的是 immutable 实例。原计划的三个 Open Questions(`@Observed` 隔离、`setAttributedString` 内部拷贝、transformer 误触发)全部有了确定答案,不再是风险。 +- **`flatMapLatest` 双层取消**:fetch 半程换对象/选项时取消在飞的旧 fetch;render 半程主题连变时丢弃落后的构建结果,只发布最新一份。 +- **错误处理位置即语义**:catch 在内层 = "这一次失败";catch 在外层 = "整条订阅完蛋"。修复后单次失败表现为一次 nil(UI 保持旧文本),管线继续活着——有专门的回归测试钉死。 +- **`InterfaceProvider` 注入而非 mock 引擎**:`RuntimeEngine` 是具体类型难以替身;把"拉接口"收窄成一个 `@Sendable` 闭包,默认实现按调用时读 `documentState.runtimeEngine`(引擎可在文档生命周期内被切换,不能 init 时冻结),测试注入计数器/失败器。public API 不变(原 init 变为 convenience)。 + +**放弃/未做的方案**:PR2(`.semanticType` 自定义 attribute + 增量重涂)与 PR3(in-place `addAttributes` 避免全文档重排)维持原计划的度量门控——PR1 之后主题变化的剩余成本只有"后台全量重建 + 主线程 `setAttributedString`",只有大接口上字号 tap 到首屏可见仍 >100ms 才值得加复杂度。`renderAttributedString` 的字节等价测试已就位,将来 PR2 的重涂输出必须与它逐字节一致。 + +## 4. 结果与验证 + +- 回归测试(`ContentTextPipeline` suite,3 条): + 1. **字号变化零重拉**:fetch 计数在初次渲染后为 1,字号 +3 触发重渲染(新字号已生效)后计数仍为 1。 + 2. **失败不杀管线**:首次 fetch 抛错后改生成选项,第二次 fetch 成功、`attributedString` 恢复输出,计数 = 2。 + 3. **渲染等价 + immutable 出口**:`renderAttributedString` 与直接调 builder 逐字节相等,且返回值不是 `NSMutableAttributedString`。 +- 全部包测试 64/64 通过(9 个 suite,含侧边栏基线套件)。 +- Instruments 度量入口:Logging template 过滤 `Content.TextPipeline`,两个区间分别对应 XPC 拉取与富文本构建;PR2/PR3 是否启动以此为准。 + +## 5. 影响面 + +- 行为语义不变:初次渲染、对象切换、选项/transformer 变化的路径与之前一致(loading 指示仍只包 fetch 段);主题/字号变化从"重拉 + 主线程重建"降为"后台重建"。 +- 肉眼可见变化:大文档上连按字号 ± 不再卡顿;接口拉取失败后主题/选项调整仍然生效(原来会永久冻结)。 +- UIKit / Catalyst:`themeObservable` / `transformerObservable` 在该分支是 `.just(...)` 常量,管线拆分对其零行为变化;`ConcurrentDispatchQueueScheduler` 与 builder 平台中立。 +- `RuntimeObjectInterface` 新增 public init、`GenerationOptions` 新增 `Equatable` 均为纯增量 API,无破坏。 + +## 6. 迁移 / 跟进注意事项 + +- **`Observable.tracking` 新契约**:access 闭包内禁止解析 `@Dependency`(文档已写进桥接层)。现存两处调用点均已改为 arm 时捕获;新增调用点请遵循同一模式。 +- **PR2 门控条件**:大接口(UIView.h 量级)字号 tap → 首屏可见 >100ms(用 `content.attributedStringBuild` signpost 量),才启动 `.semanticType` 增量重涂;启动时字节等价测试直接复用本套件第 3 条。 +- **PR3 门控条件**:PR2 后颜色-only 主题切换若 `setAttributedString` 全文档重排仍可感知,再评估 in-place `addAttributes`。 +- 测试套件会真实读写本机 `Settings.shared`(字号、生成选项),每条测试均在 `defer` 中恢复原值;suite 是 `.serialized`,不与其它 settings 触碰型测试并行。 diff --git a/Documentations/Plans/2026-08-04-navigation-interface-cache.md b/Documentations/Plans/2026-08-04-navigation-interface-cache.md new file mode 100644 index 00000000..dce9f8aa --- /dev/null +++ b/Documentations/Plans/2026-08-04-navigation-interface-cache.md @@ -0,0 +1,59 @@ +# 导航接口缓存(RuntimeInterfaceCache:来回导航 / 切 tab / 重复点链接不再重拉 XPC) + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md`(内容管线拆分——本缓存复用其 fetch/render 分离与 `InterfaceProvider` 测试缝)、`Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md`(同一"流畅度卖点"主线) +- **Regression suite**: `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift` + `ContentTextPipelineTests.navigationRevisitRendersFromCache` + +## 1. 动机(为什么做) + +内容导航的每一步(点类型链接、切 tab、back/forward、侧边栏点击)都会让 `ContentCoordinator` 重新绑定一个全新的 `ContentTextViewModel`,而全工程没有任何接口结果缓存——在 A、B 两个类之间来回跳,每一步都重新跨 XPC 拉一遍接口。附带一个隐藏的双倍浪费:**点一次链接实际是两次 XPC**——`transform(_:)` 先用默认 `GenerationOptions()` 拉一遍目标接口(只为解析出跳转对象),push 之后新 ViewModel 再用用户实际 options 拉第二遍,两次的 key 甚至不同。 + +另有一个顺带确认的既有不一致:工具栏"保存文件"和拖拽分享走的是 `appDefaults.options` **未合并 transformer 配置**的裸 options,用户改过 transformer 设置后,**保存出的文本和内容区显示的文本可能不一样**。 + +## 2. 范围(改了哪些部分) + +| 文件 | 改动 | +|---|---| +| `RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift`(新增) | 文档级 LRU 缓存:`@MainActor`,key = `(RuntimeObject, GenerationOptions)`,容量 16;同 key 并发请求共享同一 in-flight `Task`;`nil` 与 error 不缓存;generation token 保证"晚到的旧 fetch 不能污染 flush 后的缓存";init 内订阅 `$runtimeEngine`(换引擎→清空+换订阅)与 `dataChangePublisher`(任何事件→保守全清)。 | +| `RuntimeViewerApplication/DocumentState.swift` | 新增 `public private(set) lazy var interfaceCache`(仿 `backgroundIndexingCoordinator` 模式)。 | +| `RuntimeViewerApplication/ViewModel.swift` | 新增 `currentMergedGenerationOptions` 计算属性:stored options + 实时 transformer 配置,与内容管线 fetch 半程的合并逻辑逐字一致——所有想与内容区共享缓存条目的取用点都必须用它,key 才对得上。 | +| `RuntimeViewerApplication/Content/ContentTextViewModel.swift` | 默认 `InterfaceProvider` 从直连引擎改为路由缓存;provider 提升为存储属性,`transform(_:)` 的两条链接解析流也走同一 provider(注入的测试 provider 因此能观测到全部 fetch);链接解析的 options 从 `.init()` 改为 `currentMergedGenerationOptions`——解析那次直接暖缓存,push 后的展示 fetch 命中,**一次点击一次 XPC**。 | +| `RuntimeViewerUsingAppKit/Main/MainViewModel.swift` | 保存文件、拖拽分享两处改走 `documentState.interfaceCache` + `currentMergedGenerationOptions`:保存可见对象为缓存命中,且导出文本从此与屏幕显示一致(顺带修掉 §1 的不一致)。 | +| `RuntimeViewerCore/Core/RuntimeObjCSection.swift`、`Core/RuntimeSwiftSection.swift`、`Common/RuntimeObjectInterface+GenerationOptions.swift` | `ObjCGenerationOptions` / `SwiftGenerationOptions`(含 `MemberSortOrder`)/ `GenerationOptions` 补 `Hashable`(成员全是 Bool 与 String enum,纯编译器合成;与上一批补 `Equatable` 同类的纯增量 API)。 | + +## 3. 关键设计与取舍 + +- **缓存 fetch 半程的产物(`RuntimeObjectInterface`),不缓存 `NSAttributedString`**。接口的 `SemanticString` 与主题无关,是跨 XPC 最贵的一段;富文本构建已在 PR1 后台化(几十 ms 级),且它依赖主题——缓存它意味着 key 要卷入 fontSize 连续值(2026-05-17 计划否决过的 Option C)。 +- **对象解析与 options 无关,已核实**:`RuntimeEngine._interface(for:options:)` 按 `RuntimeObject` 的 kind/mangled name/imagePath 定位 section 并解析目标,options 只进文本生成(`updateConfiguration` / `interface(for:using:transformer:)` 的文本参数)。因此链接解析改用 merged options 是安全的——解析出的对象相同,顺手暖了缓存。 +- **`@MainActor` 而非 actor**:与全项目文档级服务一致(`DocumentState`、`backgroundIndexingCoordinator` 均 `@MainActor`),Rx 订阅接线零摩擦;每次导航一次 main hop 做字典查找,成本可忽略。所有存储变更被 main actor 串行化,配合"只有 fetch 创建者写回存储"的结构,无锁无竞争。 +- **generation token 兜底 flush 与 straggler 的竞态**:flush 先 bump generation;早于 flush 启动的 fetch 完成后发现 generation 不符,把结果交还调用方但不写回存储。已有专门测试钉死。 +- **in-flight 共享带来的免费收益**:链接点击的解析 fetch 与 push 后新 ViewModel 的展示 fetch 若在时间上重叠,第二个直接挂在第一个的 Task 上——两条消费路径合计一次引擎往返。 +- **失效策略取保守全清**:`fullReload`(镜像加载、reloadData)与 `specializationAdded` 都直接 `invalidateAll()`。事件稀少(用户级操作触发),全清永远正确;按 imagePath 精细失效的复杂度不值得。注入路径已核实被覆盖:注入后要么引擎广播 `.fullReload`(`_loadImage` / `reloadData`),要么文档整体换引擎(`$runtimeEngine` 订阅清空)。 +- **`nil` 与 error 不缓存**:"找不到"可能在另一镜像加载后变为"找得到",dead link 重查成本极低;error 缓存则会把一次瞬时 XPC 故障放大成持续错误。 +- **批量消费者绕过缓存**:接口批量导出与 MCP 工具继续直连引擎——一次批量扫描会把导航即将回访的条目全部逐出,属于反收益。 + +**放弃的方案**: +- 引擎侧缓存——缓存必须在 XPC 客户端这一侧才省得掉往返;引擎侧还要处理多客户端一致性。 +- `NSCache` ——不提供 LRU 序且逐出时机不可测试;手写 16 容量 LRU 共 20 行。 +- 按 `(engineID, imagePath)` 精细失效——事件频率不支撑这个复杂度。 + +## 4. 结果与验证 + +- `RuntimeInterfaceCacheTests`(8 条):重复命中 1 次 fetch;不同 options 分 key;并发合并为 1 次 fetch;error / nil 均不缓存、下次重试;`invalidateAll` 强制重拉;**真实引擎 `reloadData` 广播经 RxCombine 接线冲刷缓存**(wiring 集成测试);LRU 按最近使用逐出(touch 后的旧条目存活,未 touch 的被逐出);flush 后晚到的 in-flight 结果交还调用方但不入缓存。 +- `ContentTextPipelineTests.navigationRevisitRendersFromCache`:同一 `DocumentState` 上先后创建两个 `ContentTextViewModel`(模拟 push 走、back 回来),第二个渲染完成时 fetch 计数仍为 1。 +- 全部包测试 74/74 通过(10 个 suite)。 +- App 目标经 sibling workspace 构建通过(helper + 主 App,0 error / 0 warning)。 + +## 5. 影响面 + +- **行为语义变化只有一处理论窗口**:attach 到活进程时,若目标进程的运行时在**无任何数据事件**的情况下原地变化(既没加载镜像、没触发 reload、没换引擎),16 条内的旧接口会显示缓存值;此前每次导航都重拉、能"碰巧"看到最新。所有已知变更路径(加载镜像、注入、specialization、手动 reload、换 source)都会广播事件或换引擎,均触发全清。 +- 链接点击、保存文件、拖拽分享的输出文本从"裸 options"统一为"merged options":保存/分享的文本与内容区显示逐字一致(此前 transformer 配置不参与保存路径,是既有 bug)。 +- iOS / Catalyst:缓存与合并逻辑平台中立(UIKit 分支 transformer 恒为 `.init()`,与 fetch 半程一致);无 public API 破坏,`Hashable` 均为纯增量。 +- 内存上界:16 × 典型几十 KB `SemanticString`,个别 `UIView.h` 量级 MB 级条目参与 LRU 自然轮换,无需内存压力钩子。 + +## 6. 迁移 / 跟进注意事项 + +- **新增单对象接口取用点的规矩**:走 `documentState.interfaceCache` + `currentMergedGenerationOptions`,key 才能与内容区共享;批量路径(导出、MCP)继续直连引擎,不要"顺手"套缓存。 +- 若未来出现"引擎数据在无事件下变化"的合法场景(例如注入后的热改写不经任何广播),补一条 `dataChangePublisher` 事件即可,缓存侧无需改动——失效面是事件驱动的。 +- 容量 16 为经验值;若 Instruments 显示导航深度普遍超过 16,调大即可(`RuntimeInterfaceCache.init` 的 `capacity` 参数,测试已参数化)。 diff --git a/Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md b/Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md new file mode 100644 index 00000000..fb23fcdc --- /dev/null +++ b/Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md @@ -0,0 +1,58 @@ +# Open Quickly 惰性物化 + 根侧边栏后台过滤 + Outline 展开持久化合并 + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md`(本文完成其 §6 跟进项 1 与 4)、`Documentations/Plans/specialization-typepicker-perf-r2.md`(惰性 cellVM 思路的先例) +- **Regression suites**: `OpenQuicklyLazyConstructionTests.swift`、`SidebarRootFilterPipelineTests.swift`、`StatefulOutlineViewAutosaveTests.swift` + +## 1. 动机(为什么做) + +2026-08 侧边栏过滤重构(前述 sidebar-filter-pipeline 文档)之后,性能排查清单上还剩三个主线程热点,本次一并处理: + +1. **Open Quickly 的 2×N eager 构建**:`SidebarRuntimeObjectListViewModel.reloadData` 在主线程为 Open Quickly 再 eager 构建一整套 `forOpenQuickly: true` 的 cell view model(图标 + 富文本标题 + 子树),与侧边栏那份合计 2×N。10k 行 debug 实测约 250 ms/次镜像加载——而多数会话根本不打开 Open Quickly。 +2. **根侧边栏过滤全在主线程**:`SidebarRootViewModel` 每个 keystroke 在主线程对整棵镜像树做递归 `localizedCaseInsensitiveContains` 级联(首次还要在主线程递归拼接每个节点的聚合名)。shared cache 场景镜像树数千节点。 +3. **`StatefulOutlineView` 展开持久化 O(n²)**:每收到一条 `itemDidExpand` / `itemDidCollapse` 通知就全行扫描 + 写一次 `UserDefaults`。option-点击展开大子树时每个条目一条通知,N 条通知 × O(N) 扫描 = O(N²)。 + +## 2. 范围(改了哪些部分) + +| 文件 | 改动 | +|---|---| +| `Sidebar/SidebarRuntimeObjectCellViewModel.swift` | 新增静态纯函数 `haystack(for: RuntimeObject)`:不物化 cellVM 直接从值树算出与实例属性 `currentAndChildrenNames` 逐字节一致的 haystack(两边都按 `displayName` 排序子节点、单空格连接;高亮 range 的映射正确性依赖这一契约,有对拍测试钉死)。 | +| `Sidebar/SidebarRuntimeObjectListViewModel.swift` | 删除 `nodesForOpenQuickly`(全项目无外部使用者)。reload 只存排序好的 `[RuntimeObject]` 值数组;haystack 在首次查询时后台计算并按 reload 代次缓存;匹配后只为命中行物化 cellVM(`openQuicklyCellViewModelsByRowIndex` 按行号缓存、跨 keystroke 复用,DifferenceKit 行身份稳定);清空只给已物化的行去高亮。类从 `final` 放开(测试需要 seeded 子类,与基类一致)。 | +| `Sidebar/SidebarRootCellViewModel.swift` | 删除 `filter` didSet 级联与 `currentAndChildrenNames` lazy 聚合名(双双失去唯一调用者);新增 `unfilteredChildren` 与 `applyFilterOutcome(filteredChildren:)` 作为管线的主线程应用出口。 | +| `Sidebar/SidebarRootFilterPipeline.swift`(新增) | 三段式管线(snapshot 主线程 → verdicts 任意线程 → apply 主线程),逐层复刻旧级联语义:聚合名 contains 匹配、**自身名字命中则整棵子树不过滤**、否则按子树聚合名递归过滤。聚合名在 verdict 阶段后移到后台一趟 post-order 算完。空查询走同步 `resetToUnfiltered` 快路径(纯指针写)。 | +| `Sidebar/SidebarRootViewModel.swift` | keystroke 处理改为 `scheduleRootRefilter()`:取消前任 + 代次令牌 + 空查询同步复位 + 非空查询后台 verdict;`$nodes` 重建时作废在飞任务(视觉行为与旧版一致:镜像树重建时过滤复位)。 | +| `RuntimeViewerUI/AppKit/StatefulOutlineView.swift` | 通知处理改为 `scheduleExpansionPersist()`:一个 burst 只在下一个 main-queue turn 执行一次全行扫描 + 一次 UserDefaults 写入(前置条件在 flush 时重查)。新增 `package private(set) var expansionAutosavePersistCount` 作为回归测试 seam。view 释放时未 flush 的 burst 丢弃——展开状态本就是 best-effort UI 状态。 | +| `RuntimeViewerPackages/Package.swift` | 测试 target 增加 `RuntimeViewerUI` 依赖(`package` 可见性的 seam 需要同包 import)。 | +| 测试(3 个新套件 + 基建) | 见 §4。另有测试基建 `SharedLocalEngineTestLock.swift`:跨 suite 互斥锁 + 引擎启动屏障(见 §5 的意外发现)。 | + +## 3. 关键设计与取舍 + +- **Open Quickly 的匹配对象从 cellVM 换成纯值 haystack**。匹配只需要字符串;cellVM 只有被显示的行才需要。物化成本从 reload 时 O(N) 主线程一次性支付,变为查询时 O(命中数) 增量支付且跨 keystroke 复用。最坏情况(宽 fuzzy 查询命中过万)单次仍会物化大量行——但那正是「用户真的要看这一万行」的场景,成本花在刀刃上,且退格收窄时全部复用。 +- **haystack 双实现的字节级契约**。fuzzy 高亮 range 是相对纯值 haystack 计算的,物化后的 cellVM 又拿自己的 `currentAndChildrenNames` 做 range 映射——两边必须逐字节一致。静态函数与实例属性互相交叉引用注释 + `haystackParity` 测试钉死。 +- **根侧边栏不复用 `SidebarRuntimeObjectFilterPipeline`**:两者语义不同(根侧边栏是「自身命中 → 子树全显」的目录树语义,运行时对象树是 scope 剪枝 + 模式化匹配语义),硬泛化会让两边都难读。管线骨架(三段式 + 代次令牌 + 形状校验)按同一模式各自实现。 +- **聚合名后移到 verdict 阶段**:旧实现的 lazy 聚合名缓存在首次过滤时于主线程递归拼接整棵树。管线把聚合名和匹配放进同一趟后台 post-order 递归,主线程 snapshot 只抄节点名。相应地 cellVM 上的 lazy 聚合名属性删除,避免两套实现漂移。 +- **Outline 持久化用「合并」而非「节流」**:burst 内第一条通知排队一次 flush,其余通知免费;flush 时重查全部前置条件(filter 状态可能在窗口内变化)。不用定时器——`DispatchQueue.main.async` 的下一 turn 就够,用户感知不到延迟,also 不引入取消管理。 + +**放弃的方案**:给 Open Quickly 上 `DifferentiableBox` 惰性 cellVM(CLAUDE.md §9 的三条件不满足——行有高亮状态,需要跨 keystroke 的订阅身份);在后台构建 cellVM(`RuntimeObjectIcon` 的静态缓存无锁、只在主线程访问,后台构建会引入数据竞争换 250ms,不值);给根侧边栏做高亮(旧版根侧边栏本就无高亮,本次不加行为)。 + +## 4. 结果与验证 + +- **回归测试**(3 个新套件,共 7 条,全部通过;全包 81 测试 4 连跑无 flake): + - `OpenQuicklyLazyConstruction`:haystack 字节级对拍;端到端——reload 物化 **0** 行(旧版此处已建满 N 行)、查询只物化命中行且全带高亮、重复查询复用同一批实例(`ObjectIdentifier` 集合相等)、清空去高亮但缓存保温、reload 全量作废。 + - `SidebarRootFilterPipeline`:管线输出与独立参考实现在 5 组查询(含目录段命中、全 miss)下逐行一致;空查询复位恢复全树;VM 端到端(150ms 合并窗 + 后台 verdict + 清空复位 + 不触发导航)。 + - `StatefulOutlineViewAutosave`:60 项 expand-all burst 同步阶段 0 次持久化、flush 后 ≤2 次全行扫描(旧版 60+ 次)、持久化集合正确;collapse 后增量持久化正确。 +- **量级变化**(debug、与前文基线同机同 N 推算):镜像加载的 Open Quickly 份额 ~250 ms → **0**(首次查询时后台补 haystack,一次性、不在主线程);根侧边栏 keystroke 的主线程成本从「整树 ICU contains 级联」降为「snapshot 抄名 + apply 指针写」;outline 展开 burst 从 O(N²) 行访问 + N 次 defaults 写降为一次扫描 + 一次写。 +- App 构建(sibling workspace,Catalyst helper → RuntimeViewer macOS)见任务输出:0 error / 0 warning。 + +## 5. 影响面与意外发现 + +- 行为语义不变:Open Quickly 的结果集与顺序(fuzzy 分数序)、根侧边栏的目录树过滤语义、outline 的持久化内容均有对拍/断言钉死。肉眼可见变化只有结果出现方式(后台化后异步出现)与持久化时机(burst 结束后一拍)。 +- `SidebarRuntimeObjectListViewModel` 从 `final` 放开为可子类化(测试 seeding 需要,且与基类姿态一致);`nodesForOpenQuickly` 属性删除(无外部使用者)。 +- **意外发现(测试基建)**:swift-testing 跨 suite 并行 + 进程共享的 `RuntimeEngine.local` 会互扰——(a) 真引擎 `reloadData` 集成测试的 `.fullReload` 广播会命中并发 suite 里正在断言的缓存/seeded VM;(b) `RuntimeEngine.local` 首次 `connect()` 的 `observeRuntime()` 会经由非结构化 `Task` 在**任意时刻**补发一次启动 `.fullReload`。两者都是既有行为,此前 74 测试时靠调度运气没撞上,81 测试后撞上了。修复:`SharedLocalEngineTestLock.swift` 提供跨 suite 互斥锁(广播方与广播敏感方都持锁)+ 一次性启动屏障(等 image nodes 就绪 + 250ms 宽限);`RuntimeInterfaceCacheTests` 以 async suite init 统一过屏障。**新增会订阅共享引擎数据变更的测试时必须遵循同一模式。** + +## 6. 迁移 / 跟进注意事项 + +- 前文 sidebar 文档 §6 的跟进项 1(Open Quickly eager 构建)与 4(根侧边栏管线化)由本文落地;其余项(TypePicker debounce 对齐、fuzzy 高亮构建后台化)维持按测量再决定。 +- `SidebarRuntimeObjectCellViewModel.haystack(for:)` 与 `currentAndChildrenNames` 的字节级契约:改任何一边的排序/连接规则必须同步另一边,并跑 `OpenQuicklyLazyConstruction` 套件。 +- 启动 `.fullReload` 补发与广播的 `Task` 包裹(`RuntimeEngine.broadcast`)是产品代码的既有事实;如果未来它在 app 侧也造成可观测问题(例如启动早期的 UI 闪烁),修复点在引擎侧,不要在测试屏障上加码掩盖。 diff --git a/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md b/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md new file mode 100644 index 00000000..1bb852cc --- /dev/null +++ b/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md @@ -0,0 +1,89 @@ +# Sidebar 过滤管线性能重构(didSet 守卫 + 后台匹配 + Open Quickly 竞态修复) + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/specialization-typepicker-perf-r2.md`(同类问题在 TypePicker 上的先例)、`Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md`(内容区渲染管线,仍待批准,不在本次范围内) +- **Regression suite**: `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift` + +## 1. 动机(为什么做) + +侧边栏(按镜像浏览运行时对象)的文本过滤在大镜像(dyld shared cache 中 10k+ 类型)上每个 keystroke 造成 **~3.5 秒主线程冻结**,零命中查询同样付全价。基线测量(debug 构建、Apple Silicon、N = 10,000,见回归测试套件的 `[baseline]` 输出): + +| 场景 | 修复前 | 标题重建次数(前) | +|---|---|---| +| contains 过滤(默认模式),任意命中数 | 3454–3654 ms | 10,000 | +| 清空搜索框 | 3763 ms | 20,000 | +| fuzzy 过滤(Open Quickly 配置),命中 100 | 4132 ms | 10,100 | +| 构建 10k 个 cellVM(镜像加载) | 3618 ms | — | +| VM 端到端一次搜索 | 3661 ms | — | + +根因有四层,全部在主线程上叠加: + +1. **每行两个无守卫的 didSet**:`FilterEngine.filter` 的复位循环对每个 item 触发 `filterResult = nil`,didSet 无条件重建 `NSAttributedString` 标题(nil→nil 也重建);清空查询时该复位执行两遍(2N 次重建)。 +2. **didSet 副作用级联**:`filter` 属性的 didSet 递归重过滤整棵子树,且每个节点每次级联都读一次 `appDefaults.filterMode`(`@Dependency` + `UserDefaults` 读取,实测约占 cellVM 构建成本的 90%——3618 ms 中约 3300 ms)。 +3. **haystack 无缓存**:`currentAndChildrenNames` 每次访问递归拼接整棵子树的名字,一轮过滤每节点访问 1–3 次。 +4. **"500 ms debounce" 从未生效**:`.just(pair).debounce(500ms)` 中 `just` 发出元素后立即 complete,而 RxSwift 的 `debounce` 在上游 complete 时立即冲刷挂起元素——所以每个 keystroke 都立刻同步执行全量过滤(端到端实测 64 ms 内出结果,证明无任何延迟发生过)。 + +另有两个顺带确认的正确性问题: + +- **大小写分支反转**:`FilterEngine` 的 contains 分支写反了——`isCaseInsensitive == true` 用大小写敏感的 `contains`,`false` 反而用 `localizedCaseInsensitiveContains`。 +- **Open Quickly 竞态**:搜索用无取消、无代次校验的 `Task.detached`,两次搜索可并发改写同一批 cellVM,且慢的旧查询可能后返回、覆盖新结果。 + +## 2. 范围(改了哪些部分) + +全部在 `RuntimeViewerPackages/Sources/RuntimeViewerApplication` + AppKit 侧一行按钮默认值: + +| 文件 | 改动 | +|---|---| +| `FilterEngine.swift` | 拆出纯函数核心 `FilterEngine.match(_:haystacks:) -> [FilterMatchVerdict]`(无副作用、线程安全、保序:fuzzy 按分数、contains 按输入序);`filter/isCaseInsensitive/mode` 合并为 `FilterContext: Equatable`;变异式 `filter(context:items:)` 保留给单层调用(cellVM 局部路径),只在值变化时写回;**修正大小写分支**。 | +| `Sidebar/SidebarRuntimeObjectCellViewModel.swift` | `filterResult` didSet 加 nil→nil 守卫;`filterContext` 带判等守卫的 computed setter(局部级联入口);`currentAndChildrenNames` 缓存 + `rebuildChildren`/`children` setter 时沿 parent 链向上失效;新增 `applyFilterOutcome(...)`(管线主线程应用出口,不触发级联);`scope` 改为普通存储属性;删除 `applyScopeRecursively`。 | +| `Sidebar/SidebarRuntimeObjectFilterPipeline.swift`(新增) | 三段式管线:`snapshot`(主线程,值类型树,读缓存 haystack)→ `verdicts`(任意线程,逐层复刻旧级联语义,含协作取消)→ `apply`(主线程,形状不匹配时放弃而非错配应用)。 | +| `Sidebar/SidebarRuntimeObjectViewModel.swift` | `rebuildFilteredNodes` 替换为 `scheduleRefilter()`:取消前任 + 代次令牌 + 空查询同步快路径 + 非空查询后台匹配;`debounce` 改为 `delay(150ms)`(真正生效的合并窗口,flatMapLatest 负责取消)。 | +| `Sidebar/SidebarRuntimeObjectListViewModel.swift` | Open Quickly 的 `Task.detached` 替换为取消 + 代次守卫的 `scheduleOpenQuicklyRefilter`;`nodesForOpenQuickly` 重建时同步作废在飞搜索;debounce 500→150 ms;不再向永不显示的子级 cellVM 级联高亮。 | +| `Sidebar/SidebarRootViewModel.swift` | 同样的 `debounce`→`delay(150ms)` 修正(根侧边栏过滤本身仍在主线程,见 §6)。 | +| `RuntimeViewerUsingAppKit/.../SidebarRuntimeObjectViewController.swift` | 大小写按钮默认 `.on`:引擎修正反转逻辑后,保持默认行为(大小写不敏感)不变,且按钮高亮状态从此与实际行为一致。 | + +## 3. 关键设计与取舍 + +- **匹配下后台、变异留主线程**。cellVM 同时被可见 cell 绑定、被 outline view 数据源在主线程读取,且 CLAUDE.md 规定 sidebar cellVM 必须保持 eager(持有过滤高亮与订阅身份)。因此与 TypePicker 方案的唯一结构差异是:后台只算"谁命中"(纯值快照 → verdict 树),所有 cellVM 写入回到主线程 apply 步骤,天然无竞争。 +- **守卫让 apply 步骤 O(变化的高亮) 而非 O(节点)**。nil→nil 跳过是最大单项收益;contains 模式(默认)无高亮,keystroke 主线程成本降为 0 次标题重建。 +- **代次令牌而非锁**。reload / splice / 新查询都发生在主线程同步块内并 bump 代次;旧任务的 apply 在其后到达主线程时发现代次不符直接丢弃。`apply` 再以形状校验兜底(不匹配则保留旧结果)。 +- **`delay` 替代无效的 `debounce`,且窗口从 500 压到 150 ms**。匹配已后台化且可取消,不再需要保守窗口;空查询保持立即应用(清空不闪旧结果)。 +- **逐层复刻旧排序语义**。verdict 递归在每一层调用与旧代码相同的匹配(fuzzy 分数序 / contains 输入序 / scope 先剪枝再匹配),有专门的对拍测试(`pipelineMatchesMutatingCascade`)保证管线与单层级联逐字节一致。 +- **保留变异式 `FilterEngine.filter`**:specialization splice 路径需要同步重建单个节点的 `_filteredChildren`(`reloadRow` 信号发出时子级必须已就位),这条局部路径继续走带守卫的级联。 + +**放弃的方案**:lazy cellVM(`DifferentiableBox`)——CLAUDE.md 明确排除 sidebar;给 `DifferentiableBox` 加缓存——Evolution 0004 已裁决不做;把 `NSAttributedString` 构建也移下后台——当前唯一剩余的大额主线程转换(fuzzy 全命中↔清空,10k 次合法重建 ≈ 160-200 ms debug)只出现在 fuzzy 宽查询转换上,不值得为它破坏 cellVM 的主线程所有权模型(见 §6 跟进项)。 + +## 4. 结果(同一测试套件、同机、debug 构建) + +| 场景 | 修复前 | 修复后 | 标题重建(前 → 后) | +|---|---|---|---| +| contains 过滤,全命中 | 3654 ms | **54 ms** | 10,000 → **0** | +| contains 过滤,命中 100 | 3561 ms | **62–69 ms** | 10,000 → **0** | +| contains 过滤,零命中 | 3454 ms | **64–81 ms** | 10,000 → **0** | +| 清空搜索 | 3763 ms | **17–20 ms** | 20,000 → **0** | +| fuzzy 命中 100 | 4132 ms | **331–423 ms** | 10,100 → **100** | +| fuzzy 全命中 | 4599 ms | **638–760 ms** | 20,000 → 10,000(合法:每行高亮真实变化) | +| fuzzy 清空 | 3565 ms | **159–204 ms** | 20,000 → 10,000(合法:逐行去高亮) | +| 树形 10k 节点 contains | 3652 ms | **112–138 ms** | 10,000 → **0** | +| 构建 10k cellVM | 3618 ms | **237–272 ms**(去掉了每节点的 UserDefaults 读取) | — | +| VM 种子 reload | 7429 ms | **227–293 ms** | — | +| VM 端到端一次搜索 | 3661 ms(同步冻结) | **250 ms**(含 150 ms 合并窗,主线程零冻结) | — | + +且修复后所有耗时中的匹配部分都已移出主线程;上表 contains/fuzzy 行的数值是回归测试里同步调用变异式包装的量测值,真实 UI 路径只在主线程支付 apply 步骤。 + +## 5. 影响面 + +- 侧边栏搜索、scope 过滤、Open Quickly、根侧边栏搜索的行为语义不变(有对拍测试);肉眼可见变化只有两个:结果出现的延迟由"冻结后一起出现"变为"150 ms 合并窗后异步出现";大小写按钮默认高亮(行为与从前的默认一致)。 +- iOS / Catalyst:改动全部平台中立,`Input`/`Output`/public API 无变化。 +- `FilterableItem` / `FilterEngine` 是 internal API,唯一 conformer 是 `SidebarRuntimeObjectCellViewModel`,无外部波及。 + +## 6. 迁移 / 跟进注意事项 + +- **回归断言已翻转**:`SidebarFilterPerformanceBaselineTests` 现在钉死"keystroke 只允许重建高亮真实变化的行"。任何让计数回升的改动都会被测试抓住。 +- **跟进(未做,按测量再决定)**: + 1. ~~Open Quickly 的 `nodesForOpenQuickly` 仍在主线程 eager 构建第二份 N 个 cellVM(现约 250 ms/10k,debug)~~ —— **已落地**:改为纯值 haystack 匹配 + 命中行惰性物化,见 `2026-08-04-openquickly-root-outline-perf.md`。 + 2. TypePicker 的 debounce 仍是 500 ms(真 debounce,生效中);如要与 sidebar 的 150 ms 手感对齐,单独一行改动。 + 3. fuzzy 宽查询 ↔ 清空的 10k 次合法高亮重建(~200 ms debug)如成为可感知瓶颈,方案是把高亮 `NSAttributedString` 构建挪进 verdict 阶段(后台),apply 只做赋值——需要先给 cellVM 的 title 通道设计后台构建协议,勿轻做。 + 4. ~~根侧边栏(`SidebarRootViewModel`)过滤仍在主线程~~ —— **已落地**:`SidebarRootFilterPipeline` 三段式后台过滤,见 `2026-08-04-openquickly-root-outline-perf.md`。 + 5. 内容区渲染管线优化(2026-05-17 计划的 PR1/PR2)—— PR1 已另行落地(`2026-08-04-content-text-pipeline-pr1.md`),PR2/PR3 按度量门控。 diff --git a/Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md b/Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md new file mode 100644 index 00000000..e0a38470 --- /dev/null +++ b/Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md @@ -0,0 +1,95 @@ +# 2026-08-10 - Land the PR #88 Open Quickly perf fixes + +- **日期**: 2026-08-10 +- **任务**: Land the PR #88 Open Quickly perf fixes +- **作者**: Mx-Iris +- **仓库**: git@github.com:MxIris-Reverse-Engineering/RuntimeViewer.git + +## 1. 问题 / 任务 + +接手另一会话卡住的工作:PR #88(`perf/pipeline-optimizations`)第二轮 review 的三条 +Open Quickly 修复(F9 被作废趟丢弃 haystack 构建、F10 materialize 时主线程重建 haystack、 +F11 宽查询无界 materialize)生产代码已写好、能编译,但三条依赖引擎的复现测试全部卡在 +`loadState == .notLoaded` 超时。要求:排查卡点、每条完成「修复前红、修复后绿」验证、 +全量测试在分支锁定 pin 下通过、按发现拆成独立 commit 推送,并补写第二轮 15 条发现的 +裁决文档与 KnownIssues 索引。工作区限定在独立 worktree +`.claude/worktrees/pr88`,不碰主工作区。 + +## 2. 探索与调研 + +### 调研内容 + +- `SidebarRuntimeObjectViewModel.reloadData()` 的 `loadState` 状态机(`.notLoaded` 的唯一赋值来源) +- 卡住的 `OpenQuicklyMaterializationBoundsTests` 与能通过的对照 `OpenQuicklyLazyConstructionTests` 的 Harness 逐行对比 +- `RuntimeEngine.local` / `imageList` / `isImageLoaded` / `dispatch` 语义;`pollUntil`、`SharedLocalEngineTestLock`、`withLiveDependencyContext` 实现 +- `RuntimeImageNode` 的 `parent`(weak)、`absolutePath`(lazy)、`rootNode(for:name:)`、`removeFirstPathComponent()` 全文 +- 对照实验:同一环境、同一编译产物下分别运行两个套件 + +### 关键发现 + +- `.notLoaded` 不是初始态(初始为 `.unknown`),是 `isImageLoaded(path:) == false` 的主动赋值——问题即「引擎不认这个 `imagePath`」,不是 reload 没跑、不是依赖上下文、不是 init 链差异(前会话排除清单里的方向全部无关)。 +- **根因**:`RuntimeImageNode.parent` 是 weak 边、`absolutePath` 是 lazy 且靠 parent 链推导;`var imageNode = rootNode(...)` 原地下钻在第一次重赋值时丢掉 root 的唯一强引用,祖先链逐层释放,叶子 `path` 坍缩为 `"/"`。 +- 对照测试同款逻辑靠一个 `let rootImageNode` 中间变量在 -Onone 下侥幸存活——依赖未承诺的 ARC 行为,本质同样脆弱。 +- **F9 测试设计缺陷**(红态推演时发现):`gate.release()` 同时放行两趟构建,未被作废的当前趟自己会装缓存,该测试在旧代码下也绿,抓不住回归。 + +### 候选方案 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| Harness 持有 root 属性保活整棵树 | 直观 | 多一个字段,且叶子路径仍依赖树存活时序 | +| 下钻前锚定 root + `withExtendedLifetime` 内固化叶子 `absolutePath`(选定) | 路径值固化后与树生命周期解耦,改动最小 | 需要一段注释解释为什么这行不能删 | +| 改生产 `RuntimeImageNode`(parent 改强引用或 absolutePath 非 lazy) | 根治 | 超出授权范围(引用环风险、影响面大),不属于本次测试脚手架修复 | + +## 3. 最终方案 + +三条生产修复方案维持前会话已获批准的原样(F11 按分数截断前 500 行、F10 seed 播种、 +F9 版本号守卫下提前安装),仅修测试脚手架:`makeImageNode()` 锚定 root 并在 +`withExtendedLifetime` 内固化叶子 `absolutePath`;F9 测试的 gate 增加 `releaseNext()` +只放行被作废那趟,断言后再全量 `release()` 清理。落地方式按既有批准执行:每条发现 +独立 commit(代码 + 测试同 commit)推 `perf/pipeline-optimizations`,普通 push; +另补第二轮裁决文档与 KnownIssues 索引。用户在执行中途通过发起会话补充确认 +「完成之后直接推送,不必回来等确认」。 + +## 4. 实际执行与改动 + +### 改动清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift` | 修改 | 修 `makeImageNode()` 生命周期陷阱;gate 加 `releaseNext()`;F9 测试改为只放行被作废趟;suite 注释 "two costs" 修为 "residual costs" | +| `RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift` | 修改 | 前会话已写好的 F9/F10/F11 改动,按逆向中间态拆分入三个 commit | +| `RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift` | 修改 | 前会话已写好的 `seedCurrentAndChildrenNames(_:)`,归入 F10 commit | +| `Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md` | 新建 | 第二轮 15 条发现裁决(`PR88R2.`):6 已修含哈希、F5/F12 降级、F14 正确性段撤销、6 backlog、复核提级项与本次新发现 | +| `Documentations/KnownIssues/README.md` | 修改 | 补 2026-08-09 与 2026-08-10 两份裁决文件的索引行 | + +落地 commit(推送区间 `9f32e85e..4a979469`): + +- `523d98dd` — F9 `perf(sidebar): install a superseded Open Quickly pass's haystack build` +- `b3c65095` — F10 `perf(sidebar): seed materialized Open Quickly cells with the pass's haystack` +- `11238751` — F11 `perf(sidebar): cap Open Quickly materialization at the top 500 matches` +- `4a979469` — `docs(known-issues): adjudicate the second PR #88 review pass` + +### 关键命令 + +``` +swift test --scratch-path /tmp/claude/SwiftPM/RuntimeViewerPackages-pinned \ + --disable-automatic-resolution [--filter ] +# 成败一律以 ${pipestatus[1]} 原始退出码判定,未经 xcsift +git push origin perf/pipeline-optimizations # 普通 push,fast-forward +``` + +### 验证 + +- 红/绿逐条:F9 还原安装位置 → 21.2s 超时红(exit 1);F10 移除 seed → SeedMarker 断言 1.1s 红;F11 取消截断 → 两条 count==cap 断言红(1200≠500);各自恢复后绿。 +- 三个 commit 按逆向中间态构建(F9-only → +F10 → +F11),每个中间态独立编译并跑在场测试(1/3/4 条)全绿后才提交。 +- 全量:101 tests in 18 suites 全部通过(exit 0),`Package.resolved` 全程无改动。 + +### 与原方案的差异 + +- **差异点**: F9 测试的 gate 从单一 `release()` 改为 `releaseNext()` + 末尾 `release()`。 + **原因**: 红态推演证明原设计在旧代码下也绿(未被作废的当前趟自己装缓存),测试无效。 + **影响**: 该测试现在能真实抓住回归(红态验证 21.2s 超时失败证实)。 +- **差异点**: 测试文件 suite 注释 "the two costs" 改为 "the residual costs"。 + **原因**: 原文 "two" 与实际列出的三个 bullet 数目不符。 + **影响**: 仅措辞,无行为变化。 +- 其余与最终方案一致;三条生产修复未做任何方案级改动。 diff --git a/Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md b/Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md new file mode 100644 index 00000000..cf645e67 --- /dev/null +++ b/Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md @@ -0,0 +1,72 @@ +# 2026-08-10 - PR #88 F9/F10/F11 Landing Retrospective + +- **日期**: 2026-08-10 +- **性质**: 过程复盘(配套的任务报告见同目录 task report 文件) +- **范围**: 跨会话接手 PR #88 三条 Open Quickly 修复的测试卡点排查、红绿验证与分批落地 + +## 一句话总结 + +三条修复的生产代码本身没有任何问题,卡住两个会话的是测试脚手架里**一行看不见的生命周期差异**;定位它靠的不是继续读代码,而是一次对照实验。 + +## 卡点是怎么定位的(方法复盘) + +1. **先把「卡住」翻译成事实**。前一个会话的描述是「loadState 永远停在 `.notLoaded`」。查状态机后发现 `.notLoaded` 不是初始值(初始值是 `.unknown`),它只有一个赋值来源:`reloadData()` 里 `isImageLoaded(path:)` 返回 false。这一步把问题从「reload 没跑」改写成「reload 跑了,但引擎不认这个路径」——排查范围立刻缩小到 `imagePath` 的来源。 +2. **静态对比穷尽后立即转实证**。两个测试文件的 Harness 逐行对比看不出差异(事后证明差异是一个中间变量的生命周期,静态读根本看不出来)。在同一环境先复现失败(4 测试 8 issue),再跑「据说能过」的对照套件(2 秒全绿)——这一步把「环境问题 / 共同基础设施问题」整类假设一次排除,剩下的只能是两个文件之间的差异。 +3. **带着「必然存在差异」的结论回头再读**,才注意到对照测试多写的 `let rootImageNode` 不是冗余代码,而是生命周期锚。 + +**教训**:并发/生命周期类问题上,「逐行对比看起来一样」不构成证据。一次对照实验的信息量大于任何时长的静态推演——这与本项目 UI flicker 的既有经验(先测帧再改码)是同一条原理。 + +## 三个值得沉淀的发现 + +### 1. `RuntimeImageNode` 的 weak-parent 陷阱(根因) + +`parent` 是 weak 边、`children` 是强边、`absolutePath` 是 lazy 且靠 parent 链推导。用 +`var node = rootNode(...)` 原地下钻时,第一次重赋值就丢掉 root 的唯一强引用,祖先链逐层 +dealloc,叶子的 `path` 坍缩为 `"/"`。`absolutePath` 的文档注释其实早已记录过同款坑 +(bookmark decode 场景),但没有人把它和「局部变量下钻」联系起来。 + +更微妙的是:**对照测试 `OpenQuicklyLazyConstructionTests` 的同款代码是侥幸绿**——它多了 +一个 `let rootImageNode` 局部变量,-Onone 下局部变量恰好活到作用域尾。这依赖未承诺的 +ARC 行为,严格说也是坏的,只是还没坏给你看。已记入裁决文档新发现一节,留待测试基建 +批次统一加 `withExtendedLifetime` 锚。 + +### 2. 「测试绿」不等于「测试有效」(F9 gate 的设计缺陷) + +前会话写的 F9 测试用 `gate.release()` 同时放行两趟 haystack 构建——第二趟(未被作废的 +当前趟)自己就会装缓存,所以断言「缓存非 nil」在**旧代码下也是绿的**。这条测试若原样 +落地,会是一条永远绿、什么也保护不了的回归测试。 + +暴露它的正是「修复前必须红」的强制流程:推演红态时序时发现红不出来,才回头改造 gate +(加 `releaseNext()` 只放行被作废那趟)。这是该流程价值的一次实证——它拦下的不是错误 +的修复,而是**无效的测试**。 + +### 3. 测试成败判定只认原始退出码 + +全程遵守本次新增的全局约定:所有 `swift test` 判定都取管道退出码(zsh +`${pipestatus[1]}`)加原始 swift-testing 输出,未经 xcsift。红态验证的三次 exit 1 与 +绿态的 exit 0 都以此为准。 + +## 做得对的事 + +- **逆向拆 commit**:从终态出发反向剥出 F9-only、F9+F10 两个中间态,每个中间态独立 + 编译并跑在场测试后才 commit——保证 PR 上每个 commit 可独立检出构建,git bisect 友好。 + 同一文件内三条改动交织时,这比 `git add -p` 手切 hunk 可靠得多(中间态真实编译过)。 +- **快照锚定**:动手前把三个文件的终态 cp 到 scratchpad。红态验证的三次「反转→恢复」 + 与拆 commit 的中间态重建,最后都用 `diff` 对快照校验,杜绝手工编辑漂移。 +- **红态即证据**:三条红态各自的失败形态(21.2s 超时 / SeedMarker 断言 / 1200≠500)与 + 预期的失败机制一一对应,而不是笼统的「测试挂了」。 + +## 可以更好的地方 + +- 静态对比阶段花了偏多的轮次(逐行读了两个 Harness、pollUntil、engine、 + SharedLocalEngineTestLock),其实在把 `.notLoaded` 溯源到 `isImageLoaded` 之后就该 + 直接跑对照实验。「先跑对照」应该更早成为反射动作。 +- 第一次后台跑测试用了 `| tail` 管道,导致中间进度不可见、只能等整体结束。后续改为 + 输出落文件 + Monitor/TaskOutput 才顺畅。长命令的输出策略应当在第一次就选对。 + +## 遗留事项 + +- 对照套件 `OpenQuicklyLazyConstructionTests` 的同款 weak-parent 写法未修(本次范围外, + 已记档),测试基建批次统一处理。 +- 裁决文档中的 6 条 backlog(PR88R2.4/6/7/8/13/14 性能段/15)与复核提级的 + lazy `_children` 竞争,等待后续批次拾起。 diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift index f1409a6c..c29586af 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift @@ -4,7 +4,7 @@ import MetaCodable extension RuntimeObjectInterface { @Codable @MemberInit - public struct GenerationOptions: Sendable { + public struct GenerationOptions: Sendable, Equatable, Hashable { @Default(ObjCGenerationOptions.default) public var objcHeaderOptions: ObjCGenerationOptions diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift index 788a47cc..1209b989 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift @@ -3,6 +3,11 @@ public import Semantic public struct RuntimeObjectInterface: Codable, Sendable { public let object: RuntimeObject - + public let interfaceString: SemanticString + + public init(object: RuntimeObject, interfaceString: SemanticString) { + self.object = object + self.interfaceString = interfaceString + } } diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index a86f529e..65b595be 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift @@ -13,7 +13,7 @@ typealias LoadingEventContinuation = AsyncThrowingStream { + /// Fetches the theme-independent interface of a runtime object. + /// Injectable so tests can count fetches and simulate failures; the + /// default implementation routes through the document's + /// `RuntimeInterfaceCache`, which reads the current runtime engine at + /// call time (the engine can be swapped mid-document) and flushes + /// itself on engine swaps and data-change events. + typealias InterfaceProvider = @Sendable (RuntimeObject, RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? + + /// Single fetch path shared by the content pipeline's fetch half and + /// the link-resolution flows in `transform(_:)`, so an injected test + /// provider observes every fetch this ViewModel performs. + private let interfaceProvider: InterfaceProvider + @Observed public private(set) var theme: ThemeProfile @@ -26,19 +49,37 @@ public final class ContentTextViewModel: ViewModel { @Observed public private(set) var attributedString: NSAttributedString? - public init(runtimeObject: RuntimeObject, documentState: DocumentState, router: any Router) { + public convenience init(runtimeObject: RuntimeObject, documentState: DocumentState, router: any Router) { + self.init(runtimeObject: runtimeObject, documentState: documentState, router: router, interfaceProvider: nil) + } + + init( + runtimeObject: RuntimeObject, + documentState: DocumentState, + router: any Router, + interfaceProvider: InterfaceProvider? + ) { self.runtimeObject = runtimeObject self.theme = ResolvedTheme.fallback + let interfaceCache = documentState.interfaceCache + self.interfaceProvider = interfaceProvider ?? { [interfaceCache] runtimeObject, options in + try await interfaceCache.interface(for: runtimeObject, options: options) + } super.init(documentState: documentState, router: router) self.imageNameOfRuntimeObject = runtimeObject.imageName + let resolvedInterfaceProvider = self.interfaceProvider + let transformerObservable: Observable #if canImport(AppKit) && !targetEnvironment(macCatalyst) + // The Settings instance is resolved once out here (base-class + // `@Dependency`), never inside the tracking closure — the re-arm + // hop loses the dependency context; see `ResolvedThemeStream`. + let trackedSettings = settings transformerObservable = Observable .tracking { - @Dependency(\.settings) var settings - return settings.transformer + trackedSettings.transformer } .share(replay: 1, scope: .whileConnected) #else @@ -63,27 +104,91 @@ public final class ContentTextViewModel: ViewModel { .bind(to: $theme) .disposed(by: rx.disposeBag) + // ── Fetch half (theme-independent) ────────────────────────────── + // Only object / generation-option / transformer changes reach the + // engine; theme and font-size changes never trigger an XPC + // round-trip — they replay the latest fetched interface into the + // render half below. + // // Capture the document-scoped dependencies instead of `self`: the // `Observable.async` Task keeps running briefly after disposal // (cancellation is cooperative), so an `unowned self` here aborts in // `swift_unknownObjectUnownedLoadStrong` whenever the ViewModel is // rebound away (tab switch / close) mid-generation. - Observable.combineLatest($runtimeObject, appDefaults.$options, themeObservable, transformerObservable) - .flatMapLatest { [documentState = self.documentState, _commonLoading = self._commonLoading] runtimeObject, options, theme, transformer in + let interfaceStream = Observable + .combineLatest( + $runtimeObject, + appDefaults.$options.distinctUntilChanged(), + transformerObservable.distinctUntilChanged() + ) + .flatMapLatest { [_commonLoading = self._commonLoading] runtimeObject, options, transformer -> Observable<(interfaceString: SemanticString, runtimeObject: RuntimeObject)?> in var mergedOptions = options mergedOptions.transformer = transformer return Observable.async { - try await documentState.runtimeEngine.interface(for: runtimeObject, options: mergedOptions).map { ($0.interfaceString, theme, runtimeObject) } + let fetchInterval = contentTextSignposter.beginInterval("content.interfaceFetch", id: contentTextSignposter.makeSignpostID()) + defer { contentTextSignposter.endInterval("content.interfaceFetch", fetchInterval) } + return try await resolvedInterfaceProvider(runtimeObject, mergedOptions).map { + (interfaceString: $0.interfaceString, runtimeObject: runtimeObject) + } } .trackActivity(_commonLoading) + // The catch must live on this inner sequence: one failed + // fetch surfaces as a single nil emission while the outer + // subscription stays alive for subsequent object / options / + // theme changes. A trailing `catchAndReturn` on the outer + // chain would complete the whole pipeline on first error and + // permanently freeze this tab's content. + .catchAndReturn(nil) + } + .share(replay: 1, scope: .whileConnected) + + // ── Render half (theme-dependent, off-main) ───────────────────── + // Rebuilds the attributed string whenever the fetched interface or + // the theme changes. The build runs on a background scheduler; + // `flatMapLatest` drops a superseded build's emission, so a burst of + // font-size clicks only publishes the newest result. + // + // One scheduler for the pipeline's lifetime: the convenience + // initializer allocates a fresh DispatchQueue, so constructing it + // inside the closure would churn a queue per emission. + let renderScheduler = ConcurrentDispatchQueueScheduler(qos: .userInitiated) + Observable + .combineLatest(interfaceStream, themeObservable) + .flatMapLatest { [_commonLoading = self._commonLoading] interfacePair, theme -> Observable in + // Tracked so the indicator covers click → new text on + // screen: with a warm interface cache the fetch half is + // near-instant, and theme / font-size changes skip it + // entirely — without this, every visible wait would fall in + // an untracked gap. No dark gap between the halves either: + // the fetch's element propagates here (incrementing the + // activity) before its `Observable.async` completes and + // decrements. + Observable.just(()) + .observe(on: renderScheduler) + .map { Self.renderAttributedString(for: interfacePair, theme: theme) } + .trackActivity(_commonLoading) } - .catchAndReturn(nil) .observeOnMainScheduler() - .map { $0.map { $0.attributedString(for: $1, runtimeObjectName: $2) } } .bind(to: $attributedString) .disposed(by: rx.disposeBag) } + /// Builds the display-ready attributed string for a fetched interface. + /// + /// `nonisolated`: invoked on the render half's background scheduler. + /// Safe off the main thread — `ResolvedTheme`'s color/font lookups are + /// init-time-precomputed read-only tables, and the builder allocates + /// only immutable font/color/string values, returning an immutable copy. + nonisolated static func renderAttributedString( + for interfacePair: (interfaceString: SemanticString, runtimeObject: RuntimeObject)?, + theme: ThemeProfile + ) -> NSAttributedString? { + guard let interfacePair else { return nil } + let buildInterval = contentTextSignposter.beginInterval("content.attributedStringBuild", id: contentTextSignposter.makeSignpostID()) + defer { contentTextSignposter.endInterval("content.attributedStringBuild", buildInterval) } + return interfacePair.interfaceString.attributedString(for: theme, runtimeObjectName: interfacePair.runtimeObject) + } + @MemberwiseInit(.public) public struct Input { public let runtimeObjectClicked: Signal @@ -104,12 +209,21 @@ public final class ContentTextViewModel: ViewModel { public func transform(_ input: Input) -> Output { let runtimeObjectNotFoundRelay = PublishRelay() + // Both link flows resolve through the shared `interfaceProvider` + // with the same merged options the destination ContentTextViewModel + // will fetch with, so the resolution fetch warms the cache entry the + // post-push display fetch then hits — one engine round-trip per link + // click instead of two. Which object the engine resolves does not + // depend on the options; they only shape the generated text. input.runtimeObjectClicked - .flatMapLatest { [documentState = self.documentState, _commonLoading = self._commonLoading] runtimeObject in - Observable.async { - try await documentState.runtimeEngine.interface(for: runtimeObject, options: .init()) + .flatMapLatest { [weak self] runtimeObject -> Signal in + guard let self else { return .empty() } + let interfaceProvider = self.interfaceProvider + let mergedOptions = self.currentMergedGenerationOptions + return Observable.async { + try await interfaceProvider(runtimeObject, mergedOptions) } - .trackActivity(_commonLoading) + .trackActivity(self._commonLoading) .asSignal(onErrorJustReturn: nil) } .emit(with: self) { target, interface in @@ -122,11 +236,14 @@ public final class ContentTextViewModel: ViewModel { .disposed(by: rx.disposeBag) input.runtimeObjectOpenedInNewTab - .flatMapLatest { [documentState = self.documentState, _commonLoading = self._commonLoading] runtimeObject in - Observable.async { - try await documentState.runtimeEngine.interface(for: runtimeObject, options: .init()) + .flatMapLatest { [weak self] runtimeObject -> Signal in + guard let self else { return .empty() } + let interfaceProvider = self.interfaceProvider + let mergedOptions = self.currentMergedGenerationOptions + return Observable.async { + try await interfaceProvider(runtimeObject, mergedOptions) } - .trackActivity(_commonLoading) + .trackActivity(self._commonLoading) .asSignal(onErrorJustReturn: nil) } .emit(with: self) { target, interface in diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift new file mode 100644 index 00000000..69d3c3ac --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift @@ -0,0 +1,234 @@ +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures + +/// Per-document LRU cache of generated runtime-object interfaces. +/// +/// Navigation constantly rebinds `ContentTextViewModel` (every push, tab +/// switch, back/forward step), and every rebind used to re-fetch the +/// interface over XPC even when the object was viewed seconds earlier. +/// This cache sits between the content pipeline and the engine so a +/// revisit renders from memory — the only remaining cost is the off-main +/// attributed-string build. +/// +/// Scope and invalidation: +/// - One instance per `DocumentState`, keyed by `(object, options)`. The +/// object is the one the *returned interface* names, which is not always +/// the one that was asked about: a link click resolves a synthetic target +/// into the defining section's authoritative `RuntimeObject`, and it is +/// that object the navigation and the destination view model then use. +/// - Any `dataChangePublisher` event and any engine swap flushes the whole +/// cache. Both are rare, and a conservative full flush can never serve a +/// stale interface after the runtime data set changed (image loads and +/// reloads broadcast `.fullReload`; injection lands as either a reload +/// or an engine swap). +/// - `nil` results and errors are never cached: a "not found" can become +/// found once another image loads, and dead-link re-fetches are cheap. +/// - Bulk consumers (interface export, MCP) deliberately bypass this cache +/// and talk to the engine directly — a bulk sweep would evict exactly +/// the entries navigation is about to revisit. +/// +/// Concurrency: `@MainActor`, like every other document-scoped service. +/// Concurrent requests for the same key share one in-flight fetch task, so +/// a link click's resolution fetch and the destination view model's display +/// fetch cost one engine round-trip between them. A fetch that outlives an +/// invalidation still returns its value to the caller but is not stored +/// (generation token), so a flush can never be undone by a straggler. +@MainActor +public final class RuntimeInterfaceCache { + /// Fetches an interface from the engine. Injectable so tests can count + /// fetches, simulate failures, and control timing. The default reads + /// `documentState.runtimeEngine` at call time because the engine can be + /// swapped mid-document. + typealias Fetcher = @Sendable (RuntimeObject, RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? + + private struct Key: Hashable { + let object: RuntimeObject + let options: RuntimeObjectInterface.GenerationOptions + } + + private enum Entry { + case inFlight(Task) + case ready(RuntimeObjectInterface) + } + + /// Maximum number of `.ready` entries. 16 covers the tab strip plus a + /// realistic back/forward window; a typical interface is tens of + /// kilobytes of `SemanticString`, so the worst case stays bounded even + /// with a few `UIView.h`-scale outliers in the mix. + private let capacity: Int + + private let fetcher: Fetcher + + private var entries: [Key: Entry] = [:] + + /// Keys of `.ready` entries, least recently used first. In-flight + /// entries are not tracked here — they either graduate to `.ready` + /// (and enter this list) or are removed. + private var readyKeysByRecency: [Key] = [] + + /// Learned redirects from a request key to the key its answer was filed + /// under. Filing by `interface.object` is what makes the post-push + /// display fetch hit, but it leaves the *request* key holding nothing — + /// so without this table a second click on the same type token misses + /// forever while its answer sits one key over, and regenerates the whole + /// interface with every detail flag the content pane is using. + /// + /// Redirects outlive their target's eviction. A stale one costs one + /// extra dictionary lookup and then misses exactly as it would have + /// anyway, and the following store overwrites it. + private var storageKeysByRequestKey: [Key: Key] = [:] + + /// Bumped by `invalidateAll()`. A fetch only stores its result when the + /// generation it started under is still current. + private var generation = 0 + + private let disposeBag = DisposeBag() + + init(documentState: DocumentState, capacity: Int = 16, fetcher: Fetcher? = nil) { + self.capacity = capacity + // `weak`: DocumentState owns this cache, and the fetch task can + // outlive a closing document — a strong capture would cycle, an + // unowned one would crash a straggler fetch. + self.fetcher = fetcher ?? { [weak documentState] object, options in + guard let documentState else { throw CancellationError() } + return try await documentState.runtimeEngine.interface(for: object, options: options) + } + + // Engine swap → flush; every data-change event on the current + // engine → flush. `flatMapLatest` unsubscribes from the previous + // engine's publisher the moment a swap lands. + let engineSwapped = documentState.$runtimeEngine + .asObservable() + .skip(1) + .map { _ in () } + let engineDataChanged = documentState.$runtimeEngine + .asObservable() + .flatMapLatest { engine in + engine.dataChangePublisher.asObservable().map { _ in () } + } + Observable.merge(engineSwapped, engineDataChanged) + .subscribeOnNextMainActor { [weak self] in + guard let self else { return } + invalidateAll() + } + .disposed(by: disposeBag) + } + + /// Returns the cached interface for `(object, options)`, fetching and + /// caching it on a miss. Concurrent calls for the same key await one + /// shared fetch. + public func interface( + for object: RuntimeObject, + options: RuntimeObjectInterface.GenerationOptions + ) async throws -> RuntimeObjectInterface? { + let key = Key(object: object, options: options) + // A previous fetch under this request key may have filed its answer + // elsewhere (see `storageKeysByRequestKey`); follow the redirect + // before declaring a miss. + let lookupKey = storageKeysByRequestKey[key] ?? key + + if let entry = entries[lookupKey] { + switch entry { + case .ready(let interface): + markRecentlyUsed(lookupKey) + return interface + case .inFlight(let task): + return try await task.value + } + } + + let fetchGeneration = generation + let fetcher = fetcher + let task = Task { try await fetcher(object, options) } + // Parked under the redirect target so concurrent callers for the + // same object join this fetch instead of starting a second one. + // Resolution is deterministic for a given request, so an existing + // redirect names the key this fetch is about to store under anyway; + // a data change would have flushed the table along with the entries. + entries[lookupKey] = .inFlight(task) + + // Storage below is guarded two ways: the generation token keeps a + // straggler from resurrecting an entry a flush dropped, and + // `clearInFlight(_:ifStillOwnedBy:)` keeps this path from deleting + // an entry a *different* request key's fetch legitimately installed + // under the same storage key. + do { + let interface = try await task.value + if generation == fetchGeneration { + clearInFlight(lookupKey, ifStillOwnedBy: task) + if let interface { + // Indexed by the interface's own object, not the + // requested one. A link click asks about a *synthetic* + // target built at the click site from the clicked token + // — it carries the currently displayed object's + // `imagePath`, and on the ObjC arm its `children` — and + // the engine answers with the defining section's + // authoritative `RuntimeObject`. That resolved object is + // what the push navigates to and what the destination + // view model fetches under, and `RuntimeObject`'s + // `Hashable` folds in `imagePath` and `children`, so + // indexing by the request would make the display fetch a + // guaranteed miss and burn a slot on an entry it can + // never match. + let storageKey = Key(object: interface.object, options: options) + entries[storageKey] = .ready(interface) + markRecentlyUsed(storageKey) + if storageKey != key { + storageKeysByRequestKey[key] = storageKey + } + evictBeyondCapacity() + } + } + return interface + } catch { + if generation == fetchGeneration { + clearInFlight(lookupKey, ifStillOwnedBy: task) + } + throw error + } + } + + /// Removes the in-flight entry only while it is still *this* fetch's. + /// + /// Two fetches with different request keys can converge on one storage + /// key — a link click resolving a synthetic object into O while another + /// tab is already fetching O directly. Whichever finishes first stores + /// `.ready` under that shared key; an unconditional delete by the other + /// would destroy a live entry and strand its key in + /// `readyKeysByRecency`, where the phantom permanently consumes an LRU + /// slot and can later evict a healthy neighbour. + private func clearInFlight( + _ key: Key, + ifStillOwnedBy task: Task + ) { + guard case .inFlight(let storedTask) = entries[key], storedTask == task else { return } + entries[key] = nil + } + + /// Drops every entry and revokes in-flight fetches' right to store + /// their results. Callers already awaiting a shared fetch still receive + /// its value — they asked before the flush. + func invalidateAll() { + generation &+= 1 + entries.removeAll() + readyKeysByRecency.removeAll() + // Redirects describe resolutions made against the old data set; a + // reload can move a type to a different image. + storageKeysByRequestKey.removeAll() + } + + private func markRecentlyUsed(_ key: Key) { + if let existingIndex = readyKeysByRecency.firstIndex(of: key) { + readyKeysByRecency.remove(at: existingIndex) + } + readyKeysByRecency.append(key) + } + + private func evictBeyondCapacity() { + while readyKeysByRecency.count > capacity { + let evictedKey = readyKeysByRecency.removeFirst() + entries[evictedKey] = nil + } + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 09411252..9cc27043 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -136,6 +136,14 @@ public final class DocumentState { /// a new engine via the `$runtimeEngine` subscription on every source /// switch — see that property's doc comment for the swap contract. public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) + + /// Per-Document interface cache. Content navigation (push, tab switch, + /// back/forward) rebinds `ContentTextViewModel` and used to re-fetch + /// the interface over XPC on every rebind; routing single-object + /// fetches through this cache makes revisits render from memory. See + /// `RuntimeInterfaceCache` for the invalidation contract (engine swaps + /// and `dataChangePublisher` events flush it). + public private(set) lazy var interfaceCache = RuntimeInterfaceCache(documentState: self) } private final class SelectionRouter: Router { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift index c228bc2a..1b88589b 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift @@ -3,7 +3,7 @@ import FoundationToolbox import Ifrit import FuzzySearch -public enum FilterMode: Int, CaseIterable, Codable, CustomStringConvertible { +public enum FilterMode: Int, CaseIterable, Codable, CustomStringConvertible, Sendable { case fuzzySearch case ifrit @@ -17,72 +17,118 @@ public enum FilterMode: Int, CaseIterable, Codable, CustomStringConvertible { } } -enum FilterEngine { - @dynamicMemberLookup - private struct FuzzySearchableBox: FuzzySearchable { - let wrappedValue: Item +/// Everything a text-filter pass depends on, bundled so conformers can +/// guard their didSet cascades with a single equality check ("query text +/// unchanged but case toggle flipped" must still re-filter). +struct FilterContext: Equatable, Sendable { + var query: String = "" + var isCaseInsensitive: Bool = false + var mode: FilterMode? - init(_ wrappedValue: Item) { - self.wrappedValue = wrappedValue - } + var isEmpty: Bool { query.isEmpty } +} - var fuzzyStringToMatch: String { wrappedValue.filterableString } +/// One match produced by `FilterEngine.match`: which haystack matched and +/// the highlight ranges to render. Verdicts come back in display order +/// (fuzzy modes sort by score, plain contains preserves input order), so +/// callers can build their filtered arrays by straight index mapping. +struct FilterMatchVerdict { + let haystackIndex: Int + let result: FuzzyFilterResult? +} - subscript(dynamicMember keyPath: KeyPath) -> Value { - wrappedValue[keyPath: keyPath] - } +enum FilterEngine { + /// String-only adapter so the pure `match` path can reuse the + /// FuzzySearch collection algorithm without touching any cell + /// view model state. + private struct FuzzySearchableHaystack: FuzzySearchable { + let haystackIndex: Int + let fuzzyStringToMatch: String } - static func filter(_ filter: String, items: [Item], mode: FilterMode?, isCaseInsensitive: Bool) -> [Item] { - for item in items { - item.filter = filter - item.isCaseInsensitive = isCaseInsensitive - item.filterResult = nil - } - guard !filter.isEmpty else { - for item in items { - item.filterResult = nil - } - return items + /// Pure matching core: no side effects, safe to call from any thread. + /// An empty query is the identity filter — every haystack "matches" + /// with no highlight, in input order — so callers can run one code + /// path for both searching and clearing. + static func match(_ context: FilterContext, haystacks: [String]) -> [FilterMatchVerdict] { + guard !context.isEmpty else { + return haystacks.indices.map { FilterMatchVerdict(haystackIndex: $0, result: nil) } } - switch mode { + switch context.mode { case .fuzzySearch: - let results = items.map { FuzzySearchableBox($0) }.fuzzyMatch(filter) - var filteredItems: [Item] = [] - for result in results { - let item = result.item.wrappedValue - item.filterResult = result.result - filteredItems.append(item) + let searchables = haystacks.enumerated().map { haystackIndex, haystack in + FuzzySearchableHaystack(haystackIndex: haystackIndex, fuzzyStringToMatch: haystack) + } + return searchables.fuzzyMatch(context.query).map { matched in + FilterMatchVerdict(haystackIndex: matched.item.haystackIndex, result: matched.result) } - return filteredItems case .ifrit: let fuse = Fuse() - let results = fuse.searchSync(filter, in: items.map { [FuseProp($0.filterableString)] }).map { FuzzySrchResultWrapper($0) }.sorted() - var filteredItems: [Item] = [] - for result in results { - let item = items[result.index] - item.filterResult = result - filteredItems.append(item) + let sortedResults = fuse.searchSync(context.query, in: haystacks.map { [FuseProp($0)] }) + .map { FuzzySrchResultWrapper($0) } + .sorted() + return sortedResults.map { result in + FilterMatchVerdict(haystackIndex: result.index, result: result) } - return filteredItems case .none: - return items.filter { - if isCaseInsensitive { - $0.filterableString.contains(filter) - } else { - $0.filterableString.localizedCaseInsensitiveContains(filter) + // `isCaseInsensitive == true` really means case-insensitive + // matching now. The pre-2026-08 implementation had the branch + // inverted; the sidebar's toggle default flipped to `.on` in + // the same change so the effective default behavior + // (case-insensitive) is preserved. + let compareOptions: String.CompareOptions = context.isCaseInsensitive ? [.caseInsensitive] : [] + return haystacks.indices.compactMap { haystackIndex in + guard haystacks[haystackIndex].range(of: context.query, options: compareOptions) != nil else { + return nil } + return FilterMatchVerdict(haystackIndex: haystackIndex, result: nil) } } } + + /// Mutating convenience over `match` for single-level item lists: keeps + /// each item's stored `filterContext` in sync (conformers guard their + /// own cascades), assigns `filterResult` for matches, resets it for + /// misses, and returns the matched items in display order. Conformers + /// are expected to make a redundant `filterResult = nil` assignment + /// cheap (see `SidebarRuntimeObjectCellViewModel`), so a keystroke that + /// changes nothing rebuilds nothing. + @discardableResult + static func filter(context: FilterContext, items: [Item]) -> [Item] { + for item in items { + item.filterContext = context + } + + guard !context.isEmpty else { + for item in items { + item.filterResult = nil + } + return items + } + + let verdicts = match(context, haystacks: items.map(\.filterableString)) + + var isMatchedByIndex = [Bool](repeating: false, count: items.count) + var filteredItems: [Item] = [] + filteredItems.reserveCapacity(verdicts.count) + for verdict in verdicts { + isMatchedByIndex[verdict.haystackIndex] = true + let item = items[verdict.haystackIndex] + item.filterResult = verdict.result + filteredItems.append(item) + } + for (itemIndex, item) in items.enumerated() where !isMatchedByIndex[itemIndex] { + item.filterResult = nil + } + return filteredItems + } } protocol FilterableItem: AnyObject { - var filter: String { set get } + var filterContext: FilterContext { set get } var filterResult: FuzzyFilterResult? { set get } var filterableString: String { get } - var isCaseInsensitive: Bool { set get } } protocol FuzzyFilterResult { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift index a443aafb..5fc90905 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift @@ -16,42 +16,32 @@ public final class InspectorRelationshipsCellViewModel: NSObject, @unchecked Sen public let runtimeObject: RuntimeObject @Observed - public private(set) var primaryIcon: NSUIImage - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance: RuntimeObjectCellAppearance public init(runtimeObject: RuntimeObject) { self.runtimeObject = runtimeObject - + let iconSize: CGFloat = 20 - primaryIcon = RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize) - secondaryIcon = runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) } + var composedAppearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize), + secondaryIcon: runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) }, + title: NSAttributedString { + AText(runtimeObject.displayName) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 12)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) if runtimeObject.properties.contains(.isGeneric) { - tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) } if runtimeObject.properties.contains(.isSpecialized) { - tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) - } - title = NSAttributedString { - AText(runtimeObject.displayName) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 12)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) } let imageName = runtimeObject.imageName if !imageName.isEmpty { - subtitle = NSAttributedString { + composedAppearance.subtitle = NSAttributedString { AText(imageName) .foregroundColor(.secondaryLabelColor) .font(.systemFont(ofSize: 10)) @@ -59,6 +49,7 @@ public final class InspectorRelationshipsCellViewModel: NSObject, @unchecked Sen .lineBreakeMode(.byTruncatingTail) } } + self.appearance = composedAppearance super.init() } } @@ -74,11 +65,7 @@ extension InspectorRelationshipsCellViewModel: Differentiable { } extension InspectorRelationshipsCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } - public var subtitleDriver: Driver { $subtitle.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift index 4fc5302b..0dfd01e4 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift @@ -16,39 +16,30 @@ public final class InspectorSwiftSpecializationCellViewModel: NSObject, @uncheck public let runtimeObject: RuntimeObject @Observed - public private(set) var primaryIcon: NSUIImage = .init() - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString = .init() - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance: RuntimeObjectCellAppearance public init(runtimeObject: RuntimeObject) { self.runtimeObject = runtimeObject - super.init() let iconSize = RuntimeObjectIcon.defaultIconSize - primaryIcon = RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize) - secondaryIcon = runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) } + var composedAppearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize), + secondaryIcon: runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) }, + title: NSAttributedString { + AText(runtimeObject.displayName) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 13)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) if runtimeObject.properties.contains(.isGeneric) { - tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) } if runtimeObject.properties.contains(.isSpecialized) { - tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) - } - title = NSAttributedString { - AText(runtimeObject.displayName) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 13)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) } + self.appearance = composedAppearance + super.init() } } @@ -63,11 +54,7 @@ extension InspectorSwiftSpecializationCellViewModel: Differentiable { } extension InspectorSwiftSpecializationCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } - public var subtitleDriver: Driver { $subtitle.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift new file mode 100644 index 00000000..ed406e1f --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift @@ -0,0 +1,39 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import RuntimeViewerUI + +/// The complete visual state of a runtime-object cell, published as one value +/// so a high-cardinality cell view model pays for a single Rx stream instead +/// of one per outlet (see proposal 0005). +/// +/// Every member is a reference (or Optional reference), so copying the struct +/// copies references only. `Equatable` lets publishers drop equal-value +/// updates: icons come from `RuntimeObjectIcon`'s cache (pointer equality +/// holds for unchanged icons) and the attributed strings compare by content. +public struct RuntimeObjectCellAppearance: Equatable { + public var primaryIcon: NSUIImage + public var secondaryIcon: NSUIImage? + public var tertiaryIcon: NSUIImage? + public var title: NSAttributedString + public var subtitle: NSAttributedString? + + public init( + primaryIcon: NSUIImage = .init(), + secondaryIcon: NSUIImage? = nil, + tertiaryIcon: NSUIImage? = nil, + title: NSAttributedString = .init(), + subtitle: NSAttributedString? = nil + ) { + self.primaryIcon = primaryIcon + self.secondaryIcon = secondaryIcon + self.tertiaryIcon = tertiaryIcon + self.title = title + self.subtitle = subtitle + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift index 1069cdda..0ca29bce 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift @@ -6,17 +6,7 @@ import RxCocoa import RuntimeViewerUI public protocol RuntimeObjectCellDisplayable: AnyObject { - var primaryIconDriver: Driver { get } - var secondaryIconDriver: Driver { get } - var tertiaryIconDriver: Driver { get } - var titleDriver: Driver { get } - var subtitleDriver: Driver { get } -} - -extension RuntimeObjectCellDisplayable { - public var secondaryIconDriver: Driver { .just(nil) } - public var tertiaryIconDriver: Driver { .just(nil) } - public var subtitleDriver: Driver { .just(nil) } + var appearanceDriver: Driver { get } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift index 7c5f42b2..cf3739a9 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift @@ -33,48 +33,49 @@ public final class SidebarRootCellViewModel: NSObject, OutlineNodeType, @uncheck return children.sorted { $0.node.name < $1.node.name } }() - public private(set) lazy var currentAndChildrenNames: String = { - let childrenNames = _children.map { $0.currentAndChildrenNames }.joined(separator: " ") - if childrenNames.isEmpty { - return node.name - } else { - return "\(node.name) \(childrenNames)" - } - }() + /// The unfiltered child list. The root filter pipeline + /// (`SidebarRootFilterPipeline`) snapshots and re-applies against this + /// array so an active filter never hides nodes from its own + /// recomputation. Filtering itself has no mutating cascade anymore — + /// the pipeline computes every level off-main and installs the + /// results through `applyFilterOutcome(filteredChildren:)`. + var unfilteredChildren: [SidebarRootCellViewModel] { _children } + + /// Single entry point for the root filter pipeline's main-actor apply + /// step: installs the pre-computed filtered children without any + /// cascade or string matching. + func applyFilterOutcome(filteredChildren: [SidebarRootCellViewModel]) { + _filteredChildren = filteredChildren + } - var filter: String = "" { - didSet { - if filter.isEmpty { - _children.forEach { $0.filter = filter } - _filteredChildren = _children - } else if node.name.localizedCaseInsensitiveContains(filter) { - // Node itself matches - show all children unfiltered - _children.forEach { $0.filter = "" } - _filteredChildren = _children - } else { - // Node doesn't match - filter children recursively - _children.forEach { $0.filter = filter } - _filteredChildren = _children.filter { $0.currentAndChildrenNames.localizedCaseInsensitiveContains(filter) } - } + + /// Both display outlets in one stream: the image list keeps ~13k of these + /// rows resident, so per-row Rx fixed cost matters (proposal 0005). + public struct Appearance: Equatable { + public var icon: NSUIImage? + public var name: NSAttributedString + + public init(icon: NSUIImage?, name: NSAttributedString) { + self.icon = icon + self.name = name } } - - @Observed - public private(set) var icon: NSUIImage? @Observed - public private(set) var name: NSAttributedString + public private(set) var appearance: Appearance public init(node: RuntimeImageNode) { self.node = node - self.name = NSAttributedString { - AText(node.name) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 13)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } - self.icon = node.icon + self.appearance = Appearance( + icon: node.icon, + name: NSAttributedString { + AText(node.name) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 13)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) } public func makeIterator() -> Iterator { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift new file mode 100644 index 00000000..afe8390c --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift @@ -0,0 +1,160 @@ +import Foundation + +/// Off-main text filtering for the root sidebar's image tree. +/// +/// Mirrors `SidebarRuntimeObjectFilterPipeline`'s three-step shape +/// (snapshot on main → verdicts anywhere → apply on main) but replicates +/// the root sidebar's own legacy semantics, which differ from the +/// runtime-object tree's: +/// +/// - matching is a plain `localizedCaseInsensitiveContains` against a +/// node's aggregate name (its own name plus every descendant's), +/// - a node whose OWN name matches shows its entire subtree unfiltered, +/// - otherwise its children are filtered recursively by their aggregates. +/// +/// Aggregate names are computed inside the verdict step (post-order, one +/// pass over the value tree) rather than read off the cells, so even the +/// first query after an image-list rebuild pays no recursive string +/// concatenation on the main thread. +/// +/// Alignment contract: the cell tree must not change shape between +/// `snapshot` and `apply`. `SidebarRootViewModel` enforces this with a +/// generation token bumped on every `$nodes` rebuild; `apply` still bails +/// out on a shape mismatch as the last line of defense. +enum SidebarRootFilterPipeline { + struct SnapshotNode: Sendable { + let name: String + let children: [SnapshotNode] + } + + struct VerdictNode { + /// Indices into `children` that survive the filter, in input + /// order. When the node's own name matches, this is ALL indices — + /// the legacy cascade cleared the filter below a matching node. + var filteredChildIndices: [Int] + var children: [VerdictNode] + } + + struct ForestVerdict { + var filteredTopIndices: [Int] + var topVerdicts: [VerdictNode] + + static let empty = ForestVerdict(filteredTopIndices: [], topVerdicts: []) + } + + // MARK: - Snapshot (main actor) + + @MainActor + static func snapshot(of cells: [SidebarRootCellViewModel]) -> [SnapshotNode] { + cells.map { cell in + SnapshotNode(name: cell.node.name, children: snapshot(of: cell.unfilteredChildren)) + } + } + + // MARK: - Verdicts (any thread, cancellable) + + /// Computes the full verdict forest for a non-empty `query` (the + /// empty query takes the synchronous `resetToUnfiltered` fast path + /// instead). Checks for cooperative cancellation between top-level + /// nodes; a cancelled run returns `.empty`, which callers must + /// discard (they already do via their generation guard). + static func verdicts(for forest: [SnapshotNode], query: String) -> ForestVerdict { + // Contract tripwire: an empty query would clear the whole tree + // (`localizedCaseInsensitiveContains("")` is false for every + // haystack). The sole caller's empty-query fast path upholds this + // today; a second call site is where it would silently break. + assert(!query.isEmpty, "empty queries take the resetToUnfiltered fast path, never verdicts(for:query:)") + var topVerdicts: [VerdictNode] = [] + topVerdicts.reserveCapacity(forest.count) + var topAggregates: [String] = [] + topAggregates.reserveCapacity(forest.count) + for node in forest { + guard !Task.isCancelled else { return .empty } + let (verdict, aggregate) = verdictNode(for: node, query: query) + topVerdicts.append(verdict) + topAggregates.append(aggregate) + } + let filteredTopIndices = forest.indices.filter { + topAggregates[$0].localizedCaseInsensitiveContains(query) + } + return ForestVerdict(filteredTopIndices: filteredTopIndices, topVerdicts: topVerdicts) + } + + /// Post-order recursion computing each node's verdict and its + /// aggregate name ("name childAggregate1 childAggregate2 …") in the + /// same pass. + private static func verdictNode(for node: SnapshotNode, query: String) -> (verdict: VerdictNode, aggregate: String) { + var childVerdicts: [VerdictNode] = [] + childVerdicts.reserveCapacity(node.children.count) + var childAggregates: [String] = [] + childAggregates.reserveCapacity(node.children.count) + for child in node.children { + let (childVerdict, childAggregate) = verdictNode(for: child, query: query) + childVerdicts.append(childVerdict) + childAggregates.append(childAggregate) + } + + let aggregate = childAggregates.isEmpty + ? node.name + : "\(node.name) \(childAggregates.joined(separator: " "))" + + let filteredChildIndices: [Int] + if node.name.localizedCaseInsensitiveContains(query) { + filteredChildIndices = Array(node.children.indices) + unfilterSubtree(&childVerdicts) + } else { + filteredChildIndices = node.children.indices.filter { + childAggregates[$0].localizedCaseInsensitiveContains(query) + } + } + return (VerdictNode(filteredChildIndices: filteredChildIndices, children: childVerdicts), aggregate) + } + + /// Rewrites a verdict subtree to "show everything" — used below a + /// node whose own name matched the query. + private static func unfilterSubtree(_ verdicts: inout [VerdictNode]) { + for verdictIndex in verdicts.indices { + verdicts[verdictIndex].filteredChildIndices = Array(verdicts[verdictIndex].children.indices) + unfilterSubtree(&verdicts[verdictIndex].children) + } + } + + // MARK: - Apply (main actor) + + /// Installs the verdict forest onto the live cell tree and returns + /// the ordered top-level filtered array. Bails out (returning `nil`) + /// on a shape mismatch — that means the tree changed under the + /// pipeline, so keeping the previous filter output is safer than + /// applying misaligned verdicts. + @MainActor + static func apply( + _ forestVerdict: ForestVerdict, + to cells: [SidebarRootCellViewModel] + ) -> [SidebarRootCellViewModel]? { + guard applyNodes(verdicts: forestVerdict.topVerdicts, to: cells) else { return nil } + return forestVerdict.filteredTopIndices.map { cells[$0] } + } + + @MainActor + private static func applyNodes(verdicts: [VerdictNode], to cells: [SidebarRootCellViewModel]) -> Bool { + guard verdicts.count == cells.count else { return false } + for (cell, verdict) in zip(cells, verdicts) { + let unfilteredChildren = cell.unfilteredChildren + guard applyNodes(verdicts: verdict.children, to: unfilteredChildren) else { return false } + cell.applyFilterOutcome(filteredChildren: verdict.filteredChildIndices.map { unfilteredChildren[$0] }) + } + return true + } + + /// Synchronous reset for the empty-query fast path: every node shows + /// its full child list again. Pointer writes only — no string work — + /// so clearing the search never flashes a stale tree. + @MainActor + static func resetToUnfiltered(_ cells: [SidebarRootCellViewModel]) { + for cell in cells { + let unfilteredChildren = cell.unfilteredChildren + resetToUnfiltered(unfilteredChildren) + cell.applyFilterOutcome(filteredChildren: unfilteredChildren) + } + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index a5a5bab1..21020397 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift @@ -24,6 +24,20 @@ public class SidebarRootViewModel: ViewModel { @Observed public private(set) var isFiltering: Bool = false + /// In-flight root filter pass. Cancelled and superseded by every new + /// (coalesced) query so a slow older match can never overwrite a + /// newer query's results. + private var currentRootFilterTask: Task? + + /// Generation guard for `currentRootFilterTask` — also bumped when + /// `$nodes` is rebuilt, so verdicts computed against a discarded cell + /// tree are never applied. + /// + /// `package` rather than `private` so `SidebarRootFilterInvalidationTests` + /// can pin that the rebuild bumps it *synchronously*; deferring the bump + /// is exactly the defect the guard exists to prevent. + package private(set) var currentRootFilterGeneration: Int = 0 + public init(documentState: DocumentState, router: any Router, nodesSource: Observable<[RuntimeImageNode]>) { self.nodesSource = nodesSource @@ -72,11 +86,89 @@ public class SidebarRootViewModel: ViewModel { self.nodesIndexed = indexedNodes.trackActivity(_commonLoading).asSignal().mapToVoid() + // `$nodes` is fed by the `observe(on: MainScheduler.instance)` + // chain above and replayed on this main-actor `init`, so every + // delivery is already on the main actor. $nodes - .bind(to: $filteredNodes) + .asObservable() + .subscribeOnNext { [weak self] rebuiltNodes in + MainActor.assumeIsolated { + self?.installRebuiltNodes(rebuiltNodes) + } + } .disposed(by: rx.disposeBag) } + /// Installs a rebuilt image tree: drops the visual filter (matching the + /// legacy behavior of clearing it on an image-list rebuild) and + /// invalidates any in-flight filter pass, whose verdicts belong to + /// cells that are no longer on screen. + /// + /// Both halves must land in the same main-actor turn, which is why this + /// is one synchronous step rather than a `bind(to: $filteredNodes)` + /// alongside a `subscribeOnNextMainActor` invalidation. The latter + /// expands to `Task { @MainActor in … }`, so the cancellation and the + /// generation bump were merely *enqueued* while the list swap ran + /// synchronously — and a verdict continuation resuming in that window + /// passed both guards (`Task.isCancelled` still false, the generation + /// still its captured value) and republished the discarded cell tree + /// over the fresh one. Nothing reschedules a filter afterwards, so the + /// stale rows stayed on screen until the user typed again. + @MainActor + private func installRebuiltNodes(_ rebuiltNodes: [SidebarRootCellViewModel]) { + currentRootFilterTask?.cancel() + currentRootFilterTask = nil + currentRootFilterGeneration &+= 1 + filteredNodes = rebuiltNodes + } + + /// Root filter pass: snapshot the cell tree on the main actor, + /// compute verdicts (aggregate-name construction + matching) on the + /// global executor, then apply on the main actor iff still current. + /// The legacy path ran the whole cascade synchronously on the main + /// thread per (never actually delayed) keystroke. + @MainActor + private func scheduleRootRefilter(query: String) { + currentRootFilterTask?.cancel() + currentRootFilterGeneration &+= 1 + let generation = currentRootFilterGeneration + + if query.isEmpty { + currentRootFilterTask = nil + if isFiltering { + isFiltering = false + } + SidebarRootFilterPipeline.resetToUnfiltered(nodes) + filteredNodes = nodes + return + } + + if !isFiltering { + isFiltering = true + } + + let cells = nodes + let snapshotForest = SidebarRootFilterPipeline.snapshot(of: cells) + currentRootFilterTask = Task { @MainActor [weak self] in + let forestVerdict = await Self.computeVerdictsOffMain(for: snapshotForest, query: query) + guard !Task.isCancelled, let self else { return } + guard self.currentRootFilterGeneration == generation else { return } + guard let filteredCells = SidebarRootFilterPipeline.apply(forestVerdict, to: cells) else { return } + self.filteredNodes = filteredCells + self.currentRootFilterTask = nil + } + } + + /// Hop for the verdict computation: `nonisolated async` runs on the + /// global concurrent executor, keeping the recursive aggregate-name + /// construction and ICU `contains` matching off the main thread. + private nonisolated static func computeVerdictsOffMain( + for forest: [SidebarRootFilterPipeline.SnapshotNode], + query: String + ) async -> SidebarRootFilterPipeline.ForestVerdict { + SidebarRootFilterPipeline.verdicts(for: forest, query: query) + } + @MemberwiseInit(.public) public struct Input { public let clickedNode: Signal @@ -122,31 +214,24 @@ public class SidebarRootViewModel: ViewModel { } .disposed(by: rx.disposeBag) + // Keystroke coalescing: non-empty queries wait 150 ms (cancelled by + // the next keystroke via `flatMapLatest`), clearing applies + // immediately. NOTE: this must be `delay`, not `debounce` — on a + // single-element `.just` sequence, `debounce` flushes the pending + // element the moment the source completes, so the previous + // `.just(...).debounce(500ms)` never delayed anything. input.searchString .flatMapLatest { filter -> Signal in if filter.isEmpty { return .just(filter) } else { return .just(filter) - .debounce(.milliseconds(500)) + .delay(.milliseconds(150)) } } - .emitOnNextMainActor { [weak self] filter in + .emitOnNextMainActor { [weak self] query in guard let self else { return } - for node in nodes { - node.filter = filter - } - if filter.isEmpty { - if isFiltering { - isFiltering = false - } - filteredNodes = nodes - } else { - if !isFiltering { - isFiltering = true - } - filteredNodes = nodes.filter { $0.currentAndChildrenNames.localizedCaseInsensitiveContains(filter) } - } + scheduleRootRefilter(query: query) }.disposed(by: rx.disposeBag) return Output( diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index d6121468..02dc477d 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -79,6 +79,7 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, set { _children = newValue _filteredChildren = newValue + invalidateNamesCacheUpwards() } } @@ -88,49 +89,115 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, private var _children: [SidebarRuntimeObjectCellViewModel] = [] - /// Computed (not cached) so it always reflects the current subtree. Used - /// only as a filter haystack; updates are infrequent (debounced search) - /// so the recomputation cost is acceptable in exchange for eliminating - /// the lazy-cache invalidation problem when children change. + /// The unfiltered child list. The filter pipeline snapshots and + /// re-applies against this array so the active filter never hides + /// nodes from its own recomputation. + var unfilteredChildren: [SidebarRuntimeObjectCellViewModel] { _children } + + /// Cached subtree haystack. Invalidated (upwards through the ancestor + /// chain, since every ancestor's haystack embeds this subtree's names) + /// whenever `_children` changes — the only mutation points are + /// `rebuildChildren()` and the `children` setter, both main-actor. + private var cachedCurrentAndChildrenNames: String? + + /// Filter haystack: this node's display name plus every descendant's, + /// so a parent whose match lives in a descendant still surfaces. public var currentAndChildrenNames: String { + if let cachedCurrentAndChildrenNames { + return cachedCurrentAndChildrenNames + } let childrenNames = _children.map { $0.currentAndChildrenNames }.joined(separator: " ") + let computedNames: String if childrenNames.isEmpty { - return runtimeObject.displayName + computedNames = runtimeObject.displayName } else { - return "\(runtimeObject.displayName) \(childrenNames)" + computedNames = "\(runtimeObject.displayName) \(childrenNames)" } + cachedCurrentAndChildrenNames = computedNames + return computedNames } - @Dependency(\.appDefaults) - private var appDefaults + /// Seeds the subtree-haystack cache with a string an off-main pass + /// already produced for this object. + /// + /// `Self.haystack(for:)` and `currentAndChildrenNames` are byte-for-byte + /// identical by contract, so Open Quickly can hand a freshly + /// materialized cell the string it matched against instead of making + /// the cell rebuild the whole subtree on the main actor the moment a + /// highlight is stamped on it. No-op once a value is cached, so it can + /// never contradict a locally derived haystack. + func seedCurrentAndChildrenNames(_ haystack: String) { + guard cachedCurrentAndChildrenNames == nil else { return } + cachedCurrentAndChildrenNames = haystack + } - var isCaseInsensitive: Bool = false + private func invalidateNamesCacheUpwards() { + var currentCell: SidebarRuntimeObjectCellViewModel? = self + while let cell = currentCell { + cell.cachedCurrentAndChildrenNames = nil + currentCell = cell.parent + } + } - var filter: String = "" { - didSet { applyFilter() } + /// Pure-value replica of `currentAndChildrenNames`, computed straight + /// from a `RuntimeObject` tree so callers can match against the + /// haystack without materializing a cell view model first (Open + /// Quickly matches all rows this way, then materializes cells only + /// for hits). + /// + /// Byte-for-byte parity with the instance property is a hard + /// contract: fuzzy highlight ranges are computed against this string + /// and later mapped by the materialized cell against ITS haystack. + /// Both sides therefore sort children by `displayName` and join with + /// single spaces — change one and you must change the other (pinned + /// by `OpenQuicklyLazyConstructionTests`). + static func haystack(for runtimeObject: RuntimeObject) -> String { + let sortedChildren = runtimeObject.children.sorted { leftChild, rightChild in + leftChild.displayName < rightChild.displayName + } + let childrenNames = sortedChildren.map { haystack(for: $0) }.joined(separator: " ") + if childrenNames.isEmpty { + return runtimeObject.displayName + } + return "\(runtimeObject.displayName) \(childrenNames)" } - /// Scope filter applied to `_children` before the text filter runs. - /// Pushed down from `SidebarRuntimeObjectViewModel` whenever the user - /// edits the scope popover. The scope itself does not cascade through - /// this setter — callers responsible for tree-wide propagation use - /// `applyScopeRecursively(_:)` so every descendant ends up with the - /// same value before any parent's `_filteredChildren` is read. - var scope: RuntimeObjectScope = .init() { - didSet { - guard oldValue != scope else { return } - applyFilter() + private var filterContextStorage = FilterContext() + + /// The active text-filter context. Setting a *different* context + /// re-derives `_filteredChildren` (which cascades into descendants via + /// `FilterEngine.filter`); setting an equal context is free. The filter + /// pipeline writes the storage directly through + /// `applyFilterOutcome(...)` because it delivers the cascade's results + /// itself. + var filterContext: FilterContext { + get { filterContextStorage } + set { + guard filterContextStorage != newValue else { return } + filterContextStorage = newValue + applyLocalFilter() } } - private func applyFilter() { + /// Scope filter applied to `_children` before the text filter runs. + /// Plain storage: tree-wide propagation is owned by the filter + /// pipeline's apply step (`applyFilterOutcome`), which visits every + /// cell anyway; this stored copy only feeds `applyLocalFilter()` on + /// the splice path. + var scope: RuntimeObjectScope = .init() + + /// Re-derives `_filteredChildren` from the stored scope + filter + /// context. Only invoked for cell-local mutations (children rebuilt + /// after a specialization splice); bulk filtering goes through the + /// pipeline's `applyFilterOutcome` instead. + private func applyLocalFilter() { let scopeFiltered: [SidebarRuntimeObjectCellViewModel] if scope.isActive { scopeFiltered = _children.filter { $0.matchesScopeRecursively(scope) } } else { scopeFiltered = _children } - _filteredChildren = FilterEngine.filter(filter, items: scopeFiltered, mode: appDefaults.filterMode, isCaseInsensitive: isCaseInsensitive) + _filteredChildren = FilterEngine.filter(context: filterContextStorage, items: scopeFiltered) } /// Returns `true` if this cell or any of its descendants pass `scope`. @@ -146,49 +213,80 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, return false } - /// Push `newScope` into this cell and every descendant depth-first. - /// Each cell's own `_filteredChildren` rebuild happens via the - /// `scope` didSet, so after this call returns the entire subtree - /// reflects the new scope consistently. - func applyScopeRecursively(_ newScope: RuntimeObjectScope) { - for child in _children { - child.applyScopeRecursively(newScope) - } - scope = newScope + /// Single entry point for the filter pipeline's main-actor apply step: + /// synchronizes the stored context/scope WITHOUT re-triggering the + /// local cascade (the pipeline already computed every level), installs + /// the pre-ordered filtered children, and updates the highlight. + func applyFilterOutcome( + context: FilterContext, + scope: RuntimeObjectScope, + result: FuzzyFilterResult?, + filteredChildren: [SidebarRuntimeObjectCellViewModel] + ) { + filterContextStorage = context + self.scope = scope + _filteredChildren = filteredChildren + filterResult = result } var filterResult: FuzzyFilterResult? { didSet { - if let filterResult { - let title = NSMutableAttributedString { - AText(runtimeObject.displayName) - .font(.systemFont(ofSize: fontSize)) - .foregroundColor(forOpenQuickly ? .secondaryLabelColor : .tertiaryLabelColor) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } - - guard let range = currentAndChildrenNames.ranges(of: runtimeObject.displayName).first else { - self.title = title - return - } - - let currentNSRange = NSRange(currentAndChildrenNames.integerRange(from: range)) - - for resultNSRange in filterResult.ranges { - guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } - title.addAttributes([ - .foregroundColor: NSUIColor.labelColor, - .font: NSUIFont.systemFont(ofSize: fontSize, weight: .semibold), - ], range: resultNSRange) - } - self.title = title - } else { - title = defaultAttributedTitle() - } + // nil -> nil is the overwhelmingly common per-keystroke case + // (rows that neither had nor gained a highlight); skipping it + // is what keeps a filter pass from rebuilding every row's + // attributed title. See SidebarFilterPerformanceBaselineTests. + if oldValue == nil, filterResult == nil { return } + rebuildTitleForFilterResult() } } + /// Replaces only the title within the current appearance. The icons keep + /// their existing references, so the `publishAppearance` equality check + /// reduces to comparing the two attributed titles. + private func rebuildTitleForFilterResult() { + var rebuiltAppearance = appearance + rebuiltAppearance.title = composedTitle() + publishAppearance(rebuiltAppearance) + } + + /// The display title under the current `filterResult`: the fuzzy-highlight + /// rendition when a result is active, the plain default title otherwise. + private func composedTitle() -> NSAttributedString { + guard let filterResult else { + return defaultAttributedTitle() + } + + let title = NSMutableAttributedString { + AText(runtimeObject.displayName) + .font(.systemFont(ofSize: fontSize)) + .foregroundColor(forOpenQuickly ? .secondaryLabelColor : .tertiaryLabelColor) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + + // This row's own name is the prefix of its subtree haystack by + // construction — `currentAndChildrenNames` builds + // "displayName child1 child2 …" and `haystack(for:)` matches it + // byte for byte. Searching the haystack for the name rediscovered + // offset 0 by scanning the entire subtree string, on every row of + // every keystroke; `ranges(of:)` collects *all* occurrences, so it + // could not even stop at the first hit. + // + // Offsets stay in Characters, matching `integerRange(from:)` + // (`distance(from:to:)`), so the ranges compared below are + // unchanged. + let currentNSRange = NSRange(location: 0, length: runtimeObject.displayName.count) + + for resultNSRange in filterResult.ranges { + guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } + title.addAttributes([ + .foregroundColor: NSUIColor.labelColor, + .font: NSUIFont.systemFont(ofSize: fontSize, weight: .semibold), + ], range: resultNSRange) + } + return title + } + var filterableString: String { currentAndChildrenNames } @@ -201,20 +299,10 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, forOpenQuickly ? SidebarRuntimeObjectCellViewModel.openQuicklyFontSize : SidebarRuntimeObjectCellViewModel.normalFontSize } + /// All visual state in one stream: one subject + lock per row instead of + /// five, and every refresh publishes atomically (proposal 0005). @Observed - public private(set) var primaryIcon: NSUIImage = .init() - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString = .init() - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance = RuntimeObjectCellAppearance() @NSAttributedStringBuilder private func defaultAttributedTitle() -> NSAttributedString { @@ -261,7 +349,8 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, leftChild.runtimeObject.displayName < rightChild.runtimeObject.displayName } _children = rebuiltChildren - applyFilter() + invalidateNamesCacheUpwards() + applyLocalFilter() } /// Returns a RuntimeObject tree that reflects the current child viewmodels, @@ -291,26 +380,35 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, /// Recompute icons and the highlighted name. Called whenever /// `runtimeObject` changes (e.g. a new specialized child arrives, - /// flipping the parent's `properties` bookkeeping). + /// flipping the parent's `properties` bookkeeping). Composes the full + /// appearance and publishes it as one event. private func refreshAppearance() { let iconSize = forOpenQuickly ? 24 : RuntimeObjectIcon.defaultIconSize - primaryIcon = RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize) - secondaryIcon = runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) } + var refreshedAppearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize), + secondaryIcon: runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) }, + title: composedTitle() + ) if runtimeObject.properties.contains(.isGeneric) { - tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) + refreshedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) } if runtimeObject.properties.contains(.isSpecialized) { - tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) + refreshedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) } - if let filterResult { - // Trigger didSet to reapply highlight ranges over the new displayName. - self.filterResult = filterResult - } else { - title = defaultAttributedTitle() - } + publishAppearance(refreshedAppearance) + } + + /// Equal-value updates are dropped so a refresh that changes nothing (a + /// splice that leaves this row's display state untouched, or a filter + /// pass re-delivering the same highlight) emits no event. Icons come from + /// `RuntimeObjectIcon`'s cache, so unchanged icons are pointer-equal and + /// the comparison cost concentrates on the attributed title. + private func publishAppearance(_ newAppearance: RuntimeObjectCellAppearance) { + guard newAppearance != appearance else { return } + appearance = newAppearance } } @@ -324,10 +422,7 @@ extension SidebarRuntimeObjectCellViewModel: Differentiable { } extension SidebarRuntimeObjectCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift new file mode 100644 index 00000000..b623e2b0 --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift @@ -0,0 +1,188 @@ +import Foundation + +/// Off-main text filtering for the sidebar's runtime-object tree. +/// +/// The pipeline splits one filter pass into three steps so the expensive +/// part never blocks the main thread: +/// +/// 1. `snapshot(of:scope:)` (main actor) — captures each node's haystack +/// string and scope verdict into an immutable value tree. Cheap: the +/// haystacks are cached on the cell view models. +/// 2. `verdicts(for:context:)` (any thread) — pure recursion mirroring the +/// legacy synchronous semantics level by level: children are +/// scope-pruned, then matched via `FilterEngine.match`, preserving each +/// mode's display order (fuzzy score order, contains input order). +/// 3. `apply(_:to:context:scope:)` (main actor) — walks the live cell tree +/// aligned with the verdict tree and installs each node's outcome. +/// Guarded didSets on the cells make untouched rows free, so the apply +/// step costs O(changed highlights), not O(nodes). +/// +/// Alignment contract: the cell tree must not change shape between +/// `snapshot` and `apply`. `SidebarRuntimeObjectViewModel` enforces this +/// with a generation token — reloads and specialization splices bump the +/// generation, and a stale pipeline run is discarded instead of applied. +enum SidebarRuntimeObjectFilterPipeline { + struct SnapshotNode: Sendable { + let haystack: String + let subtreePassesScope: Bool + let children: [SnapshotNode] + } + + struct VerdictNode { + var result: FuzzyFilterResult? + var orderedFilteredChildIndices: [Int] + var children: [VerdictNode] + } + + struct ForestVerdict { + var orderedFilteredTopIndices: [Int] + var topVerdicts: [VerdictNode] + + static let empty = ForestVerdict(orderedFilteredTopIndices: [], topVerdicts: []) + } + + // MARK: - Snapshot (main actor) + + @MainActor + static func snapshot( + of cells: [SidebarRuntimeObjectCellViewModel], + scope: RuntimeObjectScope + ) -> [SnapshotNode] { + cells.map { cell in + SnapshotNode( + haystack: cell.currentAndChildrenNames, + subtreePassesScope: scope.isActive ? cell.matchesScopeRecursively(scope) : true, + children: snapshot(of: cell.unfilteredChildren, scope: scope) + ) + } + } + + // MARK: - Verdicts (any thread, cancellable) + + /// Computes the full verdict forest. Checks for cooperative + /// cancellation between top-level nodes; a cancelled run returns + /// `.empty`, which callers must discard (they already do via their + /// generation guard). + static func verdicts(for forest: [SnapshotNode], context: FilterContext) -> ForestVerdict { + var topVerdicts: [VerdictNode] = [] + topVerdicts.reserveCapacity(forest.count) + for node in forest { + guard !Task.isCancelled else { return .empty } + topVerdicts.append(verdictNode(for: node, context: context)) + } + + let orderedFilteredTopIndices = stampMatches( + of: forest, + into: &topVerdicts, + context: context + ) + return ForestVerdict( + orderedFilteredTopIndices: orderedFilteredTopIndices, + topVerdicts: topVerdicts + ) + } + + private static func verdictNode(for node: SnapshotNode, context: FilterContext) -> VerdictNode { + var childVerdicts = node.children.map { verdictNode(for: $0, context: context) } + let orderedFilteredChildIndices = stampMatches( + of: node.children, + into: &childVerdicts, + context: context + ) + return VerdictNode( + result: nil, + orderedFilteredChildIndices: orderedFilteredChildIndices, + children: childVerdicts + ) + } + + /// Scope-prunes `nodes`, matches the survivors' haystacks, stamps each + /// match's highlight result onto the corresponding verdict, and returns + /// the matched indices in display order — the exact semantics of the + /// legacy per-level `FilterEngine.filter` call. + private static func stampMatches( + of nodes: [SnapshotNode], + into verdicts: inout [VerdictNode], + context: FilterContext + ) -> [Int] { + let scopedIndices = nodes.indices.filter { nodes[$0].subtreePassesScope } + let matches = FilterEngine.match(context, haystacks: scopedIndices.map { nodes[$0].haystack }) + var orderedFilteredIndices: [Int] = [] + orderedFilteredIndices.reserveCapacity(matches.count) + for match in matches { + let nodeIndex = scopedIndices[match.haystackIndex] + verdicts[nodeIndex].result = match.result + orderedFilteredIndices.append(nodeIndex) + } + return orderedFilteredIndices + } + + // MARK: - Identity fast path (main actor) + + /// Installs the "no filtering" outcome on every cell — the exact result + /// `snapshot` → `verdicts` → `apply` produces for an empty query with an + /// inactive scope, minus the snapshot forest. The identity outcome needs + /// no haystacks, so this never touches `currentAndChildrenNames` — whose + /// per-cell cache is cold right after a reload, making the skipped + /// snapshot a full bottom-up haystack build over the tree. + @MainActor + static func resetToUnfiltered( + _ cells: [SidebarRuntimeObjectCellViewModel], + context: FilterContext, + scope: RuntimeObjectScope + ) { + for cell in cells { + let unfilteredChildren = cell.unfilteredChildren + resetToUnfiltered(unfilteredChildren, context: context, scope: scope) + cell.applyFilterOutcome( + context: context, + scope: scope, + result: nil, + filteredChildren: unfilteredChildren + ) + } + } + + // MARK: - Apply (main actor) + + /// Installs the verdict forest onto the live cell tree and returns the + /// ordered top-level filtered array. Bails out (returning `nil`) on a + /// shape mismatch — that means the tree changed under the pipeline and + /// the caller's generation guard failed to catch it, so keeping the + /// previous filter output is safer than applying misaligned verdicts. + @MainActor + static func apply( + _ forestVerdict: ForestVerdict, + to cells: [SidebarRuntimeObjectCellViewModel], + context: FilterContext, + scope: RuntimeObjectScope + ) -> [SidebarRuntimeObjectCellViewModel]? { + guard applyNodes(verdicts: forestVerdict.topVerdicts, to: cells, context: context, scope: scope) else { + return nil + } + return forestVerdict.orderedFilteredTopIndices.map { cells[$0] } + } + + @MainActor + private static func applyNodes( + verdicts: [VerdictNode], + to cells: [SidebarRuntimeObjectCellViewModel], + context: FilterContext, + scope: RuntimeObjectScope + ) -> Bool { + guard verdicts.count == cells.count else { return false } + for (cell, verdict) in zip(cells, verdicts) { + let unfilteredChildren = cell.unfilteredChildren + guard applyNodes(verdicts: verdict.children, to: unfilteredChildren, context: context, scope: scope) else { + return false + } + cell.applyFilterOutcome( + context: context, + scope: scope, + result: verdict.result, + filteredChildren: verdict.orderedFilteredChildIndices.map { unfilteredChildren[$0] } + ) + } + return true + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index 03e36755..cfa71f17 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -3,26 +3,118 @@ import RuntimeViewerCore import RuntimeViewerArchitectures import MemberwiseInit -public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { +// Not `final`: tests subclass this to seed a canned object list through +// the real `reloadData` path (mirroring `SidebarRuntimeObjectViewModel`, +// which is subclassable for the same reason). +public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { public typealias CellLookup = (cell: SidebarRuntimeObjectCellViewModel, ancestors: [SidebarRuntimeObjectCellViewModel]) @Observed public private(set) var searchStringForOpenQuickly: String = "" - @Observed public private(set) var nodesForOpenQuickly: [SidebarRuntimeObjectCellViewModel] = [] @Observed public private(set) var filteredNodesForOpenQuickly: [SidebarRuntimeObjectCellViewModel] = [] @Observed public private(set) var isFilteringForOpenQuickly: Bool = false + /// Sorted top-level objects backing Open Quickly. Rows materialize + /// into cell view models lazily (see `openQuicklyCellViewModel(at:)`), + /// so a reload no longer eagerly constructs a second full copy of the + /// sidebar's cell view models on the main thread — the legacy path + /// paid N cell constructions (icons, attributed titles, child trees) + /// per image load for a list most sessions never open. + private var openQuicklyRuntimeObjects: [RuntimeObject] = [] + + /// Bumped every time `openQuicklyRuntimeObjects` is replaced. A + /// completed haystack build is keyed to the object list it was built + /// from, which the filter generation alone cannot express — that + /// counter also moves on every keystroke. + private var openQuicklyRuntimeObjectsVersion: Int = 0 + + /// Haystack strings aligned index-for-index with + /// `openQuicklyRuntimeObjects`. Computed off-main by the first query + /// after a reload, then reused for every subsequent keystroke. + /// Internal (not private) so tests can pin that a superseded pass + /// still installs the build it completed. + private(set) var openQuicklyHaystacksCache: [String]? + + /// Cell view models materialized so far, keyed by row index into + /// `openQuicklyRuntimeObjects`. Only rows some query has actually + /// matched exist here; repeat matches across keystrokes reuse the + /// same instance so DifferenceKit sees stable row identities. + /// Internal (not private) so tests can pin the lazy contract. + private(set) var openQuicklyCellViewModelsByRowIndex: [Int: SidebarRuntimeObjectCellViewModel] = [:] + + /// Rows the previous pass highlighted. Clearing stale highlights only + /// has to touch these — the materialized-cell map is deliberately kept + /// warm across searches, so it accumulates every row any query has ever + /// surfaced and sweeping it whole made per-keystroke main-actor cost + /// grow with session length. + private var highlightedOpenQuicklyRowIndices: Set = [] + + /// Haystack build shared by every pass over the same object list, so + /// overlapping keystrokes join one build instead of each starting an + /// identical full one. Cleared once the build installs. + private var inFlightOpenQuicklyHaystackBuild: (objectListVersion: Int, task: Task<[String], Never>)? + /// Latest non-nil root object the document is inspecting, waiting to /// be resolved to a concrete cell once it appears in `nodes`. Driven /// by `documentState.$selectionStack` (see `transform`) — never by an /// external imperative call. private let pendingSelectRelay = PublishRelay() + /// In-flight Open Quickly fuzzy match. Cancelled and superseded by + /// every new (debounced) query so two searches never mutate the same + /// cell view models concurrently, and a slow older match can never + /// overwrite a newer query's results. + private var currentOpenQuicklyFilterTask: Task? + + /// Generation guard for `currentOpenQuicklyFilterTask` — also bumped + /// when `nodesForOpenQuickly` is rebuilt, so a match computed against + /// a discarded node array is never applied. + private var currentOpenQuicklyFilterGeneration: Int = 0 + + /// Upper bound on rows materialized for one query. + /// + /// `.fuzzySearch` keeps every haystack with a non-zero score, and a + /// haystack is the object's name plus every descendant's, so a one- or + /// two-character query matches essentially the whole image. Without a + /// bound the apply loop constructed a cell view model — and, through + /// `rebuildChildren()`, one per descendant, each with icon lookups and + /// an attributed title — for every row in a single main-actor turn, + /// which is the O(N) main-thread cost lazy materialization exists to + /// remove. `FuzzySearchable.fuzzyMatch` returns matches sorted by + /// descending weight, so the cap keeps the best ones; the rows it drops + /// are the near-zero-score tail nobody scrolls to. + static let openQuicklyMaximumMaterializedRows = 500 + + /// Builds the Open Quickly haystacks for an object list. Injectable so + /// tests can gate the build and drive supersession deterministically; + /// the default is pure value work with no reference to the view model. + typealias HaystackBuilder = @Sendable ([RuntimeObject]) async -> [String] + + private let haystackBuilder: HaystackBuilder + override var isSorted: Bool { true } public override init(imageNode: RuntimeImageNode, documentState: DocumentState, router: any Router) { + self.haystackBuilder = Self.defaultHaystackBuilder super.init(imageNode: imageNode, documentState: documentState, router: router) } + init( + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router, + haystackBuilder: @escaping HaystackBuilder + ) { + self.haystackBuilder = haystackBuilder + super.init(imageNode: imageNode, documentState: documentState, router: router) + } + + /// Off-main haystack computation — building 10k+ tree haystacks is + /// the other expensive half of the legacy eager reload. Pure value + /// work over the captured `RuntimeObject` array. + private static let defaultHaystackBuilder: HaystackBuilder = { runtimeObjects in + runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) } + } + public static func findCell( for object: RuntimeObject, in nodes: [SidebarRuntimeObjectCellViewModel] @@ -73,17 +165,178 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo } } - override func reloadData() async throws { - try await super.reloadData() - try Task.checkCancellation() + override func didInstallReloadedNodes() { + super.didInstallReloadedNodes() + currentOpenQuicklyFilterTask?.cancel() + currentOpenQuicklyFilterTask = nil + currentOpenQuicklyFilterGeneration &+= 1 + searchStringForOpenQuickly = "" + // `nodes` is already name-sorted (`isSorted == true`), so the + // Open Quickly row order comes for free. Everything derived + // from the previous object list is invalidated together. + openQuicklyRuntimeObjects = nodes.map(\.runtimeObject) + openQuicklyRuntimeObjectsVersion &+= 1 + openQuicklyHaystacksCache = nil + // The version bump already makes this build unusable (its indices + // address the previous list); drop the reference so it is not held + // for the rest of the document's life. + inFlightOpenQuicklyHaystackBuild = nil + openQuicklyCellViewModelsByRowIndex = [:] + highlightedOpenQuicklyRowIndices = [] + filteredNodesForOpenQuickly = [] + } - await MainActor.run { - self.searchStringForOpenQuickly = "" - self.nodesForOpenQuickly = nodes.map { $0.runtimeObject }.sorted().map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: true) } - self.filteredNodesForOpenQuickly = [] + /// Returns the row's cell view model, materializing it on first use. + /// Construction is the expensive part of the legacy reload (icons + + /// attributed title + child tree), so it is deferred to rows a query + /// actually surfaces and amortized across keystrokes by the cache. + @MainActor + private func openQuicklyCellViewModel(at rowIndex: Int, haystack: String) -> SidebarRuntimeObjectCellViewModel { + if let materializedCellViewModel = openQuicklyCellViewModelsByRowIndex[rowIndex] { + return materializedCellViewModel } + let cellViewModel = SidebarRuntimeObjectCellViewModel( + runtimeObject: openQuicklyRuntimeObjects[rowIndex], + forOpenQuickly: true + ) + // Hand over the haystack the off-main pass already built for this + // exact object. Stamping `filterResult` on a fresh cell otherwise + // triggers `composedTitle()`, whose cold `currentAndChildrenNames` + // rebuilds the entire subtree name string on the main actor — the + // byte-identical twin of the string one line up, per the parity + // contract on `haystack(for:)`. + cellViewModel.seedCurrentAndChildrenNames(haystack) + openQuicklyCellViewModelsByRowIndex[rowIndex] = cellViewModel + return cellViewModel } + /// Open Quickly filter pass: fuzzy-match off-main, apply on main iff + /// still current. Mirrors the sidebar's `scheduleRefilter()` but over + /// the flat value-array of top-level objects with the fixed Open + /// Quickly configuration. Matching runs against pure haystack strings + /// (computed off-main and cached per reload); only matched rows are + /// materialized into cell view models, so a keystroke costs + /// O(matches) main-thread work instead of O(all rows). + @MainActor + private func scheduleOpenQuicklyRefilter(query: String) { + currentOpenQuicklyFilterTask?.cancel() + currentOpenQuicklyFilterGeneration &+= 1 + let generation = currentOpenQuicklyFilterGeneration + + if query.isEmpty { + currentOpenQuicklyFilterTask = nil + if isFilteringForOpenQuickly { + isFilteringForOpenQuickly = false + } + // Clear stale highlights so the next search starts clean. + // Only the previous pass's matches can carry one. + for rowIndex in highlightedOpenQuicklyRowIndices { + openQuicklyCellViewModelsByRowIndex[rowIndex]?.filterResult = nil + } + highlightedOpenQuicklyRowIndices = [] + filteredNodesForOpenQuickly = [] + return + } + + if !isFilteringForOpenQuickly { + isFilteringForOpenQuickly = true + } + + let context = FilterContext(query: query, isCaseInsensitive: false, mode: .fuzzySearch) + let runtimeObjects = openQuicklyRuntimeObjects + let runtimeObjectsVersion = openQuicklyRuntimeObjectsVersion + currentOpenQuicklyFilterTask = Task { @MainActor [weak self] in + guard let haystacks = await self?.openQuicklyHaystacks( + forObjectListVersion: runtimeObjectsVersion, + runtimeObjects: runtimeObjects + ) else { return } + guard !Task.isCancelled, let self, self.currentOpenQuicklyFilterGeneration == generation else { return } + let verdicts = await Self.matchOffMain(context: context, haystacks: haystacks) + guard !Task.isCancelled else { return } + guard self.currentOpenQuicklyFilterGeneration == generation else { return } + + // Verdicts arrive sorted by descending fuzzy weight, so the + // prefix is the best-scoring window (see + // `openQuicklyMaximumMaterializedRows`). + let displayedVerdicts = verdicts.prefix(Self.openQuicklyMaximumMaterializedRows) + var matchedRowIndices = Set(minimumCapacity: displayedVerdicts.count) + var filteredCellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + filteredCellViewModels.reserveCapacity(displayedVerdicts.count) + for verdict in displayedVerdicts { + matchedRowIndices.insert(verdict.haystackIndex) + let cellViewModel = self.openQuicklyCellViewModel( + at: verdict.haystackIndex, + haystack: haystacks[verdict.haystackIndex] + ) + cellViewModel.filterResult = verdict.result + filteredCellViewModels.append(cellViewModel) + } + // Un-highlight the rows the previous pass lit up that this one + // missed. Rows that were never highlighted never had one, so + // the warm materialized-cell map does not need sweeping. + for rowIndex in self.highlightedOpenQuicklyRowIndices.subtracting(matchedRowIndices) { + self.openQuicklyCellViewModelsByRowIndex[rowIndex]?.filterResult = nil + } + self.highlightedOpenQuicklyRowIndices = matchedRowIndices + self.filteredNodesForOpenQuickly = filteredCellViewModels + self.currentOpenQuicklyFilterTask = nil + } + } + + /// Returns the haystacks for `runtimeObjects`, joining a build already + /// running for the same object list instead of starting a second one. + /// + /// Sampling `openQuicklyHaystacksCache` at schedule time and never + /// re-reading it meant every keystroke landing during the first build + /// began its own full O(N) build of the identical array — + /// `defaultHaystackBuilder` has no cancellation points, so cancelling + /// the superseded pass freed nothing and the builds ran concurrently. + /// Sharing one task is the shape `RuntimeInterfaceCache` already uses + /// for the same problem. + /// + /// The task is deliberately unstructured: it must outlive the pass that + /// happened to start it, since the artifact belongs to the object list + /// rather than to any one query. + @MainActor + private func openQuicklyHaystacks( + forObjectListVersion objectListVersion: Int, + runtimeObjects: [RuntimeObject] + ) async -> [String] { + if let cachedHaystacks = openQuicklyHaystacksCache, + openQuicklyRuntimeObjectsVersion == objectListVersion { + return cachedHaystacks + } + if let inFlightBuild = inFlightOpenQuicklyHaystackBuild, + inFlightBuild.objectListVersion == objectListVersion { + return await inFlightBuild.task.value + } + + let haystackBuilder = haystackBuilder + let buildTask = Task { await haystackBuilder(runtimeObjects) } + inFlightOpenQuicklyHaystackBuild = (objectListVersion, buildTask) + let builtHaystacks = await buildTask.value + + // Install even when the pass that started this build was superseded: + // the haystacks depend only on the object list, so a later pass would + // otherwise rebuild what this one already finished. The version check + // is what the generation counter cannot do — that one moves on every + // keystroke, while these haystacks stay valid until a reload swaps + // the list, and installing them against a swapped list would misalign + // every index. + if openQuicklyRuntimeObjectsVersion == objectListVersion { + openQuicklyHaystacksCache = builtHaystacks + inFlightOpenQuicklyHaystackBuild = nil + } + return builtHaystacks + } + + /// Hop for the fuzzy matcher: `nonisolated async` runs on the global + /// concurrent executor, keeping the scoring off the main thread. + private nonisolated static func matchOffMain(context: FilterContext, haystacks: [String]) async -> [FilterMatchVerdict] { + FilterEngine.match(context, haystacks: haystacks) + } + + public func transform(_ input: Input) -> Output { input.addBookmark.emitOnNext { [weak self] viewModel in guard let self else { return } @@ -92,27 +345,16 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo } .disposed(by: rx.disposeBag) + // A live-stream debounce (unlike the sidebar's per-element delay): + // 150 ms of typing silence triggers one match. Short window on + // purpose — the match runs off-main and stale passes are cancelled + // by `scheduleOpenQuicklyRefilter`. input.searchStringForOpenQuickly .skip(1) - .debounce(.milliseconds(500)) - .emitOnNextMainActor { [weak self] filter in + .debounce(.milliseconds(150)) + .emitOnNextMainActor { [weak self] query in guard let self else { return } - if filter.isEmpty { - if isFilteringForOpenQuickly { - isFilteringForOpenQuickly = false - } - filteredNodesForOpenQuickly = [] - } else { - if !isFilteringForOpenQuickly { - isFilteringForOpenQuickly = true - } - Task.detached { - let filteredNodesForOpenQuickly = await FilterEngine.filter(filter, items: self.nodesForOpenQuickly, mode: .fuzzySearch, isCaseInsensitive: false) - await MainActor.run { - self.filteredNodesForOpenQuickly = filteredNodesForOpenQuickly - } - } - } + scheduleOpenQuicklyRefilter(query: query) } .disposed(by: rx.disposeBag) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift index adc68c14..4d64a64e 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift @@ -92,6 +92,17 @@ public class SidebarRuntimeObjectViewModel: ViewModel /// clear `currentReloadTask` or leave the successor in place). private var currentReloadGeneration: Int = 0 + /// In-flight off-main filter pass. `scheduleRefilter` cancels this + /// before starting a new one so rapid triggers (keystroke bursts, + /// scope edits, splices) never race each other's apply step. + private var currentFilterTask: Task? + + /// Monotonic token bumped by every `scheduleRefilter` call. The apply + /// step re-checks it after the background match so verdicts computed + /// against a superseded tree (newer search, reload, or splice) are + /// discarded instead of applied to mismatched cells. + private var currentFilterGeneration: Int = 0 + public init(imageNode: RuntimeImageNode, documentState: DocumentState, router: any Router) { let imagePath = imageNode.path self.runtimeEngine = documentState.runtimeEngine @@ -196,13 +207,21 @@ public class SidebarRuntimeObjectViewModel: ViewModel public func transform(_ input: Input) -> Output { // input.isSearchCaseInsensitive.drive($isSearchCaseInsensitive).disposed(by: rx.disposeBag) + // Keystroke coalescing: non-empty queries wait 150 ms (cancelled by + // the next keystroke via `flatMapLatest`), clearing applies + // immediately. NOTE: this must be `delay`, not `debounce` — on a + // single-element `.just` sequence, `debounce` flushes the pending + // element the moment the source completes, so the previous + // `.just(...).debounce(500ms)` never delayed anything. The window + // can be short because the matching itself runs off-main and stale + // passes are cancelled. Driver.combineLatest(input.searchString, input.isSearchCaseInsensitive) .flatMapLatest { searchString, isSearchCaseInsensitive -> Driver<(String, Bool)> in if searchString.isEmpty { return .just((searchString, isSearchCaseInsensitive)) } else { return .just((searchString, isSearchCaseInsensitive)) - .debounce(.milliseconds(500)) + .delay(.milliseconds(150)) } } .driveOnNextMainActor { [weak self] searchString, isSearchCaseInsensitive in @@ -211,7 +230,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel self.searchString = searchString self.isSearchCaseInsensitive = isSearchCaseInsensitive - rebuildFilteredNodes() + scheduleRefilter() } .disposed(by: rx.disposeBag) @@ -220,7 +239,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel .skip(1) // initial value already covered by `nodes` reload .driveOnNextMainActor { [weak self] _ in guard let self else { return } - rebuildFilteredNodes() + scheduleRefilter() } .disposed(by: rx.disposeBag) @@ -374,32 +393,44 @@ public class SidebarRuntimeObjectViewModel: ViewModel } else { self.nodes = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } } - rebuildFilteredNodes() + self.didInstallReloadedNodes() + scheduleRefilter() } } - /// Apply the scope pre-filter and then the text filter, publishing the - /// result to `filteredNodes`. Centralized so every call site (initial - /// load, search-string change, scope change, specialization splice) hits - /// the same ordering. + /// Hook for subclass state derived from `nodes`, called inside the same + /// synchronous main-actor block that installs them. /// - /// Three passes: - /// 1. Push the active scope into every cell in the tree so each cell's - /// `_filteredChildren` excludes children that fail the scope. The - /// cell-level scope filter is what keeps a node's expansion clean — - /// without it, a parent that passes via `matchesScopeRecursively` - /// would still show every sibling under it, including those that - /// fail the scope. - /// 2. Filter the top-level `nodes` array by `matchesScopeRecursively` - /// so parents whose hits live only in descendants are still - /// surfaced. - /// 3. Run the text filter via `FilterEngine.filter`. Always invoked — - /// even with an empty search string — because it cascades the - /// `filter` value through child cells and clears stale - /// `filterResult` highlighting from a previous search. + /// Invalidating derived state in a *later* `MainActor.run` leaves a + /// window: `reloadData()` suspends at every one of its `MainActor.run` + /// blocks, so an in-flight pass can resume between the install and the + /// invalidation, find its generation token still current, and publish + /// results built from the pre-reload list. Doing both in one critical + /// section removes the window rather than narrowing it — the same shape + /// `installRebuiltNodes(_:)` uses on the root sidebar. + @MainActor + func didInstallReloadedNodes() {} + + /// Single entry point for every filter trigger (initial load, search + /// change, scope change, specialization splice). Snapshots the tree on + /// the main actor, runs the matching off-main via + /// `SidebarRuntimeObjectFilterPipeline`, and applies the outcome back + /// on the main actor iff no newer trigger superseded it. The apply + /// step's guarded didSets make untouched rows free, so one keystroke + /// costs O(matches) main-thread work instead of O(nodes) attributed- + /// title rebuilds. @MainActor - private func rebuildFilteredNodes() { - let scope = scope + func scheduleRefilter() { + currentFilterTask?.cancel() + currentFilterGeneration &+= 1 + let generation = currentFilterGeneration + + let context = FilterContext( + query: searchString, + isCaseInsensitive: isSearchCaseInsensitive, + mode: appDefaults.filterMode + ) + let activeScope = scope // Drive `isFiltering` off the union of text + scope. This flag // controls the outline view's beginFiltering / endFiltering @@ -407,35 +438,46 @@ public class SidebarRuntimeObjectViewModel: ViewModel // surfaces matching descendants automatically. Must be set // *before* `filteredNodes` is reassigned so `didChangeFiltering` // (`withLatestFrom($isFiltering)`) sees the new value. - let shouldFilter = !searchString.isEmpty || scope.isActive + let shouldFilter = !context.query.isEmpty || activeScope.isActive if shouldFilter != isFiltering { isFiltering = shouldFilter } - // Pass 1: cascade scope into every cell so deeper levels rebuild - // their `_filteredChildren` before the top-level filter reads them. - for cell in nodes { - cell.applyScopeRecursively(scope) + // Fast path — an empty query with an inactive scope is the + // identity filter; apply synchronously so clearing the search + // never flashes stale results. Runs before the snapshot: the + // identity outcome needs no haystacks, and right after a reload the + // haystack caches are cold, so building a snapshot forest here + // would pay a full O(nodes) bottom-up name build just to discard + // it. The guarded didSets still skip every unchanged row. + if !shouldFilter { + currentFilterTask = nil + SidebarRuntimeObjectFilterPipeline.resetToUnfiltered(nodes, context: context, scope: activeScope) + filteredNodes = nodes + return } - // Pass 2: prune top-level by matchesScopeRecursively (a parent - // survives if itself or any descendant passes the scope). - let scoped: [SidebarRuntimeObjectCellViewModel] - if scope.isActive { - scoped = nodes.filter { $0.matchesScopeRecursively(scope) } - } else { - scoped = nodes + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: nodes, scope: activeScope) + + currentFilterTask = Task { @MainActor [weak self] in + let verdictForest = await Self.computeVerdictsOffMain(for: snapshotForest, context: context) + guard !Task.isCancelled, let self else { return } + guard self.currentFilterGeneration == generation else { return } + if let filtered = SidebarRuntimeObjectFilterPipeline.apply(verdictForest, to: self.nodes, context: context, scope: activeScope) { + self.filteredNodes = filtered + } + self.currentFilterTask = nil } + } - // Pass 3: text filter — FilterEngine handles an empty search by - // clearing every item's `filterResult` and cascading the empty - // filter through child cells. - filteredNodes = FilterEngine.filter( - searchString, - items: scoped, - mode: appDefaults.filterMode, - isCaseInsensitive: isSearchCaseInsensitive - ) + /// Hop for the matching recursion: `nonisolated async` runs on the + /// global concurrent executor, keeping fuzzy scoring off the main + /// thread while the caller awaits. + private nonisolated static func computeVerdictsOffMain( + for snapshotForest: [SidebarRuntimeObjectFilterPipeline.SnapshotNode], + context: FilterContext + ) async -> SidebarRuntimeObjectFilterPipeline.ForestVerdict { + SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: context) } /// Splice a newly specialized child into the existing sidebar tree without @@ -457,7 +499,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel return } nodes = nodes - rebuildFilteredNodes() + scheduleRefilter() // `nodes`/`filteredNodes` re-emissions above are no-ops for the // outline view (same `SidebarRuntimeObjectCellViewModel` instance in // both snapshots → DifferenceKit's `isContentEqual` always true → diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift index 3e5e1668..da39bd94 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift @@ -26,10 +26,18 @@ public final class ResolvedThemeStream { public let observable: Observable private init() { + // Resolve the Settings instance once, outside the tracking closure: + // the tracking bridge re-runs `access` on a bare main-queue hop + // (task-locals lost), so a `@Dependency` resolution inside the + // closure re-resolves against the ambient default context — in a + // test process that is `.test`, which silently swaps in a different + // Settings instance and kills the tracking chain after the first + // re-arm. See `Observable.tracking`'s documentation. + @Dependency(\.settings) var settings + let trackedSettings = settings observable = Observable .tracking { - @Dependency(\.settings) var settings - return ResolvedTheme(settings: settings) + ResolvedTheme(settings: trackedSettings) } .distinctUntilChanged() .share(replay: 1, scope: .forever) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift index f4d62e7d..cf2de749 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift @@ -116,7 +116,10 @@ extension SemanticString { } attributedString.endEditing() - return attributedString + // Immutable exit: the content pipeline builds this string on a + // background scheduler and hands it to the main thread, so the + // mutable builder instance must not escape. + return attributedString.copy() as! NSAttributedString } #if canImport(AppKit) && !targetEnvironment(macCatalyst) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift index d424cb40..1f8dd018 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift @@ -1,5 +1,6 @@ import Foundation import FoundationToolbox +import RuntimeViewerCore import RuntimeViewerArchitectures import RuntimeViewerSettings @@ -49,6 +50,22 @@ open class ViewModel: NSObject, ViewModelProtocol { self.documentState = documentState self.router = router } + + /// The generation options a single-object interface fetch should use + /// right now: the stored options merged with the live transformer + /// configuration, exactly as the content pipeline's fetch half merges + /// them. Every fetch that should share a `RuntimeInterfaceCache` entry + /// with the content pane must use these options so the cache keys line + /// up (and so exported text matches what the pane displays). + public var currentMergedGenerationOptions: RuntimeObjectInterface.GenerationOptions { + var mergedOptions = appDefaults.options + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + mergedOptions.transformer = settings.transformer + #else + mergedOptions.transformer = .init() + #endif + return mergedOptions + } } @MainActor diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift b/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift index d3aec1f2..275563f7 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift @@ -29,6 +29,14 @@ extension Observable { /// beforehand), tracking will silently stop firing. When the dependency /// isn't obvious at the call site, touch it explicitly inside `access` /// (`_ = settings.theme`) so the contract survives refactors. + /// + /// - Important: Never resolve a `@Dependency` **inside** `access`. The + /// re-arm after each change runs on a bare `DispatchQueue.main.async` + /// hop, which drops task-local values — the resolution then falls back + /// to the ambient default context (`.test` in a test process), silently + /// swaps in a different instance, and the tracking chain dies after the + /// first re-arm because it now observes the wrong object. Resolve the + /// dependency once at arm time and capture the instance instead. public static func tracking( _ access: @escaping () -> Element ) -> Observable { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift index 0b4b49a1..0653a345 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift @@ -46,6 +46,42 @@ open class StatefulOutlineView: OutlineView { private var expansionAutosaveObservers: [NSObjectProtocol] = [] private var isApplyingExpansionAutosave = false + /// Whether a coalesced autosave flush is already queued on the main + /// queue. Expand/collapse notifications arrive once per item — an + /// option-click "expand all" posts one per descendant — and each used + /// to trigger a full row walk plus a `UserDefaults` write, turning + /// the burst into O(rows²). One queued flush per burst keeps the + /// total cost at a single O(rows) walk. + private var isExpansionPersistScheduled = false + + /// Monotonic counter of structural data changes, bumped by every + /// `NSOutlineView` entry point that can replace or reshape the item + /// tree. A queued persist samples it at schedule time and refuses to + /// flush against a different value. + /// + /// Without that check the coalescing window is long enough for a tree + /// rebuild to land between the expand and the walk. The rebuilt tree + /// comes back fully collapsed — the sidebar maps every `$nodes` + /// emission through a fresh cell view model and its `Differentiable` + /// conformance resolves `differenceIdentifier` to `self`, so every row + /// is a new item — and the walk then wrote an empty array over the + /// user's saved state. `restoreExpansionFromAutosave()` runs once per + /// document, so nothing recovers it. + /// + /// Expand / collapse notifications are delivered synchronously + /// (`NotificationCenter` runs the block inline when the observer queue + /// is the posting queue), so the sample always describes the tree the + /// user acted on. + private var dataStructureVersion = 0 + + /// `dataStructureVersion` sampled when the pending persist was + /// scheduled; nil when no flush is queued. + private var scheduledExpansionPersistStructureVersion: Int? + + /// Number of persist walks actually performed. Regression seam for + /// the coalescing behavior (see `StatefulOutlineViewAutosaveTests`). + package private(set) var expansionAutosavePersistCount = 0 + open func beginFiltering() { guard filteringState == .idle else { return } saveExpansionState() @@ -151,6 +187,7 @@ open class StatefulOutlineView: OutlineView { isReloadingData = true defer { isReloadingData = false } + dataStructureVersion &+= 1 super.reloadData() switch filteringState { @@ -165,6 +202,44 @@ open class StatefulOutlineView: OutlineView { } } + // The incremental counterparts of `reloadData()`. A diffing adapter + // reaches for these whenever it can — RxAppKit only falls back to + // `reloadData()` when the changeset carries `elementUpdated` entries or + // exceeds its animation threshold — so hooking the full-reload path + // alone would miss the common tree replacement. + + open override func insertItems( + at indexes: IndexSet, + inParent parent: Any?, + withAnimation animationOptions: NSTableView.AnimationOptions = [] + ) { + dataStructureVersion &+= 1 + super.insertItems(at: indexes, inParent: parent, withAnimation: animationOptions) + } + + open override func removeItems( + at indexes: IndexSet, + inParent parent: Any?, + withAnimation animationOptions: NSTableView.AnimationOptions = [] + ) { + dataStructureVersion &+= 1 + super.removeItems(at: indexes, inParent: parent, withAnimation: animationOptions) + } + + open override func moveItem(at fromIndex: Int, inParent oldParent: Any?, to toIndex: Int, inParent newParent: Any?) { + dataStructureVersion &+= 1 + super.moveItem(at: fromIndex, inParent: oldParent, to: toIndex, inParent: newParent) + } + + open override func reloadItem(_ item: Any?, reloadChildren: Bool) { + // `reloadChildren: false` only re-reads one row's display values; + // the subtree, and therefore the expansion state, is untouched. + if reloadChildren { + dataStructureVersion &+= 1 + } + super.reloadItem(item, reloadChildren: reloadChildren) + } + // MARK: - Expansion Autosave private var expansionAutosaveUserDefaultsKey: String? { @@ -185,26 +260,57 @@ open class StatefulOutlineView: OutlineView { object: self, queue: .main ) { [weak self] _ in - self?.persistExpansionStateIfNeeded() + self?.scheduleExpansionPersist() } let didCollapse = center.addObserver( forName: NSOutlineView.itemDidCollapseNotification, object: self, queue: .main ) { [weak self] _ in - self?.persistExpansionStateIfNeeded() + self?.scheduleExpansionPersist() } expansionAutosaveObservers = [didExpand, didCollapse] } + /// Coalesces a burst of expand/collapse notifications into one + /// persist walk on the next main-queue turn. The full precondition + /// set re-runs at flush time because the state can change within the + /// coalescing window (a filter starting, the autosave name clearing). + /// Best-effort by design: a flush pending when the view deallocates + /// is dropped, losing at most the burst from the final runloop turn. + private func scheduleExpansionPersist() { + guard !isApplyingExpansionAutosave, + filteringState == .idle, + expansionAutosaveUserDefaultsKey != nil, + persistentObjectForExpansion != nil, + !isExpansionPersistScheduled else { return } + isExpansionPersistScheduled = true + scheduledExpansionPersistStructureVersion = dataStructureVersion + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isExpansionPersistScheduled = false + defer { self.scheduledExpansionPersistStructureVersion = nil } + self.persistExpansionStateIfNeeded() + } + } + private func persistExpansionStateIfNeeded() { // Skip during filter-induced expand/collapse churn and during programmatic // restore; only user-driven changes in the idle state should be persisted. + // + // The structure version must still match the one sampled at schedule + // time: the walk below describes whatever tree is installed *now*, + // and persisting a tree the user never acted on destroys their saved + // state. Note the guard is on the data changing, not on the walk + // coming back empty — collapsing every row is a legitimate way to + // persist an empty set. guard !isApplyingExpansionAutosave, filteringState == .idle, + scheduledExpansionPersistStructureVersion == dataStructureVersion, let key = expansionAutosaveUserDefaultsKey, let persistentObjectForExpansion else { return } + expansionAutosavePersistCount += 1 var persistentObjects: [String] = [] let totalRows = numberOfRows for rowIndex in 0 ..< totalRows { diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift new file mode 100644 index 00000000..282b19ea --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift @@ -0,0 +1,369 @@ +import AppKit +import Foundation +import Dependencies +import RxSwift +import RuntimeViewerCore +import RuntimeViewerSettings +import RuntimeViewerArchitectures +import Semantic +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the split content-text pipeline +/// (`ContentTextViewModel`'s fetch half vs. render half). +/// +/// History: before the 2026-08 split, the interface fetch and the attributed +/// string build lived in a single `combineLatest` that also observed the +/// theme — so every theme / font-size change re-fetched the interface over +/// XPC and rebuilt the whole attributed string on the main thread, and a +/// trailing `catchAndReturn` on the outer chain completed the pipeline on +/// the first fetch error, permanently freezing the tab. The assertions +/// below pin all three fixes: theme-only changes must not re-fetch, a +/// failed fetch must not kill the pipeline, and the off-main render helper +/// must reproduce the direct builder output byte for byte. +@Suite("ContentTextPipeline", .serialized) +@MainActor +struct ContentTextPipelineTests { + // MARK: - Theme-only changes must not re-fetch + + @Test("font-size change re-renders without re-fetching the interface") + func fontSizeChangeDoesNotRefetch() async throws { + let fetchRecorder = InterfaceFetchRecorder() + let fixtureRuntimeObject = makeRuntimeObject() + let (viewModel, mockRouter) = makeViewModel( + runtimeObject: fixtureRuntimeObject, + interfaceProvider: { runtimeObject, _ in + _ = fetchRecorder.recordFetch() + return RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + ) + + let initialRendered = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString != nil + } + #expect(initialRendered, "initial fetch never produced an attributed string") + #expect(fetchRecorder.fetchCount == 1) + let initialAttributedString = try #require(viewModel.attributedString) + + let settings = liveSettings() + let originalFontSize = settings.theme.fontSize + defer { withLiveDependencyContext { settings.theme.fontSize = originalFontSize } } + + let changedFontSize = originalFontSize + 3 + withLiveDependencyContext { settings.theme.fontSize = changedFontSize } + + let rebuiltWithNewFontSize = try await pollUntil(timeout: .seconds(10)) { + guard let rebuiltAttributedString = viewModel.attributedString, + rebuiltAttributedString !== initialAttributedString, + rebuiltAttributedString.length > 0, + let font = rebuiltAttributedString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + else { return false } + return font.pointSize == CGFloat(changedFontSize) + } + #expect(rebuiltWithNewFontSize, "font-size change never produced a re-rendered attributed string") + #expect(fetchRecorder.fetchCount == 1, "a theme-only change must not re-fetch the interface") + + // The view model holds its router unowned — keep the mock alive + // until every assertion has run. + withExtendedLifetime(mockRouter) {} + } + + // MARK: - The loading indicator must cover the render half + + /// PR #88 review, finding 3: after the fetch/render split, only the + /// fetch half was tracked by `_commonLoading` — a theme or font-size + /// change re-renders without ever entering a tracked region, so the + /// indicator never appears even though the user is waiting on the + /// rebuild. This fails against a fetch-only `trackActivity` placement. + @Test("font-size change surfaces the loading indicator through the render half") + func fontSizeChangeSurfacesLoadingIndicator() async throws { + let fixtureRuntimeObject = makeRuntimeObject() + let (viewModel, mockRouter) = makeViewModel( + runtimeObject: fixtureRuntimeObject, + interfaceProvider: { runtimeObject, _ in + RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + ) + + let initialRendered = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString != nil + } + #expect(initialRendered, "initial fetch never produced an attributed string") + let initialAttributedString = try #require(viewModel.attributedString) + + // Record loading emissions only from here on, so the initial + // fetch's activity cannot satisfy the assertion. + let loadingRecorder = LoadingEmissionRecorder() + let subscriptionDisposeBag = DisposeBag() + viewModel._commonLoading.asDriver() + .driveOnNext { isLoading in + loadingRecorder.record(isLoading) + } + .disposed(by: subscriptionDisposeBag) + + let settings = liveSettings() + let originalFontSize = settings.theme.fontSize + defer { withLiveDependencyContext { settings.theme.fontSize = originalFontSize } } + withLiveDependencyContext { settings.theme.fontSize = originalFontSize + 3 } + + let rebuilt = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString !== initialAttributedString && viewModel.attributedString != nil + } + #expect(rebuilt, "font-size change never produced a re-rendered attributed string") + let indicatorAppeared = try await pollUntil(timeout: .seconds(5)) { + loadingRecorder.sawLoading + } + #expect(indicatorAppeared, "a theme-only re-render must pass through a tracked region so the indicator can appear") + + withExtendedLifetime(mockRouter) {} + withExtendedLifetime(subscriptionDisposeBag) {} + } + + // MARK: - Fetch errors must not kill the pipeline + + @Test("a failed fetch keeps the pipeline alive for subsequent changes") + func failedFetchKeepsPipelineAlive() async throws { + let fetchRecorder = InterfaceFetchRecorder(failingFirstFetches: 1) + let fixtureRuntimeObject = makeRuntimeObject() + let (viewModel, mockRouter) = makeViewModel( + runtimeObject: fixtureRuntimeObject, + interfaceProvider: { runtimeObject, _ in + if fetchRecorder.recordFetch() { + throw StubInterfaceFetchError() + } + return RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + ) + + let firstFetchCompleted = try await pollUntil(timeout: .seconds(10)) { + fetchRecorder.fetchCount == 1 + } + #expect(firstFetchCompleted, "initial fetch never ran") + #expect(viewModel.attributedString == nil) + + // Re-trigger the fetch half via a generation-option change; before + // the split this subscription was already dead (`catchAndReturn` on + // the outer chain completed it on the first error). + let appDefaults = liveAppDefaults() + let originalOptions = appDefaults.options + defer { appDefaults.options = originalOptions } + appDefaults.options.swiftInterfaceOptions.printFieldOffset.toggle() + + let recovered = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString != nil + } + #expect(recovered, "an options change after a failed fetch never recovered the pipeline") + #expect(fetchRecorder.fetchCount == 2) + + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Navigation revisits render from the interface cache + + @Test("recreating the view model for the same object renders from the cache without a new fetch") + func navigationRevisitRendersFromCache() async throws { + // The interface cache listens for `.fullReload` broadcasts on the + // shared local engine; a concurrent suite broadcasting one (the + // real-engine flush test) would flush this cache between the two + // view models and turn the "no second fetch" assertion flaky. + // Hold the cross-suite lock (see SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + try await runNavigationRevisitRendersFromCache() + } + } + + private func runNavigationRevisitRendersFromCache() async throws { + let fetchRecorder = InterfaceFetchRecorder() + let fixtureRuntimeObject = makeRuntimeObject() + let documentState = withLiveDependencyContext { DocumentState() } + // One shared cache fed by a counting fetcher, with both view models + // routed through it — the same shape `ContentCoordinator` produces + // when navigation rebinds `ContentTextViewModel` onto one document. + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { runtimeObject, _ in + _ = fetchRecorder.recordFetch() + return RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + let cacheProvider: ContentTextViewModel.InterfaceProvider = { runtimeObject, options in + try await interfaceCache.interface(for: runtimeObject, options: options) + } + + let firstMockRouter = MockRouter() + var firstViewModel: ContentTextViewModel? = withLiveDependencyContext { + ContentTextViewModel( + runtimeObject: fixtureRuntimeObject, + documentState: documentState, + router: firstMockRouter, + interfaceProvider: cacheProvider + ) + } + let firstRendered = try await pollUntil(timeout: .seconds(10)) { + firstViewModel?.attributedString != nil + } + #expect(firstRendered, "the first view model never rendered") + #expect(fetchRecorder.fetchCount == 1) + + // Navigate away: the coordinator drops the old view model… + firstViewModel = nil + + // …and navigating back binds a fresh one for the same object. + let secondMockRouter = MockRouter() + let secondViewModel = withLiveDependencyContext { + ContentTextViewModel( + runtimeObject: fixtureRuntimeObject, + documentState: documentState, + router: secondMockRouter, + interfaceProvider: cacheProvider + ) + } + let secondRendered = try await pollUntil(timeout: .seconds(10)) { + secondViewModel.attributedString != nil + } + #expect(secondRendered, "the revisiting view model never rendered") + #expect(fetchRecorder.fetchCount == 1, "a navigation revisit must render from the cache, not refetch") + + withExtendedLifetime(firstMockRouter) {} + withExtendedLifetime(secondMockRouter) {} + } + + // MARK: - Render helper equivalence (pins the PR2 restyle baseline) + + @Test("renderAttributedString matches a direct builder invocation and returns an immutable string") + func renderMatchesDirectBuilderInvocation() { + let fixtureRuntimeObject = makeRuntimeObject() + let interfaceString: SemanticString = "class ContentPipelineFixture {}" + let theme = ResolvedTheme.fallback + + let rendered = ContentTextViewModel.renderAttributedString( + for: (interfaceString: interfaceString, runtimeObject: fixtureRuntimeObject), + theme: theme + ) + let direct = interfaceString.attributedString(for: theme, runtimeObjectName: fixtureRuntimeObject) + #expect(rendered?.isEqual(to: direct) == true) + + // The cross-thread handoff contract: the builder must not leak its + // mutable working copy. + #expect(!(rendered is NSMutableAttributedString)) + #expect(ContentTextViewModel.renderAttributedString(for: nil, theme: theme) == nil) + } + + // MARK: - Fixtures + + private func makeViewModel( + runtimeObject: RuntimeObject, + interfaceProvider: @escaping ContentTextViewModel.InterfaceProvider + ) -> (viewModel: ContentTextViewModel, router: MockRouter) { + withLiveDependencyContext { + let documentState = DocumentState() + let mockRouter = MockRouter() + let viewModel = ContentTextViewModel( + runtimeObject: runtimeObject, + documentState: documentState, + router: mockRouter, + interfaceProvider: interfaceProvider + ) + return (viewModel, mockRouter) + } + } + + private func makeRuntimeObject() -> RuntimeObject { + RuntimeObject( + name: "TestFramework.ContentPipelineFixture", + displayName: "TestFramework.ContentPipelineFixture", + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: [], + properties: [] + ) + } + + /// Thread-safe fetch recorder for the injected `InterfaceProvider` + /// (invoked on the pipeline's background fetch Task). + private final class InterfaceFetchRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedFetchCount = 0 + private var storedFailuresRemaining: Int + + init(failingFirstFetches failureCount: Int = 0) { + storedFailuresRemaining = failureCount + } + + var fetchCount: Int { + lock.withLock { storedFetchCount } + } + + /// Records one fetch; returns whether this fetch should fail. + func recordFetch() -> Bool { + lock.withLock { + storedFetchCount += 1 + guard storedFailuresRemaining > 0 else { return false } + storedFailuresRemaining -= 1 + return true + } + } + } + + // `Swift.Error` spelled out: an imported module also exports a type + // named `Error`, which otherwise shadows the standard library protocol. + private struct StubInterfaceFetchError: Swift.Error {} + + /// Thread-safe recorder for `_commonLoading` emissions (delivered on + /// the main thread by the driver, read from the polling loop). + private final class LoadingEmissionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedSawLoading = false + + var sawLoading: Bool { + lock.withLock { storedSawLoading } + } + + func record(_ isLoading: Bool) { + guard isLoading else { return } + lock.withLock { storedSawLoading = true } + } + } + + // MARK: - Dependency helpers + + /// Forces the live dependency context: the pipeline resolves + /// `\.settings` / `\.resolvedThemeStream` internally, and those entries + /// declare no test value. + private func withLiveDependencyContext(_ operation: () throws -> Result) rethrows -> Result { + try withDependencies { + $0.context = .live + } operation: { + try operation() + } + } + + private func liveSettings() -> Settings { + withLiveDependencyContext { + @Dependency(\.settings) var settings + return settings + } + } + + private func liveAppDefaults() -> AppDefaults { + withLiveDependencyContext { + @Dependency(\.appDefaults) var appDefaults + return appDefaults + } + } + + // MARK: - Polling helper + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift new file mode 100644 index 00000000..fa752ced --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import RuntimeViewerApplication + +/// Contract suite for `FilterEngine`'s case-sensitivity semantics. +/// +/// History: the pre-2026-08 `.none` (plain contains) branch had the flag +/// inverted — `isCaseInsensitive == true` selected the case-*sensitive* +/// `contains`. When the engine was fixed to honor the flag, every caller +/// supplying a constant had to flip with it; the UIKit sidebar's +/// `.just(false)` was missed and shipped iOS a case-sensitive search +/// (PR #88 review, finding 2). These tests pin the honest semantics at the +/// engine so a future inversion breaks loudly here instead of silently +/// flipping whichever platform forgot to compensate. +@Suite("FilterEngineCaseSensitivity") +struct FilterEngineCaseSensitivityTests { + private let haystacks = ["NSString", "NSAttributedString", "UIView"] + + @Test("plain contains honors isCaseInsensitive = true") + func caseInsensitiveMatchesDifferentCase() { + let context = FilterContext(query: "nsstring", isCaseInsensitive: true, mode: nil) + let matchedIndices = FilterEngine.match(context, haystacks: haystacks).map(\.haystackIndex) + #expect(matchedIndices == [0], "lowercase query must match differently-cased haystacks when the flag is on") + } + + @Test("plain contains honors isCaseInsensitive = false") + func caseSensitiveRequiresExactCase() { + let differentCase = FilterContext(query: "nsstring", isCaseInsensitive: false, mode: nil) + #expect( + FilterEngine.match(differentCase, haystacks: haystacks).isEmpty, + "a case-sensitive query must not match differently-cased haystacks" + ) + + let exactCase = FilterContext(query: "NSString", isCaseInsensitive: false, mode: nil) + let matchedIndices = FilterEngine.match(exactCase, haystacks: haystacks).map(\.haystackIndex) + #expect(matchedIndices == [0]) + } + + @Test("an empty query is the identity filter regardless of the flag") + func emptyQueryIsIdentity() { + for isCaseInsensitive in [true, false] { + let context = FilterContext(query: "", isCaseInsensitive: isCaseInsensitive, mode: nil) + let matchedIndices = FilterEngine.match(context, haystacks: haystacks).map(\.haystackIndex) + #expect(matchedIndices == [0, 1, 2]) + } + } + + @Test("filter(context:items:) resets results and stamps the context on the empty-query path") + func emptyQueryFilterResetsItems() { + let items = haystacks.map(StubFilterableItem.init) + items[1].filterResult = StubFilterResult() + + let emptyContext = FilterContext(query: "", isCaseInsensitive: true, mode: nil) + let returnedItems = FilterEngine.filter(context: emptyContext, items: items) + + #expect(returnedItems.count == items.count) + for item in items { + #expect(item.filterContext == emptyContext, "the context must be stamped before the empty-query early return") + #expect(item.filterResult == nil) + } + } + + private final class StubFilterableItem: FilterableItem { + var filterContext = FilterContext() + var filterResult: FuzzyFilterResult? + let filterableString: String + + init(_ filterableString: String) { + self.filterableString = filterableString + } + } + + private struct StubFilterResult: FuzzyFilterResult { + var ranges: [NSRange] { [] } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift new file mode 100644 index 00000000..c6c1e89e --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift @@ -0,0 +1,24 @@ +import RuntimeViewerArchitectures + +/// Test double for `Router`. Records every triggered route so tests can +/// assert on navigation side effects without spinning up a real +/// coordinator hierarchy. The completion handler is invoked immediately +/// with an empty transition context, mirroring an instantaneous +/// transition. +/// +/// `ViewModel` holds its router `unowned`, so tests MUST keep the +/// `MockRouter` alive for the whole lifetime of the view model under +/// test (a stored `let` in the test body is enough). +@MainActor +final class MockRouter: Router { + private struct EmptyTransitionContext: TransitionContext { + let presentables: [any Presentable] = [] + } + + private(set) var triggeredRoutes: [Route] = [] + + func contextTrigger(_ route: Route, with options: TransitionOptions, completion: ContextPresentationHandler?) { + triggeredRoutes.append(route) + completion?(EmptyTransitionContext()) + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift new file mode 100644 index 00000000..c54c1035 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift @@ -0,0 +1,234 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for Open Quickly's lazy row materialization. +/// +/// History: before this change, `SidebarRuntimeObjectListViewModel.reloadData` +/// eagerly built a second full copy of the sidebar's cell view models +/// (`forOpenQuickly: true`) on the main thread — N cell constructions +/// (icons, attributed titles, child trees) per image load, ~250 ms at +/// N = 10k in a debug build, for a list most sessions never open. Now the +/// reload stores only the sorted value array; matching runs against pure +/// haystack strings computed off-main, and cell view models materialize +/// only for rows a query actually surfaces. +@Suite("OpenQuicklyLazyConstruction", .serialized) +@MainActor +struct OpenQuicklyLazyConstructionTests { + private static let seededObjectCount = 2_000 + + // MARK: - Haystack parity (the highlight-range contract) + + @Test("pure-value haystack is byte-identical to the cell view model's") + func haystackParity() { + let grandchild = makeRuntimeObject(displayName: "TestFramework.Parent.Child.Grandchild") + let secondChild = makeRuntimeObject(displayName: "TestFramework.Parent.AChild") + let firstChild = makeRuntimeObject(displayName: "TestFramework.Parent.Child", children: [grandchild]) + // Children intentionally out of display order: both sides must + // sort by displayName before joining, or fuzzy highlight ranges + // computed against the pure haystack would land on the wrong + // characters of the materialized cell's haystack. + let parent = makeRuntimeObject( + displayName: "TestFramework.Parent", + children: [firstChild, secondChild] + ) + + for runtimeObject in [parent, firstChild, grandchild, makeRuntimeObject(displayName: "TestFramework.Leaf")] { + let cellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: runtimeObject, forOpenQuickly: true) + #expect( + SidebarRuntimeObjectCellViewModel.haystack(for: runtimeObject) == cellViewModel.currentAndChildrenNames, + "haystack diverged for \(runtimeObject.displayName)" + ) + } + } + + // MARK: - End-to-end lazy contract + + @Test("reload materializes zero rows; queries materialize only matches") + func lazyMaterializationEndToEnd() async throws { + // The seeded view model subscribes to the shared local engine's + // data-change broadcasts; a concurrent suite firing a real + // `.fullReload` (the interface-cache flush test) would reload the + // fixture mid-assertion and rebuild the materialized-row cache. + // Hold the cross-suite lock (see SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + try await runLazyMaterializationEndToEnd() + } + } + + private func runLazyMaterializationEndToEnd() async throws { + let localRuntimeEngine = RuntimeEngine.local + + var imageList: [String] = [] + let engineReady = try await pollUntil(timeout: .seconds(15)) { + imageList = await localRuntimeEngine.imageList + return !imageList.isEmpty + } + #expect(engineReady, "local engine never published an image list") + let loadedImagePath = try #require( + imageList.first { $0.hasSuffix("/Foundation") } ?? imageList.first + ) + + let rootImageNode = RuntimeImageNode.rootNode(for: [loadedImagePath], name: "Root") + var imageNode = rootImageNode + while let firstChild = imageNode.children.first { + imageNode = firstChild + } + + let documentState = DocumentState() + let mockRouter = MockRouter() + let seededRuntimeObjects = makeFlatRuntimeObjects(count: Self.seededObjectCount) + let viewModel = SeededOpenQuicklyListViewModel( + seededRuntimeObjects: seededRuntimeObjects, + imageNode: imageNode, + documentState: documentState, + router: mockRouter + ) + let reloadFinished = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded + } + #expect(reloadFinished, "seeded reload never reached .loaded") + + // The headline assertion: a reload materializes NOTHING for Open + // Quickly. The legacy path had already built all N rows here. + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.isEmpty) + #expect(viewModel.filteredNodesForOpenQuickly.isEmpty) + + let searchStringRelay = PublishRelay() + let input = SidebarRuntimeObjectListViewModel.Input( + runtimeObjectClickedForOpenQuickly: .never(), + searchStringForOpenQuickly: searchStringRelay.asSignal(), + addBookmark: .never() + ) + _ = viewModel.transform(input) + + // The input stream drops its first element (the search field's + // initial value in the real UI), so prime it before querying. + searchStringRelay.accept("") + + let expectedNeedleMatchCount = Self.seededObjectCount / 100 + searchStringRelay.accept("Needle") + let searchApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodesForOpenQuickly.count == expectedNeedleMatchCount + } + #expect(searchApplied, "search never produced \(expectedNeedleMatchCount) filtered rows") + + // Only the matched rows exist as cell view models, every one of + // them carries a highlight, and the published array is exactly + // the materialized set. + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.count == expectedNeedleMatchCount) + #expect(viewModel.filteredNodesForOpenQuickly.allSatisfy { $0.filterResult != nil }) + #expect(viewModel.isFilteringForOpenQuickly) + + // Repeating the same query must reuse the materialized rows (same + // instances, stable DifferenceKit identity), not build new ones. + let firstPassRows = viewModel.filteredNodesForOpenQuickly + searchStringRelay.accept("NeedleGenerated") + // The list already shows the same 20 rows, so give the second + // pass its full debounce window + match time before asserting + // that it reused (rather than regrew) the materialized cache. + try await Task.sleep(for: .milliseconds(600)) + let narrowedApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodesForOpenQuickly.count == expectedNeedleMatchCount + && viewModel.filteredNodesForOpenQuickly.allSatisfy { $0.filterResult != nil } + } + #expect(narrowedApplied) + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.count == expectedNeedleMatchCount) + #expect(Set(viewModel.filteredNodesForOpenQuickly.map(ObjectIdentifier.init)) + == Set(firstPassRows.map(ObjectIdentifier.init))) + + // Clearing empties the list and un-highlights the materialized + // rows, but keeps the cache warm for the next search. + searchStringRelay.accept("") + let cleared = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodesForOpenQuickly.isEmpty && !viewModel.isFilteringForOpenQuickly + } + #expect(cleared, "clearing the search never emptied the list") + #expect(firstPassRows.allSatisfy { $0.filterResult == nil }) + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.count == expectedNeedleMatchCount) + + // A reload invalidates the whole lazy state: objects, haystacks, + // and materialized rows. + viewModel.scheduleReload() + let reloaded = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded && viewModel.openQuicklyCellViewModelsByRowIndex.isEmpty + } + #expect(reloaded, "reload never cleared the materialized row cache") + + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Fixtures + + private func makeFlatRuntimeObjects(count: Int) -> [RuntimeObject] { + (0 ..< count).map { index in + let displayName = index.isMultiple(of: 100) + ? "TestFramework.NeedleGeneratedType\(index)" + : "TestFramework.GeneratedType\(index)" + return makeRuntimeObject(displayName: displayName) + } + } + + private func makeRuntimeObject( + displayName: String, + children: [RuntimeObject] = [] + ) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: [] + ) + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} + +/// Open Quickly list view model whose reload publishes a canned object +/// list instead of asking the engine, so tests control the data set while +/// still exercising the real `reloadData` path (the `isImageLoaded` +/// engine gate stays live). +@MainActor +private final class SeededOpenQuicklyListViewModel: SidebarRuntimeObjectListViewModel { + private let seededRuntimeObjects: [RuntimeObject] + + init( + seededRuntimeObjects: [RuntimeObject], + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router + ) { + self.seededRuntimeObjects = seededRuntimeObjects + super.init(imageNode: imageNode, documentState: documentState, router: router) + } + + override func buildRuntimeObjects() async throws -> [RuntimeObject] { + seededRuntimeObjects + } + + override func buildRuntimeObjectsStream() -> AsyncThrowingStream { + AsyncThrowingStream { [seededRuntimeObjects] continuation in + continuation.yield(.completed(seededRuntimeObjects)) + continuation.finish() + } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift new file mode 100644 index 00000000..ccf53d88 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift @@ -0,0 +1,326 @@ +import Foundation +import RuntimeViewerArchitectures +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the residual costs lazy materialization still +/// carried after PR #88's rewrite. +/// +/// - The materialized-row memo was unbounded and cleared only by a reload. +/// `.fuzzySearch` keeps every non-zero-score haystack, and a haystack is +/// the object's name plus every descendant's, so a one- or two-character +/// query matched essentially the whole image and the apply loop built a +/// cell view model (and, recursively, one per descendant) for every row +/// in a single main-actor turn — reintroducing the O(N) main-thread cost +/// the lazy path exists to remove, and retaining all of it for the +/// document's life. +/// - A superseded pass threw away a completed haystack build. The +/// haystacks depend only on the object list, never on the query, so +/// whenever the build outran the 150 ms debounce, continuous typing +/// discarded a full build per query and the cache never populated. +/// - Stamping a highlight on a freshly materialized cell rebuilt, on the +/// main actor, the byte-identical twin of the haystack the off-main pass +/// was still holding. +@Suite("OpenQuicklyMaterializationBounds", .serialized) +@MainActor +struct OpenQuicklyMaterializationBoundsTests { + private static let seededObjectCount = 1_200 + + @Test("a query matching every row materializes at most the row cap") + func wideQueryStopsAtTheRowCap() async throws { + try await withSharedLocalEngineLock { + let harness = try await Harness(objectCount: Self.seededObjectCount) + + // Matches every seeded object, which is the shape a one- or + // two-character query has against a real image. + harness.search("Type") + + let applied = try await pollUntil(timeout: .seconds(20)) { + !harness.viewModel.filteredNodesForOpenQuickly.isEmpty + } + #expect(applied, "the wide query never produced any rows") + + let cap = SidebarRuntimeObjectListViewModel.openQuicklyMaximumMaterializedRows + #expect(Self.seededObjectCount > cap, "the fixture must exceed the cap for this to mean anything") + #expect(harness.viewModel.filteredNodesForOpenQuickly.count == cap) + #expect( + harness.viewModel.openQuicklyCellViewModelsByRowIndex.count == cap, + "every displayed row is materialized and nothing beyond the cap is" + ) + #expect(harness.viewModel.filteredNodesForOpenQuickly.allSatisfy { $0.filterResult != nil }) + } + } + + /// Two contracts in one timeline, both about a build that belongs to + /// the object list rather than to any one query: + /// + /// 1. Overlapping passes share it. Sampling the cache at schedule time + /// and never re-reading it made every keystroke landing during the + /// first build start its own full O(N) build of the identical array, + /// and `defaultHaystackBuilder` has no cancellation points, so + /// cancelling the superseded pass freed nothing — the builds simply + /// ran concurrently. + /// 2. A superseded pass's completed build is still installed. Discarding + /// it meant that whenever the build outran the debounce, continuous + /// typing threw away a complete build per query and the cache was + /// never populated at all. + @Test("overlapping passes share one haystack build, and its result is installed") + func overlappingPassesShareOneHaystackBuild() async throws { + try await withSharedLocalEngineLock { + let gate = HaystackBuildGate() + let harness = try await Harness(objectCount: 64, gate: gate) + + harness.search("Alpha") + let firstBuildStarted = try await pollUntil(timeout: .seconds(20)) { gate.startedBuildCount == 1 } + #expect(firstBuildStarted, "the first query never started a haystack build") + + // Supersedes the first pass while its build is still gated. Give + // it its full debounce window plus margin — a pass that were + // going to start its own build would have done so by now. + harness.search("Alphab") + try await Task.sleep(for: .milliseconds(600)) + #expect( + gate.startedBuildCount == 1, + "the second query started a redundant build instead of joining the one in flight" + ) + + // Releasing the single shared build must populate the cache even + // though the pass that started it was cancelled and + // out-generationed. + gate.releaseNext() + + let cachePopulated = try await pollUntil(timeout: .seconds(20)) { + harness.viewModel.openQuicklyHaystacksCache != nil + } + #expect( + cachePopulated, + "the shared build's result was discarded with the pass that started it" + ) + + gate.release() + } + } + + @Test("a materialized row reuses the haystack the matching pass computed") + func materializedRowReusesThePassHaystack() async throws { + try await withSharedLocalEngineLock { + // The builder appends a marker the cell could never derive on its + // own, so the assertion proves the cell was seeded from the pass's + // string rather than rebuilding its own subtree names. + let harness = try await Harness(objectCount: 64) { runtimeObjects in + runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) + " SeedMarker" } + } + + harness.search("Type") + let applied = try await pollUntil(timeout: .seconds(20)) { + !harness.viewModel.filteredNodesForOpenQuickly.isEmpty + } + #expect(applied, "the query never produced any rows") + + let materializedRow = try #require(harness.viewModel.filteredNodesForOpenQuickly.first) + #expect( + materializedRow.currentAndChildrenNames.hasSuffix("SeedMarker"), + "the materialized row rebuilt its own haystack instead of reusing the pass's" + ) + } + } + + @Test("seeding never overwrites a haystack the cell already derived") + func seedingDoesNotOverwriteAnExistingHaystack() { + let runtimeObject = Harness.makeRuntimeObject(displayName: "TestFramework.GeneratedType0") + let cellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: runtimeObject, forOpenQuickly: true) + + let derivedHaystack = cellViewModel.currentAndChildrenNames + cellViewModel.seedCurrentAndChildrenNames("something else entirely") + + #expect(cellViewModel.currentAndChildrenNames == derivedHaystack) + } +} + +// MARK: - Harness + +extension OpenQuicklyMaterializationBoundsTests { + /// Gates every haystack build so a test can hold one open across a + /// supersession. + fileprivate final class HaystackBuildGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var pendingContinuations: [CheckedContinuation] = [] + private var startedBuilds = 0 + + var startedBuildCount: Int { lock.withLock { startedBuilds } } + + func waitForRelease() async { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = lock.withLock { + startedBuilds += 1 + if isOpen { return true } + pendingContinuations.append(continuation) + return false + } + if shouldResumeImmediately { + continuation.resume() + } + } + } + + /// Resumes only the earliest gated build, leaving later ones held. + /// The superseded-pass test needs this: releasing everything would + /// let the *current* pass finish and install the cache itself, + /// which the old always-discard code also did — the assertion + /// only pins the fix if the superseded build is the sole finisher. + func releaseNext() { + let continuationToResume = lock.withLock { + pendingContinuations.isEmpty ? nil : pendingContinuations.removeFirst() + } + continuationToResume?.resume() + } + + func release() { + let continuationsToResume = lock.withLock { + isOpen = true + let pending = pendingContinuations + pendingContinuations = [] + return pending + } + continuationsToResume.forEach { $0.resume() } + } + } + + @MainActor + fileprivate struct Harness { + let viewModel: SidebarRuntimeObjectListViewModel + + private let searchStringRelay = PublishRelay() + private let router: MockRouter + + init( + objectCount: Int, + gate: HaystackBuildGate? = nil, + haystackBuilder: SidebarRuntimeObjectListViewModel.HaystackBuilder? = nil + ) async throws { + let router = MockRouter() + let seededRuntimeObjects = Self.makeRuntimeObjects(count: objectCount) + let builder: SidebarRuntimeObjectListViewModel.HaystackBuilder = { runtimeObjects in + if let gate { + await gate.waitForRelease() + } + if let haystackBuilder { + return await haystackBuilder(runtimeObjects) + } + return runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) } + } + let imageNode = try await Self.makeImageNode() + let documentState = DocumentState() + let viewModel = SeededListViewModel( + seededRuntimeObjects: seededRuntimeObjects, + imageNode: imageNode, + documentState: documentState, + router: router, + haystackBuilder: builder + ) + self.router = router + self.viewModel = viewModel + + let reloadFinished = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded + } + #expect(reloadFinished, "seeded reload never reached .loaded; state=\(viewModel.loadState)") + + let input = SidebarRuntimeObjectListViewModel.Input( + runtimeObjectClickedForOpenQuickly: .never(), + searchStringForOpenQuickly: searchStringRelay.asSignal(), + addBookmark: .never() + ) + _ = viewModel.transform(input) + // The input stream drops its first element (the search field's + // initial value in the real UI), so prime it before querying. + searchStringRelay.accept("") + } + + func search(_ query: String) { + searchStringRelay.accept(query) + } + + /// The reload path resolves its objects through a real image node, + /// so the fixture borrows a leaf node from the shared local + /// engine's image list the way `OpenQuicklyLazyConstructionTests` + /// does. Only the node's shape matters — `buildRuntimeObjects()` is + /// overridden to return the seeded array. + private static func makeImageNode() async throws -> RuntimeImageNode { + let localRuntimeEngine = RuntimeEngine.local + var imageList: [String] = [] + let engineReady = try await pollUntil(timeout: .seconds(15)) { + imageList = await localRuntimeEngine.imageList + return !imageList.isEmpty + } + #expect(engineReady, "local engine never published an image list") + let imagePath = try #require(imageList.first { $0.hasSuffix("/Foundation") } ?? imageList.first) + + let rootImageNode = RuntimeImageNode.rootNode(for: [imagePath], name: "Root") + var leafImageNode = rootImageNode + while let firstChild = leafImageNode.children.first { + leafImageNode = firstChild + } + // `parent` links are weak and `absolutePath` derives from them + // lazily. Materialize it while the root still owns the ancestor + // chain — hand back a bare leaf instead and the chain deallocates + // behind it, collapsing `path` to "/" and pinning the view model + // at `.notLoaded` (`isImageLoaded("/")` is never true). + withExtendedLifetime(rootImageNode) { + _ = leafImageNode.absolutePath + } + return leafImageNode + } + + private static func makeRuntimeObjects(count: Int) -> [RuntimeObject] { + (0 ..< count).map { index in + makeRuntimeObject(displayName: "TestFramework.AlphabetGeneratedType\(index)") + } + } + + static func makeRuntimeObject(displayName: String, children: [RuntimeObject] = []) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: [] + ) + } + } +} + +private final class SeededListViewModel: SidebarRuntimeObjectListViewModel { + private let seededRuntimeObjects: [RuntimeObject] + + init( + seededRuntimeObjects: [RuntimeObject], + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router, + haystackBuilder: @escaping SidebarRuntimeObjectListViewModel.HaystackBuilder + ) { + self.seededRuntimeObjects = seededRuntimeObjects + super.init( + imageNode: imageNode, + documentState: documentState, + router: router, + haystackBuilder: haystackBuilder + ) + } + + override func buildRuntimeObjects() async throws -> [RuntimeObject] { + seededRuntimeObjects + } + + override func buildRuntimeObjectsStream() -> AsyncThrowingStream { + AsyncThrowingStream { [seededRuntimeObjects] continuation in + continuation.yield(.completed(seededRuntimeObjects)) + continuation.finish() + } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift new file mode 100644 index 00000000..39b63cdd --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift @@ -0,0 +1,451 @@ +import Foundation +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for `RuntimeInterfaceCache` — the per-document LRU that +/// lets content navigation revisits render without an engine round-trip. +/// +/// The assertions pin the cache contract: repeat lookups cost one fetch, +/// concurrent lookups share one in-flight fetch, errors and `nil` results +/// are never cached, `invalidateAll` (and the `dataChangePublisher` wiring +/// that drives it) forces a refetch, eviction is least-recently-used, and a +/// fetch that started before a flush can never repopulate the cache after +/// it. +@Suite("RuntimeInterfaceCache", .serialized) +@MainActor +struct RuntimeInterfaceCacheTests { + /// Every cache in this suite subscribes to the shared local engine's + /// data-change channel; its one-shot startup `.fullReload` broadcast + /// would flush whichever test's cache it happens to land in. The + /// per-test async init waits that traffic out (see + /// SharedLocalEngineTestLock.swift). + init() async { + await ensureSharedLocalEngineSettled() + } + + // MARK: - Hit / miss basics + + @Test("a repeated lookup for the same key costs one fetch") + func repeatedFetchHitsCache() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + let firstResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + let secondResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(firstResult?.object == fixtureObject) + #expect(secondResult?.object == fixtureObject) + #expect(fetchRecorder.totalFetchCount == 1) + } + + @Test("distinct generation options are distinct cache keys") + func distinctOptionsFetchSeparately() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + var alternativeOptions = RuntimeObjectInterface.GenerationOptions() + alternativeOptions.swiftInterfaceOptions.printFieldOffset.toggle() + + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + _ = try await interfaceCache.interface(for: fixtureObject, options: alternativeOptions) + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(fetchRecorder.totalFetchCount == 2) + } + + // MARK: - In-flight coalescing + + @Test("concurrent lookups for the same key share one in-flight fetch") + func concurrentRequestsShareOneFetch() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + try? await Task.sleep(for: .milliseconds(50)) + return RuntimeObjectInterface(object: object, interfaceString: "class CacheFixture {}") + } + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + async let firstResult = interfaceCache.interface(for: fixtureObject, options: .init()) + async let secondResult = interfaceCache.interface(for: fixtureObject, options: .init()) + + let resolvedFirst = try await firstResult + let resolvedSecond = try await secondResult + #expect(resolvedFirst?.object == fixtureObject) + #expect(resolvedSecond?.object == fixtureObject) + #expect(fetchRecorder.totalFetchCount == 1) + } + + // MARK: - Link resolution + + /// A link click resolves a *synthetic* target — built at the click site + /// from the clicked token, carrying the currently displayed object's + /// `imagePath` (and, on the ObjC arm, its `children`) — and the engine + /// answers with the defining section's authoritative `RuntimeObject`. + /// The push navigates to that resolved object and the destination + /// content view model fetches under it, so the entry must be indexed by + /// the interface's own object. `RuntimeObject`'s `Hashable` folds in + /// `imagePath` and `children`, so storing under the requested object + /// instead left the display fetch a guaranteed miss: two full + /// generations per link click, plus a dead entry occupying one of the + /// sixteen slots — the opposite of the one-round-trip design the link + /// flow documents. + @Test("a fetch that resolves to a different object caches under the resolved object") + func resolvedObjectIsTheCacheKey() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let clickedToken = makeRuntimeObject(named: "CacheFixtureClickedToken") + let resolvedType = makeRuntimeObject(named: "CacheFixtureResolvedType") + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + return RuntimeObjectInterface(object: resolvedType, interfaceString: "class CacheFixture {}") + } + + let resolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(resolution?.object == resolvedType) + #expect(fetchRecorder.totalFetchCount == 1) + + let display = try await interfaceCache.interface(for: resolvedType, options: .init()) + #expect(display?.object == resolvedType) + #expect( + fetchRecorder.totalFetchCount == 1, + "the resolution fetch must warm the entry the post-push display fetch hits" + ) + } + + /// Filing the answer under the resolved object is only half the link + /// flow. The *request* key — the synthetic object built at the click + /// site — was cleared and never written back, so every later click on + /// the same token missed and regenerated the whole interface with + /// whatever detail flags the content pane is carrying, while the + /// byte-identical answer sat one key over. Back / Forward and "Open in + /// New Tab" over one token repeat that forever. + @Test("clicking the same type token twice costs one fetch") + func repeatedResolutionOfTheSameTokenHitsCache() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let clickedToken = makeRuntimeObject(named: "CacheFixtureRepeatToken") + let resolvedType = makeRuntimeObject(named: "CacheFixtureRepeatType") + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + return RuntimeObjectInterface(object: resolvedType, interfaceString: "class CacheFixture {}") + } + + let firstResolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(firstResolution?.object == resolvedType) + #expect(fetchRecorder.totalFetchCount == 1) + + let secondResolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(secondResolution?.object == resolvedType) + #expect( + fetchRecorder.totalFetchCount == 1, + "a second click on the same token must follow the learned redirect to the resolved object" + ) + } + + /// Two fetches with different request keys can converge on one storage + /// key: a link click resolving a synthetic token into O while another + /// tab is already fetching O directly. Whichever finishes first stores + /// `.ready` there; the other's cleanup must not delete it. An + /// unconditional `entries[key] = nil` destroyed the live entry *and* + /// stranded its key in `readyKeysByRecency`, where the phantom + /// permanently consumed one of the sixteen slots. + @Test("a failing fetch does not delete an entry another fetch stored under the same key") + func failingFetchLeavesAConvergedEntryIntact() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let clickedToken = makeRuntimeObject(named: "CacheFixtureConvergedToken") + let resolvedType = makeRuntimeObject(named: "CacheFixtureConvergedType") + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + if object == resolvedType { + // The display fetch: outlives the resolution fetch, then fails. + try? await Task.sleep(for: .milliseconds(150)) + throw StubInterfaceFetchError() + } + // The resolution fetch: converges on the resolved type's key and + // finishes first. + try? await Task.sleep(for: .milliseconds(30)) + return RuntimeObjectInterface(object: resolvedType, interfaceString: "class CacheFixture {}") + } + + async let displayResult: RuntimeObjectInterface? = interfaceCache.interface(for: resolvedType, options: .init()) + try await Task.sleep(for: .milliseconds(10)) + let resolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(resolution?.object == resolvedType) + + // `async let` bindings cannot be captured by the `#expect(throws:)` + // closure, so the failure is observed directly. + var displayFetchThrew = false + do { + _ = try await displayResult + } catch is StubInterfaceFetchError { + displayFetchThrew = true + } + #expect(displayFetchThrew, "the display fetch was set up to fail") + + let fetchCountBeforeReadback = fetchRecorder.totalFetchCount + let readback = try await interfaceCache.interface(for: resolvedType, options: .init()) + #expect(readback?.object == resolvedType) + #expect( + fetchRecorder.totalFetchCount == fetchCountBeforeReadback, + "the entry the resolution fetch stored must survive the other fetch's failure" + ) + } + + // MARK: - Errors and nil results are never cached + + @Test("a failed fetch is not cached — the next lookup retries") + func failedFetchIsNotCached() async throws { + let fetchRecorder = FetchRecorder(failingFirstFetches: 1) + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + await #expect(throws: StubInterfaceFetchError.self) { + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + } + let recoveredResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(recoveredResult?.object == fixtureObject) + #expect(fetchRecorder.totalFetchCount == 2) + } + + @Test("a nil result is not cached — the next lookup retries") + func nilResultIsNotCached() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + return nil + } + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + let firstResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + let secondResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(firstResult == nil) + #expect(secondResult == nil) + #expect(fetchRecorder.totalFetchCount == 2) + } + + // MARK: - Invalidation + + @Test("invalidateAll forces the next lookup to refetch") + func invalidateAllForcesRefetch() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + interfaceCache.invalidateAll() + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(fetchRecorder.totalFetchCount == 2) + } + + @Test("a dataChangePublisher event flushes the cache") + func dataChangeEventFlushesCache() async throws { + // This test broadcasts on the process-shared engine — every other + // live subscriber in the test process hears the `.fullReload` — + // so it must hold the cross-suite lock (see + // SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + #expect(fetchRecorder.totalFetchCount == 1) + + // Broadcasts `.fullReload` through the real engine → RxCombine → + // main-actor invalidation wiring the cache installs in its init. + await documentState.runtimeEngine.reloadData(isReloadImageNodes: false) + + // The broadcast hops engine Task → Combine subject → main-actor + // task, so poll: a lookup stays a cache hit until the flush lands, + // then refetches exactly once. + let refetchedAfterFlush = try await pollUntil(timeout: .seconds(10)) { + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + return fetchRecorder.totalFetchCount == 2 + } + #expect(refetchedAfterFlush, "the data-change broadcast never flushed the cache") + } + } + + @Test("a fetch that started before a flush cannot repopulate the cache") + func staleInFlightFetchDoesNotRepopulateAfterInvalidation() async throws { + let fetchRecorder = FetchRecorder() + let fetchLatch = AsyncLatch() + let documentState = DocumentState() + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + await fetchLatch.wait() + return RuntimeObjectInterface(object: object, interfaceString: "class CacheFixture {}") + } + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + async let inFlightResult = interfaceCache.interface(for: fixtureObject, options: .init()) + let fetchStarted = try await pollUntil(timeout: .seconds(10)) { + fetchRecorder.totalFetchCount == 1 + } + #expect(fetchStarted, "the gated fetch never started") + + interfaceCache.invalidateAll() + fetchLatch.open() + + // The straggler still delivers its value to the caller that asked… + let resolvedInFlight = try await inFlightResult + #expect(resolvedInFlight?.object == fixtureObject) + + // …but must not have been stored: the next lookup refetches. + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + #expect(fetchRecorder.totalFetchCount == 2) + } + + // MARK: - Eviction + + @Test("eviction removes the least recently used entry") + func leastRecentlyUsedEntryIsEvicted() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, capacity: 2, fetchRecorder: fetchRecorder) + let objectAlpha = makeRuntimeObject(named: "CacheFixtureAlpha") + let objectBravo = makeRuntimeObject(named: "CacheFixtureBravo") + let objectCharlie = makeRuntimeObject(named: "CacheFixtureCharlie") + + _ = try await interfaceCache.interface(for: objectAlpha, options: .init()) + _ = try await interfaceCache.interface(for: objectBravo, options: .init()) + // Touch Alpha so Bravo becomes the least recently used entry… + _ = try await interfaceCache.interface(for: objectAlpha, options: .init()) + // …and let Charlie push the cache past its capacity of 2. + _ = try await interfaceCache.interface(for: objectCharlie, options: .init()) + + _ = try await interfaceCache.interface(for: objectAlpha, options: .init()) + #expect(fetchRecorder.fetchCount(for: objectAlpha.name) == 1, "Alpha was refreshed and must have survived the eviction") + + _ = try await interfaceCache.interface(for: objectBravo, options: .init()) + #expect(fetchRecorder.fetchCount(for: objectBravo.name) == 2, "Bravo was the least recently used entry and must have been evicted") + + #expect(fetchRecorder.totalFetchCount == 4) + } + + // MARK: - Fixtures + + private func makeCache( + documentState: DocumentState, + capacity: Int = 16, + fetchRecorder: FetchRecorder + ) -> RuntimeInterfaceCache { + RuntimeInterfaceCache(documentState: documentState, capacity: capacity) { object, _ in + if fetchRecorder.recordFetch(of: object.name) { + throw StubInterfaceFetchError() + } + return RuntimeObjectInterface(object: object, interfaceString: "class CacheFixture {}") + } + } + + private func makeRuntimeObject(named name: String) -> RuntimeObject { + RuntimeObject( + name: "TestFramework.\(name)", + displayName: "TestFramework.\(name)", + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: [], + properties: [] + ) + } + + /// Thread-safe fetch recorder, counted per object name so eviction + /// tests can distinguish which key refetched. + private final class FetchRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedTotalFetchCount = 0 + private var storedFetchCountsByObjectName: [String: Int] = [:] + private var storedFailuresRemaining: Int + + init(failingFirstFetches failureCount: Int = 0) { + storedFailuresRemaining = failureCount + } + + var totalFetchCount: Int { + lock.withLock { storedTotalFetchCount } + } + + func fetchCount(for objectName: String) -> Int { + lock.withLock { storedFetchCountsByObjectName[objectName, default: 0] } + } + + /// Records one fetch; returns whether this fetch should fail. + @discardableResult + func recordFetch(of objectName: String) -> Bool { + lock.withLock { + storedTotalFetchCount += 1 + storedFetchCountsByObjectName[objectName, default: 0] += 1 + guard storedFailuresRemaining > 0 else { return false } + storedFailuresRemaining -= 1 + return true + } + } + } + + /// One-shot gate: `wait()` suspends until `open()`; once open, every + /// current and future waiter resumes immediately. + private final class AsyncLatch: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var pendingContinuations: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = lock.withLock { + if isOpen { return true } + pendingContinuations.append(continuation) + return false + } + if shouldResumeImmediately { + continuation.resume() + } + } + } + + func open() { + let continuationsToResume = lock.withLock { + isOpen = true + let pending = pendingContinuations + pendingContinuations = [] + return pending + } + continuationsToResume.forEach { $0.resume() } + } + } + + // `Swift.Error` spelled out: an imported module also exports a type + // named `Error`, which otherwise shadows the standard library protocol. + private struct StubInterfaceFetchError: Swift.Error {} + + // MARK: - Polling helper + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift new file mode 100644 index 00000000..9adda50c --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift @@ -0,0 +1,93 @@ +import Foundation +import RuntimeViewerCore + +/// Cross-suite mutual exclusion for tests coupled to the process-shared +/// `RuntimeEngine.local`. +/// +/// swift-testing runs tests concurrently ACROSS suites (`.serialized` +/// only orders tests within one suite), and every `DocumentState()` in +/// this target shares the `RuntimeEngine.local` singleton. A test that +/// broadcasts on it — `dataChangeEventFlushesCache` calls the real +/// `reloadData`, fanning `.fullReload` out to every live subscriber — +/// can therefore fire mid-flight into another suite's test and invalidate +/// exactly the state it is asserting on (interface caches flush, seeded +/// sidebar view models reload and drop their materialized rows). +/// +/// Wrap both kinds of test in `withSharedLocalEngineLock`: +/// - tests that BROADCAST on the shared engine, and +/// - tests whose assertions a broadcast would invalidate. +/// +/// Tests that never depend on broadcast-quiet windows (pure value tests, +/// injected-provider pipelines) must not take the lock — it serializes. +func withSharedLocalEngineLock(_ body: () async throws -> Result) async rethrows -> Result { + await ensureSharedLocalEngineSettled() + await SharedLocalEngineTestLock.shared.acquire() + defer { + Task { + await SharedLocalEngineTestLock.shared.release() + } + } + return try await body() +} + +/// One-shot process-wide barrier against the shared engine's bring-up +/// traffic. +/// +/// The first touch of `RuntimeEngine.local` spawns `connect()`, whose +/// `observeRuntime()` ends by broadcasting a `.fullReload` — from yet +/// another unstructured `Task`, so the send lands at an arbitrary moment +/// early in the test run. Any interface cache or seeded view model alive +/// at that moment gets flushed/reloaded mid-assertion. Every test that +/// subscribes (directly or via `RuntimeInterfaceCache` / sidebar view +/// models) to the shared engine's data-change channel must await this +/// barrier first. +func ensureSharedLocalEngineSettled() async { + await SharedLocalEngineStartupBarrier.shared.settle() +} + +private actor SharedLocalEngineStartupBarrier { + static let shared = SharedLocalEngineStartupBarrier() + + private var hasSettled = false + + func settle() async { + guard !hasSettled else { return } + let localRuntimeEngine = RuntimeEngine.local + // `observeRuntime()` assigns the image list, builds the image + // nodes, and only then spawns the startup broadcast Task — so + // non-empty image nodes prove the spawn happened, and the grace + // period lets the spawned send fan out to current subscribers. + while localRuntimeEngine.imageNodes.isEmpty { + try? await Task.sleep(for: .milliseconds(25)) + } + try? await Task.sleep(for: .milliseconds(250)) + hasSettled = true + } +} + +private actor SharedLocalEngineTestLock { + static let shared = SharedLocalEngineTestLock() + + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !isLocked { + isLocked = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + // Ownership was handed over by `release()` without clearing + // `isLocked`, so nothing more to do here. + } + + func release() { + if waiters.isEmpty { + isLocked = false + } else { + waiters.removeFirst().resume() + } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift new file mode 100644 index 00000000..7835681f --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift @@ -0,0 +1,125 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Pins the atomic-appearance contract introduced by proposal 0005: a cell +/// view model publishes its entire visual state as ONE +/// `RuntimeObjectCellAppearance` event per actual change, and equal-value +/// updates are suppressed instead of re-published. +@Suite("SidebarCellAppearance") +@MainActor +struct SidebarCellAppearanceTests { + @Test("fuzzy filter transitions publish one appearance event each; identical reapplication publishes none") + func filterTransitionsPublishAtomically() { + let cellViewModel = SidebarRuntimeObjectCellViewModel( + runtimeObject: makeRuntimeObject(displayName: "TestFramework.NeedleGeneratedType"), + forOpenQuickly: false + ) + let counter = AppearanceEmissionCounter(observing: cellViewModel) + + // Gaining a highlight changes only the title, but the event carries + // the whole appearance — exactly one emission. + let fuzzyNeedleContext = FilterContext(query: "Needle", isCaseInsensitive: false, mode: .fuzzySearch) + let matches = FilterEngine.filter(context: fuzzyNeedleContext, items: [cellViewModel]) + #expect(matches.count == 1) + #expect(counter.emissionCount == 1) + + // Re-running the identical query re-fires the filterResult didSet + // with a fresh result object, so the title genuinely rebuilds — but + // it compares equal and the equal-value appearance must be dropped. + counter.reset() + _ = FilterEngine.filter(context: fuzzyNeedleContext, items: [cellViewModel]) + #expect(counter.emissionCount == 0) + + // Clearing restores the default title: exactly one event again. + counter.reset() + _ = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: false, mode: .fuzzySearch), + items: [cellViewModel] + ) + #expect(counter.emissionCount == 1) + } + + @Test("a runtimeObject change publishes icons and title in a single event") + func runtimeObjectChangePublishesOneEvent() { + let cellViewModel = SidebarRuntimeObjectCellViewModel( + runtimeObject: makeRuntimeObject(displayName: "TestFramework.GenericType"), + forOpenQuickly: false + ) + #expect(cellViewModel.appearance.tertiaryIcon == nil) + let counter = AppearanceEmissionCounter(observing: cellViewModel) + + // Same display name, new `.isGeneric` flag: the refresh adds the + // tertiary icon. Pre-0005 this fanned out over five relays; now it + // must be one atomic event. + cellViewModel.runtimeObject = makeRuntimeObject( + displayName: "TestFramework.GenericType", + properties: [.isGeneric] + ) + + #expect(counter.emissionCount == 1) + #expect(cellViewModel.appearance.tertiaryIcon != nil) + } + + @Test("a splice that leaves this row's display state untouched publishes no event") + func displayNeutralSplicePublishesNoEvent() { + let child = makeRuntimeObject(displayName: "TestFramework.Parent.Child") + let parent = makeRuntimeObject(displayName: "TestFramework.Parent", children: [child]) + let parentCellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: parent, forOpenQuickly: false) + let counter = AppearanceEmissionCounter(observing: parentCellViewModel) + + let splicedChild = makeRuntimeObject(displayName: "TestFramework.Parent.SplicedChild") + #expect(parentCellViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(splicedChild)) + + // The parent's own name, kind and properties are unchanged, so the + // recomposed appearance compares equal (icons are cache-stable) and + // nothing reaches the cell view. + #expect(counter.emissionCount == 0) + #expect(parentCellViewModel.children.count == 2) + } + + // MARK: - Fixtures + + private func makeRuntimeObject( + displayName: String, + children: [RuntimeObject] = [], + properties: RuntimeObject.Properties = [] + ) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: properties + ) + } +} + +/// Counts `$appearance` relay emissions on a single cell view model. +/// `@Observed` is backed by a `BehaviorRelay`, so emissions land +/// synchronously and the counts asserted above are exact. +@MainActor +private final class AppearanceEmissionCounter { + private(set) var emissionCount = 0 + + private let disposeBag = DisposeBag() + + init(observing cellViewModel: SidebarRuntimeObjectCellViewModel) { + cellViewModel.$appearance + .skip(1) // BehaviorRelay replays the current appearance on subscribe + .subscribeOnNext { [weak self] _ in + guard let self else { return } + emissionCount += 1 + } + .disposed(by: disposeBag) + } + + func reset() { + emissionCount = 0 + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift new file mode 100644 index 00000000..d57da8fd --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift @@ -0,0 +1,115 @@ +import Foundation +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Pins the empty-query fast path of the sidebar filter pipeline. +/// +/// `scheduleRefilter()` used to serve the empty-query / inactive-scope case +/// by building a full snapshot forest and running it through +/// `verdicts` → `apply` — a bottom-up haystack build over the whole tree +/// (cold caches right after a reload) whose output is, by definition, the +/// identity (PR #88 review, finding 4). The fast path now calls +/// `resetToUnfiltered`, which installs the identity outcome directly. +/// These tests prove the two produce the same observable cell state, so +/// the snapshot-free rewrite cannot drift from the legacy semantics. +@Suite("SidebarFilterFastPath") +@MainActor +struct SidebarFilterFastPathTests { + private let emptyContext = FilterContext(query: "", isCaseInsensitive: true, mode: nil) + private let inactiveScope = RuntimeObjectScope() + + @Test("resetToUnfiltered matches the snapshot pipeline's identity output") + func resetMatchesSnapshotPipelineIdentity() throws { + let pipelineTree = makeTree() + let resetTree = makeTree() + + // Legacy fast path: full snapshot -> empty-context verdicts -> apply. + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: [pipelineTree], scope: inactiveScope) + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: emptyContext) + let applied = SidebarRuntimeObjectFilterPipeline.apply( + verdictForest, to: [pipelineTree], context: emptyContext, scope: inactiveScope + ) + #expect(applied?.map(\.runtimeObject) == [pipelineTree.runtimeObject]) + + // Snapshot-free fast path. + SidebarRuntimeObjectFilterPipeline.resetToUnfiltered([resetTree], context: emptyContext, scope: inactiveScope) + + try expectIdenticalFilterState(pipelineTree, resetTree) + } + + @Test("resetToUnfiltered clears a previously applied filter back to identity") + func resetClearsPreviousFilter() throws { + let tree = makeTree() + let filteringContext = FilterContext(query: "Second", isCaseInsensitive: true, mode: nil) + + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: [tree], scope: inactiveScope) + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: filteringContext) + _ = SidebarRuntimeObjectFilterPipeline.apply( + verdictForest, to: [tree], context: filteringContext, scope: inactiveScope + ) + #expect(tree.children.count == 1, "the filtering pass must prune to the single matching child") + #expect(tree.children.first?.runtimeObject.displayName == "Module.Root.Second") + + SidebarRuntimeObjectFilterPipeline.resetToUnfiltered([tree], context: emptyContext, scope: inactiveScope) + + try expectIdentityFilterState(tree) + } + + // MARK: - Assertions + + /// Both trees must expose the same post-pass state on every node: + /// stamped context, no highlight result, and an unpruned child list. + private func expectIdenticalFilterState( + _ expected: SidebarRuntimeObjectCellViewModel, + _ actual: SidebarRuntimeObjectCellViewModel + ) throws { + #expect(actual.filterContext == expected.filterContext) + #expect(actual.filterResult == nil && expected.filterResult == nil) + #expect(actual.children.map(\.runtimeObject) == expected.children.map(\.runtimeObject)) + #expect(actual.children.count == actual.unfilteredChildren.count) + for (expectedChild, actualChild) in zip(expected.children, actual.children) { + try expectIdenticalFilterState(expectedChild, actualChild) + } + } + + private func expectIdentityFilterState(_ cell: SidebarRuntimeObjectCellViewModel) throws { + #expect(cell.filterContext == emptyContext) + #expect(cell.filterResult == nil) + #expect(cell.children.map(\.runtimeObject) == cell.unfilteredChildren.map(\.runtimeObject)) + for child in cell.children { + try expectIdentityFilterState(child) + } + } + + // MARK: - Fixtures + + private func makeTree() -> SidebarRuntimeObjectCellViewModel { + let grandchild = object(name: "Root.Second.Leaf", displayName: "Module.Root.Second.Leaf") + let root = object( + name: "Root", + displayName: "Module.Root", + children: [ + object(name: "Root.First", displayName: "Module.Root.First"), + object(name: "Root.Second", displayName: "Module.Root.Second", children: [grandchild]), + ] + ) + return SidebarRuntimeObjectCellViewModel(runtimeObject: root, forOpenQuickly: false) + } + + private func object( + name: String, + displayName: String, + children: [RuntimeObject] = [] + ) -> RuntimeObject { + RuntimeObject( + name: name, + displayName: displayName, + kind: .swift(.type(.struct)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + children: children, + properties: [] + ) + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift new file mode 100644 index 00000000..e0569c14 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift @@ -0,0 +1,555 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the sidebar text-filter hot path +/// (`FilterEngine` + `SidebarRuntimeObjectFilterPipeline` over +/// `SidebarRuntimeObjectCellViewModel` trees). +/// +/// History: before the 2026-08 filter overhaul, every (debounced) +/// keystroke reset `filterResult` on every row, whose didSet rebuilt the +/// attributed title unconditionally — 10,000 `NSAttributedString` rebuilds +/// per keystroke (20,000 on clear), ~3.5 s of main-thread freeze per +/// keystroke at N = 10k in a debug build. The emission-count assertions +/// below pin the fixed behavior: a keystroke may only rebuild titles for +/// rows whose highlight actually changed. Wall-clock prints are +/// informational; run with `--filter SidebarFilterPerformanceBaseline` +/// and look for `[baseline]` lines. +@Suite("SidebarFilterPerformanceBaseline", .serialized) +@MainActor +struct SidebarFilterPerformanceBaselineTests { + private static let flatListCount = 10_000 + + private static let treeParentCount = 2_000 + + private static let treeChildrenPerParent = 4 + + // MARK: - Flat list, default contains mode (sidebar default: filterMode == nil) + + @Test("flat list, default contains mode: keystrokes rebuild zero titles") + func flatListDefaultModeKeystrokeCost() { + let runtimeObjects = makeFlatRuntimeObjects(count: Self.flatListCount) + + var cellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + measure("construct \(Self.flatListCount) flat cell view models") { + cellViewModels = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + } + + let counter = TitleRebuildCounter(observing: cellViewModels) + let needleMatchCount = Self.flatListCount / 100 + + // Broad query — the realistic first keystroke; matches every row. + counter.reset() + var broadMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter 'GeneratedType' (matches all rows)") { + broadMatches = FilterEngine.filter( + context: FilterContext(query: "GeneratedType", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(broadMatches.count == Self.flatListCount) + // Contains mode carries no highlight, so no row's title changes. + #expect(counter.titleRebuildCount == 0) + + // Narrow query — every 100th row carries the "Needle" marker. + counter.reset() + var narrowMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter 'Needle' (matches \(needleMatchCount) rows)") { + narrowMatches = FilterEngine.filter( + context: FilterContext(query: "Needle", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(narrowMatches.count == needleMatchCount) + #expect(counter.titleRebuildCount == 0) + + // Miss — zero matches must cost zero rebuilds. + counter.reset() + var missMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter 'QQQQQQ' (matches 0 rows)") { + missMatches = FilterEngine.filter( + context: FilterContext(query: "QQQQQQ", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(missMatches.isEmpty) + #expect(counter.titleRebuildCount == 0) + + // Clear — every row is already un-highlighted, so nothing rebuilds. + counter.reset() + var clearedMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter '' (clear search)") { + clearedMatches = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(clearedMatches.count == Self.flatListCount) + #expect(counter.titleRebuildCount == 0) + } + + // MARK: - Flat list, fuzzy mode (Open Quickly configuration) + + @Test("flat list, fuzzy mode: rebuilds scale with matches, not rows") + func flatListFuzzyModeKeystrokeCost() { + let runtimeObjects = makeFlatRuntimeObjects(count: Self.flatListCount) + + var cellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + measure("construct \(Self.flatListCount) flat cell view models (open quickly)") { + cellViewModels = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: true) } + } + + let counter = TitleRebuildCounter(observing: cellViewModels) + + // Narrow fuzzy query: only the matched rows gain a highlight. + counter.reset() + var narrowMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("fuzzy filter 'Needle' over \(Self.flatListCount) rows") { + narrowMatches = FilterEngine.filter( + context: FilterContext(query: "Needle", isCaseInsensitive: false, mode: .fuzzySearch), + items: cellViewModels + ) + } + #expect(!narrowMatches.isEmpty) + #expect(counter.titleRebuildCount == narrowMatches.count) + print("[baseline] fuzzy narrow: \(narrowMatches.count) matches, \(counter.titleRebuildCount) title rebuilds") + + // Broad fuzzy query — every matched row genuinely changes + // highlight, so this transition legitimately pays per-match. + counter.reset() + var broadMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("fuzzy filter 'Type' over \(Self.flatListCount) rows") { + broadMatches = FilterEngine.filter( + context: FilterContext(query: "Type", isCaseInsensitive: false, mode: .fuzzySearch), + items: cellViewModels + ) + } + #expect(!broadMatches.isEmpty) + #expect(counter.titleRebuildCount == broadMatches.count) + print("[baseline] fuzzy broad: \(broadMatches.count) matches, \(counter.titleRebuildCount) title rebuilds") + + // Clear after a broad match: every highlighted row must un-highlight + // (real work), but no more than that. + counter.reset() + measure("fuzzy filter '' (clear search)") { + _ = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: false, mode: .fuzzySearch), + items: cellViewModels + ) + } + #expect(counter.titleRebuildCount == broadMatches.count) + print("[baseline] fuzzy clear title rebuilds: \(counter.titleRebuildCount)") + } + + // MARK: - Tree (parents with children): didSet cascade cost + + @Test("tree: contains-mode keystrokes rebuild zero titles") + func treeCascadeKeystrokeCost() { + let runtimeObjects = makeTreeRuntimeObjects( + parentCount: Self.treeParentCount, + childrenPerParent: Self.treeChildrenPerParent + ) + let totalNodeCount = Self.treeParentCount * (1 + Self.treeChildrenPerParent) + + var parentCellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + measure("construct \(totalNodeCount) tree cell view models") { + parentCellViewModels = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + } + + let allCellViewModels = flatten(parentCellViewModels) + #expect(allCellViewModels.count == totalNodeCount) + let counter = TitleRebuildCounter(observing: allCellViewModels) + let needleParentCount = Self.treeParentCount / 100 + + // Narrow query hitting children only: the parent survives because + // its haystack (`currentAndChildrenNames`) embeds descendant names. + counter.reset() + var narrowMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("tree contains filter 'Needle' (\(needleParentCount) parents survive)") { + narrowMatches = FilterEngine.filter( + context: FilterContext(query: "Needle", isCaseInsensitive: true, mode: nil), + items: parentCellViewModels + ) + } + #expect(narrowMatches.count == needleParentCount) + #expect(counter.titleRebuildCount == 0) + + counter.reset() + measure("tree contains filter 'QQQQQQ' (matches 0)") { + _ = FilterEngine.filter( + context: FilterContext(query: "QQQQQQ", isCaseInsensitive: true, mode: nil), + items: parentCellViewModels + ) + } + #expect(counter.titleRebuildCount == 0) + + counter.reset() + measure("tree contains filter '' (clear search)") { + _ = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: true, mode: nil), + items: parentCellViewModels + ) + } + #expect(counter.titleRebuildCount == 0) + } + + // MARK: - Case sensitivity (regression for the inverted flag) + + @Test("contains mode honors the case-insensitive flag") + func containsModeHonorsCaseInsensitiveFlag() { + // Pre-fix, the branch was inverted: `isCaseInsensitive == true` + // ran a case-SENSITIVE `contains`. These assertions fail on the + // old implementation. + let haystacks = ["TestFramework.NeedleGeneratedType0"] + + let caseInsensitiveMatches = FilterEngine.match( + FilterContext(query: "needle", isCaseInsensitive: true, mode: nil), + haystacks: haystacks + ) + #expect(caseInsensitiveMatches.count == 1) + + let caseSensitiveMatches = FilterEngine.match( + FilterContext(query: "needle", isCaseInsensitive: false, mode: nil), + haystacks: haystacks + ) + #expect(caseSensitiveMatches.isEmpty) + + let caseSensitiveExactMatches = FilterEngine.match( + FilterContext(query: "Needle", isCaseInsensitive: false, mode: nil), + haystacks: haystacks + ) + #expect(caseSensitiveExactMatches.count == 1) + } + + // MARK: - Pipeline parity with the single-level cascade + + @Test("pipeline output matches the mutating cascade level by level") + func pipelineMatchesMutatingCascade() throws { + let scope = RuntimeObjectScope(generic: .only) + let contexts = [ + FilterContext(query: "Needle", isCaseInsensitive: true, mode: nil), + FilterContext(query: "Needle", isCaseInsensitive: false, mode: .fuzzySearch), + FilterContext(query: "", isCaseInsensitive: true, mode: nil), + ] + for context in contexts { + for activeScope in [RuntimeObjectScope(), scope] { + let runtimeObjects = makeTreeRuntimeObjects(parentCount: 200, childrenPerParent: 3) + let pipelineCells = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + let cascadeCells = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + + // Pipeline path (what the view model runs). + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: pipelineCells, scope: activeScope) + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: context) + let pipelineFiltered = try #require( + SidebarRuntimeObjectFilterPipeline.apply(verdictForest, to: pipelineCells, context: context, scope: activeScope) + ) + + // Reference path: the legacy per-level cascade semantics via + // the mutating wrapper (independent recursion, same matcher). + for cell in cascadeCells { + applyScopeToTreeForReference(cell, scope: activeScope) + } + let scopedCascadeCells = activeScope.isActive + ? cascadeCells.filter { $0.matchesScopeRecursively(activeScope) } + : cascadeCells + let cascadeFiltered = FilterEngine.filter(context: context, items: scopedCascadeCells) + + #expect( + displayNameTree(of: pipelineFiltered) == displayNameTree(of: cascadeFiltered), + "mode: \(String(describing: context.mode)), query: '\(context.query)', scopeActive: \(activeScope.isActive)" + ) + #expect( + highlightTree(of: pipelineFiltered) == highlightTree(of: cascadeFiltered), + "mode: \(String(describing: context.mode)), query: '\(context.query)', scopeActive: \(activeScope.isActive)" + ) + } + } + } + + // MARK: - Haystack cache invalidation + + @Test("splicing a child invalidates cached haystacks up the ancestor chain") + func splicedChildInvalidatesAncestorHaystacks() throws { + let grandchild = makeRuntimeObject(displayName: "TestFramework.Parent.Child.Grandchild") + let child = makeRuntimeObject(displayName: "TestFramework.Parent.Child", children: [grandchild]) + let parent = makeRuntimeObject(displayName: "TestFramework.Parent", children: [child]) + let parentCellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: parent, forOpenQuickly: false) + let childCellViewModel = try #require(parentCellViewModel.children.first) + + // Warm every level's cache. + #expect(parentCellViewModel.currentAndChildrenNames.contains("Grandchild")) + #expect(childCellViewModel.currentAndChildrenNames.contains("Grandchild")) + + let splicedChild = makeRuntimeObject(displayName: "TestFramework.Parent.Child.SplicedNeedle") + #expect(childCellViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(splicedChild)) + + // Both the mutated cell and its ancestor must see the new name. + #expect(childCellViewModel.currentAndChildrenNames.contains("SplicedNeedle")) + #expect(parentCellViewModel.currentAndChildrenNames.contains("SplicedNeedle")) + } + + // MARK: - View model end-to-end (MockRouter + seeded reload + debounced search) + + @Test("view model end-to-end: seeded reload and debounced search") + func viewModelEndToEndSearch() async throws { + // The seeded view model subscribes to the shared local engine's + // data-change broadcasts; a concurrent suite firing a real + // `.fullReload` (the interface-cache flush test) would reload the + // fixture and reset the search state mid-assertion. Hold the + // cross-suite lock (see SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + try await runViewModelEndToEndSearch() + } + } + + private func runViewModelEndToEndSearch() async throws { + let localRuntimeEngine = RuntimeEngine.local + + // Wait for the local engine to publish the test process's image list. + var imageList: [String] = [] + let engineReady = try await pollUntil(timeout: .seconds(15)) { + imageList = await localRuntimeEngine.imageList + return !imageList.isEmpty + } + #expect(engineReady, "local engine never published an image list") + let loadedImagePath = try #require( + imageList.first { $0.hasSuffix("/Foundation") } ?? imageList.first + ) + + // Build a RuntimeImageNode whose `path` resolves to a genuinely + // loaded image so `reloadData`'s `isImageLoaded` gate passes. + let rootImageNode = RuntimeImageNode.rootNode(for: [loadedImagePath], name: "Root") + var imageNode = rootImageNode + while let firstChild = imageNode.children.first { + imageNode = firstChild + } + #expect(imageNode.path == loadedImagePath) + + let documentState = DocumentState() + let mockRouter = MockRouter() + let seededRuntimeObjects = makeFlatRuntimeObjects(count: Self.flatListCount) + + let reloadClock = ContinuousClock() + let reloadStart = reloadClock.now + let viewModel = SeededSidebarRuntimeObjectViewModel( + seededRuntimeObjects: seededRuntimeObjects, + imageNode: imageNode, + documentState: documentState, + router: mockRouter + ) + let reloadFinished = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded + } + print("[baseline] seeded reload (engine gate + \(Self.flatListCount) cell view models): \(millisecondsDescription(of: reloadClock.now - reloadStart))") + #expect(reloadFinished, "seeded reload never reached .loaded") + #expect(viewModel.filteredNodes.count == Self.flatListCount) + + let searchStringRelay = PublishRelay() + let input = SidebarRuntimeObjectViewModel.Input( + runtimeObjectClicked: .never(), + runtimeObjectOpenedInNewTab: .never(), + loadImageClicked: .never(), + searchString: searchStringRelay.asDriver(onErrorJustReturn: ""), + isSearchCaseInsensitive: .just(true) + ) + _ = viewModel.transform(input) + + // One keystroke through the real pipeline. The measured time + // includes the 150 ms coalescing delay, the off-main match, and + // the poll granularity. + let expectedNeedleCount = Self.flatListCount / 100 + let searchStart = reloadClock.now + searchStringRelay.accept("Needle") + let searchApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodes.count == expectedNeedleCount + } + print("[baseline] end-to-end 'Needle' search (includes 150 ms coalescing delay): \(millisecondsDescription(of: reloadClock.now - searchStart))") + #expect(searchApplied, "debounced search never produced \(expectedNeedleCount) filtered nodes") + + // Clearing the search must restore the full list (fast path). + searchStringRelay.accept("") + let searchCleared = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodes.count == Self.flatListCount + } + #expect(searchCleared, "clearing the search never restored the full list") + + // The search path must not navigate anywhere. + #expect(mockRouter.triggeredRoutes.isEmpty) + + // The view model holds its router unowned — keep the mock alive + // until every assertion has run. + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Fixtures + + /// Deterministic flat list: every 100th object carries the "Needle" + /// marker so narrow queries have a fixed match set, and every object + /// shares the "GeneratedType" stem so broad queries match the whole + /// list. + private func makeFlatRuntimeObjects(count: Int) -> [RuntimeObject] { + (0 ..< count).map { index in + let displayName = index.isMultiple(of: 100) + ? "TestFramework.NeedleGeneratedType\(index)" + : "TestFramework.GeneratedType\(index)" + return makeRuntimeObject(displayName: displayName) + } + } + + /// Deterministic tree: `parentCount` parents with `childrenPerParent` + /// children each. Every 100th parent's first child carries the + /// "Needle" marker (so narrow queries only survive through the + /// parent's descendant haystack), and every 3rd parent is generic + /// (so scope-constrained runs prune a deterministic subset). + private func makeTreeRuntimeObjects(parentCount: Int, childrenPerParent: Int) -> [RuntimeObject] { + (0 ..< parentCount).map { parentIndex in + let children = (0 ..< childrenPerParent).map { childIndex -> RuntimeObject in + let marker = (parentIndex.isMultiple(of: 100) && childIndex == 0) ? "Needle" : "" + return makeRuntimeObject( + displayName: "TestFramework.GeneratedParent\(parentIndex).\(marker)Child\(childIndex)" + ) + } + return makeRuntimeObject( + displayName: "TestFramework.GeneratedParent\(parentIndex)", + children: children, + properties: parentIndex.isMultiple(of: 3) ? [.isGeneric] : [] + ) + } + } + + private func makeRuntimeObject( + displayName: String, + children: [RuntimeObject] = [], + properties: RuntimeObject.Properties = [] + ) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: properties + ) + } + + private func flatten(_ cellViewModels: [SidebarRuntimeObjectCellViewModel]) -> [SidebarRuntimeObjectCellViewModel] { + cellViewModels.flatMap { [$0] + flatten($0.children) } + } + + /// Reference-path helper replicating the legacy tree-wide scope + /// cascade: push the scope into every cell depth-first so each cell's + /// stored scope is in place before the text filter cascades. The + /// subsequent `FilterEngine.filter` call re-derives every level's + /// `_filteredChildren` under this scope (all reference contexts differ + /// from the cells' default context, so the cascade is guaranteed to + /// fire). + private func applyScopeToTreeForReference(_ cell: SidebarRuntimeObjectCellViewModel, scope: RuntimeObjectScope) { + for child in cell.unfilteredChildren { + applyScopeToTreeForReference(child, scope: scope) + } + cell.scope = scope + } + + /// Nested display-name structure of the *filtered* tree, for + /// order-sensitive equality between the pipeline and cascade paths. + private func displayNameTree(of cellViewModels: [SidebarRuntimeObjectCellViewModel]) -> [String] { + cellViewModels.flatMap { cellViewModel -> [String] in + [cellViewModel.runtimeObject.displayName] + displayNameTree(of: cellViewModel.children).map { " " + $0 } + } + } + + /// Which filtered nodes carry a highlight, in display order. + private func highlightTree(of cellViewModels: [SidebarRuntimeObjectCellViewModel]) -> [Bool] { + cellViewModels.flatMap { cellViewModel -> [Bool] in + [cellViewModel.filterResult != nil] + highlightTree(of: cellViewModel.children) + } + } + + // MARK: - Measurement helpers + + @discardableResult + private func measure(_ label: String, _ body: () -> Void) -> Duration { + let duration = ContinuousClock().measure(body) + print("[baseline] \(label): \(millisecondsDescription(of: duration))") + return duration + } + + private func millisecondsDescription(of duration: Duration) -> String { + let totalMilliseconds = Double(duration.components.seconds) * 1_000 + + Double(duration.components.attoseconds) / 1e15 + return String(format: "%.2f ms", totalMilliseconds) + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} + +/// Counts `$appearance` relay emissions across a set of cell view models. +/// `@Observed` is backed by a `BehaviorRelay`, so every `filterResult` +/// didSet that changes the published appearance lands here synchronously — +/// the counts asserted above are exact, not scheduler-delayed. Since +/// proposal 0005 the appearance is a single deduplicated stream: an +/// emission during a filter pass means the attributed title actually +/// changed (icons are untouched by filtering), so the counter still +/// measures exactly the title rebuilds the suite pins. +@MainActor +private final class TitleRebuildCounter { + private(set) var titleRebuildCount = 0 + + private let disposeBag = DisposeBag() + + init(observing cellViewModels: [SidebarRuntimeObjectCellViewModel]) { + for cellViewModel in cellViewModels { + cellViewModel.$appearance + .skip(1) // BehaviorRelay replays the current appearance on subscribe + .subscribeOnNext { [weak self] _ in + guard let self else { return } + titleRebuildCount += 1 + } + .disposed(by: disposeBag) + } + } + + func reset() { + titleRebuildCount = 0 + } +} + +/// Sidebar view model whose reload publishes a canned object list instead +/// of asking the engine, so end-to-end tests control the data set while +/// still exercising the real `reloadData` / filter pipeline (the +/// `isImageLoaded` engine gate stays live). +@MainActor +private final class SeededSidebarRuntimeObjectViewModel: SidebarRuntimeObjectViewModel { + private let seededRuntimeObjects: [RuntimeObject] + + init( + seededRuntimeObjects: [RuntimeObject], + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router + ) { + self.seededRuntimeObjects = seededRuntimeObjects + super.init(imageNode: imageNode, documentState: documentState, router: router) + } + + override func buildRuntimeObjects() async throws -> [RuntimeObject] { + seededRuntimeObjects + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift new file mode 100644 index 00000000..3cc9c49d --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift @@ -0,0 +1,140 @@ +import Foundation +import RuntimeViewerArchitectures +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Pins that a rebuilt image tree invalidates the in-flight root filter +/// pass in the *same* main-actor turn that installs the new tree. +/// +/// The two halves used to be separate subscriptions: `$nodes.bind(to: +/// $filteredNodes)` swapped the list synchronously, while the cancellation +/// and generation bump went through `subscribeOnNextMainActor` — which +/// expands to `Task { @MainActor in … }` and therefore only *enqueued* +/// them. A verdict continuation resuming in that window saw +/// `Task.isCancelled == false` and an unchanged generation, so it passed +/// both guards and republished the discarded cell tree over the fresh one. +/// Nothing reschedules a filter afterwards, so the sidebar kept showing +/// the previous tree until the user typed again. +/// +/// The sibling `SidebarRuntimeObjectViewModel` bumps its generation +/// synchronously inside `scheduleRefilter()`, so only the root pipeline +/// ever had this hole. +/// +/// Note on method: the assertions sample the generation counter from a +/// `$filteredNodes` observer rather than after the `accept` returns. +/// `observe(on: MainScheduler.instance)` only delivers synchronously while +/// the scheduler is idle, so an "assert right after accept" test passes +/// alone and fails under concurrent suites. Sampling at installation time +/// pins the ordering itself, independent of when the emission lands. +@Suite("SidebarRootFilterInvalidation", .serialized) +@MainActor +struct SidebarRootFilterInvalidationTests { + @Test("an image-tree rebuild bumps the filter generation before installing the new tree") + func rebuildBumpsFilterGenerationBeforeInstallingNodes() async throws { + let harness = try await Harness(seededGeneration: 0) + + let generationBeforeRebuild = harness.viewModel.currentRootFilterGeneration + harness.nodesRelay.accept(Harness.makeImageNodes(generation: 1)) + + let installed = try await pollUntil(timeout: .seconds(5)) { + harness.probe.generationAtInstallation != nil + } + #expect(installed, "the rebuilt tree was never installed into filteredNodes") + #expect( + harness.probe.generationAtInstallation != generationBeforeRebuild, + "the rebuild deferred its invalidation; a verdict resuming before the enqueued bump would republish the discarded tree" + ) + #expect( + harness.viewModel.filteredNodes.map(\.node.name) == ["generation1"], + "the rebuild must still drop the visual filter and install the new tree" + ) + } + + /// Every emission must invalidate, not just the ones that change the + /// contents: the cell view models are rebuilt per emission, so even an + /// identical image list hands out a fresh tree that an older verdict + /// must not be applied to. + @Test("a rebuild carrying the same image names still invalidates") + func rebuildWithIdenticalNamesStillInvalidates() async throws { + let harness = try await Harness(seededGeneration: 0) + + let generationBeforeRebuild = harness.viewModel.currentRootFilterGeneration + let cellsBeforeRebuild = harness.viewModel.nodes + harness.nodesRelay.accept(Harness.makeImageNodes(generation: 0)) + + let installed = try await pollUntil(timeout: .seconds(5)) { + harness.probe.generationAtInstallation != nil + } + #expect(installed, "the rebuilt tree was never installed into filteredNodes") + #expect(harness.probe.generationAtInstallation != generationBeforeRebuild) + #expect( + harness.viewModel.nodes.first !== cellsBeforeRebuild.first, + "each emission is expected to hand out freshly built cell view models" + ) + } +} + +// MARK: - Harness + +extension SidebarRootFilterInvalidationTests { + /// Records the filter generation as it stood when a rebuilt tree was + /// installed into `filteredNodes` — the moment the invalidation must + /// already have happened. + @MainActor + fileprivate final class InstallationProbe { + var generationAtInstallation: Int? + } + + @MainActor + fileprivate struct Harness { + let viewModel: SidebarRootViewModel + let nodesRelay: BehaviorRelay<[RuntimeImageNode]> + let probe = InstallationProbe() + + /// `ViewModel` holds its router `unowned`, so the mock must outlive + /// every assertion. + private let router: MockRouter + private let disposeBag = DisposeBag() + + init(seededGeneration: Int) async throws { + let nodesRelay = BehaviorRelay<[RuntimeImageNode]>(value: Self.makeImageNodes(generation: seededGeneration)) + let router = MockRouter() + let viewModel = withLiveDependencyContext { + SidebarRootViewModel( + documentState: DocumentState(), + router: router, + nodesSource: nodesRelay.asObservable() + ) + } + self.nodesRelay = nodesRelay + self.router = router + self.viewModel = viewModel + + let seeded = try await pollUntil(timeout: .seconds(5)) { + viewModel.nodes.map(\.node.name) == ["generation\(seededGeneration)"] + } + #expect(seeded, "the view model never picked up the seeded image tree") + + // Installed after the seeding so only rebuild-driven emissions + // are recorded. This observer runs in the same synchronous step + // as the view model's own `filteredNodes` assignment. + let probe = probe + viewModel.$filteredNodes + .asObservable() + .skip(1) + .subscribeOnNext { [weak viewModel] _ in + MainActor.assumeIsolated { + probe.generationAtInstallation = viewModel?.currentRootFilterGeneration + } + } + .disposed(by: disposeBag) + } + + static func makeImageNodes(generation: Int) -> [RuntimeImageNode] { + let root = RuntimeImageNode("generation\(generation)") + _ = root.child(named: "System") + return [root] + } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift new file mode 100644 index 00000000..ac68b66f --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift @@ -0,0 +1,207 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the root sidebar's off-main filter pipeline. +/// +/// History: before this change, every root-sidebar keystroke ran a +/// recursive `localizedCaseInsensitiveContains` cascade over the whole +/// image tree on the main thread (including building each node's +/// aggregate name on first use). The pipeline moves aggregate +/// construction and matching to the global executor; these tests pin the +/// legacy display semantics against an independent reference +/// implementation and exercise the full view-model path. +@Suite("SidebarRootFilterPipeline", .serialized) +@MainActor +struct SidebarRootFilterPipelineTests { + private static let fixtureImagePaths = [ + "/System/Library/Frameworks/AppKit.framework/AppKit", + "/System/Library/Frameworks/Foundation.framework/Foundation", + "/System/Library/PrivateFrameworks/NeedleKit.framework/NeedleKit", + "/System/iOSSupport/System/Library/Frameworks/SwiftUI.framework/SwiftUI", + "/usr/lib/libNeedleHelper.dylib", + "/usr/lib/swift/libswiftCore.dylib", + ] + + // MARK: - Semantics parity against an independent reference + + @Test("pipeline output matches the legacy cascade semantics") + func pipelineMatchesReferenceSemantics() throws { + // "needle" hits leaf/framework names in two branches; "system" + // hits a directory segment (matching-node-shows-subtree rule); + // "framework" hits both segment and leaf names broadly; + // "qqqqqq" hits nothing. + for query in ["needle", "system", "framework", "swift", "qqqqqq"] { + let rootImageNode = RuntimeImageNode.rootNode(for: Self.fixtureImagePaths, name: "Dyld Shared Cache") + let cells = [SidebarRootCellViewModel(node: rootImageNode)] + + let snapshotForest = SidebarRootFilterPipeline.snapshot(of: cells) + let forestVerdict = SidebarRootFilterPipeline.verdicts(for: snapshotForest, query: query) + let filteredCells = try #require( + SidebarRootFilterPipeline.apply(forestVerdict, to: cells) + ) + + let referenceForest = referenceFilteredForest( + of: snapshotForest.map(referenceNode(from:)), + query: query + ) + #expect( + displayedNameForest(of: filteredCells) == referenceForest, + "query: '\(query)'" + ) + } + } + + @Test("empty-query reset restores the full tree") + func emptyQueryResetRestoresFullTree() throws { + let rootImageNode = RuntimeImageNode.rootNode(for: Self.fixtureImagePaths, name: "Dyld Shared Cache") + let cells = [SidebarRootCellViewModel(node: rootImageNode)] + let fullForest = displayedNameForest(of: cells) + + let snapshotForest = SidebarRootFilterPipeline.snapshot(of: cells) + let forestVerdict = SidebarRootFilterPipeline.verdicts(for: snapshotForest, query: "needle") + _ = try #require(SidebarRootFilterPipeline.apply(forestVerdict, to: cells)) + #expect(displayedNameForest(of: cells) != fullForest) + + SidebarRootFilterPipeline.resetToUnfiltered(cells) + #expect(displayedNameForest(of: cells) == fullForest) + } + + // MARK: - View model end-to-end + + @Test("view model end-to-end: coalesced search filters off-main and clear resets") + func viewModelEndToEndSearch() async throws { + let rootImageNode = RuntimeImageNode.rootNode(for: Self.fixtureImagePaths, name: "Dyld Shared Cache") + let documentState = DocumentState() + let mockRouter = MockRouter() + let viewModel = SidebarRootViewModel( + documentState: documentState, + router: mockRouter, + nodesSource: .just([rootImageNode]) + ) + + let nodesPopulated = try await pollUntil(timeout: .seconds(10)) { + !viewModel.nodes.isEmpty + } + #expect(nodesPopulated, "nodesSource never populated the root cells") + + let searchStringRelay = PublishRelay() + let input = SidebarRootViewModel.Input( + clickedNode: .never(), + selectedNode: .never(), + searchString: searchStringRelay.asSignal() + ) + _ = viewModel.transform(input) + + searchStringRelay.accept("needle") + let searchApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.isFiltering && self.forestContainsNeedleBranchesOnly(viewModel.filteredNodes) + } + #expect(searchApplied, "search never converged on the needle-only tree") + + searchStringRelay.accept("") + let cleared = try await pollUntil(timeout: .seconds(10)) { + !viewModel.isFiltering && viewModel.filteredNodes.count == viewModel.nodes.count + } + #expect(cleared, "clearing the search never restored the full list") + + // The root cell must show its full child list again after reset. + let rootCell = try #require(viewModel.nodes.first) + #expect(rootCell.children.count == rootCell.unfilteredChildren.count) + + #expect(mockRouter.triggeredRoutes.isEmpty) + withExtendedLifetime(mockRouter) {} + } + + /// The "needle" query must keep exactly the two branches whose leaves + /// carry the marker (NeedleKit.framework and libNeedleHelper.dylib) + /// and prune everything else. + private func forestContainsNeedleBranchesOnly(_ cells: [SidebarRootCellViewModel]) -> Bool { + let leafNames = leafNodeNames(of: cells) + return leafNames == Set(["NeedleKit", "libNeedleHelper.dylib"]) + } + + private func leafNodeNames(of cells: [SidebarRootCellViewModel]) -> Set { + var collected: Set = [] + func visit(_ cell: SidebarRootCellViewModel) { + if cell.children.isEmpty { + collected.insert(cell.node.name) + } else { + cell.children.forEach(visit) + } + } + cells.forEach(visit) + return collected + } + + // MARK: - Reference implementation (legacy cascade semantics) + + /// Value-tree mirror of a snapshot node, so the reference path shares + /// the pipeline's input but nothing else. + private struct ReferenceNode { + let name: String + let children: [ReferenceNode] + } + + private func referenceNode(from snapshotNode: SidebarRootFilterPipeline.SnapshotNode) -> ReferenceNode { + ReferenceNode(name: snapshotNode.name, children: snapshotNode.children.map(referenceNode(from:))) + } + + private func referenceAggregate(of node: ReferenceNode) -> String { + let childrenNames = node.children.map { referenceAggregate(of: $0) }.joined(separator: " ") + return childrenNames.isEmpty ? node.name : "\(node.name) \(childrenNames)" + } + + /// Independent re-statement of the legacy didSet cascade: + /// - a top-level node survives iff its aggregate contains the query; + /// - a node whose own name matches shows its entire subtree; + /// - otherwise children are kept iff their aggregate matches, + /// recursively. + private func referenceFilteredForest(of nodes: [ReferenceNode], query: String) -> [String] { + nodes + .filter { referenceAggregate(of: $0).localizedCaseInsensitiveContains(query) } + .flatMap { referenceLines(for: $0, query: query, depth: 0) } + } + + private func referenceLines(for node: ReferenceNode, query: String, depth: Int) -> [String] { + let ownLine = String(repeating: " ", count: depth) + node.name + if node.name.localizedCaseInsensitiveContains(query) { + return [ownLine] + node.children.flatMap { unfilteredLines(for: $0, depth: depth + 1) } + } + let survivingChildren = node.children.filter { + referenceAggregate(of: $0).localizedCaseInsensitiveContains(query) + } + return [ownLine] + survivingChildren.flatMap { referenceLines(for: $0, query: query, depth: depth + 1) } + } + + private func unfilteredLines(for node: ReferenceNode, depth: Int) -> [String] { + [String(repeating: " ", count: depth) + node.name] + + node.children.flatMap { unfilteredLines(for: $0, depth: depth + 1) } + } + + /// Indented name lines of the *displayed* (filtered) tree. + private func displayedNameForest(of cells: [SidebarRootCellViewModel], depth: Int = 0) -> [String] { + cells.flatMap { cell -> [String] in + [String(repeating: " ", count: depth) + cell.node.name] + + displayedNameForest(of: cell.children, depth: depth + 1) + } + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift new file mode 100644 index 00000000..06ad1a05 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift @@ -0,0 +1,323 @@ +import AppKit +import Foundation +import Testing +import RuntimeViewerUI + +/// Regression suite for `StatefulOutlineView`'s coalesced expansion +/// autosave. +/// +/// History: before this change, every `itemDidExpand` / `itemDidCollapse` +/// notification triggered a full row walk plus a `UserDefaults` write. An +/// option-click "expand all" posts one notification per expandable item, +/// so a burst over N rows cost O(N²) row visits and N defaults writes. +/// The persist is now scheduled once per burst and flushed on the next +/// main-queue turn (`expansionAutosavePersistCount` is the seam that pins +/// this). +@Suite("StatefulOutlineViewAutosave", .serialized) +@MainActor +struct StatefulOutlineViewAutosaveTests { + @Test("an expand-all burst coalesces into a single persist walk") + func expandAllBurstCoalescesIntoOneWalk() async throws { + let parentCount = 60 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = StatefulOutlineView() + let column = NSTableColumn(identifier: .init("primary")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = dataSource + + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + #expect(outlineView.numberOfRows == parentCount * 2) + + // The burst itself must not persist synchronously — the legacy + // implementation had already walked the rows dozens of times by + // this point. + #expect(outlineView.expansionAutosavePersistCount == 0) + + let flushed = try await pollUntil(timeout: .seconds(5)) { + outlineView.expansionAutosavePersistCount > 0 + } + #expect(flushed, "coalesced persist never ran") + + // Give any stragglers a chance to run, then pin the coalescing. + // Notification delivery through `OperationQueue.main` may + // interleave one extra flush with the burst; the legacy behavior + // was one walk per expanded item (60+), so ≤ 2 still pins the + // regression hard. + try await Task.sleep(for: .milliseconds(200)) + #expect(outlineView.expansionAutosavePersistCount <= 2) + + let persistedIdentifiers = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + #expect(Set(persistedIdentifiers) == Set(dataSource.parents.map(\.identifier))) + } + + @Test("a collapse after the flush persists the removal") + func collapsePersistsRemoval() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = StatefulOutlineView() + let column = NSTableColumn(identifier: .init("primary")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = dataSource + + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let allPersisted = try await pollUntil(timeout: .seconds(5)) { + let persisted = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + return Set(persisted) == Set(dataSource.parents.map(\.identifier)) + } + #expect(allPersisted, "initial expand-all never persisted") + + let collapsedParent = dataSource.parents[0] + outlineView.collapseItem(collapsedParent) + let removalPersisted = try await pollUntil(timeout: .seconds(5)) { + let persisted = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + return !persisted.isEmpty && !persisted.contains(collapsedParent.identifier) + } + #expect(removalPersisted, "collapse never persisted the removal") + } + + // MARK: - Staleness guard + // + // Coalescing moved the persist walk one main-queue turn after the + // expand that scheduled it. Expand / collapse notifications are + // delivered synchronously — `NotificationCenter` runs the block inline + // when the observer queue is the posting queue — so the schedule always + // happens on the tree the user acted on, but the flush does not: a data + // change landing in the coalescing window makes the walk describe a + // different tree. The rebuilt tree comes back fully collapsed, so the + // walk collected nothing and wrote an empty array over the user's saved + // state. That loss is permanent: `restoreExpansionFromAutosave()` runs + // once per document, driven by `nodesIndexed.first()`. + + @Test("a tree replacement between the expand and the flush must not wipe the persisted state") + func treeReplacedByReloadDataBeforeFlushKeepsPersistedState() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = makeOutlineView(dataSource: dataSource) + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let savedIdentifiers = Set(dataSource.parents.map(\.identifier)) + let persisted = try await pollUntil(timeout: .seconds(5)) { + Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) == savedIdentifiers + } + #expect(persisted, "initial expand-all never persisted") + + let walksBeforeReplacement = outlineView.expansionAutosavePersistCount + + // One main-queue turn, three synchronous steps: the user collapses a + // row (which schedules the coalesced flush inline), then a + // `.fullReload` — broadcast on every image load — replaces the tree. + outlineView.collapseItem(dataSource.parents[0]) + dataSource.replaceTree(parentCount: parentCount, generation: 1) + outlineView.reloadData() + + try await Task.sleep(for: .milliseconds(300)) + + let survivingIdentifiers = Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) + #expect( + survivingIdentifiers == savedIdentifiers, + "the stale flush overwrote the saved expansion state with \(survivingIdentifiers.sorted())" + ) + #expect( + outlineView.expansionAutosavePersistCount == walksBeforeReplacement, + "the flush walked a tree the user never acted on" + ) + } + + @Test("an incremental row replacement between the expand and the flush must not wipe the persisted state") + func treeReplacedIncrementallyBeforeFlushKeepsPersistedState() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = makeOutlineView(dataSource: dataSource) + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let savedIdentifiers = Set(dataSource.parents.map(\.identifier)) + let persisted = try await pollUntil(timeout: .seconds(5)) { + Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) == savedIdentifiers + } + #expect(persisted, "initial expand-all never persisted") + + let walksBeforeReplacement = outlineView.expansionAutosavePersistCount + + // The path the app actually takes. With every row a new object the + // changeset carries no `elementUpdated`, so RxAppKit's adapter stays + // on the incremental branch (`setData` → `removeItems` → + // `insertItems`) instead of falling back to `reloadData()`. + outlineView.collapseItem(dataSource.parents[0]) + let replacedRowCount = dataSource.parents.count + dataSource.replaceTree(parentCount: parentCount, generation: 1) + outlineView.beginUpdates() + outlineView.removeItems(at: IndexSet(0 ..< replacedRowCount), inParent: nil, withAnimation: []) + outlineView.insertItems(at: IndexSet(0 ..< parentCount), inParent: nil, withAnimation: []) + outlineView.endUpdates() + + try await Task.sleep(for: .milliseconds(300)) + + let survivingIdentifiers = Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) + #expect( + survivingIdentifiers == savedIdentifiers, + "the stale flush overwrote the saved expansion state with \(survivingIdentifiers.sorted())" + ) + #expect( + outlineView.expansionAutosavePersistCount == walksBeforeReplacement, + "the flush walked a tree the user never acted on" + ) + } + + @Test("collapsing every row on an unchanged tree still persists the empty state") + func collapsingEveryRowPersistsAnEmptyState() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = makeOutlineView(dataSource: dataSource) + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let savedIdentifiers = Set(dataSource.parents.map(\.identifier)) + let persisted = try await pollUntil(timeout: .seconds(5)) { + Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) == savedIdentifiers + } + #expect(persisted, "initial expand-all never persisted") + + // An empty result is legitimate when it describes the tree the user + // acted on — the staleness guard must key on the data changing, not + // on the walk coming back empty. + for parent in dataSource.parents { + outlineView.collapseItem(parent) + } + + let emptied = try await pollUntil(timeout: .seconds(5)) { + (UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []).isEmpty + } + #expect(emptied, "collapsing every row never persisted the empty state") + } + + // MARK: - Helpers + + private func makeOutlineView(dataSource: OutlineTreeDataSource) -> StatefulOutlineView { + let outlineView = StatefulOutlineView() + let column = NSTableColumn(identifier: .init("primary")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = dataSource + return outlineView + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} + +// MARK: - Fixture data source + +private final class OutlineTreeItem: NSObject { + let identifier: String + let children: [OutlineTreeItem] + + init(identifier: String, children: [OutlineTreeItem] = []) { + self.identifier = identifier + self.children = children + } +} + +/// Minimal expandable tree: `parentCount` parents with one leaf child +/// each, so `expandItem(nil, expandChildren: true)` posts one +/// `itemDidExpand` notification per parent. +private final class OutlineTreeDataSource: NSObject, NSOutlineViewDataSource { + private(set) var parents: [OutlineTreeItem] + + init(parentCount: Int, generation: Int = 0) { + self.parents = Self.makeParents(parentCount: parentCount, generation: generation) + } + + /// Replaces every item with a freshly allocated one, the way the root + /// sidebar does: `SidebarRootViewModel` maps each `$nodes` emission + /// through `SidebarRootCellViewModel.init`, and the empty + /// `Differentiable` conformance resolves `differenceIdentifier` to + /// `self` (NSObject pointer identity), so every row is a new item and + /// the rebuilt tree comes back fully collapsed. + func replaceTree(parentCount: Int, generation: Int) { + parents = Self.makeParents(parentCount: parentCount, generation: generation) + } + + private static func makeParents(parentCount: Int, generation: Int) -> [OutlineTreeItem] { + (0 ..< parentCount).map { parentIndex in + OutlineTreeItem( + identifier: "generation\(generation)-parent-\(parentIndex)", + children: [OutlineTreeItem(identifier: "generation\(generation)-child-\(parentIndex)")] + ) + } + } + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + guard let treeItem = item as? OutlineTreeItem else { return parents.count } + return treeItem.children.count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + guard let treeItem = item as? OutlineTreeItem else { return parents[index] } + return treeItem.children[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + guard let treeItem = item as? OutlineTreeItem else { return false } + return !treeItem.children.isEmpty + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift new file mode 100644 index 00000000..fec73d41 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift @@ -0,0 +1,35 @@ +import Dependencies +import Foundation + +/// Runs `operation` with the live dependency context. +/// +/// swift-dependencies defaults to `.test` inside a test target, which +/// makes every `@Dependency` without an explicit `testValue` trap. View +/// models under test resolve `\.settings` / `\.appDefaults` eagerly, so +/// they must be constructed here. +func withLiveDependencyContext(_ operation: () throws -> Result) rethrows -> Result { + try withDependencies { + $0.context = .live + } operation: { + try operation() + } +} + +/// Polls `condition` until it holds or `timeout` elapses. +/// +/// Returns whether the condition was met, so the call site can `#expect` +/// on it with a message rather than failing on a bare timeout. +func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool +) async rethrows -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try? await Task.sleep(for: .milliseconds(25)) + } + return false +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift index 8229dcff..2c9acd7f 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift @@ -107,17 +107,25 @@ final class RuntimeObjectCellView: Tabl func bind(to viewModel: ViewModel) { rx.disposeBag = DisposeBag() - viewModel.primaryIconDriver.drive(primaryIconImageView.rx.image).disposed(by: rx.disposeBag) + viewModel.appearanceDriver.driveOnNext { [weak self] appearance in + guard let self else { return } + apply(appearance) + } + .disposed(by: rx.disposeBag) + } + + private func apply(_ appearance: RuntimeObjectCellAppearance) { + primaryIconImageView.image = appearance.primaryIcon - viewModel.secondaryIconDriver.drive(secondaryIconImageView.rx.image).disposed(by: rx.disposeBag) - viewModel.secondaryIconDriver.map { $0 == nil }.drive(secondaryIconImageView.rx.isHidden).disposed(by: rx.disposeBag) + secondaryIconImageView.image = appearance.secondaryIcon + secondaryIconImageView.isHidden = appearance.secondaryIcon == nil - viewModel.tertiaryIconDriver.drive(tertiaryIconImageView.rx.image).disposed(by: rx.disposeBag) - viewModel.tertiaryIconDriver.map { $0 == nil }.drive(tertiaryIconImageView.rx.isHidden).disposed(by: rx.disposeBag) + tertiaryIconImageView.image = appearance.tertiaryIcon + tertiaryIconImageView.isHidden = appearance.tertiaryIcon == nil - viewModel.titleDriver.drive(titleLabel.rx.attributedStringValue).disposed(by: rx.disposeBag) + titleLabel.attributedStringValue = appearance.title - viewModel.subtitleDriver.map { $0 ?? NSAttributedString() }.drive(subtitleLabel.rx.attributedStringValue).disposed(by: rx.disposeBag) - viewModel.subtitleDriver.map { $0 == nil }.drive(subtitleLabel.rx.isHidden).disposed(by: rx.disposeBag) + subtitleLabel.attributedStringValue = appearance.subtitle ?? NSAttributedString() + subtitleLabel.isHidden = appearance.subtitle == nil } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift index 47bfe09f..49dbee06 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift @@ -231,7 +231,12 @@ final class MainViewModel: ViewModel { guard let self else { return } Task { do { - let semanticString = try await self.documentState.runtimeEngine.interface(for: runtimeObject, options: self.appDefaults.options)?.interfaceString + // Fetch through the interface cache with the same + // merged options as the content pane, so saving the + // visible object is a cache hit and the written text + // matches what the pane displays (the transformer + // configuration participates in both). + let semanticString = try await self.documentState.interfaceCache.interface(for: runtimeObject, options: self.currentMergedGenerationOptions)?.interfaceString try semanticString?.string.write(to: url, atomically: true, encoding: .utf8) } catch { self.errorRelay.accept(error) @@ -263,7 +268,10 @@ final class MainViewModel: ViewModel { Task { [weak self] in guard let self else { return } do { - let semanticString = try await documentState.runtimeEngine.interface(for: runtimeObjectType, options: self.appDefaults.options)?.interfaceString + // Same cache + merged options as the save flow: + // sharing the visible object costs no engine + // round-trip and yields the displayed text. + let semanticString = try await documentState.interfaceCache.interface(for: runtimeObjectType, options: self.currentMergedGenerationOptions)?.interfaceString completion(semanticString?.string.data(using: .utf8), nil) } catch { completion(nil, error) diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift index 52529058..6cde3ffd 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift @@ -13,7 +13,11 @@ final class SidebarRootTableCellView: ImageTextTableCellView { func bind(to viewModel: SidebarRootCellViewModel) { rx.disposeBag = DisposeBag() - viewModel.$icon.asDriver().drive(_imageView.rx.image).disposed(by: rx.disposeBag) - viewModel.$name.asDriver().drive(_textField.rx.attributedStringValue).disposed(by: rx.disposeBag) + viewModel.$appearance.asDriver().driveOnNext { [weak self] appearance in + guard let self else { return } + _imageView.image = appearance.icon + _textField.attributedStringValue = appearance.name + } + .disposed(by: rx.disposeBag) } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift index f176f977..61dfa50e 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift @@ -158,6 +158,6 @@ class SidebarRootViewController: UXKitViewContr func outlineView(_ outlineView: NSOutlineView, typeSelectStringFor tableColumn: NSTableColumn?, item: Any) -> String? { guard let cellViewModel = item as? SidebarRootCellViewModel else { return nil } - return cellViewModel.name.string + return cellViewModel.appearance.name.string } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift index 6270d2f6..c6a48b14 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift @@ -268,7 +268,7 @@ class SidebarRuntimeObjectViewController String? { guard let cellViewModel = item as? SidebarRuntimeObjectCellViewModel else { return nil } - return cellViewModel.title.string + return cellViewModel.appearance.title.string } /// Context-menu entries for the clicked row. Override point: this base @@ -362,6 +362,12 @@ extension SidebarRuntimeObjectViewController { systemSymbolName: "textformat", toolTip: "Case Insensitive", ) + // Case-insensitive search is the default. FilterEngine used to + // invert this flag (state .off accidentally meant insensitive); + // now that the engine honors it, the button starts .on so the + // effective default behavior is unchanged and the highlighted + // state finally tells the truth. + searchCaseInsensitiveButton.state = .on hierarchy { scrollView diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift index 6b116179..4164cc17 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift @@ -33,40 +33,30 @@ public final class SpecializationTypePickerCellViewModel: NSObject, @unchecked S public let candidate: RuntimeSpecializationRequest.Candidate @Observed - public private(set) var primaryIcon: NSUIImage = .init() - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString = .init() - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance: RuntimeObjectCellAppearance public init(candidate: RuntimeSpecializationRequest.Candidate) { self.candidate = candidate - super.init() let iconSize = RuntimeObjectIcon.defaultIconSize - primaryIcon = RuntimeObjectIcon.icon(for: candidate.kind.runtimeObjectKind, size: iconSize) - secondaryIcon = candidate.isGeneric ? RuntimeObjectIcon.iconForGeneric(size: iconSize) : nil - title = NSAttributedString { - AText(candidate.displayName) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 12)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } - subtitle = NSAttributedString { - AText(candidate.imagePath.lastPathComponent) - .foregroundColor(.secondaryLabelColor) - .font(.systemFont(ofSize: 10)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } + self.appearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: candidate.kind.runtimeObjectKind, size: iconSize), + secondaryIcon: candidate.isGeneric ? RuntimeObjectIcon.iconForGeneric(size: iconSize) : nil, + title: NSAttributedString { + AText(candidate.displayName) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 12)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + }, + subtitle: NSAttributedString { + AText(candidate.imagePath.lastPathComponent) + .foregroundColor(.secondaryLabelColor) + .font(.systemFont(ofSize: 10)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) + super.init() } } @@ -92,11 +82,7 @@ extension RuntimeSpecializationRequest.Candidate.Kind { // the table's `rx.items` builder closure per render and never participates // in the DifferenceKit diff. See CLAUDE.md §9 for the rationale. extension SpecializationTypePickerCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } - public var subtitleDriver: Driver { $subtitle.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift index 140429fa..64d789e5 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift @@ -72,8 +72,8 @@ class SidebarRootViewController: UIKitViewContr output.nodes.drive(collectionView.rx.nodes(source:)) { (collectionView: UICollectionView, indexPath: IndexPath, viewModel: SidebarRootCellViewModel, cell: UICollectionViewListCell) in var content = cell.defaultContentConfiguration() content.textProperties.allowsDefaultTighteningForTruncation = false - content.attributedText = viewModel.name - content.image = viewModel.icon + content.attributedText = viewModel.appearance.name + content.image = viewModel.appearance.icon cell.contentConfiguration = content cell.indentationWidth = 8 } diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift index 7103a9d4..2acaacec 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift @@ -63,7 +63,12 @@ class SidebarRuntimeObjectViewController