From 59ea6353ef79f1653c756d1f286ce480bc18e0e0 Mon Sep 17 00:00:00 2001 From: lin-coco <77844710+lin-coco@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:28:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(macos):=20#1550=20=E7=BC=93=E5=AD=98=20?= =?UTF-8?q?LSP=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E8=AF=AD=E8=A8=80=E6=91=98?= =?UTF-8?q?=E8=A6=81=E5=B9=B6=E5=87=8F=E5=B0=91=E4=B8=BB=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/LSPProjectUsage.swift | 200 ++++++++++++++++ .../LanguageToolingFeatureModel.swift | 19 ++ macos/Sources/Lithe/LitheApp.swift | 2 +- .../Services/Monitoring/LitheSignpost.swift | 13 ++ .../Lithe/Views/App/SettingsView.swift | 17 +- .../Views/Components/LitheSplitPaneView.swift | 12 +- .../SettingsPageLatencyProbe.swift | 171 ++++++++++++++ .../Views/Language/LSPControlCenterView.swift | 120 ++++++---- .../LitheTests/LSPProjectUsageTests.swift | 221 ++++++++++++++++++ .../LitheSplitPaneCollapseTests.swift | 37 +++ .../SettingsPageLatencyTraceTests.swift | 33 +++ ...kspaceLanguageServerPreferencesTests.swift | 17 ++ .../lsp-control-center-performance.json | 29 +++ 13 files changed, 846 insertions(+), 45 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift create mode 100644 macos/Sources/Lithe/Views/Diagnostics/SettingsPageLatencyProbe.swift create mode 100644 macos/Tests/LitheTests/LSPProjectUsageTests.swift create mode 100644 macos/Tests/LitheTests/SettingsPageLatencyTraceTests.swift create mode 100644 shared/platform-feature-matrix/features/lsp-control-center-performance.json diff --git a/macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift b/macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift new file mode 100644 index 0000000000..07e046d995 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/LSPProjectUsage.swift @@ -0,0 +1,200 @@ +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 = [] +} + +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 final class Work { + let id = UUID() + let request: LSPProjectUsageRequest + let task: Task + var consumers: Set = [] + + init(request: LSPProjectUsageRequest, task: Task) { + self.request = request + self.task = task + } + } + + 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 { + guard !Task.isCancelled else { return } + if summary(for: request) != nil { + // A request for a cached snapshot can supersede a different scan. + work?.task.cancel() + // @Published also emits for equal values. A warm page already has + // its summary, so avoid scheduling a second SwiftUI update. + if state != .ready { state = .ready } + return + } + let active: Work + if let current = work, current.request == request, !current.task.isCancelled { + active = current + } else { + let previous = work?.task + previous?.cancel() + let scanner = self.scanner + let task = Task.detached(priority: .utility) { + // Even on rapid changes there is only one scanner executing. + if let previous { _ = await previous.value } + guard !Task.isCancelled else { return nil as LSPProjectUsageSummary? } + return await scanner(files, request.rules) + } + active = Work(request: request, task: task) + work = active + } + let consumer = UUID() + let workID = active.id + let computation = active.task + active.consumers.insert(consumer) + state = .loading + let value = await withTaskCancellationHandler { + await computation.value + } onCancel: { [weak self] in + Task { @MainActor in self?.cancel(consumer: consumer, workID: workID) } + } + guard work?.id == active.id else { return } + active.consumers.remove(consumer) + guard !Task.isCancelled, !active.task.isCancelled, let value else { + if active.consumers.isEmpty { + work = nil + state = cached == nil ? .idle : .ready + } + return + } + cached = (request, value) + work = nil + state = .ready + } + + func reset() { + work?.task.cancel() + // Retain the cancelled task until it drains so a new workspace's worker + // can await it instead of overlapping with its last batch. + cached = nil + state = .idle + } + + private func cancel(consumer: UUID, workID: UUID) { + guard let work, work.id == workID else { return } + work.consumers.remove(consumer) + if work.consumers.isEmpty { + work.task.cancel() + state = cached == nil ? .idle : .ready + } + } + + deinit { work?.task.cancel() } +} diff --git a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift index dd859511f9..105dbf8de7 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,25 @@ 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 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 f8970e2894..973f4c5545 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 13d47d65c7..febf1e3442 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. @@ -59,6 +62,7 @@ struct LitheSplitPaneView: View { minimum: CGFloat, maximum: CGFloat, flexibleMinimum: CGFloat? = nil, + idealSize: CGSize? = nil, clipsSizedPane: Bool = false, isSizedPaneCollapsed: Bool = false, trackBackground: Color = .clear, @@ -74,6 +78,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 @@ -97,7 +102,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) @@ -201,12 +206,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..79b530fc45 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -3,23 +3,52 @@ import LitheModuleAPI struct LSPControlCenterView: View { @EnvironmentObject private var model: AppModel - @EnvironmentObject private var settings: AppSettings + var body: some View { + LSPControlCenterContent( + usageModel: model.languageToolingFeature.projectUsage, + request: model.languageToolingFeature.projectUsageRequest( + workspaceURL: model.workspaceURL, snapshotID: model.workspaceSnapshotID + ) + ) + } +} + +private struct LSPControlCenterContent: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings + @ObservedObject var usageModel: LSPProjectUsageModel + let request: LSPProjectUsageRequest 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("Loading…")) } if model.languageProviderCatalogSnapshot.isDegraded { degradedCatalogNotice @@ -33,6 +62,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(request, files: model.projectFiles) + } } private var projectSummary: some View { @@ -119,15 +158,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 +177,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 +224,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 +245,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/LSPProjectUsageTests.swift b/macos/Tests/LitheTests/LSPProjectUsageTests.swift new file mode 100644 index 0000000000..a3562dd4d2 --- /dev/null +++ b/macos/Tests/LitheTests/LSPProjectUsageTests.swift @@ -0,0 +1,221 @@ +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 replacedRequestCannotPublishItsLateResultOrOverlapScanners() 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 old = request(rules: pluginRules()) + let newest = request(rules: pluginRules()) + let first = Task { await model.load(old, files: [URL(fileURLWithPath: "/fixture/go.mod")]) } + defer { first.cancel(); release.open(); model.reset() } + #expect(await entered.waitUntilOpen()) + // Replacement cancels the first gate wait. The probe deliberately still + // returns a value so the publication guard, not just the scanner, is tested. + await model.load(newest, files: [URL(fileURLWithPath: "/fixture/pom.xml")]) + await first.value + #expect(model.summary(for: old) == nil) + #expect(model.summary(for: newest)?.pluginIDs == [OfficialPluginCatalog.javaPluginID.rawValue]) + #expect(await probe.maximumActive == 1) + } + + @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? + + init(entered: TestGate? = nil, release: TestGate? = nil) { + self.entered = entered + self.release = release + } + + func scan(_ files: [URL], _ rules: [LSPProjectUsageRequest.Rule]) async -> LSPProjectUsageSummary? { + calls += 1 + active += 1 + maximumActive = max(maximumActive, active) + defer { active -= 1 } + if calls == 1 { + entered?.open() + 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..7f8e2accc8 100644 --- a/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift +++ b/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift @@ -89,6 +89,23 @@ 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 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..6d4d4fe07e --- /dev/null +++ b/shared/platform-feature-matrix/features/lsp-control-center-performance.json @@ -0,0 +1,29 @@ +{ + "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" + ] + }, + "windows": { + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable", + "evidence": [ + "windows/tauri/src/features/editor/lsp" + ] + }, + "owner": "LanguageToolingFeatureModel / LSP 设置页", + "verification": "定向42项Swift测试、6个suite通过;30万URL算法基准耗时降低82.68%–92.83%,额外内存采样低于128MiB。2026-10-28本机Swift6.4/Xcode27.1的ARM64 Release辅助验收:300007个真实文件,已完成原生10次切页和10次窗口重开,热切页扫描入口0次、稳定CPU1.70%(前轮独立测量)。本轮按同一点击ID记录进程内阶段;20轮不查询界面的输入至窗口更新p95从154.61ms降至132.45ms、主队列可运行p95从155.13ms降至132.85ms。该里程碑不是物理呈现;先前183.82ms的AX总时间包含工具开销,不能直接归因于页面或通过相减分摊。100ms优化目标仍未达到;Swift6.3.3/Xcode26.6正式工具链验收仍未完成,保持pending。" +} From a5c45677cda08eb5cae58e4f332ec0b4a039d472 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 29 Oct 2026 04:02:40 -0700 Subject: [PATCH 2/2] docs: update LSP cancellation validation script paths --- .../bug-fix/2026-10-28-lsp-project-usage-cancellation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 0e2cb434c0..6503ea62c1 100644 --- 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 @@ -48,8 +48,8 @@ LSP 设置页离开或切换文件快照时,页面应立即结束等待,下 - `./.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/verify-service-boundaries.sh` -- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/architecture/verify-service-boundaries.sh` +- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` - 用有界 gate 暂停并忽略旧扫描取消,验证新请求和旧消费方都能在释放 gate 前结束。 原生挂载测试保持同一个 LSP 页面,依次应用新增、重命名和删除后的快照。