diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md b/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md new file mode 100644 index 0000000000..6503ea62c1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md @@ -0,0 +1,62 @@ +# Agent 笔记:LSP 设置摘要的取消与快照输入 + +状态:已实现 + +## 先说结论 + +LSP 设置页离开或切换文件快照时,页面应立即结束等待,下一份摘要直接开始计算。 +旧扫描收到取消后可能还在处理最后一批文件;摘要模型继续持有它,但旧结果不能更新页面。 +快照标识和文件清单必须一起捕获,避免把较新的文件清单算进较旧的缓存键。 + +## 问题 + +取消 Swift 任务是协作式操作,等待任务的 `value` 不会因为等待方被取消而立即返回。 +让新扫描先等待旧扫描完成,会把旧扫描的退出延迟带到下一次切页和工作区切换。 +只取消扫描、仍让页面等待其 `value`,也不能保证离页后的页面任务及时释放。 + +## 决策 + +- `LSPProjectUsageModel` 用 continuation(可由所有者主动结束的等待句柄)管理每个页面消费方。 + 同请求共享一个扫描;取消一个消费方只结束它自己的等待,最后一个退出时才取消扫描。 +- 请求替换和工作区重置先解除旧消费方,再取消扫描。模型保存尚未退出的任务,完成后移除; + 完成回调用唯一工作 ID 校验当前所有权,旧结果和旧取消回调都不能修改新请求。 +- 后台任务只弱引用模型,模型释放时取消全部任务。生产扫描每 256 个 URL 检查取消并释放 + 临时对象;取消后的批次可短暂重叠,不承诺任意忽略取消的自定义扫描器能被强制终止。 +- `LanguageToolingFeatureModel` 从同一份已应用快照生成不可变输入。文件数组按写时复制语义捕获, + 任务身份只包含工作区、快照 UUID 和规范化规则,不遍历或哈希整个文件数组。 +- LSP 叶子视图继续直接观察 `AppModel`。工作区通知已会经 `objectWillChange` 到达叶子; + `affectsSettings: false` 只抑制设置根树刷新,不代表叶子收不到变化。 + 不额外向 `settingsChanges` 转发文件快照,保持设置窗口的低频刷新边界。 + +正确做法:旧快照 A 扫描暂停时提交 B,B 直接完成;A 随后退出只清理自己的任务。 +不要让 B 先 `await A.value`,也不要在 B 的任务开始时再单独读取可变的 `projectFiles`。 + +## 考虑过的备选方案 + +- 串行等待旧扫描:峰值只运行一份扫描,但不合作的旧扫描会阻塞新页面,因此不采用。 +- 丢弃取消任务的句柄:新请求可以启动,但所有权和清理无法核验,因此保留到任务完成。 +- 给设置根树增加快照通知:现有叶子订阅已覆盖更新,再增加通知会扩大重绘范围。 + +## 后果 + +- 页面任务的生命周期不再取决于旧扫描的退出速度,热缓存仍只保存一份摘要。 +- 需要管理消费方等待和退出中的扫描两种资源;取消边界允许旧批次短暂重叠。 +- 此功能只读取已有 URL 清单,不访问文件内容、不启动语言服务器、不新增磁盘缓存, + 不写发行目录,因此不改变代码签名或 Sparkle 增量更新所依赖的发行基线。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'LSPProjectUsageTests|AppModelSettingsChangesTests|WorkspaceLanguageServerPreferencesTests'` +- `./scripts/architecture/verify-service-boundaries.sh` +- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` +- 用有界 gate 暂停并忽略旧扫描取消,验证新请求和旧消费方都能在释放 gate 前结束。 + 原生挂载测试保持同一个 LSP 页面,依次应用新增、重命名和删除后的快照。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift` +- `macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift` +- `macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift` +- `macos/Tests/LitheTests/LSPProjectUsageTests.swift` +- `macos/Tests/LitheTests/AppModelSettingsChangesTests.swift` diff --git a/macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift b/macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift new file mode 100644 index 0000000000..04c880ed51 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift @@ -0,0 +1,226 @@ +import Combine +import Foundation + +/// Matching facts only: localization, plugin readiness and session state do not +/// change which files use a language, and must not invalidate this cache. +struct LSPProjectUsageRequest: Hashable, Sendable { + enum Target: Hashable, Sendable { + case provider(String) + case plugin(String) + + fileprivate var sortKey: String { + switch self { + case .provider(let id): return "provider:" + id + case .plugin(let id): return "plugin:" + id + } + } + } + + struct Rule: Hashable, Sendable { + let target: Target + let fileExtensions: Set + let fileNames: Set + let fileNamePrefixes: Set + + init(target: Target, fileExtensions: Set, fileNames: Set = [], + fileNamePrefixes: Set = []) { + self.target = target + self.fileExtensions = Set(fileExtensions.map { $0.lowercased() }) + self.fileNames = Set(fileNames.map { $0.lowercased() }) + self.fileNamePrefixes = Set(fileNamePrefixes.map { $0.lowercased() }) + } + } + + let workspaceURL: URL? + let snapshotID: UUID? + let rules: [Rule] + + init(workspaceURL: URL?, snapshotID: UUID?, rules: [Rule]) { + self.workspaceURL = workspaceURL + self.snapshotID = snapshotID + self.rules = rules.sorted { $0.target.sortKey < $1.target.sortKey } + } +} + +struct LSPProjectUsageSummary: Equatable, Sendable { + var providerIDs: Set = [] + var pluginIDs: Set = [] +} + +/// Capture the identity and copy-on-write inventory from one applied snapshot. +/// Only the request participates in task identity; files are never hashed. +struct LSPProjectUsageInput: Sendable { + let request: LSPProjectUsageRequest + let files: [URL] +} + +enum LSPProjectUsageScanner { + /// The caller runs this on its owned worker. Inspect each URL at most once + /// and retain only matched IDs, not an additional inventory of file names. + static func scan(files: [URL], rules: [LSPProjectUsageRequest.Rule]) -> LSPProjectUsageSummary? { + typealias Target = LSPProjectUsageRequest.Target + var extensions: [String: Set] = [:] + var names: [String: Set] = [:] + var prefixes: [String: Set] = [:] + for rule in rules { + for value in rule.fileExtensions { extensions[value, default: []].insert(rule.target) } + for value in rule.fileNames { names[value, default: []].insert(rule.target) } + for value in rule.fileNamePrefixes { prefixes[value, default: []].insert(rule.target) } + } + let allTargets = Set(rules.map(\.target)) + var matched: Set = [] + for start in stride(from: 0, to: files.count, by: 256) { + guard !Task.isCancelled else { return nil } + if matched == allTargets { break } + // Foundation's URL accessors can bridge through autoreleased NSURL + // strings. Drain them within a batch, not at the end of 300k files. + autoreleasepool { + for index in start.. LSPProjectUsageSummary? + + @Published private(set) var state: State = .idle + private let scanner: Scanner + private var cached: (request: LSPProjectUsageRequest, summary: LSPProjectUsageSummary)? + private var work: Work? + private var retiring: [UUID: Task] = [:] + + private final class Work { + let id: UUID + let request: LSPProjectUsageRequest + let task: Task + var consumers: [UUID: (cancellation: Cancellation, continuation: CheckedContinuation)] = [:] + + init(id: UUID, request: LSPProjectUsageRequest, task: Task) { + self.id = id + self.request = request + self.task = task + } + } + + /// The cancellation handler runs outside the main actor. Mark the lease + /// synchronously so a queued completion cannot publish for cancelled pages. + private final class Cancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + var isCancelled: Bool { lock.withLock { cancelled } } + func cancel() { lock.withLock { cancelled = true } } + } + + init(scanner: @escaping Scanner = { files, rules in + LSPProjectUsageScanner.scan(files: files, rules: rules) + }) { + self.scanner = scanner + } + + func summary(for request: LSPProjectUsageRequest) -> LSPProjectUsageSummary? { + cached?.request == request ? cached?.summary : nil + } + + func load(_ request: LSPProjectUsageRequest, files: [URL]) async { + let consumer = UUID() + let cancellation = Cancellation() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !cancellation.isCancelled, !Task.isCancelled else { continuation.resume(); return } + if summary(for: request) != nil { + retireWork() + // @Published emits even for equal values. Warm loads must + // not schedule another SwiftUI update. + if state != .ready { state = .ready } + continuation.resume() + return + } + if work?.request != request { + retireWork() + let id = UUID() + let scanner = self.scanner + let task = Task.detached(priority: .utility) { [weak self] in + let value = Task.isCancelled ? nil : await scanner(files, request.rules) + await self?.complete(id: id, value: value) + } + work = Work(id: id, request: request, task: task) + } + work?.consumers[consumer] = (cancellation, continuation) + if state != .loading { state = .loading } + } + } onCancel: { [weak self] in + cancellation.cancel() + Task { @MainActor in self?.cancel(consumer: consumer) } + } + } + + func reset() { + retireWork() + retiring.values.forEach { $0.cancel() } + cached = nil + state = .idle + } + + private func cancel(consumer: UUID) { + guard let work, let lease = work.consumers.removeValue(forKey: consumer) else { return } + lease.continuation.resume() + if work.consumers.isEmpty { + retireWork() + state = cached == nil ? .idle : .ready + } + } + + private func complete(id: UUID, value: LSPProjectUsageSummary?) { + retiring[id] = nil + guard let active = work, active.id == id else { return } + work = nil + if let value, active.consumers.values.contains(where: { !$0.cancellation.isCancelled }) { + cached = (active.request, value) + } + state = cached == nil ? .idle : .ready + active.consumers.values.forEach { $0.continuation.resume() } + } + + private func retireWork() { + guard let active = work else { return } + work = nil + // Cancellation is cooperative: own the worker until its next batch + // exits, but never put that completion on the next page's critical path. + retiring[active.id] = active.task + active.task.cancel() + active.consumers.values.forEach { $0.continuation.resume() } + } + + deinit { + work?.task.cancel() + work?.consumers.values.forEach { $0.continuation.resume() } + retiring.values.forEach { $0.cancel() } + } +} diff --git a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift index dd859511f9..caa9569bff 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift @@ -13,6 +13,7 @@ final class LanguageToolingFeatureModel: ObservableObject { @Published private(set) var catalogSnapshot: LanguageProviderCatalogSnapshot @Published private(set) var disabledProviderIDs: Set = [] @Published private(set) var projectPreparation: ProjectPreparationSnapshot? + let projectUsage = LSPProjectUsageModel() private var preparationObservation: AnyCancellable? private(set) var startupFailures: [String: String] = [:] @@ -64,7 +65,32 @@ final class LanguageToolingFeatureModel: ObservableObject { startupFailures.removeAll() } + func projectUsageRequest(workspaceURL: URL?, snapshotID: UUID?) -> LSPProjectUsageRequest { + let providers = catalog.descriptors + .filter { $0.capabilities.contains(.languageServer) && $0.languageServerLaunch != nil } + .map { + LSPProjectUsageRequest.Rule(target: .provider($0.id), fileExtensions: $0.fileExtensions, + fileNames: $0.fileNames, fileNamePrefixes: $0.fileNamePrefixes) + } + let plugins = OfficialPluginCatalog.entries.map { + LSPProjectUsageRequest.Rule(target: .plugin($0.id.rawValue), fileExtensions: Set($0.fileExtensions), + fileNames: Set($0.projectFileNames)) + } + return LSPProjectUsageRequest(workspaceURL: workspaceURL, snapshotID: snapshotID, rules: providers + plugins) + } + + func projectUsageInput(workspaceURL: URL?, snapshot: WorkspaceSnapshot?) -> LSPProjectUsageInput { + LSPProjectUsageInput( + request: projectUsageRequest(workspaceURL: workspaceURL, snapshotID: snapshot?.id), + files: snapshot?.files ?? [] + ) + } + func reloadCatalog(for workspaceURL: URL?) { + // Runtime/index resets also call resetWorkspaceState while Settings is + // still visible. Only a workspace boundary invalidates the usage cache; + // otherwise an unchanged .task(id:) would not reload the cleared value. + projectUsage.reset() self.workspaceURL = workspaceURL?.standardizedFileURL // Restore preferences before workspace restoration can activate documents // or prewarm Java. Runtime resets must not erase explicit user choices. diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 4fe4314397..031cf26f11 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -499,7 +499,7 @@ struct LitheApp: App { exit(1) } let store = MacUserDefaultsStore() - if LithePerformanceBaseline.isEnabled { + if LithePerformanceBaseline.isEnabled || LitheSignpost.settingsLatencyTracingEnabled { LitheSignpost.configureBaselineOutput { line in FileHandle.standardError.write(Data(line.utf8)) } diff --git a/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift index 74ef391f43..64811c30d2 100644 --- a/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift +++ b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift @@ -15,11 +15,24 @@ enum LitheSignpost { private static let baselineEnabled = ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1" private static var baselineOutput: ((String) -> Void)? + static let settingsLatencyTracingEnabled = + ProcessInfo.processInfo.environment["LITHE_SETTINGS_LATENCY_TRACE"] == "1" static func configureBaselineOutput(_ output: @escaping (String) -> Void) { baselineOutput = output } + /// Opt-in native timestamps, independent of the external AX observer and + /// of the baseline mode that disables the normal frame-rate monitor. + static func settingsLatencyEvent( + _ phase: String, category: String, eventID: Int64, + input: UInt64, received: UInt64, selected: UInt64 + ) { + guard settingsLatencyTracingEnabled else { return } + let time = DispatchTime.now().uptimeNanoseconds + baselineOutput?("LITHE_SETTINGS_LATENCY phase=\(phase) category=\(category) event_id=\(eventID) time_ns=\(time) input_ns=\(input) received_ns=\(received) selected_ns=\(selected)\n") + } + static func begin(_ name: StaticString) -> State { State( osState: signposter.beginInterval(name), diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 49824c6deb..7f146b28e0 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -108,6 +108,9 @@ struct SettingsView: View { minimum: Self.categoryMinimumWidth, maximum: Self.categoryMaximumWidth, flexibleMinimum: Self.contentMinimumWidth, + // The footer is 50pt plus its 1pt divider. Settings pages scroll + // inside this known minimum instead of sizing the window. + idealSize: CGSize(width: 900, height: 617), onCommit: { categorySidebarWidth = Double($0) }, sized: { categories }, @@ -124,6 +127,7 @@ struct SettingsView: View { .ignoresSafeArea() } .onAppear { + SettingsPageLatencyTrace.installIfEnabled() expandGroup(containing: viewState.selection) syncVisibilityDrafts() model.refreshAIConfigurations() @@ -201,13 +205,17 @@ struct SettingsView: View { } private var categoryNodes: [LitheTreeListNode] { - SettingsSidebarItem.layout.flatMap { item -> [LitheTreeListNode] in + // Plugin discovery can inspect settings and executables. Share one + // current result among all groups in this sidebar construction. + let contributedPages = filteredContributedPages + let categories = filteredCategories + return SettingsSidebarItem.layout.flatMap { item -> [LitheTreeListNode] in switch item { case .page(let category): - return filteredCategories.contains(category) ? [categoryNode(category)] : [] + return categories.contains(category) ? [categoryNode(category)] : [] case .group(let group): - let visible = group.categories.filter { filteredCategories.contains($0) } - let pages = filteredContributedPages.filter { $0.group == group } + let visible = group.categories.filter { categories.contains($0) } + let pages = contributedPages.filter { $0.group == group } guard !visible.isEmpty || !pages.isEmpty else { return [] } return [LitheTreeListNode(id: "group:" + group.rawValue, isExpanded: expandedSidebarGroups.contains(group.rawValue) || !viewState.searchQuery.isEmpty, @@ -263,6 +271,7 @@ struct SettingsView: View { private func categoryNode(_ category: SettingsCategory, nested: Bool = false) -> LitheTreeListNode { LitheTreeListNode(id: "page:" + category.id, onSelect: { + SettingsPageLatencyTrace.selected(category.id) isSearchFocused = false; viewState.contributedPageID = nil; viewState.selection = category }) { HStack(spacing: 8) { diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift index 63fe882193..4fa392e619 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift @@ -32,6 +32,9 @@ struct LitheSplitPaneView: View { /// Minimum size reserved for the flexible pane, when the hosted content /// has a product-level usability requirement of its own. let flexibleMinimum: CGFloat? + /// A host with a known page size can answer intrinsic probes without + /// recursively measuring every control in both panes. + let idealSize: CGSize? /// Clip a pane with content wider than the dragged size at the split boundary. let clipsSizedPane: Bool /// Hide the tracked pane and divider without unmounting either pane. @@ -60,6 +63,7 @@ struct LitheSplitPaneView: View { minimum: CGFloat, maximum: CGFloat, flexibleMinimum: CGFloat? = nil, + idealSize: CGSize? = nil, clipsSizedPane: Bool = false, isSizedPaneCollapsed: Bool = false, trackBackground: Color = .clear, @@ -75,6 +79,7 @@ struct LitheSplitPaneView: View { self.minimum = minimum self.maximum = maximum self.flexibleMinimum = flexibleMinimum + self.idealSize = idealSize self.clipsSizedPane = clipsSizedPane self.isSizedPaneCollapsed = isSizedPaneCollapsed self.trackBackground = trackBackground @@ -98,7 +103,7 @@ struct LitheSplitPaneView: View { let size = resolvedSize LitheSplitPaneLayout(axis: axis, placement: placement, size: size, dividerSize: isSizedPaneCollapsed ? 0 : SplitHandleView.thickness, - flexibleMinimum: flexibleMinimum ?? 0) { + flexibleMinimum: flexibleMinimum ?? 0, idealSize: idealSize) { panes(size) } .environment(\.isLithePaneResizing, ancestorIsDragging || isDragging) @@ -205,12 +210,17 @@ private struct LitheSplitPaneLayout: Layout { let size: CGFloat let dividerSize: CGFloat let flexibleMinimum: CGFloat + let idealSize: CGSize? func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { if let width = proposal.width, let height = proposal.height, width.isFinite, height.isFinite { return CGSize(width: max(0, width), height: max(0, height)) } + if let idealSize { + return CGSize(width: proposal.width.flatMap { $0.isFinite ? max(0, $0) : nil } ?? idealSize.width, + height: proposal.height.flatMap { $0.isFinite ? max(0, $0) : nil } ?? idealSize.height) + } // Intrinsic sizing is only needed if the host has not assigned bounds. let sizes = subviews.map { $0.sizeThatFits(.unspecified) } let sizedExtent = size + dividerSize + flexibleMinimum diff --git a/macos/Sources/Lithe/Views/Diagnostics/SettingsPageLatencyProbe.swift b/macos/Sources/Lithe/Views/Diagnostics/SettingsPageLatencyProbe.swift new file mode 100644 index 0000000000..07d96f8546 --- /dev/null +++ b/macos/Sources/Lithe/Views/Diagnostics/SettingsPageLatencyProbe.swift @@ -0,0 +1,171 @@ +import AppKit +import SwiftUI + +/// Diagnostic-only presentation milestones. This observes a normal window +/// update; it never forces layout, reads AX, or changes the settings selection. +@MainActor +enum SettingsPageLatencyTrace { + struct InputCorrelation { + struct Input { + let id: Int64 + let input: UInt64 + let received: UInt64 + } + private var lastCapturedID: Int64? + private var pending: Input? + + mutating func receive(id: Int64, input: UInt64, at time: UInt64) -> Input? { + guard id > 0 else { pending = nil; return nil } + guard id != lastCapturedID else { return nil } + lastCapturedID = id + let value = Input(id: id, input: input, received: time) + pending = value + return value + } + + mutating func take(eventID: Int64?, isMouse: Bool, at time: UInt64) -> Input? { + defer { pending = nil } + guard let value = pending, isMouse, eventID == value.id, + time >= value.received, time - value.received < 1_000_000_000 else { return nil } + return value + } + } + + struct Visit { + let category: String + let eventID: Int64 + let input: UInt64 + let received: UInt64 + let selected: UInt64 + } + + private static var eventMonitor: Any? + private static var terminationObserver: NSObjectProtocol? + private static var inputCorrelation = InputCorrelation() + private(set) static var visit: Visit? + + static func installIfEnabled() { + guard LitheSignpost.settingsLatencyTracingEnabled, eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseUp]) { event in + MainActor.assumeIsolated { + if event.window?.identifier?.rawValue == "settings", let native = event.cgEvent { + let id = native.getIntegerValueField(.eventSourceUserData) + // Only explicitly tagged diagnostic input is correlated. + let now = DispatchTime.now().uptimeNanoseconds + if let value = inputCorrelation.receive(id: id, input: native.timestamp, at: now) { + LitheSignpost.settingsLatencyEvent("inputReceived", category: "-", eventID: value.id, + input: value.input, received: value.received, selected: 0) + } + if id <= 0 { + visit = nil + } + } + } + return event + } + terminationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, object: nil, queue: .main + ) { _ in + MainActor.assumeIsolated { + if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } + eventMonitor = nil + if let terminationObserver { NotificationCenter.default.removeObserver(terminationObserver) } + terminationObserver = nil + } + } + } + + static func selected(_ category: String) { + guard LitheSignpost.settingsLatencyTracingEnabled else { return } + let now = DispatchTime.now().uptimeNanoseconds + let event = NSApp.currentEvent + let isMouse = event?.type == .leftMouseDown || event?.type == .leftMouseUp + guard let input = inputCorrelation.take(eventID: event?.cgEvent?.getIntegerValueField(.eventSourceUserData), + isMouse: isMouse, at: now) else { + visit = nil + return + } + let value = Visit(category: category, eventID: input.id, input: input.input, + received: input.received, selected: now) + visit = value + record("selected", value) + } + + static func record(_ phase: String, _ value: Visit) { + LitheSignpost.settingsLatencyEvent(phase, category: value.category, eventID: value.eventID, + input: value.input, received: value.received, selected: value.selected) + } + + static func completed(_ value: Visit) { + if visit?.eventID == value.eventID { visit = nil } + } +} + +struct SettingsPageLatencyProbe: NSViewRepresentable { + let category: String + + func makeNSView(context: Context) -> SettingsPageLatencyProbeView { + SettingsPageLatencyProbeView(category: category) + } + + func updateNSView(_ view: SettingsPageLatencyProbeView, context: Context) { + view.arm() + } + + static func dismantleNSView(_ view: SettingsPageLatencyProbeView, coordinator: ()) { + view.stopObserving() + } +} + +final class SettingsPageLatencyProbeView: NSView { + private let category: String + private var observer: NSObjectProtocol? + private var pending: SettingsPageLatencyTrace.Visit? + private var lastEventID: Int64? + + init(category: String) { + self.category = category + super.init(frame: .zero) + setAccessibilityElement(false) + } + + required init?(coder: NSCoder) { nil } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + stopObserving() + guard let window else { return } + arm() + observer = NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, object: window, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.windowUpdated() } + } + } + + func arm() { + guard window != nil, let value = SettingsPageLatencyTrace.visit, + value.category == category, value.eventID != lastEventID else { return } + pending = value + lastEventID = value.eventID + SettingsPageLatencyTrace.record("contentMounted", value) + } + + private func windowUpdated() { + guard let value = pending, window?.isVisible == true, !bounds.isEmpty else { return } + pending = nil + guard SettingsPageLatencyTrace.visit?.eventID == value.eventID else { return } + SettingsPageLatencyTrace.record("windowUpdated", value) + SettingsPageLatencyTrace.completed(value) + DispatchQueue.main.async { + SettingsPageLatencyTrace.record("mainQueueAvailable", value) + } + } + + func stopObserving() { + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = nil + pending = nil + } +} diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index f585828086..2f53c9343c 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -3,23 +3,60 @@ import LitheModuleAPI struct LSPControlCenterView: View { @EnvironmentObject private var model: AppModel + + var body: some View { + LSPControlCenterContent( + usageModel: model.languageToolingFeature.projectUsage, + input: model.languageToolingFeature.projectUsageInput( + workspaceURL: model.workspaceURL, snapshot: model.workspaceFeature.appliedSnapshot + ) + ) + } +} + +struct LSPControlCenterContent: View { + @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings + @ObservedObject var usageModel: LSPProjectUsageModel + let input: LSPProjectUsageInput + private let localizationBundle: Bundle + private var request: LSPProjectUsageRequest { input.request } + init(usageModel: LSPProjectUsageModel, input: LSPProjectUsageInput, localizationBundle: Bundle = .main) { + self.usageModel = usageModel + self.input = input + self.localizationBundle = localizationBundle + } var body: some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 16) { projectSummary - ForEach(pluginNotices, id: \.self) { notice in - phpPluginNotice(notice) - } - if projectLanguageServers.isEmpty && pluginNotices.isEmpty { - emptyState - } else { - ForEach(projectLanguageServers) { descriptor in - languageRow(descriptor) + if let usage = usageModel.summary(for: request) { + // Building a plugin snapshot also discovers settings and + // runtime resources. Share it across this render's rows + // and notices without caching live plugin state. + let snapshots = model.pluginSnapshots + // Conflicting packages can remain visible for inspection. + // Preserve the previous first-match lookup precedence. + let snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first }) + let notices = pluginNotices(in: usage, snapshots: snapshotsByID) + let servers = projectLanguageServers(in: usage, snapshots: snapshotsByID) + let hasChangesPendingRestart = snapshots.contains(where: \.hasChangesPendingRestart) + ForEach(notices, id: \.self) { notice in + phpPluginNotice(notice, hasChangesPendingRestart: hasChangesPendingRestart) + } + if servers.isEmpty && notices.isEmpty { + emptyState + } else { + ForEach(servers) { descriptor in + languageRow(descriptor) + } } + } else { + ProgressView().accessibilityLabel(Text(settings.language.localizedString("native.loading", bundle: localizationBundle))) } if model.languageProviderCatalogSnapshot.isDegraded { degradedCatalogNotice @@ -33,6 +70,16 @@ struct LSPControlCenterView: View { .background(LitheTheme.settingsSurface) } .background(LitheTheme.settingsSurface) + .background { + if LitheSignpost.settingsLatencyTracingEnabled, + usageModel.summary(for: request) != nil { + SettingsPageLatencyProbe(category: SettingsCategory.lsp.id) + .accessibilityHidden(true) + } + } + .task(id: request) { + await usageModel.load(input.request, files: input.files) + } } private var projectSummary: some View { @@ -119,15 +166,16 @@ struct LSPControlCenterView: View { } } - private var projectLanguageServers: [LanguageProviderDescriptor] { + private func projectLanguageServers( + in usage: LSPProjectUsageSummary, + snapshots: [PluginID: PluginManagementSnapshot] + ) -> [LanguageProviderDescriptor] { model.languageProviderCatalog.descriptors .filter { $0.capabilities.contains(.languageServer) && $0.languageServerLaunch != nil } + .filter { usage.providerIDs.contains($0.id) } .filter { descriptor in guard let entry = officialPluginEntry(languageID: descriptor.id) else { return true } - return pluginIsReady(entry) - } - .filter { descriptor in - model.projectFiles.contains { descriptor.handles(fileURL: $0) } + return pluginIsReady(entry, snapshots: snapshots) } } @@ -137,45 +185,44 @@ struct LSPControlCenterView: View { OfficialPluginCatalog.publishedEntries.first { $0.languageID == languageID } } - private func pluginSnapshot(_ entry: OfficialPluginCatalog.Entry) -> PluginManagementSnapshot? { - model.pluginSnapshots.first { $0.id == entry.id } - } - - private func pluginIsReady(_ entry: OfficialPluginCatalog.Entry) -> Bool { - guard let snapshot = pluginSnapshot(entry) else { return false } + private func pluginIsReady( + _ entry: OfficialPluginCatalog.Entry, + snapshots: [PluginID: PluginManagementSnapshot] + ) -> Bool { + guard let snapshot = snapshots[entry.id] else { return false } return snapshot.isEnabled && !snapshot.requiresRestart && !snapshot.isQuarantined } - private func projectUses(_ entry: OfficialPluginCatalog.Entry) -> Bool { - model.projectFiles.contains { fileURL in - entry.fileExtensions.contains(fileURL.pathExtension.lowercased()) - || entry.projectFileNames.contains(fileURL.lastPathComponent.lowercased()) - } - } - - private var pluginNotices: [String] { + private func pluginNotices( + in usage: LSPProjectUsageSummary, + snapshots: [PluginID: PluginManagementSnapshot] + ) -> [String] { OfficialPluginCatalog.entries.compactMap { entry in - guard projectUses(entry) else { return nil } + guard usage.pluginIDs.contains(entry.id.rawValue) else { return nil } if let replacementID = entry.communityReplacementID { - return migratedPluginNotice(entry, replacementID: replacementID) + return migratedPluginNotice(entry, replacementID: replacementID, snapshots: snapshots) } - guard !pluginIsReady(entry) else { return nil } - return pluginNotice(entry) + guard !pluginIsReady(entry, snapshots: snapshots) else { return nil } + return pluginNotice(entry, snapshots: snapshots) } } /// Go and PHP moved to Lithe Store: point at the community plugin, and at /// the uninstall-and-restart step while the retired official one remains. - private func migratedPluginNotice(_ entry: OfficialPluginCatalog.Entry, replacementID: PluginID) -> String? { + private func migratedPluginNotice( + _ entry: OfficialPluginCatalog.Entry, + replacementID: PluginID, + snapshots: [PluginID: PluginManagementSnapshot] + ) -> String? { let name = entry.displayName let language = entry.languageDisplayName - if let retired = pluginSnapshot(entry) { + if let retired = snapshots[entry.id] { if retired.installationStatus == .uninstallPending { return settings.language.localizedFormat("native.inline.restartLitheToFinishUninstallingTheOldArg1ThenInstallTheCommunityArg2FromLi", String(describing: name), String(describing: name)) } return settings.language.localizedFormat("native.inline.arg1MovedToLitheStoreAsACommunityPluginAndTheOldOfficialVersionGetsNoUpdate", String(describing: name)) } - guard let community = model.pluginSnapshots.first(where: { $0.id == replacementID }) else { + guard let community = snapshots[replacementID] else { return settings.language.localizedFormat("native.inline.thisArg1ProjectNeedsArg2InstallItFromLitheStoreInThePluginManagementMarketp", String(describing: language), String(describing: name)) } if community.requiresRestart { @@ -185,10 +232,13 @@ struct LSPControlCenterView: View { return settings.language.localizedFormat("native.inline.arg1IsDisabledEnableItFromPluginManagementFirst", String(describing: name)) } - private func pluginNotice(_ entry: OfficialPluginCatalog.Entry) -> String { + private func pluginNotice( + _ entry: OfficialPluginCatalog.Entry, + snapshots: [PluginID: PluginManagementSnapshot] + ) -> String { let name = entry.displayName let language = entry.languageDisplayName - guard let snapshot = pluginSnapshot(entry) else { + guard let snapshot = snapshots[entry.id] else { return settings.language.localizedFormat("native.inline.thisArg1ProjectNeedsArg2DownloadAndInstallItFromPluginManagement", String(describing: language), String(describing: name)) } if snapshot.installationStatus == .removed { @@ -203,14 +253,14 @@ struct LSPControlCenterView: View { return settings.language.localizedFormat("native.inline.arg1IsDisabledEnableItFromPluginManagementFirst", String(describing: name)) } - private func phpPluginNotice(_ message: String) -> some View { + private func phpPluginNotice(_ message: String, hasChangesPendingRestart: Bool) -> some View { LitheBanner(status: .info) { Text(message).fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { Button(settings.language.localizedString("native.inline.openPluginManagement")) { model.showSettings(category: .plugins) } - if model.pluginSnapshots.contains(where: \.hasChangesPendingRestart) { + if hasChangesPendingRestart { RestartLitheButton(prominent: false) } } diff --git a/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift b/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift index 4d9ba0d349..e3ee1418f8 100644 --- a/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift +++ b/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift @@ -1,5 +1,9 @@ import Combine +import AppKit import Foundation +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI import Testing @testable import Lithe @@ -52,22 +56,156 @@ struct AppModelSettingsChangesTests { #expect(probe.modelChangeCount == 1) #expect(probe.settingsChangeCount == 1) } + + @Test(arguments: [AppLanguage.english, .simplifiedChinese]) + func mountedLSPPageRefreshesSnapshotsWithoutNotifyingTheSettingsRoot(language: AppLanguage) async throws { + let root = URL(fileURLWithPath: "/fixture/lsp-snapshot-updates") + let operations = UsageSnapshotOperations(inventories: [ + ["README"], ["README", "go.mod"], ["README", "Main.java"], ["README"] + ]) + let probe = SettingsChangesProbe(workspaceOperations: operations, language: language) + let model = probe.model + let usage = model.languageToolingFeature.projectUsage + let context = SettingsModelContext(model: model) + let viewState = SettingsViewState(initialCategory: .lsp) + let updater = UpdateChecker() + let host = NSHostingView(rootView: SettingsView(settings: probe.settings, viewState: viewState, initialCategory: .lsp) + .environmentObject(context).environmentObject(updater) + .environmentObject(model).environmentObject(probe.settings) + .environment(\.locale, language == .english ? Locale(identifier: "zh-Hans") : Locale(identifier: "en"))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 668), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { + window.contentView = nil + window.close() + usage.reset() + model.workspaceFeature.reset() + probe.cancel() + } + host.layoutSubtreeIfNeeded() + try captureUsageSettings(host, name: "\(language.rawValue)-initial-loading") + await probe.drainPendingRelays() + var identities: Set = [] + // Keep the same native host mounted while the production workspace + // feature publishes create, rename and delete inventories. + for expectedPlugin in [nil, OfficialPluginCatalog.goPluginID.rawValue, + OfficialPluginCatalog.javaPluginID.rawValue, nil] as [String?] { + let result = await model.workspaceFeature.rebuild(at: root, rules: .default, isCurrent: { true }) + guard case .loaded(let snapshot) = result else { Issue.record("Snapshot was not applied"); return } + identities.insert(snapshot.id) + let input = model.languageToolingFeature.projectUsageInput(workspaceURL: model.workspaceURL, snapshot: snapshot) + let ready = await awaitChange(on: usage) { usage.summary(for: input.request) != nil } + try #require(ready, "Mounted LSP page did not reload snapshot \(snapshot.id)") + let summary = try #require(usage.summary(for: input.request)) + #expect(summary.pluginIDs == expectedPlugin.map { Set([$0]) } ?? []) + #expect(model.workspaceSnapshotID == snapshot.id) + try captureUsageSettings(host, name: "\(language.rawValue)-snapshot-\(identities.count)") + } + #expect(identities.count == 4) + #expect(probe.settingsChangeCount == 0) + window.setContentSize(NSSize(width: 1200, height: 800)) + try captureUsageSettings(host, name: "\(language.rawValue)-expanded") + window.setContentSize(NSSize(width: 900, height: 668)) + try captureUsageSettings(host, name: "\(language.rawValue)-restored") + } + + @Test(arguments: [AppLanguage.english, .simplifiedChinese]) + func loadingLabelFollowsAppLanguageWithOppositeParentLocale(language: AppLanguage) async throws { + // Match the existing native AX fixtures: SwiftUI only publishes its + // accessibility tree when an assistive client has requested it. + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previousAccessibility = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previousAccessibility, forAttribute: attribute) } + let probe = SettingsChangesProbe(language: language) + let entered = TestGate() + let release = TestGate() + let usage = LSPProjectUsageModel(scanner: { _, _ in + entered.open() + #expect(await release.waitUntilOpen(timeout: .seconds(5))) + return LSPProjectUsageSummary() + }) + let input = probe.model.languageToolingFeature.projectUsageInput(workspaceURL: nil, snapshot: nil) + // SwiftPM does not package app tables. Inject the actual shared native + // resources so a missing translation cannot pass as the semantic key. + let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources") + let bundle = try #require(Bundle(url: resources)) + let host = NSHostingView(rootView: LSPControlCenterContent(usageModel: usage, input: input, localizationBundle: bundle) + .environmentObject(probe.model).environmentObject(probe.settings) + .environment(\.locale, language == .english ? Locale(identifier: "zh-Hans") : Locale(identifier: "en"))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 500), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { + release.open() + usage.reset() + window.contentView = nil + window.close() + probe.cancel() + } + try #require(await entered.waitUntilOpen()) + let label = language == .english ? "Loading…" : "正在加载…" + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + // SwiftUI does not publish AX-tree availability through Combine. + // Poll only this native boundary, with a local monotonic deadline. + while !accessibilityLabels(in: host).contains(label), clock.now < deadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + #expect(accessibilityLabels(in: host).contains(label)) + try captureUsageSettings(host, name: "\(language.rawValue)-loading-label") + release.open() + #expect(await awaitChange(on: usage) { usage.state == .ready }) + } +} + +@MainActor +private func accessibilityLabels(in element: Any, depth: Int = 0) -> [String] { + guard depth < 32 else { return [] } + // SwiftUI's AX nodes expose selectors without adopting the full protocol. + let accessible = element as AnyObject + return [accessible.accessibilityLabel?(), accessible.accessibilityTitle?()].compactMap { $0 } + + (accessible.accessibilityChildren?() ?? []).flatMap { accessibilityLabels(in: $0, depth: depth + 1) } +} + +@MainActor +private func captureUsageSettings(_ host: NSView, name: String) throws { + guard let directory = ProcessInfo.processInfo.environment["LITHE_LSP_REVIEW_SCREENSHOT_DIR"] else { return } + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let root = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data(Bundle.main.bundleURL.path.utf8).write(to: root.appendingPathComponent("bundle-main-path.txt")) + try data.write(to: root.appendingPathComponent(name + ".png")) } @MainActor private final class SettingsChangesProbe { let model: AppModel + let settings: AppSettings private(set) var modelChangeCount = 0 private(set) var settingsChangeCount = 0 private var observations: [AnyCancellable] = [] - init() { + init(workspaceOperations: (any WorkspaceOperations)? = nil, language: AppLanguage = .english) { let store = SettingsChangesTestStore() - let settings = AppSettings(store: store) + settings = AppSettings(store: store) + settings.language = language let services = MacServiceContainer( store: store, settings: settings, - moduleLaunchMode: .safeMode + moduleLaunchMode: .safeMode, + workspaceOperations: workspaceOperations ).services model = AppModel(settings: settings, services: services) observations = [ @@ -98,6 +236,27 @@ private final class SettingsChangesProbe { } } +private final class UsageSnapshotOperations: WorkspaceOperations, @unchecked Sendable { + private let lock = NSLock() + private var inventories: [[String]] + + init(inventories: [[String]]) { self.inventories = inventories } + + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + lock.lock() + defer { lock.unlock() } + guard !inventories.isEmpty else { return nil } + let files = inventories.removeFirst().map { rootURL.appendingPathComponent($0) } + return WorkspaceSnapshot(root: FileNode(url: rootURL, isDirectory: true, children: []), files: files) + } + + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) {} + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + private final class SettingsChangesTestStore: KeyValueStore, @unchecked Sendable { private var values: [String: Any] = [:] diff --git a/macos/Tests/LitheTests/LSPProjectUsageTests.swift b/macos/Tests/LitheTests/LSPProjectUsageTests.swift new file mode 100644 index 0000000000..41c9133064 --- /dev/null +++ b/macos/Tests/LitheTests/LSPProjectUsageTests.swift @@ -0,0 +1,331 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import Testing +@testable import Lithe + +@Suite("LSP project usage") +struct LSPProjectUsageTests { + @Test func indexedMatchingPreservesProviderAndPluginSemantics() throws { + let providers = [ + provider("source", extensions: ["JAVA", "py"]), + provider("named", names: ["Makefile", "文档 % #.txt", ".toolrc"]), + provider("prefix", prefixes: ["Dockerfile", ".env."]), + provider("missing", extensions: ["absent"]), + provider("emptyExtension", extensions: [""]) + ] + let files = ["中文/Foo.JAVA", "a/test.PY", "b/Makefile", "c/文档 % #.txt", "d/.toolrc", + "e/Dockerfile.prod", "f/.ENV.local", "g/README", "h/unrelated.bin"] + .map { URL(fileURLWithPath: "/fixture/" + $0) } + let rules = providers.map { + LSPProjectUsageRequest.Rule(target: .provider($0.id), fileExtensions: $0.fileExtensions, + fileNames: $0.fileNames, fileNamePrefixes: $0.fileNamePrefixes) + } + pluginRules() + let summary = try #require(LSPProjectUsageScanner.scan(files: files, rules: rules)) + #expect(summary.providerIDs == Set(providers.filter { p in files.contains { p.handles(fileURL: $0) } }.map(\.id))) + #expect(summary.pluginIDs == expectedPluginIDs(files)) + } + + @Test func pluginProjectNamesAndSourceExtensionsShareTheSamePass() throws { + let files = ["pom.xml", "build.gradle.kts", "GO.MOD", "composer.json", "view.PHTML"] + .map { URL(fileURLWithPath: "/fixture/" + $0) } + let summary = try #require(LSPProjectUsageScanner.scan(files: files, rules: pluginRules())) + #expect(summary.pluginIDs == expectedPluginIDs(files)) + #expect(summary.pluginIDs.count == 3) + } + + @Test func emptyAndUnmatchedInventoriesProduceACompletedEmptySummary() { + #expect(LSPProjectUsageScanner.scan(files: [], rules: pluginRules()) == LSPProjectUsageSummary()) + #expect(LSPProjectUsageScanner.scan(files: [URL(fileURLWithPath: "/fixture/a.bin")], rules: pluginRules()) + == LSPProjectUsageSummary()) + } + + @Test func requestIdentityUsesSnapshotAndNormalizedRulesRatherThanFileCount() { + let snapshot = UUID() + let a = LSPProjectUsageRequest.Rule(target: .provider("a"), fileExtensions: ["JAVA"], fileNamePrefixes: ["Dockerfile"]) + let b = LSPProjectUsageRequest.Rule(target: .plugin("b"), fileExtensions: [], fileNames: ["Makefile"]) + let first = request(snapshot: snapshot, rules: [a, b]) + #expect(first == request(snapshot: snapshot, rules: [b, a])) + #expect(first != request(rules: [a, b])) + #expect(first != request(snapshot: snapshot, rules: [b])) + #expect(first != LSPProjectUsageRequest(workspaceURL: URL(fileURLWithPath: "/other"), snapshotID: snapshot, rules: [a, b])) + } + + @Test @MainActor func reopeningAndPresentationChangesReuseTheCompletedSummary() async { + let probe = UsageScanProbe() + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let input = request(rules: pluginRules()) + let files = [URL(fileURLWithPath: "/fixture/go.mod")] + for _ in 0..<10 { await model.load(input, files: files) } + #expect(await probe.calls == 1) + #expect(model.state == .ready) + #expect(model.summary(for: input)?.pluginIDs == [OfficialPluginCatalog.goPluginID.rawValue]) + model.reset() + #expect(model.summary(for: input) == nil) + #expect(model.state == .idle) + } + + @Test @MainActor func warmLoadsDoNotInvalidateTheVisibleSummaryAgain() async { + let model = LSPProjectUsageModel(scanner: { _, _ in LSPProjectUsageSummary() }) + let input = request(rules: []) + await model.load(input, files: []) + var states: [LSPProjectUsageModel.State] = [] + let observation = model.$state.dropFirst().sink { states.append($0) } + defer { observation.cancel(); model.reset() } + + // SwiftUI observes this publisher. Reopening a ready page must not + // schedule another render, but a different snapshot must still notify. + for _ in 0..<10 { await model.load(input, files: []) } + #expect(states.isEmpty) + await model.load(request(rules: []), files: []) + #expect(states == [.loading, .ready]) + } + + @Test @MainActor func changedSnapshotRulesAndWorkspaceInvalidateTheCache() async { + let probe = UsageScanProbe() + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let rule = LSPProjectUsageRequest.Rule(target: .provider("custom"), fileExtensions: ["first"]) + let initial = request(rules: [rule]) + await model.load(initial, files: [URL(fileURLWithPath: "/fixture/a.first")]) + #expect(model.summary(for: initial)?.providerIDs == ["custom"]) + let renamed = request(rules: [rule]) + await model.load(renamed, files: [URL(fileURLWithPath: "/fixture/a.second")]) + #expect(model.summary(for: renamed)?.providerIDs.isEmpty == true) + let newRules = request(snapshot: renamed.snapshotID!, rules: [ + .init(target: .provider("custom"), fileExtensions: ["second"]) + ]) + await model.load(newRules, files: [URL(fileURLWithPath: "/fixture/a.second")]) + #expect(model.summary(for: newRules)?.providerIDs == ["custom"]) + let otherWorkspace = LSPProjectUsageRequest(workspaceURL: URL(fileURLWithPath: "/other"), + snapshotID: newRules.snapshotID, rules: newRules.rules) + await model.load(otherWorkspace, files: []) + #expect(model.summary(for: otherWorkspace) == LSPProjectUsageSummary()) + #expect(await probe.calls == 4) + model.reset() + } + + @Test @MainActor func closingDuringScanCancelsWithoutPublishingAnEmptyResultAndCanRetry() async { + let entered = TestGate() + let release = TestGate() + let probe = UsageScanProbe(entered: entered, release: release) + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let input = request(rules: pluginRules()) + let task = Task { await model.load(input, files: [URL(fileURLWithPath: "/fixture/go.mod")]) } + defer { task.cancel(); release.open(); model.reset() } + #expect(await entered.waitUntilOpen()) + #expect(model.state == .loading) + task.cancel() + await task.value + #expect(model.summary(for: input) == nil) + #expect(model.state == .idle) + release.open() + await model.load(input, files: [URL(fileURLWithPath: "/fixture/go.mod")]) + #expect(model.summary(for: input)?.pluginIDs == [OfficialPluginCatalog.goPluginID.rawValue]) + #expect(await probe.calls == 2) + } + + @Test @MainActor func replacementFinishesBeforeAnUncooperativeOldScannerIsReleased() async { + let entered = TestGate() + let release = TestGate() + let returned = TestGate() + let firstFinished = TestGate() + // This independent, bounded wait deliberately does not inherit worker + // cancellation. Releasing it is cleanup, never a prerequisite for B. + let releaseWait = Task { await release.waitUntilOpen() } + let probe = UsageScanProbe(entered: entered, uncooperativeRelease: releaseWait, returned: returned) + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let old = request(rules: pluginRules()) + let newest = request(rules: pluginRules()) + let first = Task { + await model.load(old, files: [URL(fileURLWithPath: "/fixture/go.mod")]) + firstFinished.open() + } + defer { first.cancel(); release.open(); releaseWait.cancel(); model.reset() } + #expect(await entered.waitUntilOpen()) + await model.load(newest, files: [URL(fileURLWithPath: "/fixture/pom.xml")]) + #expect(await firstFinished.waitUntilOpen()) + #expect(!release.isOpen) + #expect(!returned.isOpen) + await first.value + #expect(model.summary(for: old) == nil) + #expect(model.summary(for: newest)?.pluginIDs == [OfficialPluginCatalog.javaPluginID.rawValue]) + #expect(await probe.maximumActive == 2) + release.open() + #expect(await releaseWait.value) + #expect(await returned.waitUntilOpen()) + await model.load(newest, files: []) + #expect(model.summary(for: old) == nil) + #expect(model.summary(for: newest)?.pluginIDs == [OfficialPluginCatalog.javaPluginID.rawValue]) + #expect(await probe.calls == 2) + } + + @Test @MainActor func cancellingTheLastConsumerReturnsBeforeScannerExitAndCanRetry() async { + let entered = TestGate() + let release = TestGate() + let finished = TestGate() + let releaseWait = Task { await release.waitUntilOpen() } + let probe = UsageScanProbe(entered: entered, uncooperativeRelease: releaseWait) + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let input = request(rules: pluginRules()) + let task = Task { + await model.load(input, files: [URL(fileURLWithPath: "/fixture/go.mod")]) + finished.open() + } + defer { task.cancel(); release.open(); releaseWait.cancel(); model.reset() } + #expect(await entered.waitUntilOpen()) + task.cancel() + #expect(await finished.waitUntilOpen()) + #expect(!release.isOpen) + #expect(model.state == .idle) + #expect(model.summary(for: input) == nil) + await model.load(input, files: [URL(fileURLWithPath: "/fixture/pom.xml")]) + #expect(model.summary(for: input)?.pluginIDs == [OfficialPluginCatalog.javaPluginID.rawValue]) + await task.value + release.open() + #expect(await releaseWait.value) + } + + @Test @MainActor func cancellingOneOfTwoConsumersKeepsTheirSharedScannerAlive() async { + let entered = TestGate() + let release = TestGate() + let secondStarted = TestGate() + let firstFinished = TestGate() + let secondFinished = TestGate() + let probe = UsageScanProbe(entered: entered, release: release) + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let input = request(rules: pluginRules()) + let files = [URL(fileURLWithPath: "/fixture/go.mod")] + let first = Task { await model.load(input, files: files); firstFinished.open() } + let second = Task { + secondStarted.open() + await model.load(input, files: files) + secondFinished.open() + } + defer { first.cancel(); second.cancel(); release.open(); model.reset() } + #expect(await entered.waitUntilOpen()) + #expect(await secondStarted.waitUntilOpen()) + first.cancel() + #expect(await firstFinished.waitUntilOpen()) + #expect(model.state == .loading) + #expect(!secondFinished.isOpen) + #expect(await probe.calls == 1) + release.open() + #expect(await secondFinished.waitUntilOpen()) + await first.value + await second.value + #expect(model.summary(for: input)?.pluginIDs == [OfficialPluginCatalog.goPluginID.rawValue]) + #expect(await probe.calls == 1) + } + + @Test @MainActor func resetReleasesConsumersAndTheModelWhileCancelledScannerDrains() async { + let entered = TestGate() + let release = TestGate() + let returned = TestGate() + let finished = TestGate() + let releaseWait = Task { await release.waitUntilOpen() } + let probe = UsageScanProbe(entered: entered, uncooperativeRelease: releaseWait, returned: returned) + var model: LSPProjectUsageModel? = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + weak var released = model + let input = request(rules: pluginRules()) + let task = Task { [weak model] in + await model?.load(input, files: [URL(fileURLWithPath: "/fixture/go.mod")]) + finished.open() + } + defer { task.cancel(); release.open(); releaseWait.cancel(); model?.reset() } + #expect(await entered.waitUntilOpen()) + model?.reset() + #expect(await finished.waitUntilOpen()) + await task.value + #expect(!release.isOpen) + #expect(model?.state == .idle) + #expect(model?.summary(for: input) == nil) + model = nil + #expect(released == nil) + release.open() + #expect(await releaseWait.value) + #expect(await returned.waitUntilOpen()) + } + + @Test @MainActor func workspaceResetRejectsAnInFlightResult() async { + let entered = TestGate() + let release = TestGate() + let probe = UsageScanProbe(entered: entered, release: release) + let model = LSPProjectUsageModel(scanner: { files, rules in await probe.scan(files, rules) }) + let input = request(rules: pluginRules()) + let task = Task { await model.load(input, files: [URL(fileURLWithPath: "/fixture/go.mod")]) } + defer { task.cancel(); release.open(); model.reset() } + #expect(await entered.waitUntilOpen()) + model.reset() + await task.value + #expect(model.summary(for: input) == nil) + #expect(model.state == .idle) + } + + private func provider(_ id: String, extensions: Set = [], names: Set = [], + prefixes: Set = []) -> LanguageProviderDescriptor { + LanguageProviderDescriptor(id: id, displayName: id, fileExtensions: extensions, fileNames: names, + fileNamePrefixes: prefixes, capabilities: [.languageServer], activationPolicy: .onDemand) + } + + private func request(snapshot: UUID = UUID(), rules: [LSPProjectUsageRequest.Rule]) -> LSPProjectUsageRequest { + LSPProjectUsageRequest(workspaceURL: URL(fileURLWithPath: "/fixture"), snapshotID: snapshot, rules: rules) + } + + private func pluginRules() -> [LSPProjectUsageRequest.Rule] { + OfficialPluginCatalog.entries.map { + .init(target: .plugin($0.id.rawValue), fileExtensions: Set($0.fileExtensions), fileNames: Set($0.projectFileNames)) + } + } + + private func expectedPluginIDs(_ files: [URL]) -> Set { + Set(OfficialPluginCatalog.entries.filter { entry in + files.contains { entry.fileExtensions.contains($0.pathExtension.lowercased()) + || entry.projectFileNames.contains($0.lastPathComponent.lowercased()) } + }.map { $0.id.rawValue }) + } +} + +private actor UsageScanProbe { + private(set) var calls = 0 + private(set) var maximumActive = 0 + private var active = 0 + let entered: TestGate? + let release: TestGate? + let uncooperativeRelease: Task? + let returned: TestGate? + + init(entered: TestGate? = nil, release: TestGate? = nil, + uncooperativeRelease: Task? = nil, returned: TestGate? = nil) { + self.entered = entered + self.release = release + self.uncooperativeRelease = uncooperativeRelease + self.returned = returned + } + + func scan(_ files: [URL], _ rules: [LSPProjectUsageRequest.Rule]) async -> LSPProjectUsageSummary? { + calls += 1 + active += 1 + maximumActive = max(maximumActive, active) + let isFirst = calls == 1 + defer { active -= 1; if isFirst { returned?.open() } } + if isFirst { + entered?.open() + if let uncooperativeRelease { #expect(await uncooperativeRelease.value) } + if let release { _ = await release.waitUntilOpen() } + } + // Ignore the worker's cancelled flag to exercise the model's stale guard. + var result = LSPProjectUsageSummary() + for rule in rules where files.contains(where: { file in + rule.fileExtensions.contains(file.pathExtension.lowercased()) + || rule.fileNames.contains(file.lastPathComponent.lowercased()) + || rule.fileNamePrefixes.contains { file.lastPathComponent.lowercased().hasPrefix($0) } + }) { + switch rule.target { + case .provider(let id): result.providerIDs.insert(id) + case .plugin(let id): result.pluginIDs.insert(id) + } + } + return result + } +} diff --git a/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift b/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift index 2b21279916..9e5c8a1311 100644 --- a/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift +++ b/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift @@ -6,6 +6,43 @@ import Testing @MainActor @Suite("Split pane collapse") struct LitheSplitPaneCollapseTests { + @Test("A declared page size avoids content-driven intrinsic sizing while panes still follow window bounds", + arguments: [LitheSplitAxis.horizontal, .vertical]) + func declaredPageSizePreservesPaneGeometry(axis: LitheSplitAxis) throws { + let surfaces = CollapseSurfaceRecorder() + let host = NSHostingView(rootView: LitheSplitPaneView( + axis: axis, placement: .leading, defaultSize: 220, minimum: 30, maximum: 400, + idealSize: CGSize(width: 900, height: 617), + sized: { CollapseSurface(name: "sidebar", recorder: surfaces) }, + flexible: { + CollapseSurface(name: "content", recorder: surfaces) + .frame(idealWidth: 1800, idealHeight: 2000) + })) + #expect(host.fittingSize == CGSize(width: 900, height: 617)) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 700), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.contentView = nil; window.close() } + for size in [CGSize(width: 1000, height: 700), CGSize(width: 1200, height: 800)] { + window.setContentSize(size) + host.layoutSubtreeIfNeeded() + let sidebar = try #require(surfaces.views["sidebar"]?.first) + let content = try #require(surfaces.views["content"]?.first) + if axis == .horizontal { + #expect(sidebar.bounds.width == 220) + #expect(content.bounds.width == size.width - 220 - SplitHandleView.thickness) + #expect(content.bounds.height == size.height) + } else { + #expect(sidebar.bounds.height == 220) + #expect(content.bounds.height == size.height - 220 - SplitHandleView.thickness) + #expect(content.bounds.width == size.width) + } + } + #expect(surfaces.views.values.allSatisfy { $0.count == 1 }) + } + @Test("Release holds the final size until the host returns its committed value", arguments: [LitheSplitAxis.horizontal, .vertical]) func releaseWaitsForCommittedSize(axis: LitheSplitAxis) async throws { diff --git a/macos/Tests/LitheTests/SettingsPageLatencyTraceTests.swift b/macos/Tests/LitheTests/SettingsPageLatencyTraceTests.swift new file mode 100644 index 0000000000..1880b6a416 --- /dev/null +++ b/macos/Tests/LitheTests/SettingsPageLatencyTraceTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import Lithe + +@Suite("Settings latency input correlation") +@MainActor +struct SettingsPageLatencyTraceTests { + @Test func mouseDownAndUpCanSelectOnlyOnce() { + var inputs = SettingsPageLatencyTrace.InputCorrelation() + #expect(inputs.receive(id: 7, input: 10, at: 20)?.id == 7) + #expect(inputs.take(eventID: 7, isMouse: true, at: 30)?.id == 7) + // The mouse-up is the same tagged click, not a second correlation. + #expect(inputs.receive(id: 7, input: 10, at: 40) == nil) + #expect(inputs.take(eventID: 7, isMouse: true, at: 50) == nil) + } + + @Test func keyboardNavigationCannotReuseAPreviousClick() { + var inputs = SettingsPageLatencyTrace.InputCorrelation() + _ = inputs.receive(id: 7, input: 10, at: 20) + #expect(inputs.take(eventID: 7, isMouse: false, at: 30) == nil) + #expect(inputs.take(eventID: 7, isMouse: true, at: 40) == nil) + } + + @Test func unrelatedUnmarkedAndExpiredInputsAreRejected() { + var inputs = SettingsPageLatencyTrace.InputCorrelation() + _ = inputs.receive(id: 7, input: 10, at: 20) + #expect(inputs.take(eventID: 8, isMouse: true, at: 30) == nil) + _ = inputs.receive(id: 8, input: 40, at: 50) + _ = inputs.receive(id: 0, input: 60, at: 70) + #expect(inputs.take(eventID: 8, isMouse: true, at: 80) == nil) + _ = inputs.receive(id: 9, input: 90, at: 100) + #expect(inputs.take(eventID: 9, isMouse: true, at: 1_000_000_100) == nil) + } +} diff --git a/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift b/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift index 428098ea64..f072506963 100644 --- a/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift +++ b/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheCoreContracts import LitheLanguageIntelligenceModule import LitheModuleAPI import Testing @@ -89,6 +90,44 @@ struct WorkspaceLanguageServerPreferencesTests { #expect(feature.isDisabled("java")) } + @Test + func runtimeResetKeepsVisibleProjectUsageButWorkspaceCloseClearsIt() async { + let feature = makeFeature(store: LanguagePreferencesTestStore()) + feature.reloadCatalog(for: workspace) + let input = feature.projectUsageRequest(workspaceURL: workspace, snapshotID: UUID()) + await feature.projectUsage.load(input, files: [workspace.appendingPathComponent("go.mod")]) + let original = feature.projectUsage.summary(for: input) + #expect(original?.pluginIDs.contains(OfficialPluginCatalog.goPluginID.rawValue) == true) + feature.resetWorkspaceState() + feature.toolConfigurationDidChange(providerID: "go") + #expect(feature.projectUsage.summary(for: input) == original) + #expect(feature.projectUsage.state == .ready) + feature.reloadCatalog(for: nil) + #expect(feature.projectUsage.summary(for: input) == nil) + #expect(feature.projectUsage.state == .idle) + } + + @Test + func projectUsageInputKeepsTheInventoryPairedWithItsCapturedSnapshot() async { + let feature = makeFeature(store: LanguagePreferencesTestStore()) + let root = FileNode(url: workspace, isDirectory: true, children: []) + let first = WorkspaceSnapshot(root: root, files: [workspace.appendingPathComponent("go.mod")]) + let captured = feature.projectUsageInput(workspaceURL: workspace, snapshot: first) + let newer = WorkspaceSnapshot(root: root, files: [workspace.appendingPathComponent("README")]) + let latest = feature.projectUsageInput(workspaceURL: workspace, snapshot: newer) + #expect(captured.request.snapshotID == first.id) + #expect(captured.files == first.files) + #expect(latest.request != captured.request) + #expect(latest.files == newer.files) + await feature.projectUsage.load(captured.request, files: captured.files) + #expect(feature.projectUsage.summary(for: captured.request)?.pluginIDs + .contains(OfficialPluginCatalog.goPluginID.rawValue) == true) + feature.projectUsage.reset() + let empty = feature.projectUsageInput(workspaceURL: nil, snapshot: nil) + #expect(empty.request.snapshotID == nil) + #expect(empty.files.isEmpty) + } + @Test func togglesPublishEvenWithoutARunningLanguageModule() { let feature = makeFeature(store: LanguagePreferencesTestStore()) diff --git a/shared/platform-feature-matrix/features/lsp-control-center-performance.json b/shared/platform-feature-matrix/features/lsp-control-center-performance.json new file mode 100644 index 0000000000..25eaf9f329 --- /dev/null +++ b/shared/platform-feature-matrix/features/lsp-control-center-performance.json @@ -0,0 +1,32 @@ +{ + "id": "lsp-control-center-performance", + "area": "语言支持", + "group": "语言服务", + "capability": "大型工作区的 LSP 设置页后台识别、快照缓存与可及时取消的页面等待", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift", + "macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift", + "macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "macos/Tests/LitheTests/LSPProjectUsageTests.swift", + "macos/Sources/Lithe/Views/Diagnostics/SettingsPageLatencyProbe.swift", + "macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift", + "macos/Tests/LitheTests/SettingsPageLatencyTraceTests.swift", + "macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift", + "macos/Tests/LitheTests/AppModelSettingsChangesTests.swift", + "macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift", + ".agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md" + ] + }, + "windows": { + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable", + "evidence": [ + "windows/tauri/src/features/editor/lsp" + ] + }, + "owner": "LanguageToolingFeatureModel / LSP 设置页", + "verification": "2026-10-28 Review 修复在本机 Swift6.4/Xcode27.1 上通过51项定向Swift测试、7个suite,逐项HTML/JUnit计时已生成;其中原生挂载测试最慢1.332秒(两种语言合计)。取消回归以有界gate证明:旧扫描忽略取消且未释放时,新请求及旧消费方已完成;共享消费方、最后一方取消、reset、模型释放与热缓存复用均通过。完整SettingsView保持同一宿主,README→Go→Java→README四份快照自动更新,settingsChanges保持0;不可变输入绑定同一快照ID和文件数组。真实中英文AX加载名称在相反父locale下通过。另在已打包现有翻译/字体/图标的独立原生测试宿主通过5项补充测试,导出16张加载/提示/空状态/缩放截图;宿主前后584个文件和链接条目及内容哈希不变。现有30万URL算法与132.45ms窗口更新p95数据来自原PR测量,本次未重测性能,不代表显示器物理呈现。原提交59ea6353的Swift6.3.3/Xcode26.6 CI已通过Swift测试及ARM64打包;本次修复尚无该固定工具链CI结果,100ms目标及macOS13运行兼容性仍待验证,保持pending。" +}