From 6709b9a92446cbde47402de620e72c201c46d5dd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 11 Sep 2026 14:58:56 +0800 Subject: [PATCH 1/5] fix(macos): bound Java editor highlighting and fold drawing to the viewport --- .../Lithe/Views/Editor/CodeEditorView.swift | 195 +++++++------ .../EditorSyntaxHighlightState.swift | 112 ++++++++ .../SyntaxHighlighter.swift | 11 +- .../LitheTests/EditorJavaViewportTests.swift | 269 ++++++++++++++++++ 4 files changed, 492 insertions(+), 95 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Editor/SyntaxHighlighting/EditorSyntaxHighlightState.swift create mode 100644 macos/Tests/LitheTests/EditorJavaViewportTests.swift diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 1ff7a32f0..89def3711 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -917,10 +917,11 @@ struct CodeEditorView: NSViewRepresentable { (textView as? CodeTextView)?.rebuildLineIndex() container.gutter?.refreshLineNumberLayout() textView.setSelectedRange(NSRange(location: min(selection.location, document.text.utf16.count), length: 0)) - context.coordinator.resetHighlightCache() + context.coordinator.resetHighlightCache(textChanged: true) context.coordinator.highlight() (textView as? CodeTextView)?.updateEditorDecorations() container.gutter?.needsDisplay = true + context.coordinator.scheduleFoldRefresh() textChanged = true } if appearanceChanged { @@ -991,7 +992,7 @@ struct CodeEditorView: NSViewRepresentable { private var documentChangeTask: Task? private var caretUpdateTask: Task? private var findStateUpdateTask: Task? - private var highlightedRanges = HighlightedRangeCache() + private var syntaxHighlightState = EditorSyntaxHighlightState() private var appliedFontSize: CGFloat? private var appliedTabWidth: Int? private var appliedLanguageFeatures: LanguageServerFeatureSet? @@ -1337,6 +1338,7 @@ struct CodeEditorView: NSViewRepresentable { } let findReplacedRange = pendingReplacedRange let findInsertedLength = pendingHighlightRange?.length ?? 0 + if pendingHighlightRange == nil { resetHighlightCache(textChanged: true) } highlight( in: pendingHighlightRange, replacedLength: pendingReplacedRange?.length @@ -1461,52 +1463,42 @@ struct CodeEditorView: NSViewRepresentable { guard let textView, let textStorage = textView.textStorage else { return } let fullRange = NSRange(location: 0, length: textStorage.length) let font = textView.font ?? LitheTheme.editorFont(size: 13) + let isJava = fileExtension.lowercased() == "java" if let editedRange { - highlightedRanges.applyEdit( + syntaxHighlightState.applyEdit( replacedRange: NSRange( location: editedRange.location, length: replacedLength ?? editedRange.length ), - replacementLength: editedRange.length - ) - let target = SyntaxHighlighter.targetRange( - for: editedRange, - in: textStorage.string as NSString, - limit: fullRange + replacementLength: editedRange.length, + isJava: isJava ) - SyntaxHighlighter.applyExact( - to: textStorage, - font: font, - fileName: fileName, - fileExtension: fileExtension, - isDark: isDarkAppearance, - range: target - ) - highlightedRanges.insert(target) - return } let visible = (textView as? CodeTextView)?.visibleCharacterRange() ?? NSRange(location: 0, length: min(8_192, textStorage.length)) + // Java edits invalidate the semantic snapshot for the whole view. + // Paint only the current viewport while fresh structure is pending. let target = SyntaxHighlighter.targetRange( - for: visible, + for: isJava ? visible : (editedRange ?? visible), in: textStorage.string as NSString, limit: fullRange ) - for range in highlightedRanges.uncoveredRanges(in: target) { - SyntaxHighlighter.applyExact( - to: textStorage, - font: font, - fileName: fileName, - fileExtension: fileExtension, - isDark: isDarkAppearance, - range: range + let ranges = (textView as? CodeTextView)?.unfoldedRanges(in: target) ?? [target] + for range in ranges { + syntaxHighlightState.apply( + to: textStorage, font: font, fileName: fileName, + fileExtension: fileExtension, isDark: isDarkAppearance, range: range, + force: editedRange != nil && !isJava ) - highlightedRanges.insert(range) } } - func resetHighlightCache() { - highlightedRanges.removeAll() + func resetHighlightCache(textChanged: Bool = false) { + if textChanged { + syntaxHighlightState.invalidateText() + } else { + syntaxHighlightState.invalidateAppearance() + } } func primeJavaImportFold(_ region: JavaFoldRegion) { @@ -1597,17 +1589,15 @@ struct CodeEditorView: NSViewRepresentable { let imports = foldRegions.first(where: { $0.kind == .imports }) { collapsedFoldIDs.insert(imports.id) } - if let textStorage = textView?.textStorage { - SyntaxHighlighter.applyJavaSemanticHighlights( - structure.syntaxHighlights, - to: textStorage, - isDark: isDarkAppearance - ) - } + syntaxHighlightState.replaceJavaHighlights( + structure.syntaxHighlights, documentLength: textView?.textStorage?.length ?? 0 + ) applyFoldState() } private func clearJavaStructure() { + syntaxHighlightState.invalidateText() + highlight() if !foldRegions.isEmpty || !collapsedFoldIDs.isEmpty || !implementationMarkers.isEmpty { foldRegions = [] collapsedFoldIDs = [] @@ -1640,6 +1630,7 @@ struct CodeEditorView: NSViewRepresentable { guard let document else { return } model?.resolveJavaNavigation(marker, in: document.url) } + highlight() scheduleEditorOverlayRelayout() } @@ -2140,6 +2131,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var trackingArea: NSTrackingArea? private var hoveredFoldID: String? private var lineIndex = TextLineIndex(source: "" as NSString) + private var collapsedRanges = HighlightedRangeCache() + private var foldAttributesNeedRefresh = true private var hasInputFocus = false private var caretVisible = true private var caretPresentationGeneration = 0 @@ -2300,16 +2293,30 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } func rebuildLineIndex() { + foldAttributesNeedRefresh = true lineIndex = TextLineIndex(source: string as NSString) } func applyLineIndexEdit(replacedRange: NSRange, replacement: String) { + // NSTextStorage adjusts temporary attributes through edits. Rebuild them + // if an edit can touch/shift a fold, even if parsing returns equal ranges. + if let last = collapsedRanges.ranges.last, replacedRange.location <= NSMaxRange(last) { + foldAttributesNeedRefresh = true + } if replacement.contains("\n") || replacement.contains("\r") || !lineIndex.applySingleLineEdit(replacedRange: replacedRange, insertedLength: (replacement as NSString).length) { - rebuildLineIndex() + lineIndex = TextLineIndex(source: string as NSString) } } + func isCharacterHiddenByFold(_ location: Int) -> Bool { + collapsedRanges.contains(location) + } + + func unfoldedRanges(in range: NSRange) -> [NSRange] { + collapsedRanges.uncoveredRanges(in: range) + } + func visibleCharacterRange() -> NSRange? { guard let layoutManager, let textContainer, @@ -2702,10 +2709,23 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { !collapsedIDs.contains(hoveredFoldID) { self.hoveredFoldID = nil } + let length = (string as NSString).length + let nextRanges = HighlightedRangeCache(ranges: regions.compactMap { region in + let range = region.hiddenRange + guard collapsedIDs.contains(region.id), range.location >= 0, + range.location <= length, range.length > 0, + range.length <= length - range.location else { return nil } + return range + }) + let geometryChanged = collapsedRanges.ranges != nextRanges.ranges + collapsedRanges = nextRanges + needsDisplay = true + guard geometryChanged || foldAttributesNeedRefresh else { return } + guard let layoutManager else { return } + foldAttributesNeedRefresh = false applyFoldAttributes() applyUnusedCodeFade() applyCollapsedFoldForeground() - guard let layoutManager else { return } let fullRange = NSRange(location: 0, length: string.utf16.count) layoutManager.invalidateLayout(forCharacterRange: fullRange, actualCharacterRange: nil) if let textContainer { @@ -2720,12 +2740,11 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { layoutManager.removeTemporaryAttribute(.font, forCharacterRange: fullRange) layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: fullRange) layoutManager.removeTemporaryAttribute(.paragraphStyle, forCharacterRange: fullRange) - for region in foldRegions where collapsedFoldIDs.contains(region.id) { - guard NSMaxRange(region.hiddenRange) <= fullRange.length else { continue } + for range in collapsedRanges.ranges { layoutManager.addTemporaryAttribute( .font, value: NSFont.monospacedSystemFont(ofSize: 0.1, weight: .regular), - forCharacterRange: region.hiddenRange + forCharacterRange: range ) let collapsedParagraph = NSMutableParagraphStyle() collapsedParagraph.minimumLineHeight = 0.1 @@ -2734,21 +2753,19 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { layoutManager.addTemporaryAttribute( .paragraphStyle, value: collapsedParagraph, - forCharacterRange: region.hiddenRange + forCharacterRange: range ) } } private func applyCollapsedFoldForeground() { guard let layoutManager else { return } - let fullLength = string.utf16.count - for region in foldRegions where collapsedFoldIDs.contains(region.id) { - guard region.hiddenRange.location >= 0, - NSMaxRange(region.hiddenRange) <= fullLength else { continue } + for range in collapsedRanges.ranges { + guard NSMaxRange(range) <= (string as NSString).length else { continue } layoutManager.addTemporaryAttribute( .foregroundColor, value: NSColor.clear, - forCharacterRange: region.hiddenRange + forCharacterRange: range ) } } @@ -2785,10 +2802,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { forGlyphRange glyphRange: NSRange ) -> Bool { let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - let isCollapsedLine = collapsedFoldIDs.contains(where: { id in - guard let region = foldRegions.first(where: { $0.id == id }) else { return false } - return NSLocationInRange(characterRange.location, region.hiddenRange) - }) + let isCollapsedLine = isCharacterHiddenByFold(characterRange.location) if isCollapsedLine { lineFragmentRect.pointee.size.height = 0 @@ -4129,6 +4143,7 @@ final class LineNumberGutterView: NSView { private var onUnstageGitLineChange: ((GitLineChangeMarker) -> Void)? private var onDiscardGitLineChange: ((GitLineChangeMarker) -> Void)? private var contextGitLineChange: GitLineChangeMarker? + private var foldsByStartLine: [Int: [JavaFoldRegion]] = [:] private var gutterLayout = EditorGutterLayout(lineNumberTextWidth: 0) private var editorGutterOriginX: CGFloat { @@ -4248,13 +4263,15 @@ final class LineNumberGutterView: NSView { onToggle: @escaping (JavaFoldRegion) -> Void ) { foldRegions = regions + foldsByStartLine = Dictionary(grouping: regions, by: \.startLine) + let availableIDs = Set(regions.map(\.id)) collapsedFoldIDs = collapsedIDs onToggleFold = onToggle foldIndicatorOpacities = foldIndicatorOpacities.filter { opacity in - regions.contains { $0.id == opacity.key } + availableIDs.contains(opacity.key) } if let hoveredFoldID, - !regions.contains(where: { $0.id == hoveredFoldID }) { + !availableIDs.contains(hoveredFoldID) { self.hoveredFoldID = nil } animateFoldIndicators() @@ -4493,11 +4510,11 @@ final class LineNumberGutterView: NSView { .reduce(0) { $1 == "\n" ? $0 + 1 : $0 } var lineNumber = firstLine + 1 let maxGlyph = min(NSMaxRange(glyphRange), layoutManager.numberOfGlyphs) - let hiddenLines = EditorFoldVisibility.hiddenLines( + let hiddenLines = codeTextView == nil ? EditorFoldVisibility.hiddenLines( in: text, regions: foldRegions, collapsedIDs: collapsedFoldIDs - ) + ) : [] while glyphIndex < maxGlyph { let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) @@ -4506,7 +4523,8 @@ final class LineNumberGutterView: NSView { let lineGlyphRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) let y = lineRect.minY + textView.textContainerOrigin.y - visibleRect.minY - let isCollapsedHiddenLine = hiddenLines.contains(lineNumber - 1) + let isCollapsedHiddenLine = codeTextView?.isCharacterHiddenByFold(lineRange.location) + ?? hiddenLines.contains(lineNumber - 1) if isCollapsedHiddenLine { let nextGlyph = NSMaxRange(lineGlyphRange) glyphIndex = nextGlyph > glyphIndex ? nextGlyph : glyphIndex + 1 @@ -4569,18 +4587,17 @@ final class LineNumberGutterView: NSView { drawGitLineChange(marker, y: y, height: lineRect.height) } drawLineNumber(lineNumber, y: y, height: lineRect.height) + // This loop has already excluded hidden and offscreen lines. Reuse + // its geometry instead of scanning all folds and their parents. + for region in foldsByStartLine[lineNumber - 1] ?? [] { + drawFoldIndicator(region, y: y, height: max(lineRect.height, 16)) + } let nextGlyph = NSMaxRange(lineGlyphRange) glyphIndex = nextGlyph > glyphIndex ? nextGlyph : glyphIndex + 1 lineNumber += 1 } - drawFoldIndicators( - in: foldRegions, - source: text, - visibleRect: visibleRect, - layoutManager: layoutManager - ) drawEditorDivider(in: dirtyRect) } @@ -4602,33 +4619,6 @@ final class LineNumberGutterView: NSView { ).fill() } - private func drawFoldIndicators( - in regions: [JavaFoldRegion], - source: NSString, - visibleRect: NSRect, - layoutManager: NSLayoutManager - ) { - guard !regions.isEmpty else { return } - for region in regions { - let isHiddenByParent = regions.contains { parent in - parent.id != region.id && - collapsedFoldIDs.contains(parent.id) && - region.startLine > parent.startLine && - region.startLine <= parent.endLine - } - guard !isHiddenByParent else { continue } - - let characterIndex = characterOffset(forLine: region.startLine, in: source) - guard characterIndex < source.length else { continue } - let glyphIndex = layoutManager.glyphIndexForCharacter(at: characterIndex) - guard glyphIndex < layoutManager.numberOfGlyphs else { continue } - let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) - let y = lineRect.minY + (textView?.textContainerOrigin.y ?? 0) - visibleRect.minY - guard y + lineRect.height >= 0, y <= bounds.height else { continue } - drawFoldIndicator(region, y: y, height: max(lineRect.height, 16)) - } - } - private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) { let label = String(number) as NSString let editorFont = textView?.font ?? LitheTheme.editorFont(size: 13) @@ -5027,7 +5017,7 @@ final class LineNumberGutterView: NSView { let source = textView.string as NSString let line = (textView as? CodeTextView)?.lineNumber(at: characterIndex, in: source) ?? source.substring(to: min(characterIndex, source.length)).reduce(0) { $1 == "\n" ? $0 + 1 : $0 } - return foldRegions.first(where: { $0.startLine == line }) + return foldsByStartLine[line]?.first } override func mouseDown(with event: NSEvent) { @@ -5671,6 +5661,27 @@ private final class CodeVisionLinkButton: NSButton { struct HighlightedRangeCache { private(set) var ranges: [NSRange] = [] + init(ranges: [NSRange] = []) { + for range in ranges.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) { + if let last = self.ranges.last, NSMaxRange(last) >= range.location { + self.ranges[self.ranges.count - 1] = NSUnionRange(last, range) + } else { + self.ranges.append(range) + } + } + } + + func contains(_ location: Int) -> Bool { + var lower = 0 + var upper = ranges.count + while lower < upper { + let middle = lower + (upper - lower) / 2 + if NSMaxRange(ranges[middle]) <= location { lower = middle + 1 } + else { upper = middle } + } + return lower < ranges.count && NSLocationInRange(location, ranges[lower]) + } + mutating func insert(_ range: NSRange) { guard range.length > 0 else { return } var merged = range diff --git a/macos/Sources/Lithe/Views/Editor/SyntaxHighlighting/EditorSyntaxHighlightState.swift b/macos/Sources/Lithe/Views/Editor/SyntaxHighlighting/EditorSyntaxHighlightState.swift new file mode 100644 index 000000000..b87a94a47 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/SyntaxHighlighting/EditorSyntaxHighlightState.swift @@ -0,0 +1,112 @@ +import AppKit + +/// Keeps lexical and Java semantic colors in the same viewport cache so a +/// lexical refresh cannot overwrite semantic colors without reapplying them. +struct EditorSyntaxHighlightState { + private var paintedRanges = HighlightedRangeCache() + private var javaHighlights = JavaSemanticHighlightIndex([], documentLength: 0) + + mutating func replaceJavaHighlights(_ highlights: [JavaSyntaxHighlight], documentLength: Int) { + javaHighlights = JavaSemanticHighlightIndex(highlights, documentLength: documentLength) + invalidateAppearance() + } + + mutating func invalidateText() { + javaHighlights = JavaSemanticHighlightIndex([], documentLength: 0) + invalidateAppearance() + } + + mutating func invalidateAppearance() { + paintedRanges.removeAll() + } + + mutating func applyEdit(replacedRange: NSRange, replacementLength: Int, isJava: Bool) { + if isJava { + // Even a same-length edit can change names and multiline lexical + // state outside the edited line. Never reuse tokens from that text. + invalidateText() + } else { + paintedRanges.applyEdit(replacedRange: replacedRange, replacementLength: replacementLength) + } + } + + mutating func apply( + to storage: NSTextStorage, + font: NSFont, + fileName: String? = nil, + fileExtension: String, + isDark: Bool, + range: NSRange, + force: Bool = false + ) { + let target = NSIntersectionRange(range, NSRange(location: 0, length: storage.length)) + for uncovered in force ? [target] : paintedRanges.uncoveredRanges(in: target) { + SyntaxHighlighter.applyExact( + to: storage, font: font, fileName: fileName, + fileExtension: fileExtension, isDark: isDark, range: uncovered + ) + SyntaxHighlighter.applyJavaSemanticHighlights( + javaHighlights.intersecting(uncovered), + to: storage, isDark: isDark, range: uncovered + ) + paintedRanges.insert(uncovered) + } + } +} + +/// A balanced interval index includes tokens starting before the viewport +/// (for example text blocks) without scanning every earlier token on scroll. +struct JavaSemanticHighlightIndex { + private struct Entry { + let highlight: JavaSyntaxHighlight + let ordinal: Int + var subtreeEnd: Int + } + + private var entries: [Entry] + + init(_ highlights: [JavaSyntaxHighlight], documentLength: Int) { + entries = highlights.enumerated().compactMap { ordinal, highlight in + let range = highlight.range + guard range.location >= 0, range.location <= documentLength, + range.length > 0, range.length <= documentLength - range.location else { return nil } + return Entry(highlight: highlight, ordinal: ordinal, subtreeEnd: NSMaxRange(range)) + }.sorted { + if $0.highlight.range.location != $1.highlight.range.location { + return $0.highlight.range.location < $1.highlight.range.location + } + return $0.ordinal < $1.ordinal + } + _ = buildSubtree(in: entries.indices) + } + + private mutating func buildSubtree(in bounds: Range) -> Int { + guard !bounds.isEmpty else { return 0 } + let middle = bounds.lowerBound + bounds.count / 2 + let leftEnd = buildSubtree(in: bounds.lowerBound.. [JavaSyntaxHighlight] { + guard range.length > 0 else { return [] } + var matches: [Entry] = [] + collect(in: entries.indices, intersecting: range, into: &matches) + // Preserve the producer's precedence when semantic spans overlap. + return matches.sorted { $0.ordinal < $1.ordinal }.map(\.highlight) + } + + private func collect(in bounds: Range, intersecting range: NSRange, into matches: inout [Entry]) { + guard !bounds.isEmpty else { return } + let middle = bounds.lowerBound + bounds.count / 2 + guard entries[middle].subtreeEnd > range.location, + entries[bounds.lowerBound].highlight.range.location < NSMaxRange(range) else { return } + collect(in: bounds.lowerBound.. 0 { + matches.append(entries[middle]) + } + collect(in: (middle + 1).. 0, !highlights.isEmpty else { return } let basePalette = CodeEditorPalette(isDark: isDark, theme: LitheTheme.activeTheme) @@ -17,11 +18,15 @@ enum SyntaxHighlighter { base: basePalette ) let fullRange = NSRange(location: 0, length: storage.length) + let target = NSIntersectionRange(targetRange ?? fullRange, fullRange) storage.beginEditing() for highlight in highlights { guard let role = SyntaxHighlightingColorRole(rawValue: highlight.role) else { continue } - let range = NSIntersectionRange(highlight.range, fullRange) - guard range.length == highlight.range.length else { continue } + guard highlight.range.location >= 0, highlight.range.location <= storage.length, + highlight.range.length > 0, + highlight.range.length <= storage.length - highlight.range.location else { continue } + let range = NSIntersectionRange(highlight.range, target) + guard range.length > 0 else { continue } storage.addAttribute(.foregroundColor, value: palette.color(for: role), range: range) } storage.endEditing() diff --git a/macos/Tests/LitheTests/EditorJavaViewportTests.swift b/macos/Tests/LitheTests/EditorJavaViewportTests.swift new file mode 100644 index 000000000..2bc114d07 --- /dev/null +++ b/macos/Tests/LitheTests/EditorJavaViewportTests.swift @@ -0,0 +1,269 @@ +import AppKit +import Testing +@testable import Lithe + +@MainActor +@Suite("Java editor viewport rendering") +struct EditorJavaViewportTests { + private let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + + @Test + func semanticIndexMatchesOverlappingSpansInProducerOrder() { + let highlights = (0..<2_000).reversed().map { offset in + JavaSyntaxHighlight( + range: NSRange(location: offset * 3, length: offset % 37 + 1), role: "field" + ) + } + [JavaSyntaxHighlight(range: NSRange(location: 0, length: 6_040), role: "comment")] + let index = JavaSemanticHighlightIndex(highlights, documentLength: 6_040) + for location in stride(from: 0, through: 6_040, by: 101) { + let range = NSRange(location: location, length: 23) + #expect(index.intersecting(range) == highlights.filter { + NSIntersectionRange($0.range, range).length > 0 + }) + } + #expect(index.intersecting(NSRange(location: 30, length: 0)).isEmpty) + } + + @Test + func semanticIndexRejectsInvalidUtf16Ranges() { + let valid = JavaSyntaxHighlight(range: NSRange(location: 2, length: 3), role: "field") + let index = JavaSemanticHighlightIndex([ + valid, + JavaSyntaxHighlight(range: NSRange(location: NSNotFound, length: 1), role: "field"), + JavaSyntaxHighlight(range: NSRange(location: -1, length: 2), role: "field"), + JavaSyntaxHighlight(range: NSRange(location: 4, length: 2), role: "field"), + JavaSyntaxHighlight(range: NSRange(location: 1, length: 0), role: "field") + ], documentLength: 5) + #expect(index.intersecting(NSRange(location: 0, length: 5)) == [valid]) + #expect(index.intersecting(NSRange(location: 5, length: 1)).isEmpty) + } + + @Test + func scrollingClipsMultilineSemanticColorsAndPreservesCachedColors() throws { + let storage = NSTextStorage(string: "😀first\nsecond\nthird") + let full = NSRange(location: 0, length: storage.length) + var state = EditorSyntaxHighlightState() + state.replaceJavaHighlights([ + JavaSyntaxHighlight(range: full, role: "comment") + ], documentLength: storage.length) + let top = NSRange(location: 0, length: 8) + paint(&state, storage, range: top) + let semanticColor = try #require(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor) + #expect(storage.attribute(.foregroundColor, at: 8, effectiveRange: nil) == nil) + + let middle = NSRange(location: 8, length: 7) + paint(&state, storage, range: middle) + #expect(storage.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == semanticColor) + #expect(storage.attribute(.foregroundColor, at: 15, effectiveRange: nil) == nil) + paint(&state, storage, range: top) + #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == semanticColor) + } + + @Test + func editsAndFailedAnalysisDiscardSemanticColorsWhenTheViewportIsRevisited() throws { + let storage = NSTextStorage(string: "first\nsecond") + let full = NSRange(location: 0, length: storage.length) + var state = EditorSyntaxHighlightState() + state.replaceJavaHighlights([ + JavaSyntaxHighlight(range: full, role: "comment") + ], documentLength: storage.length) + paint(&state, storage, range: full) + let comment = try #require(storage.attribute(.foregroundColor, at: 7, effectiveRange: nil) as? NSColor) + + // Same-length changes must invalidate semantic meaning, not just offsets. + storage.replaceCharacters(in: NSRange(location: 0, length: 1), with: "F") + state.applyEdit(replacedRange: NSRange(location: 0, length: 1), replacementLength: 1, isJava: true) + paint(&state, storage, range: NSRange(location: 0, length: 6)) + paint(&state, storage, range: NSRange(location: 6, length: 6)) + #expect(storage.attribute(.foregroundColor, at: 7, effectiveRange: nil) as? NSColor != comment) + + state.replaceJavaHighlights([ + JavaSyntaxHighlight(range: NSRange(location: 6, length: 6), role: "field") + ], documentLength: storage.length) + paint(&state, storage, range: full) + let field = try #require(storage.attribute(.foregroundColor, at: 7, effectiveRange: nil) as? NSColor) + state.invalidateText() // Programmatic replacement or failed analysis. + paint(&state, storage, range: full) + #expect(storage.attribute(.foregroundColor, at: 7, effectiveRange: nil) as? NSColor != field) + } + + @Test + func shiftedUtf16TokensAndAppearanceRefreshUseTheLatestSnapshot() throws { + let storage = NSTextStorage(string: "name") + var state = EditorSyntaxHighlightState() + state.replaceJavaHighlights([ + JavaSyntaxHighlight(range: NSRange(location: 0, length: 4), role: "field") + ], documentLength: storage.length) + paint(&state, storage, range: NSRange(location: 0, length: 4)) + storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: "😀 ") + state.applyEdit(replacedRange: NSRange(location: 0, length: 0), replacementLength: 3, isJava: true) + let full = NSRange(location: 0, length: storage.length) + state.replaceJavaHighlights([ + JavaSyntaxHighlight(range: NSRange(location: 3, length: 4), role: "field") + ], documentLength: storage.length) + paint(&state, storage, range: full) + state.invalidateAppearance() + state.apply(to: storage, font: font, fileExtension: "java", isDark: false, range: full) + let reference = NSTextStorage(string: storage.string) + SyntaxHighlighter.applyJavaSemanticHighlights([ + JavaSyntaxHighlight(range: NSRange(location: 3, length: 4), role: "field") + ], to: reference, isDark: false) + #expect(storage.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor + == reference.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor) + #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor + != reference.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor) + } + + @Test + func unchangedFoldGeometryDoesNotForceLayoutAfterAnEditBelowTheFold() throws { + let (view, layout) = makeTextView("import a;\nimport b;\nclass Demo {}\n") + let source = view.string as NSString + let fold = JavaFoldRegion(kind: .imports, startLine: 0, endLine: 1, + hiddenRange: source.range(of: "import b;\n")) + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + let edit = NSRange(location: source.length, length: 0) + view.textStorage?.replaceCharacters(in: edit, with: "\n") + view.applyLineIndexEdit(replacedRange: edit, replacement: "\n") + layout.containerLayoutRequests = 0 + let expanded = JavaFoldRegion(kind: .type, startLine: 2, endLine: 3, + hiddenRange: NSRange(location: source.length - 1, length: 1)) + view.updateFolds(regions: [expanded, fold], collapsedIDs: [fold.id], onToggle: { _ in }) + #expect(layout.containerLayoutRequests == 0) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: fold.hiddenRange.location, + effectiveRange: nil) as? NSColor == .clear) + view.updateFolds(regions: [expanded, fold], collapsedIDs: [], onToggle: { _ in }) + #expect(layout.containerLayoutRequests > 0) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: fold.hiddenRange.location, + effectiveRange: nil) == nil) + } + + @Test + func editsInsideFoldAndFullTextReplacementRefreshTemporaryAttributes() throws { + let (view, layout) = makeTextView("start\nhidden\nend") + let fold = JavaFoldRegion(kind: .block, startLine: 0, endLine: 2, + hiddenRange: NSRange(location: 6, length: 7)) + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + let edit = NSRange(location: 6, length: 1) + view.textStorage?.replaceCharacters(in: edit, with: "H") + view.applyLineIndexEdit(replacedRange: edit, replacement: "H") + layout.containerLayoutRequests = 0 + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + #expect(layout.containerLayoutRequests > 0) + view.string = "start\nHidden\nend" + view.rebuildLineIndex() + layout.containerLayoutRequests = 0 + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + #expect(layout.containerLayoutRequests > 0) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: 7, + effectiveRange: nil) as? NSColor == .clear) + } + + @Test + func nestedFoldsExcludeOnlyTheirHalfOpenHiddenRangesFromHighlighting() { + let (view, _) = makeTextView("start\nfirst\nsecond\nend") + let outer = JavaFoldRegion(kind: .type, startLine: 0, endLine: 3, + hiddenRange: NSRange(location: 6, length: 13)) + let inner = JavaFoldRegion(kind: .method, startLine: 1, endLine: 3, + hiddenRange: NSRange(location: 12, length: 7)) + view.updateFolds(regions: [inner, outer], collapsedIDs: [inner.id, outer.id], onToggle: { _ in }) + #expect(view.unfoldedRanges(in: NSRange(location: 0, length: 22)) == [ + NSRange(location: 0, length: 6), NSRange(location: 19, length: 3) + ]) + view.updateFolds(regions: [inner, outer], collapsedIDs: [inner.id], onToggle: { _ in }) + #expect(view.unfoldedRanges(in: NSRange(location: 0, length: 22)) == [ + NSRange(location: 0, length: 12), NSRange(location: 19, length: 3) + ]) + } + + @Test + func lexicalEditsRepaintPreviouslyCachedLines() throws { + let storage = NSTextStorage(string: "clas") + var state = EditorSyntaxHighlightState() + state.apply(to: storage, font: font, fileExtension: "swift", isDark: true, + range: NSRange(location: 0, length: 4)) + let before = try #require(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor) + storage.replaceCharacters(in: NSRange(location: 4, length: 0), with: "s") + state.applyEdit(replacedRange: NSRange(location: 4, length: 0), replacementLength: 1, isJava: false) + state.apply(to: storage, font: font, fileExtension: "swift", isDark: true, + range: NSRange(location: 0, length: 5), force: true) + #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor != before) + } + + @Test + func shiftingAnExistingFoldReplacesItsGeometryAndTemporaryAttributes() { + let (view, layout) = makeTextView("start\nhidden\nend") + let original = JavaFoldRegion(kind: .block, startLine: 0, endLine: 2, + hiddenRange: NSRange(location: 6, length: 7)) + view.updateFolds(regions: [original], collapsedIDs: [original.id], onToggle: { _ in }) + let edit = NSRange(location: 0, length: 0) + view.textStorage?.replaceCharacters(in: edit, with: "😀") + view.applyLineIndexEdit(replacedRange: edit, replacement: "😀") + let shifted = JavaFoldRegion(kind: .block, startLine: 0, endLine: 2, + hiddenRange: NSRange(location: 8, length: 7)) + view.updateFolds(regions: [shifted], collapsedIDs: [shifted.id], onToggle: { _ in }) + #expect(!view.isCharacterHiddenByFold(7)) + #expect(view.isCharacterHiddenByFold(8)) + #expect(!view.isCharacterHiddenByFold(15)) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: 8, + effectiveRange: nil) as? NSColor == .clear) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: 7, + effectiveRange: nil) == nil) + } + + @Test + func gutterDoesNotRequestOffscreenFoldGeometry() throws { + let source = String(repeating: "void method() {}\n", count: 1_000) + let (view, layout) = makeTextView(source) + let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 480, height: 160)) + scroll.documentView = view + let gutter = LineNumberGutterView(frame: NSRect(x: 0, y: 0, width: 80, height: 160)) + gutter.attach(textView: view, scrollView: scroll) + // No timer/window is needed: drive the native drawing entry synchronously. + defer { gutter.removeFromSuperview(); scroll.documentView = nil } + let folds = (0..<1_000).map { line in + JavaFoldRegion(kind: .method, startLine: line, endLine: line, + hiddenRange: NSRange(location: line * 17, length: 0)) + } + gutter.updateFoldRegions(folds, collapsedIDs: [], onToggle: { _ in }) + let container = try #require(view.textContainer) + layout.ensureLayout(for: container) + layout.requestedLineGlyphs = [] + let bitmap = NSImage(size: gutter.bounds.size) + bitmap.lockFocus() + defer { bitmap.unlockFocus() } + gutter.draw(gutter.bounds) + #expect(!layout.requestedLineGlyphs.isEmpty) + #expect(layout.requestedLineGlyphs.allSatisfy { $0 < 17 * 30 }) + } + + private func paint(_ state: inout EditorSyntaxHighlightState, _ storage: NSTextStorage, range: NSRange) { + state.apply(to: storage, font: font, fileExtension: "java", isDark: true, range: range) + } + + private func makeTextView(_ source: String) -> (CodeTextView, ViewportLayoutRecorder) { + let view = CodeTextView(frame: NSRect(x: 0, y: 0, width: 480, height: 20_000)) + let layout = ViewportLayoutRecorder() + view.textContainer?.replaceLayoutManager(layout) + layout.delegate = view + view.font = font + view.string = source + view.rebuildLineIndex() + return (view, layout) + } +} + +@MainActor +private final class ViewportLayoutRecorder: NSLayoutManager { + var containerLayoutRequests = 0 + var requestedLineGlyphs: [Int] = [] + + override func ensureLayout(for container: NSTextContainer) { + containerLayoutRequests += 1 + super.ensureLayout(for: container) + } + + override func lineFragmentRect(forGlyphAt glyphIndex: Int, effectiveRange: NSRangePointer?) -> NSRect { + requestedLineGlyphs.append(glyphIndex) + return super.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: effectiveRange) + } +} From 28c09160b6bc13335d5a0ba003dd20de044a5801 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 11 Sep 2026 15:03:26 +0800 Subject: [PATCH 2/5] docs: archive initial Java editor performance investigation for issue 561 --- .../issue-561/01-native-diagnostic.md | 67 ++++ .../issue-561/02-product-reproduction.md | 85 +++++ docs/performance/issue-561/README.md | 21 ++ .../issue-561/native-metadata.json | 34 ++ .../performance/issue-561/native-results.json | 302 ++++++++++++++++++ .../product-reproduction-summary.json | 209 ++++++++++++ 6 files changed, 718 insertions(+) create mode 100644 docs/performance/issue-561/01-native-diagnostic.md create mode 100644 docs/performance/issue-561/02-product-reproduction.md create mode 100644 docs/performance/issue-561/README.md create mode 100644 docs/performance/issue-561/native-metadata.json create mode 100644 docs/performance/issue-561/native-results.json create mode 100644 docs/performance/issue-561/product-reproduction-summary.json diff --git a/docs/performance/issue-561/01-native-diagnostic.md b/docs/performance/issue-561/01-native-diagnostic.md new file mode 100644 index 000000000..65614feed --- /dev/null +++ b/docs/performance/issue-561/01-native-diagnostic.md @@ -0,0 +1,67 @@ +# Issue 561: isolated Java text diagnostic + +> 归档说明:本报告记录 2026-09-11 正式修复之前的第一阶段原生文本操作诊断。正文保留当时的结论、数据和限制;完整应用复现见[第二阶段报告](02-product-reproduction.md),正式修复及后续复测见 [PR #621](https://github.com/1lck/Lithe-IDEA/pull/621)。本文的约 266 KiB 探针文件与后续 447 KiB 完整产品文件规模不同,计时不可混用。 +> +> 原始计时已随文提交为 [native-results.json](native-results.json),环境和 Rust 调用记录见 [native-metadata.json](native-metadata.json)。文末命令是当时的执行记录,依赖本地 `.artifacts/issue-561/` 中的探针和输入文件;本目录归档报告与数据,不包含这些可执行诊断材料。 + +## Scope + +No product source was changed. No IDE window or LSP was started. This is an isolated native text-operation probe, not an end-to-end reproduction of the user report. + +Fixture: 8329 lines, 271949 UTF-8 bytes (265.6 KiB), 142 methods, 164 imports, 69 injected fields. The fixture has 1990 fold regions and 50701 classified tokens; its structure may differ substantially from the unavailable user file. + +LargeService.java and LargeService.txt contain identical bytes. The native probe loads the Java fixture and does not test extension routing. + +Host: macOS 27.0 / arm64. Rust library: existing release binary, modified 2026-09-07 09:13:10; no fresh product build. The inspected Java source files predate this binary, but embedded-source provenance has not been independently verified. + +## Measurements + +Each row is a median of five measured repetitions after one warmup. The attributed-string setup and initial viewport layout are excluded. Units: milliseconds. + +| Colored tokens | Layout requested | Tokens | Attribute mutation | Subsequent layout | Total | +|---|---|---:|---:|---:|---:| +| none | visible | 0 | 0.000 | 0.015 | 0.016 | +| none | full | 0 | 0.000 | 75.202 | 75.203 | +| visible | visible | 1347 | 0.375 | 6.290 | 6.685 | +| visible | full | 1347 | 0.398 | 87.341 | 87.711 | +| full | visible | 50701 | 14.591 | 9.227 | 23.931 | +| full | full | 50701 | 14.676 | 246.190 | 261.200 | + +The separate Rust java.structure C ABI call took 948.395 ms median, including response serialization and copying but excluding Python JSON decoding. This call runs in a background task in the product and must not be added directly to main-thread blocking time. + +## Interpretation + +Full-document layout after full-document token coloring was the largest measured native step. Keeping only viewport layout reduced this operation-level cost substantially; restricting token coloring reduced it further. These results justify collecting a product main-thread profile around applyJavaStructure/updateFolds. They do not prove that this is the sole cause of the reported 2–7 FPS, or exclude LSP and other product paths. + +## Limitations + +- NativeTextProbe reproduces the NSLayoutManager fold delegate and NSTextStorage foreground-attribute loop, not the complete CodeTextView or SwiftUI view tree. +- System monospaced font and simplified five-color palette; product theme/font costs are not included. +- Viewport token filtering, setup, initial viewport layout, and JSON decoding are excluded from reported native timing. +- One warmup and five measured repetitions per condition; these are diagnostic medians, not performance assertions. +- No actual typing, scrolling, or product FPS was measured. + +## Product verification matrix + +Use the same Java file and initial viewport; hold theme, font, window size, import folding and Code Vision constant. Use a Release build, separate cold-open and warm-open runs, then check a single-character edit and continuous scrolling. + +| Condition | LSP | Semantic coloring | Fold layout | What it isolates | +|---|---|---|---|---| +| A | On | Current | Current | Product baseline | +| B | Off | Current | Current | LSP contribution | +| C | Off | Viewport only | Current | Coloring contribution | +| D | Off | Current | Skip unchanged/full invalidation | Layout contribution | +| E | Off | Viewport only | Skip unchanged/full invalidation | Combined local change | + +Add timing spans for structure request/response, applyJavaSemanticHighlights, updateFolds, and editor input. Sample the main thread while the hitch occurs. Record ranges processed, token counts, layout refresh count, and input-to-visible-update latency. A matching hot stack plus repeatable improvement in the single-variable condition confirms attribution. Re-enable LSP before accepting a fix. + +## Rerun native probe + +```sh +swiftc -O .artifacts/issue-561/NativeTextProbe.swift -o .artifacts/issue-561/native-text-probe +.artifacts/issue-561/native-text-probe .artifacts/issue-561 +``` + +The original compiler and native execution were supervised with 60-second and 90-second subprocess deadlines. The optional run-core-probe.py accepts an explicit dylib path and enforces a 30-second parent-owned deadline. + +Raw native timings: native-results.json. Core times and provenance: metadata.json. Fixture and decoded Rust response are retained in the same directory. diff --git a/docs/performance/issue-561/02-product-reproduction.md b/docs/performance/issue-561/02-product-reproduction.md new file mode 100644 index 000000000..5a022b981 --- /dev/null +++ b/docs/performance/issue-561/02-product-reproduction.md @@ -0,0 +1,85 @@ +# Issue #561:完整 macOS 产品性能验证 + +> 归档说明:本报告记录 2026-09-11 正式修复之前的完整 macOS 应用复现。正文中的“当前行为”“未交付正式产品修复”和 31 个回归测试均指这一历史阶段;本报告的三项优化是定位瓶颈的实验,不等同于后来提交的正式实现。正式修复和后续复测见 [PR #621](https://github.com/1lck/Lithe-IDEA/pull/621)。 +> +> 各轮统计已随文提交为 [product-reproduction-summary.json](product-reproduction-summary.json)。本次归档保留原有结论和全部实验数据,并把本机源码链接替换为经过行号核对的固定版本链接;该固定版本的对应代码与当时工作树一致。文中其余日志、探针和缓存清单记录的是当时的本地归档,并非本目录全部收录的文件。 + +## 结论 + +在本次合成文件复现中,持续卡顿的主要热点是行号栏的折叠箭头绘制。主线程采样、直接计时和保留全文语义着色的单变量对照相互印证。全文语义染色后反复强制全文排版是另一个独立的打开/编辑停顿来源。 + +本次只做验证,未交付正式产品修复。所有临时探针和行为开关已经从产品源码撤回,原文件(包括任务开始前已有的用户改动)已按 SHA-256 验证逐字节恢复。 + +## 实验环境 + +- 日期:2026-09-11;macOS 27.0,Apple Silicon。 +- 当前工作树构建的完整 Release 产品;HEAD:6a1ae4d43f3e。工作树含任务开始前已有改动。 +- 使用本机 Swift 6.3.3。仓库要求的 Swift 6.2 工具链符号链接失效,本次没有安装/切换工具链。 +- 新构建并链接了 Rust Core,使用实际 CodeEditorView、CodeTextView、NSTextStorage、NSLayoutManager、行号栏和 SwiftUI 工作台。 +- 实际打包 .app,并复用已有捆绑 JDK/JDT LS;语言服务器开启组保留 JDT LS 初始化日志和 Java 子进程证据。 +- 固定合成 Java 文件:8329 行、457728 字节(447 KiB)、164 个 import、142 个方法、69 个注入字段,约 50701 个语义 token、1990 个折叠区域。 +- 固定默认主题、字体、窗口尺寸、默认 import 折叠和开启的 Code Vision 配置。没有修改用户真实项目文件。 + +## 主结果 + +表中多轮结果取各轮中位数的中位数;每轮独立启动进程并使用新的测试工作区。关闭 LSP 的主比较使用第二版同一探针二进制,排除了首次 UI 观察尝试的探索轮。 + +| 条件 | 轮数 | 结构结果应用中位数 | 滚动时箭头绘制中位数 | 26 次滚动序列耗时 | +|---|---:|---:|---:|---:| +| 当前行为,LSP 关闭 | 2 | 808.9 ms | 957.116 ms | 22.228 s | +| 仅优化折叠箭头遍历,LSP 关闭 | 2 | 752.9 ms | 0.881 ms | 0.553 s | +| 三项实验优化一起启用,LSP 关闭 | 2 | 9.9 ms | 0.916 ms | 0.551 s | +| 当前行为,LSP 开启 | 1 | 920.7 ms | 992.752 ms | 24.917 s | +| 仅优化折叠箭头遍历,LSP 开启 | 1 | 957.3 ms | 1.044 ms | 0.721 s | + +结构结果应用包括语义着色和 applyFoldState;其中不包含异步 Rust 分析等待时间。每轮实际经历打开、三处单字符插入、26 次滚动。滚动以 16 ms 的主线程定时器产生相同刺激,因此上表反映完成同一操作序列的耗时,不能换算成产品实际渲染 FPS。 + +## 如何定位到折叠箭头 + +1. 最初三个探索组分别保留当前行为、仅限制语义着色范围、仅跳过未变化的折叠几何刷新。三者的滚动序列分别耗时约 22.7、23.3、20.2 秒,后两者仍有持续卡顿。 +2. 编辑期间采样主线程,后两个探索组中 drawFoldIndicators 调用栈分别占 2559/4007(63.9%)、3302/4107(80.4%)样本;大量下层调用涉及字符串构造/分配。 +3. 代码在每次绘制中,对每个折叠区域再次 regions.contains 遍历全部区域,判断父折叠。视口范围判断在这之后。1990 个区域对应最坏约 396 万次父区域检查,每次还会计算字符串形式的 id。 +4. 单变量实验仅先筛出 collapsedFoldIDs 中的候选父区域,再使用原有的父区域判断;保留全文语义着色和原有全文排版。当前文件默认只折叠 import,于是内层由约 1990 个候选降为 1 个。 +5. 两轮关闭 LSP 对照均从约 22 秒降至约 0.55 秒。启用并完成初始化的 JDT LS 对照也从约 24.9 秒降至约 0.72 秒。 + +定位:[CodeEditorView.swift:4605](https://github.com/1lck/Lithe-IDEA/blob/481da5e8b55102b3d3e2cd64e1850d072053a7eb/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift#L4605),`LineNumberGutterView.drawFoldIndicators`。 +ID 构造:[JavaNavigationModels.swift:86](https://github.com/1lck/Lithe-IDEA/blob/481da5e8b55102b3d3e2cd64e1850d072053a7eb/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift#L86)。 + +## 次要但独立的停顿 + +仅优化箭头后,结构结果应用仍约 0.75 秒。updateFolds 在折叠几何没有改变时依然使全文布局失效,再同步 ensureLayout 整个文本容器。将语义着色限制到视口,并跳过未变化的折叠几何刷新,再配合箭头遍历优化后,结果应用降至约 10 ms。 + +定位:[CodeEditorView.swift:2693](https://github.com/1lck/Lithe-IDEA/blob/481da5e8b55102b3d3e2cd64e1850d072053a7eb/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift#L2693)。 + +## 限制 + +- 没有获得 issue 中的原始业务源码。合成文件对齐行数和字节数,但控制流、折叠密度、依赖完整性和语义复杂度可能不同;这些结果确认了本地热点,不能证明原报告只有这些根因。 +- 原报告为 macOS 15.5 / Lithe 0.4.3,本次为 macOS 27 / 当前工作树。此前已确认所分析的关键路径在 v0.4.3 中存在。 +- 没有搭建报告中的 3564 文件、9 个 Maven 模块工程;LSP 开启组是实际单文件 Java 工作区,不代表大型 Maven 索引负载。 +- 未单独完成同一应用会话内关闭标签页再重新打开的暖打开对照;重复启动轮不能替代该场景。 +- 操作由进程内诊断驱动调用实际 AppKit 编辑和滚动入口;没有声称测到了物理键盘/触控板到屏幕的端到端延迟。 +- 视口语义着色实验仅筛选当前视口 token,未实现完整的滚动补色、缓存失效和跨行语义处理,因此不能直接作为正式修复提交。 +- 第一版探针的 heartbeat 输出没有保留阶段标签,仅使用其整体阻塞证据;主结果使用第二版同一二进制。 +- 第一次探索轮尝试通过辅助功能观察窗口超时;该轮不进入第二版的主要对照汇总。 + +## 原始证据和复现材料 + +- `summary.json`:每轮统计。 +- 各运行目录的 `events.jsonl`:直接计时、阶段和操作记录;`sample.txt`:启动阶段采样;`active-sample.txt`:编辑阶段采样。 +- `processes.txt`、`status.json`:测试进程及完成记录;JDT LS 开启组的 Java 进程已被观测。 +- `*.jdtls.log`:两个测试工作区的 JDT LS 初始化日志副本。 +- `instrumentation-v2.patch`、`JavaEditorPerformanceProbe.swift`:临时诊断改动,已从产品源码撤回。 +- `run-product.py`:打包和逐轮运行监督器,每轮 90 秒单调时钟截止时间,并清理进程树和采样子进程。 +- `originals.json`、`restoration.json`:原文件指纹及恢复验证。 +- `workspace/LargeService.java`:可复用合成文件;`build.log` 和 `build-v2.log`:两次成功的 Release 构建。 + +## 收尾验证 + +- 两次完整 macOS Release 构建成功。 +- 临时改动期间测试稳定性静态检查、服务边界检查及 git diff --check 通过。 +- 原源码已恢复,临时 .app 和两个本次创建的 JDT LS 缓存目录已删除。 +- 源码恢复后,通过计时 harness 运行相关编辑器回归测试:31 个测试、4 个套件全部通过,测试执行共 0.379 秒。逐项耗时和 HTML/JUnit 已生成。 +- 回归命令:`./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/issue-561-macos-swift.json -- --filter 'EditorSyntaxHighlighterTests|EditorCaretGeometryTests|TextViewportLayoutTests'`。 +- 最终服务边界检查和 `git diff --check` 再次通过。 + +最慢的三个相关测试:yamlIncrementalHighlightingUsesCachedLineStateNearDocumentEnd() 106 ms;xmlIncrementalHighlightingUsesCachedLineStateNearDocumentEnd() 98 ms;caretAfterLastCharacterSitsPastTheFinalGlyphWhenFileHasNoTrailingNewline() 75 ms。所有测试均低于 1000 ms 警告阈值。 diff --git a/docs/performance/issue-561/README.md b/docs/performance/issue-561/README.md new file mode 100644 index 000000000..01f08319d --- /dev/null +++ b/docs/performance/issue-561/README.md @@ -0,0 +1,21 @@ +# Issue #561:初始诊断与完整应用复现报告 + +关联:[原始问题 #561](https://github.com/1lck/Lithe-IDEA/issues/561) · [修复 PR #621](https://github.com/1lck/Lithe-IDEA/pull/621) + +这里归档正式修复前的两份完整报告,以及支撑报告数值的测量数据。它们保留当时的结论、实验条件和限制,用于说明如何从“是否只染色可见区域”的疑问,逐步定位到 Java 全文语义着色、重复全文排版和折叠箭头的二次遍历。 + +## 阅读顺序 + +| 阶段 | 报告 | 测量范围 | 对应数据 | +| --- | --- | --- | --- | +| 1:最初的原生操作诊断 | [01-native-diagnostic.md](01-native-diagnostic.md) | 8329 行、271949 字节;独立 AppKit 文本操作,无完整 IDE、无 LSP | [原始 30 次测量](native-results.json)、[环境与 Rust 调用记录](native-metadata.json) | +| 2:完整产品复现与单变量验证 | [02-product-reproduction.md](02-product-reproduction.md) | 8329 行、457728 字节;完整 Release 应用,打开、三次编辑及 26 次滚动,包含真实 JDT LS 对照 | [全部 11 轮统计](product-reproduction-summary.json) | +| 3:正式修复与复测 | [PR #621 的正式复测、回归和验证边界](https://github.com/1lck/Lithe-IDEA/pull/621) | 正式视口缓存、折叠布局复用和可见行绘制实现;含超时对照及语义颜色校验 | PR 说明中的结果表及验证记录 | + +## 归档口径 + +- 两份报告沿用原始正文,仅增加历史阶段说明;第二份报告将本机源码链接替换为已核对的固定版本链接。 +- `native-results.json` 和 `product-reproduction-summary.json` 与初始诊断归档逐字节一致。`native-metadata.json` 的 `binary` 字段将本机绝对路径改为仓库相对路径,其余数据不变。 +- 第一阶段原生操作计时不能解释为产品交互延迟;第二阶段的实验性视口过滤也不能解释为正式修复已经完整处理了滚动补色。后续修复结果单独记录在 PR 中。 +- 第二阶段 JSON 保留探索轮和主对照轮。报告的主比较采用第二版探针的两轮 `baseline-lsp0-2/3`、两轮 `gutter-lsp0-1/2`、两轮 `all-lsp0-1/2`,以及各一轮开启 LSP 的对照;没有把第一版探索轮混入同一主统计组。JSON 中的 `null` 表示未采集到该项数据,不表示零耗时。 +- 本次提交的是报告和支撑数值的数据文件。原报告列出的完整本地诊断目录还包含运行探针、输入文件、JSONL、进程采样和日志;二进制、应用包、缓存和这些本地运行材料未随文提交。 diff --git a/docs/performance/issue-561/native-metadata.json b/docs/performance/issue-561/native-metadata.json new file mode 100644 index 000000000..aa51cea4e --- /dev/null +++ b/docs/performance/issue-561/native-metadata.json @@ -0,0 +1,34 @@ +{ + "lines": 8329, + "utf8Bytes": 271949, + "methods": 142, + "imports": 164, + "injectedFields": 69, + "tokens": 50701, + "folds": 1990, + "structureMs": [ + 945.9102919936413, + 954.8951250035316, + 945.8428340003593, + 948.3947919943603, + 951.0688330046833 + ], + "structureMedianMs": 948.3947919943603, + "binary": "rust/target/macos/aarch64-apple-darwin/release/liblithe_core.dylib", + "binaryModified": "2026-09-07 09:13:10", + "limitations": [ + "Synthetic source; no user file available.", + "Existing release Rust binary from 2026-09-07; no fresh application build.", + "No LSP or full IDE launched." + ], + "sourceSHA256": "16361866a35c82e981440d1d4ae2413da3dcce0b210107c837fa952491525897", + "hostOS": "27.0", + "hostMachine": "arm64", + "nativeProbeLimitations": [ + "NativeTextProbe reproduces the NSLayoutManager fold delegate and NSTextStorage foreground-attribute loop, not the complete CodeTextView or SwiftUI view tree.", + "System monospaced font and simplified five-color palette; product theme/font costs are not included.", + "Viewport token filtering, setup, initial viewport layout, and JSON decoding are excluded from reported native timing.", + "One warmup and five measured repetitions per condition; these are diagnostic medians, not performance assertions.", + "No actual typing, scrolling, or product FPS was measured." + ] +} diff --git a/docs/performance/issue-561/native-results.json b/docs/performance/issue-561/native-results.json new file mode 100644 index 000000000..4ca447c94 --- /dev/null +++ b/docs/performance/issue-561/native-results.json @@ -0,0 +1,302 @@ +[ + { + "coloredTokens" : 0, + "colorMs" : 0.00095799999999999998, + "colorScope" : "none", + "iteration" : 1, + "layoutMs" : 0.015583, + "layoutScope" : "visible", + "totalMs" : 0.016541, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.001083, + "colorScope" : "none", + "iteration" : 2, + "layoutMs" : 0.014999999999999999, + "layoutScope" : "visible", + "totalMs" : 0.016083, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00033300000000000002, + "colorScope" : "none", + "iteration" : 3, + "layoutMs" : 0.014833000000000001, + "layoutScope" : "visible", + "totalMs" : 0.015166000000000001, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00020799999999999999, + "colorScope" : "none", + "iteration" : 4, + "layoutMs" : 0.013667, + "layoutScope" : "visible", + "totalMs" : 0.013875, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00029100000000000003, + "colorScope" : "none", + "iteration" : 5, + "layoutMs" : 0.015834000000000001, + "layoutScope" : "visible", + "totalMs" : 0.016125, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00041599999999999997, + "colorScope" : "none", + "iteration" : 1, + "layoutMs" : 75.475958000000006, + "layoutScope" : "full", + "totalMs" : 75.476374000000007, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00025000000000000001, + "colorScope" : "none", + "iteration" : 2, + "layoutMs" : 75.558207999999993, + "layoutScope" : "full", + "totalMs" : 75.558457999999987, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00025000000000000001, + "colorScope" : "none", + "iteration" : 3, + "layoutMs" : 75.135666000000001, + "layoutScope" : "full", + "totalMs" : 75.135915999999995, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00045800000000000002, + "colorScope" : "none", + "iteration" : 4, + "layoutMs" : 75.202207999999999, + "layoutScope" : "full", + "totalMs" : 75.202665999999994, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 0, + "colorMs" : 0.00025000000000000001, + "colorScope" : "none", + "iteration" : 5, + "layoutMs" : 74.316458999999995, + "layoutScope" : "full", + "totalMs" : 74.316708999999989, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.376917, + "colorScope" : "visible", + "iteration" : 1, + "layoutMs" : 6.2760420000000003, + "layoutScope" : "visible", + "totalMs" : 6.6529590000000001, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.37458399999999997, + "colorScope" : "visible", + "iteration" : 2, + "layoutMs" : 6.5937919999999997, + "layoutScope" : "visible", + "totalMs" : 6.9683759999999992, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.36762499999999998, + "colorScope" : "visible", + "iteration" : 3, + "layoutMs" : 6.2495000000000003, + "layoutScope" : "visible", + "totalMs" : 6.6171250000000006, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.39466699999999999, + "colorScope" : "visible", + "iteration" : 4, + "layoutMs" : 6.2898750000000003, + "layoutScope" : "visible", + "totalMs" : 6.6845420000000004, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.36499999999999999, + "colorScope" : "visible", + "iteration" : 5, + "layoutMs" : 6.5425420000000001, + "layoutScope" : "visible", + "totalMs" : 6.9075420000000003, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.37054100000000001, + "colorScope" : "visible", + "iteration" : 1, + "layoutMs" : 87.340917000000005, + "layoutScope" : "full", + "totalMs" : 87.711458000000007, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.40204200000000001, + "colorScope" : "visible", + "iteration" : 2, + "layoutMs" : 84.207791, + "layoutScope" : "full", + "totalMs" : 84.609832999999995, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.36670900000000001, + "colorScope" : "visible", + "iteration" : 3, + "layoutMs" : 79.211250000000007, + "layoutScope" : "full", + "totalMs" : 79.577959000000007, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.39820800000000001, + "colorScope" : "visible", + "iteration" : 4, + "layoutMs" : 96.397041999999999, + "layoutScope" : "full", + "totalMs" : 96.795249999999996, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 1347, + "colorMs" : 0.846584, + "colorScope" : "visible", + "iteration" : 5, + "layoutMs" : 104.387958, + "layoutScope" : "full", + "totalMs" : 105.234542, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.717625, + "colorScope" : "full", + "iteration" : 1, + "layoutMs" : 9.2130410000000005, + "layoutScope" : "visible", + "totalMs" : 23.930666000000002, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.277749999999999, + "colorScope" : "full", + "iteration" : 2, + "layoutMs" : 9.0090000000000003, + "layoutScope" : "visible", + "totalMs" : 23.286749999999998, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.305624999999999, + "colorScope" : "full", + "iteration" : 3, + "layoutMs" : 9.2265420000000002, + "layoutScope" : "visible", + "totalMs" : 23.532167000000001, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.590833, + "colorScope" : "full", + "iteration" : 4, + "layoutMs" : 9.3730419999999999, + "layoutScope" : "visible", + "totalMs" : 23.963875000000002, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.842541000000001, + "colorScope" : "full", + "iteration" : 5, + "layoutMs" : 9.3969170000000002, + "layoutScope" : "visible", + "totalMs" : 24.239457999999999, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.678625, + "colorScope" : "full", + "iteration" : 1, + "layoutMs" : 257.00383299999999, + "layoutScope" : "full", + "totalMs" : 271.682458, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 15.01, + "colorScope" : "full", + "iteration" : 2, + "layoutMs" : 246.19045800000001, + "layoutScope" : "full", + "totalMs" : 261.20045800000003, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 13.989708, + "colorScope" : "full", + "iteration" : 3, + "layoutMs" : 240.33179200000001, + "layoutScope" : "full", + "totalMs" : 254.32150000000001, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 13.994583, + "colorScope" : "full", + "iteration" : 4, + "layoutMs" : 280.76008300000001, + "layoutScope" : "full", + "totalMs" : 294.75466599999999, + "visibleUTF16Length" : 9091 + }, + { + "coloredTokens" : 50701, + "colorMs" : 14.676458, + "colorScope" : "full", + "iteration" : 5, + "layoutMs" : 242.762833, + "layoutScope" : "full", + "totalMs" : 257.43929100000003, + "visibleUTF16Length" : 9091 + } +] \ No newline at end of file diff --git a/docs/performance/issue-561/product-reproduction-summary.json b/docs/performance/issue-561/product-reproduction-summary.json new file mode 100644 index 000000000..5664eacc8 --- /dev/null +++ b/docs/performance/issue-561/product-reproduction-summary.json @@ -0,0 +1,209 @@ +[ + { + "run": "all-lsp0-1", + "mode": "all", + "durationMs": 16135.51525, + "structureApplyMedianMs": 9.6905205, + "semanticMedianMs": 0.6828749999999999, + "foldLayoutMedianMs": 351.326875, + "gutterMedianMs": 0.9440625, + "gutterScrollMedianMs": 0.939375, + "gutterScrollP95Ms": 0.976875, + "nativeInputMedianMs": 27.433875, + "scrollSequenceMs": 626.3539170000004, + "mainLoopMaxGapMs": 4731.95975, + "mainLoopP95GapMs": 115.267542, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 3512, + "gutterSamples": 8 + }, + { + "run": "all-lsp0-2", + "mode": "all", + "durationMs": 15662.310292, + "structureApplyMedianMs": 10.092646, + "semanticMedianMs": 0.5066455, + "foldLayoutMedianMs": 353.702167, + "gutterMedianMs": 0.897125, + "gutterScrollMedianMs": 0.892896, + "gutterScrollP95Ms": 0.936625, + "nativeInputMedianMs": 28.825667, + "scrollSequenceMs": 475.7480839999989, + "mainLoopMaxGapMs": 4817.884041, + "mainLoopP95GapMs": 105.089625, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 3583, + "gutterSamples": 10 + }, + { + "run": "baseline-lsp0-1", + "mode": "baseline", + "durationMs": 54647.25225, + "structureApplyMedianMs": 754.4141045, + "semanticMedianMs": 8.1111255, + "foldLayoutMedianMs": 746.722333, + "gutterMedianMs": null, + "gutterScrollMedianMs": null, + "gutterScrollP95Ms": null, + "nativeInputMedianMs": 28.295375, + "scrollSequenceMs": 22709.611375, + "mainLoopMaxGapMs": 5825.807333, + "mainLoopP95GapMs": 2891.984834, + "sampledJavaProcesses": false, + "lspRequested": false + }, + { + "run": "baseline-lsp0-2", + "mode": "baseline", + "durationMs": 51828.780416, + "structureApplyMedianMs": 833.0087920000001, + "semanticMedianMs": 9.9156665, + "foldLayoutMedianMs": 765.79225, + "gutterMedianMs": 966.04625, + "gutterScrollMedianMs": 963.1514585, + "gutterScrollP95Ms": 983.834667, + "nativeInputMedianMs": 30.392042, + "scrollSequenceMs": 22306.455083, + "mainLoopMaxGapMs": 4077.909459, + "mainLoopP95GapMs": 3171.218041, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 4236, + "gutterSamples": 2386 + }, + { + "run": "baseline-lsp0-3", + "mode": "baseline", + "durationMs": 53534.224167, + "structureApplyMedianMs": 784.8403125, + "semanticMedianMs": 9.85925, + "foldLayoutMedianMs": 735.790459, + "gutterMedianMs": 955.647875, + "gutterScrollMedianMs": 951.0796245, + "gutterScrollP95Ms": 963.472125, + "nativeInputMedianMs": 29.541375, + "scrollSequenceMs": 22150.474875000004, + "mainLoopMaxGapMs": 5271.945791, + "mainLoopP95GapMs": 2816.595458, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 4173, + "gutterSamples": 1625 + }, + { + "run": "baseline-lsp1-1", + "mode": "baseline", + "durationMs": 64428.17725, + "structureApplyMedianMs": 920.6885625, + "semanticMedianMs": 10.6599585, + "foldLayoutMedianMs": 896.085583, + "gutterMedianMs": 1035.373875, + "gutterScrollMedianMs": 992.752, + "gutterScrollP95Ms": 1181.692917, + "nativeInputMedianMs": 34.588625, + "scrollSequenceMs": 24917.036458000002, + "mainLoopMaxGapMs": 4934.814208, + "mainLoopP95GapMs": 1935.062125, + "sampledJavaProcesses": true, + "lspRequested": true, + "mainSamples": 3878, + "gutterSamples": 1665 + }, + { + "run": "fold-lsp0-1", + "mode": "fold", + "durationMs": 51176.290792, + "structureApplyMedianMs": 265.445833, + "semanticMedianMs": 9.7043125, + "foldLayoutMedianMs": 349.05575, + "gutterMedianMs": null, + "gutterScrollMedianMs": null, + "gutterScrollP95Ms": null, + "nativeInputMedianMs": 32.624666, + "scrollSequenceMs": 20177.453334, + "mainLoopMaxGapMs": 4410.158375, + "mainLoopP95GapMs": 2020.265375, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 4107, + "gutterSamples": 3302 + }, + { + "run": "gutter-lsp0-1", + "mode": "gutter", + "durationMs": 18882.58425, + "structureApplyMedianMs": 752.5537919999999, + "semanticMedianMs": 9.812042, + "foldLayoutMedianMs": 734.88625, + "gutterMedianMs": 0.89525, + "gutterScrollMedianMs": 0.884584, + "gutterScrollP95Ms": 1.062792, + "nativeInputMedianMs": 39.438542, + "scrollSequenceMs": 538.1626250000008, + "mainLoopMaxGapMs": 3520.724625, + "mainLoopP95GapMs": 762.485833, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 4044, + "gutterSamples": 1040 + }, + { + "run": "gutter-lsp0-2", + "mode": "gutter", + "durationMs": 19666.169708, + "structureApplyMedianMs": 753.212479, + "semanticMedianMs": 10.272104, + "foldLayoutMedianMs": 731.31375, + "gutterMedianMs": 0.8779375, + "gutterScrollMedianMs": 0.877708, + "gutterScrollP95Ms": 0.90275, + "nativeInputMedianMs": 38.713667, + "scrollSequenceMs": 567.2565830000021, + "mainLoopMaxGapMs": 4211.400916, + "mainLoopP95GapMs": 780.064583, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 4117, + "gutterSamples": 1042 + }, + { + "run": "gutter-lsp1-1", + "mode": "gutter", + "durationMs": 23291.304958, + "structureApplyMedianMs": 957.3136045, + "semanticMedianMs": 14.6593955, + "foldLayoutMedianMs": 925.971063, + "gutterMedianMs": 1.0436044999999998, + "gutterScrollMedianMs": 1.0436044999999998, + "gutterScrollP95Ms": 1.229833, + "nativeInputMedianMs": 40.588459, + "scrollSequenceMs": 720.6698749999996, + "mainLoopMaxGapMs": 4665.652542, + "mainLoopP95GapMs": 165.474791, + "sampledJavaProcesses": true, + "lspRequested": true, + "mainSamples": 3305, + "gutterSamples": 2 + }, + { + "run": "viewport-lsp0-1", + "mode": "viewport", + "durationMs": 48061.829208, + "structureApplyMedianMs": 292.13541699999996, + "semanticMedianMs": 0.5286044999999999, + "foldLayoutMedianMs": 307.754291, + "gutterMedianMs": null, + "gutterScrollMedianMs": null, + "gutterScrollP95Ms": null, + "nativeInputMedianMs": 29.968375, + "scrollSequenceMs": 23320.388750000002, + "mainLoopMaxGapMs": 4323.491041, + "mainLoopP95GapMs": 2420.506959, + "sampledJavaProcesses": false, + "lspRequested": false, + "mainSamples": 4007, + "gutterSamples": 2559 + } +] \ No newline at end of file From f1e13d44b2f7f011702567ffe13e2cc9f997768f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 11 Sep 2026 15:22:59 +0800 Subject: [PATCH 3/5] Revert "docs: archive initial Java editor performance investigation for issue 561" This reverts commit 28c09160b6bc13335d5a0ba003dd20de044a5801. --- .../issue-561/01-native-diagnostic.md | 67 ---- .../issue-561/02-product-reproduction.md | 85 ----- docs/performance/issue-561/README.md | 21 -- .../issue-561/native-metadata.json | 34 -- .../performance/issue-561/native-results.json | 302 ------------------ .../product-reproduction-summary.json | 209 ------------ 6 files changed, 718 deletions(-) delete mode 100644 docs/performance/issue-561/01-native-diagnostic.md delete mode 100644 docs/performance/issue-561/02-product-reproduction.md delete mode 100644 docs/performance/issue-561/README.md delete mode 100644 docs/performance/issue-561/native-metadata.json delete mode 100644 docs/performance/issue-561/native-results.json delete mode 100644 docs/performance/issue-561/product-reproduction-summary.json diff --git a/docs/performance/issue-561/01-native-diagnostic.md b/docs/performance/issue-561/01-native-diagnostic.md deleted file mode 100644 index 65614feed..000000000 --- a/docs/performance/issue-561/01-native-diagnostic.md +++ /dev/null @@ -1,67 +0,0 @@ -# Issue 561: isolated Java text diagnostic - -> 归档说明:本报告记录 2026-09-11 正式修复之前的第一阶段原生文本操作诊断。正文保留当时的结论、数据和限制;完整应用复现见[第二阶段报告](02-product-reproduction.md),正式修复及后续复测见 [PR #621](https://github.com/1lck/Lithe-IDEA/pull/621)。本文的约 266 KiB 探针文件与后续 447 KiB 完整产品文件规模不同,计时不可混用。 -> -> 原始计时已随文提交为 [native-results.json](native-results.json),环境和 Rust 调用记录见 [native-metadata.json](native-metadata.json)。文末命令是当时的执行记录,依赖本地 `.artifacts/issue-561/` 中的探针和输入文件;本目录归档报告与数据,不包含这些可执行诊断材料。 - -## Scope - -No product source was changed. No IDE window or LSP was started. This is an isolated native text-operation probe, not an end-to-end reproduction of the user report. - -Fixture: 8329 lines, 271949 UTF-8 bytes (265.6 KiB), 142 methods, 164 imports, 69 injected fields. The fixture has 1990 fold regions and 50701 classified tokens; its structure may differ substantially from the unavailable user file. - -LargeService.java and LargeService.txt contain identical bytes. The native probe loads the Java fixture and does not test extension routing. - -Host: macOS 27.0 / arm64. Rust library: existing release binary, modified 2026-09-07 09:13:10; no fresh product build. The inspected Java source files predate this binary, but embedded-source provenance has not been independently verified. - -## Measurements - -Each row is a median of five measured repetitions after one warmup. The attributed-string setup and initial viewport layout are excluded. Units: milliseconds. - -| Colored tokens | Layout requested | Tokens | Attribute mutation | Subsequent layout | Total | -|---|---|---:|---:|---:|---:| -| none | visible | 0 | 0.000 | 0.015 | 0.016 | -| none | full | 0 | 0.000 | 75.202 | 75.203 | -| visible | visible | 1347 | 0.375 | 6.290 | 6.685 | -| visible | full | 1347 | 0.398 | 87.341 | 87.711 | -| full | visible | 50701 | 14.591 | 9.227 | 23.931 | -| full | full | 50701 | 14.676 | 246.190 | 261.200 | - -The separate Rust java.structure C ABI call took 948.395 ms median, including response serialization and copying but excluding Python JSON decoding. This call runs in a background task in the product and must not be added directly to main-thread blocking time. - -## Interpretation - -Full-document layout after full-document token coloring was the largest measured native step. Keeping only viewport layout reduced this operation-level cost substantially; restricting token coloring reduced it further. These results justify collecting a product main-thread profile around applyJavaStructure/updateFolds. They do not prove that this is the sole cause of the reported 2–7 FPS, or exclude LSP and other product paths. - -## Limitations - -- NativeTextProbe reproduces the NSLayoutManager fold delegate and NSTextStorage foreground-attribute loop, not the complete CodeTextView or SwiftUI view tree. -- System monospaced font and simplified five-color palette; product theme/font costs are not included. -- Viewport token filtering, setup, initial viewport layout, and JSON decoding are excluded from reported native timing. -- One warmup and five measured repetitions per condition; these are diagnostic medians, not performance assertions. -- No actual typing, scrolling, or product FPS was measured. - -## Product verification matrix - -Use the same Java file and initial viewport; hold theme, font, window size, import folding and Code Vision constant. Use a Release build, separate cold-open and warm-open runs, then check a single-character edit and continuous scrolling. - -| Condition | LSP | Semantic coloring | Fold layout | What it isolates | -|---|---|---|---|---| -| A | On | Current | Current | Product baseline | -| B | Off | Current | Current | LSP contribution | -| C | Off | Viewport only | Current | Coloring contribution | -| D | Off | Current | Skip unchanged/full invalidation | Layout contribution | -| E | Off | Viewport only | Skip unchanged/full invalidation | Combined local change | - -Add timing spans for structure request/response, applyJavaSemanticHighlights, updateFolds, and editor input. Sample the main thread while the hitch occurs. Record ranges processed, token counts, layout refresh count, and input-to-visible-update latency. A matching hot stack plus repeatable improvement in the single-variable condition confirms attribution. Re-enable LSP before accepting a fix. - -## Rerun native probe - -```sh -swiftc -O .artifacts/issue-561/NativeTextProbe.swift -o .artifacts/issue-561/native-text-probe -.artifacts/issue-561/native-text-probe .artifacts/issue-561 -``` - -The original compiler and native execution were supervised with 60-second and 90-second subprocess deadlines. The optional run-core-probe.py accepts an explicit dylib path and enforces a 30-second parent-owned deadline. - -Raw native timings: native-results.json. Core times and provenance: metadata.json. Fixture and decoded Rust response are retained in the same directory. diff --git a/docs/performance/issue-561/02-product-reproduction.md b/docs/performance/issue-561/02-product-reproduction.md deleted file mode 100644 index 5a022b981..000000000 --- a/docs/performance/issue-561/02-product-reproduction.md +++ /dev/null @@ -1,85 +0,0 @@ -# Issue #561:完整 macOS 产品性能验证 - -> 归档说明:本报告记录 2026-09-11 正式修复之前的完整 macOS 应用复现。正文中的“当前行为”“未交付正式产品修复”和 31 个回归测试均指这一历史阶段;本报告的三项优化是定位瓶颈的实验,不等同于后来提交的正式实现。正式修复和后续复测见 [PR #621](https://github.com/1lck/Lithe-IDEA/pull/621)。 -> -> 各轮统计已随文提交为 [product-reproduction-summary.json](product-reproduction-summary.json)。本次归档保留原有结论和全部实验数据,并把本机源码链接替换为经过行号核对的固定版本链接;该固定版本的对应代码与当时工作树一致。文中其余日志、探针和缓存清单记录的是当时的本地归档,并非本目录全部收录的文件。 - -## 结论 - -在本次合成文件复现中,持续卡顿的主要热点是行号栏的折叠箭头绘制。主线程采样、直接计时和保留全文语义着色的单变量对照相互印证。全文语义染色后反复强制全文排版是另一个独立的打开/编辑停顿来源。 - -本次只做验证,未交付正式产品修复。所有临时探针和行为开关已经从产品源码撤回,原文件(包括任务开始前已有的用户改动)已按 SHA-256 验证逐字节恢复。 - -## 实验环境 - -- 日期:2026-09-11;macOS 27.0,Apple Silicon。 -- 当前工作树构建的完整 Release 产品;HEAD:6a1ae4d43f3e。工作树含任务开始前已有改动。 -- 使用本机 Swift 6.3.3。仓库要求的 Swift 6.2 工具链符号链接失效,本次没有安装/切换工具链。 -- 新构建并链接了 Rust Core,使用实际 CodeEditorView、CodeTextView、NSTextStorage、NSLayoutManager、行号栏和 SwiftUI 工作台。 -- 实际打包 .app,并复用已有捆绑 JDK/JDT LS;语言服务器开启组保留 JDT LS 初始化日志和 Java 子进程证据。 -- 固定合成 Java 文件:8329 行、457728 字节(447 KiB)、164 个 import、142 个方法、69 个注入字段,约 50701 个语义 token、1990 个折叠区域。 -- 固定默认主题、字体、窗口尺寸、默认 import 折叠和开启的 Code Vision 配置。没有修改用户真实项目文件。 - -## 主结果 - -表中多轮结果取各轮中位数的中位数;每轮独立启动进程并使用新的测试工作区。关闭 LSP 的主比较使用第二版同一探针二进制,排除了首次 UI 观察尝试的探索轮。 - -| 条件 | 轮数 | 结构结果应用中位数 | 滚动时箭头绘制中位数 | 26 次滚动序列耗时 | -|---|---:|---:|---:|---:| -| 当前行为,LSP 关闭 | 2 | 808.9 ms | 957.116 ms | 22.228 s | -| 仅优化折叠箭头遍历,LSP 关闭 | 2 | 752.9 ms | 0.881 ms | 0.553 s | -| 三项实验优化一起启用,LSP 关闭 | 2 | 9.9 ms | 0.916 ms | 0.551 s | -| 当前行为,LSP 开启 | 1 | 920.7 ms | 992.752 ms | 24.917 s | -| 仅优化折叠箭头遍历,LSP 开启 | 1 | 957.3 ms | 1.044 ms | 0.721 s | - -结构结果应用包括语义着色和 applyFoldState;其中不包含异步 Rust 分析等待时间。每轮实际经历打开、三处单字符插入、26 次滚动。滚动以 16 ms 的主线程定时器产生相同刺激,因此上表反映完成同一操作序列的耗时,不能换算成产品实际渲染 FPS。 - -## 如何定位到折叠箭头 - -1. 最初三个探索组分别保留当前行为、仅限制语义着色范围、仅跳过未变化的折叠几何刷新。三者的滚动序列分别耗时约 22.7、23.3、20.2 秒,后两者仍有持续卡顿。 -2. 编辑期间采样主线程,后两个探索组中 drawFoldIndicators 调用栈分别占 2559/4007(63.9%)、3302/4107(80.4%)样本;大量下层调用涉及字符串构造/分配。 -3. 代码在每次绘制中,对每个折叠区域再次 regions.contains 遍历全部区域,判断父折叠。视口范围判断在这之后。1990 个区域对应最坏约 396 万次父区域检查,每次还会计算字符串形式的 id。 -4. 单变量实验仅先筛出 collapsedFoldIDs 中的候选父区域,再使用原有的父区域判断;保留全文语义着色和原有全文排版。当前文件默认只折叠 import,于是内层由约 1990 个候选降为 1 个。 -5. 两轮关闭 LSP 对照均从约 22 秒降至约 0.55 秒。启用并完成初始化的 JDT LS 对照也从约 24.9 秒降至约 0.72 秒。 - -定位:[CodeEditorView.swift:4605](https://github.com/1lck/Lithe-IDEA/blob/481da5e8b55102b3d3e2cd64e1850d072053a7eb/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift#L4605),`LineNumberGutterView.drawFoldIndicators`。 -ID 构造:[JavaNavigationModels.swift:86](https://github.com/1lck/Lithe-IDEA/blob/481da5e8b55102b3d3e2cd64e1850d072053a7eb/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift#L86)。 - -## 次要但独立的停顿 - -仅优化箭头后,结构结果应用仍约 0.75 秒。updateFolds 在折叠几何没有改变时依然使全文布局失效,再同步 ensureLayout 整个文本容器。将语义着色限制到视口,并跳过未变化的折叠几何刷新,再配合箭头遍历优化后,结果应用降至约 10 ms。 - -定位:[CodeEditorView.swift:2693](https://github.com/1lck/Lithe-IDEA/blob/481da5e8b55102b3d3e2cd64e1850d072053a7eb/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift#L2693)。 - -## 限制 - -- 没有获得 issue 中的原始业务源码。合成文件对齐行数和字节数,但控制流、折叠密度、依赖完整性和语义复杂度可能不同;这些结果确认了本地热点,不能证明原报告只有这些根因。 -- 原报告为 macOS 15.5 / Lithe 0.4.3,本次为 macOS 27 / 当前工作树。此前已确认所分析的关键路径在 v0.4.3 中存在。 -- 没有搭建报告中的 3564 文件、9 个 Maven 模块工程;LSP 开启组是实际单文件 Java 工作区,不代表大型 Maven 索引负载。 -- 未单独完成同一应用会话内关闭标签页再重新打开的暖打开对照;重复启动轮不能替代该场景。 -- 操作由进程内诊断驱动调用实际 AppKit 编辑和滚动入口;没有声称测到了物理键盘/触控板到屏幕的端到端延迟。 -- 视口语义着色实验仅筛选当前视口 token,未实现完整的滚动补色、缓存失效和跨行语义处理,因此不能直接作为正式修复提交。 -- 第一版探针的 heartbeat 输出没有保留阶段标签,仅使用其整体阻塞证据;主结果使用第二版同一二进制。 -- 第一次探索轮尝试通过辅助功能观察窗口超时;该轮不进入第二版的主要对照汇总。 - -## 原始证据和复现材料 - -- `summary.json`:每轮统计。 -- 各运行目录的 `events.jsonl`:直接计时、阶段和操作记录;`sample.txt`:启动阶段采样;`active-sample.txt`:编辑阶段采样。 -- `processes.txt`、`status.json`:测试进程及完成记录;JDT LS 开启组的 Java 进程已被观测。 -- `*.jdtls.log`:两个测试工作区的 JDT LS 初始化日志副本。 -- `instrumentation-v2.patch`、`JavaEditorPerformanceProbe.swift`:临时诊断改动,已从产品源码撤回。 -- `run-product.py`:打包和逐轮运行监督器,每轮 90 秒单调时钟截止时间,并清理进程树和采样子进程。 -- `originals.json`、`restoration.json`:原文件指纹及恢复验证。 -- `workspace/LargeService.java`:可复用合成文件;`build.log` 和 `build-v2.log`:两次成功的 Release 构建。 - -## 收尾验证 - -- 两次完整 macOS Release 构建成功。 -- 临时改动期间测试稳定性静态检查、服务边界检查及 git diff --check 通过。 -- 原源码已恢复,临时 .app 和两个本次创建的 JDT LS 缓存目录已删除。 -- 源码恢复后,通过计时 harness 运行相关编辑器回归测试:31 个测试、4 个套件全部通过,测试执行共 0.379 秒。逐项耗时和 HTML/JUnit 已生成。 -- 回归命令:`./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/issue-561-macos-swift.json -- --filter 'EditorSyntaxHighlighterTests|EditorCaretGeometryTests|TextViewportLayoutTests'`。 -- 最终服务边界检查和 `git diff --check` 再次通过。 - -最慢的三个相关测试:yamlIncrementalHighlightingUsesCachedLineStateNearDocumentEnd() 106 ms;xmlIncrementalHighlightingUsesCachedLineStateNearDocumentEnd() 98 ms;caretAfterLastCharacterSitsPastTheFinalGlyphWhenFileHasNoTrailingNewline() 75 ms。所有测试均低于 1000 ms 警告阈值。 diff --git a/docs/performance/issue-561/README.md b/docs/performance/issue-561/README.md deleted file mode 100644 index 01f08319d..000000000 --- a/docs/performance/issue-561/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Issue #561:初始诊断与完整应用复现报告 - -关联:[原始问题 #561](https://github.com/1lck/Lithe-IDEA/issues/561) · [修复 PR #621](https://github.com/1lck/Lithe-IDEA/pull/621) - -这里归档正式修复前的两份完整报告,以及支撑报告数值的测量数据。它们保留当时的结论、实验条件和限制,用于说明如何从“是否只染色可见区域”的疑问,逐步定位到 Java 全文语义着色、重复全文排版和折叠箭头的二次遍历。 - -## 阅读顺序 - -| 阶段 | 报告 | 测量范围 | 对应数据 | -| --- | --- | --- | --- | -| 1:最初的原生操作诊断 | [01-native-diagnostic.md](01-native-diagnostic.md) | 8329 行、271949 字节;独立 AppKit 文本操作,无完整 IDE、无 LSP | [原始 30 次测量](native-results.json)、[环境与 Rust 调用记录](native-metadata.json) | -| 2:完整产品复现与单变量验证 | [02-product-reproduction.md](02-product-reproduction.md) | 8329 行、457728 字节;完整 Release 应用,打开、三次编辑及 26 次滚动,包含真实 JDT LS 对照 | [全部 11 轮统计](product-reproduction-summary.json) | -| 3:正式修复与复测 | [PR #621 的正式复测、回归和验证边界](https://github.com/1lck/Lithe-IDEA/pull/621) | 正式视口缓存、折叠布局复用和可见行绘制实现;含超时对照及语义颜色校验 | PR 说明中的结果表及验证记录 | - -## 归档口径 - -- 两份报告沿用原始正文,仅增加历史阶段说明;第二份报告将本机源码链接替换为已核对的固定版本链接。 -- `native-results.json` 和 `product-reproduction-summary.json` 与初始诊断归档逐字节一致。`native-metadata.json` 的 `binary` 字段将本机绝对路径改为仓库相对路径,其余数据不变。 -- 第一阶段原生操作计时不能解释为产品交互延迟;第二阶段的实验性视口过滤也不能解释为正式修复已经完整处理了滚动补色。后续修复结果单独记录在 PR 中。 -- 第二阶段 JSON 保留探索轮和主对照轮。报告的主比较采用第二版探针的两轮 `baseline-lsp0-2/3`、两轮 `gutter-lsp0-1/2`、两轮 `all-lsp0-1/2`,以及各一轮开启 LSP 的对照;没有把第一版探索轮混入同一主统计组。JSON 中的 `null` 表示未采集到该项数据,不表示零耗时。 -- 本次提交的是报告和支撑数值的数据文件。原报告列出的完整本地诊断目录还包含运行探针、输入文件、JSONL、进程采样和日志;二进制、应用包、缓存和这些本地运行材料未随文提交。 diff --git a/docs/performance/issue-561/native-metadata.json b/docs/performance/issue-561/native-metadata.json deleted file mode 100644 index aa51cea4e..000000000 --- a/docs/performance/issue-561/native-metadata.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "lines": 8329, - "utf8Bytes": 271949, - "methods": 142, - "imports": 164, - "injectedFields": 69, - "tokens": 50701, - "folds": 1990, - "structureMs": [ - 945.9102919936413, - 954.8951250035316, - 945.8428340003593, - 948.3947919943603, - 951.0688330046833 - ], - "structureMedianMs": 948.3947919943603, - "binary": "rust/target/macos/aarch64-apple-darwin/release/liblithe_core.dylib", - "binaryModified": "2026-09-07 09:13:10", - "limitations": [ - "Synthetic source; no user file available.", - "Existing release Rust binary from 2026-09-07; no fresh application build.", - "No LSP or full IDE launched." - ], - "sourceSHA256": "16361866a35c82e981440d1d4ae2413da3dcce0b210107c837fa952491525897", - "hostOS": "27.0", - "hostMachine": "arm64", - "nativeProbeLimitations": [ - "NativeTextProbe reproduces the NSLayoutManager fold delegate and NSTextStorage foreground-attribute loop, not the complete CodeTextView or SwiftUI view tree.", - "System monospaced font and simplified five-color palette; product theme/font costs are not included.", - "Viewport token filtering, setup, initial viewport layout, and JSON decoding are excluded from reported native timing.", - "One warmup and five measured repetitions per condition; these are diagnostic medians, not performance assertions.", - "No actual typing, scrolling, or product FPS was measured." - ] -} diff --git a/docs/performance/issue-561/native-results.json b/docs/performance/issue-561/native-results.json deleted file mode 100644 index 4ca447c94..000000000 --- a/docs/performance/issue-561/native-results.json +++ /dev/null @@ -1,302 +0,0 @@ -[ - { - "coloredTokens" : 0, - "colorMs" : 0.00095799999999999998, - "colorScope" : "none", - "iteration" : 1, - "layoutMs" : 0.015583, - "layoutScope" : "visible", - "totalMs" : 0.016541, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.001083, - "colorScope" : "none", - "iteration" : 2, - "layoutMs" : 0.014999999999999999, - "layoutScope" : "visible", - "totalMs" : 0.016083, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00033300000000000002, - "colorScope" : "none", - "iteration" : 3, - "layoutMs" : 0.014833000000000001, - "layoutScope" : "visible", - "totalMs" : 0.015166000000000001, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00020799999999999999, - "colorScope" : "none", - "iteration" : 4, - "layoutMs" : 0.013667, - "layoutScope" : "visible", - "totalMs" : 0.013875, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00029100000000000003, - "colorScope" : "none", - "iteration" : 5, - "layoutMs" : 0.015834000000000001, - "layoutScope" : "visible", - "totalMs" : 0.016125, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00041599999999999997, - "colorScope" : "none", - "iteration" : 1, - "layoutMs" : 75.475958000000006, - "layoutScope" : "full", - "totalMs" : 75.476374000000007, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00025000000000000001, - "colorScope" : "none", - "iteration" : 2, - "layoutMs" : 75.558207999999993, - "layoutScope" : "full", - "totalMs" : 75.558457999999987, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00025000000000000001, - "colorScope" : "none", - "iteration" : 3, - "layoutMs" : 75.135666000000001, - "layoutScope" : "full", - "totalMs" : 75.135915999999995, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00045800000000000002, - "colorScope" : "none", - "iteration" : 4, - "layoutMs" : 75.202207999999999, - "layoutScope" : "full", - "totalMs" : 75.202665999999994, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 0, - "colorMs" : 0.00025000000000000001, - "colorScope" : "none", - "iteration" : 5, - "layoutMs" : 74.316458999999995, - "layoutScope" : "full", - "totalMs" : 74.316708999999989, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.376917, - "colorScope" : "visible", - "iteration" : 1, - "layoutMs" : 6.2760420000000003, - "layoutScope" : "visible", - "totalMs" : 6.6529590000000001, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.37458399999999997, - "colorScope" : "visible", - "iteration" : 2, - "layoutMs" : 6.5937919999999997, - "layoutScope" : "visible", - "totalMs" : 6.9683759999999992, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.36762499999999998, - "colorScope" : "visible", - "iteration" : 3, - "layoutMs" : 6.2495000000000003, - "layoutScope" : "visible", - "totalMs" : 6.6171250000000006, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.39466699999999999, - "colorScope" : "visible", - "iteration" : 4, - "layoutMs" : 6.2898750000000003, - "layoutScope" : "visible", - "totalMs" : 6.6845420000000004, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.36499999999999999, - "colorScope" : "visible", - "iteration" : 5, - "layoutMs" : 6.5425420000000001, - "layoutScope" : "visible", - "totalMs" : 6.9075420000000003, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.37054100000000001, - "colorScope" : "visible", - "iteration" : 1, - "layoutMs" : 87.340917000000005, - "layoutScope" : "full", - "totalMs" : 87.711458000000007, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.40204200000000001, - "colorScope" : "visible", - "iteration" : 2, - "layoutMs" : 84.207791, - "layoutScope" : "full", - "totalMs" : 84.609832999999995, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.36670900000000001, - "colorScope" : "visible", - "iteration" : 3, - "layoutMs" : 79.211250000000007, - "layoutScope" : "full", - "totalMs" : 79.577959000000007, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.39820800000000001, - "colorScope" : "visible", - "iteration" : 4, - "layoutMs" : 96.397041999999999, - "layoutScope" : "full", - "totalMs" : 96.795249999999996, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 1347, - "colorMs" : 0.846584, - "colorScope" : "visible", - "iteration" : 5, - "layoutMs" : 104.387958, - "layoutScope" : "full", - "totalMs" : 105.234542, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.717625, - "colorScope" : "full", - "iteration" : 1, - "layoutMs" : 9.2130410000000005, - "layoutScope" : "visible", - "totalMs" : 23.930666000000002, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.277749999999999, - "colorScope" : "full", - "iteration" : 2, - "layoutMs" : 9.0090000000000003, - "layoutScope" : "visible", - "totalMs" : 23.286749999999998, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.305624999999999, - "colorScope" : "full", - "iteration" : 3, - "layoutMs" : 9.2265420000000002, - "layoutScope" : "visible", - "totalMs" : 23.532167000000001, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.590833, - "colorScope" : "full", - "iteration" : 4, - "layoutMs" : 9.3730419999999999, - "layoutScope" : "visible", - "totalMs" : 23.963875000000002, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.842541000000001, - "colorScope" : "full", - "iteration" : 5, - "layoutMs" : 9.3969170000000002, - "layoutScope" : "visible", - "totalMs" : 24.239457999999999, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.678625, - "colorScope" : "full", - "iteration" : 1, - "layoutMs" : 257.00383299999999, - "layoutScope" : "full", - "totalMs" : 271.682458, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 15.01, - "colorScope" : "full", - "iteration" : 2, - "layoutMs" : 246.19045800000001, - "layoutScope" : "full", - "totalMs" : 261.20045800000003, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 13.989708, - "colorScope" : "full", - "iteration" : 3, - "layoutMs" : 240.33179200000001, - "layoutScope" : "full", - "totalMs" : 254.32150000000001, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 13.994583, - "colorScope" : "full", - "iteration" : 4, - "layoutMs" : 280.76008300000001, - "layoutScope" : "full", - "totalMs" : 294.75466599999999, - "visibleUTF16Length" : 9091 - }, - { - "coloredTokens" : 50701, - "colorMs" : 14.676458, - "colorScope" : "full", - "iteration" : 5, - "layoutMs" : 242.762833, - "layoutScope" : "full", - "totalMs" : 257.43929100000003, - "visibleUTF16Length" : 9091 - } -] \ No newline at end of file diff --git a/docs/performance/issue-561/product-reproduction-summary.json b/docs/performance/issue-561/product-reproduction-summary.json deleted file mode 100644 index 5664eacc8..000000000 --- a/docs/performance/issue-561/product-reproduction-summary.json +++ /dev/null @@ -1,209 +0,0 @@ -[ - { - "run": "all-lsp0-1", - "mode": "all", - "durationMs": 16135.51525, - "structureApplyMedianMs": 9.6905205, - "semanticMedianMs": 0.6828749999999999, - "foldLayoutMedianMs": 351.326875, - "gutterMedianMs": 0.9440625, - "gutterScrollMedianMs": 0.939375, - "gutterScrollP95Ms": 0.976875, - "nativeInputMedianMs": 27.433875, - "scrollSequenceMs": 626.3539170000004, - "mainLoopMaxGapMs": 4731.95975, - "mainLoopP95GapMs": 115.267542, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 3512, - "gutterSamples": 8 - }, - { - "run": "all-lsp0-2", - "mode": "all", - "durationMs": 15662.310292, - "structureApplyMedianMs": 10.092646, - "semanticMedianMs": 0.5066455, - "foldLayoutMedianMs": 353.702167, - "gutterMedianMs": 0.897125, - "gutterScrollMedianMs": 0.892896, - "gutterScrollP95Ms": 0.936625, - "nativeInputMedianMs": 28.825667, - "scrollSequenceMs": 475.7480839999989, - "mainLoopMaxGapMs": 4817.884041, - "mainLoopP95GapMs": 105.089625, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 3583, - "gutterSamples": 10 - }, - { - "run": "baseline-lsp0-1", - "mode": "baseline", - "durationMs": 54647.25225, - "structureApplyMedianMs": 754.4141045, - "semanticMedianMs": 8.1111255, - "foldLayoutMedianMs": 746.722333, - "gutterMedianMs": null, - "gutterScrollMedianMs": null, - "gutterScrollP95Ms": null, - "nativeInputMedianMs": 28.295375, - "scrollSequenceMs": 22709.611375, - "mainLoopMaxGapMs": 5825.807333, - "mainLoopP95GapMs": 2891.984834, - "sampledJavaProcesses": false, - "lspRequested": false - }, - { - "run": "baseline-lsp0-2", - "mode": "baseline", - "durationMs": 51828.780416, - "structureApplyMedianMs": 833.0087920000001, - "semanticMedianMs": 9.9156665, - "foldLayoutMedianMs": 765.79225, - "gutterMedianMs": 966.04625, - "gutterScrollMedianMs": 963.1514585, - "gutterScrollP95Ms": 983.834667, - "nativeInputMedianMs": 30.392042, - "scrollSequenceMs": 22306.455083, - "mainLoopMaxGapMs": 4077.909459, - "mainLoopP95GapMs": 3171.218041, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 4236, - "gutterSamples": 2386 - }, - { - "run": "baseline-lsp0-3", - "mode": "baseline", - "durationMs": 53534.224167, - "structureApplyMedianMs": 784.8403125, - "semanticMedianMs": 9.85925, - "foldLayoutMedianMs": 735.790459, - "gutterMedianMs": 955.647875, - "gutterScrollMedianMs": 951.0796245, - "gutterScrollP95Ms": 963.472125, - "nativeInputMedianMs": 29.541375, - "scrollSequenceMs": 22150.474875000004, - "mainLoopMaxGapMs": 5271.945791, - "mainLoopP95GapMs": 2816.595458, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 4173, - "gutterSamples": 1625 - }, - { - "run": "baseline-lsp1-1", - "mode": "baseline", - "durationMs": 64428.17725, - "structureApplyMedianMs": 920.6885625, - "semanticMedianMs": 10.6599585, - "foldLayoutMedianMs": 896.085583, - "gutterMedianMs": 1035.373875, - "gutterScrollMedianMs": 992.752, - "gutterScrollP95Ms": 1181.692917, - "nativeInputMedianMs": 34.588625, - "scrollSequenceMs": 24917.036458000002, - "mainLoopMaxGapMs": 4934.814208, - "mainLoopP95GapMs": 1935.062125, - "sampledJavaProcesses": true, - "lspRequested": true, - "mainSamples": 3878, - "gutterSamples": 1665 - }, - { - "run": "fold-lsp0-1", - "mode": "fold", - "durationMs": 51176.290792, - "structureApplyMedianMs": 265.445833, - "semanticMedianMs": 9.7043125, - "foldLayoutMedianMs": 349.05575, - "gutterMedianMs": null, - "gutterScrollMedianMs": null, - "gutterScrollP95Ms": null, - "nativeInputMedianMs": 32.624666, - "scrollSequenceMs": 20177.453334, - "mainLoopMaxGapMs": 4410.158375, - "mainLoopP95GapMs": 2020.265375, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 4107, - "gutterSamples": 3302 - }, - { - "run": "gutter-lsp0-1", - "mode": "gutter", - "durationMs": 18882.58425, - "structureApplyMedianMs": 752.5537919999999, - "semanticMedianMs": 9.812042, - "foldLayoutMedianMs": 734.88625, - "gutterMedianMs": 0.89525, - "gutterScrollMedianMs": 0.884584, - "gutterScrollP95Ms": 1.062792, - "nativeInputMedianMs": 39.438542, - "scrollSequenceMs": 538.1626250000008, - "mainLoopMaxGapMs": 3520.724625, - "mainLoopP95GapMs": 762.485833, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 4044, - "gutterSamples": 1040 - }, - { - "run": "gutter-lsp0-2", - "mode": "gutter", - "durationMs": 19666.169708, - "structureApplyMedianMs": 753.212479, - "semanticMedianMs": 10.272104, - "foldLayoutMedianMs": 731.31375, - "gutterMedianMs": 0.8779375, - "gutterScrollMedianMs": 0.877708, - "gutterScrollP95Ms": 0.90275, - "nativeInputMedianMs": 38.713667, - "scrollSequenceMs": 567.2565830000021, - "mainLoopMaxGapMs": 4211.400916, - "mainLoopP95GapMs": 780.064583, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 4117, - "gutterSamples": 1042 - }, - { - "run": "gutter-lsp1-1", - "mode": "gutter", - "durationMs": 23291.304958, - "structureApplyMedianMs": 957.3136045, - "semanticMedianMs": 14.6593955, - "foldLayoutMedianMs": 925.971063, - "gutterMedianMs": 1.0436044999999998, - "gutterScrollMedianMs": 1.0436044999999998, - "gutterScrollP95Ms": 1.229833, - "nativeInputMedianMs": 40.588459, - "scrollSequenceMs": 720.6698749999996, - "mainLoopMaxGapMs": 4665.652542, - "mainLoopP95GapMs": 165.474791, - "sampledJavaProcesses": true, - "lspRequested": true, - "mainSamples": 3305, - "gutterSamples": 2 - }, - { - "run": "viewport-lsp0-1", - "mode": "viewport", - "durationMs": 48061.829208, - "structureApplyMedianMs": 292.13541699999996, - "semanticMedianMs": 0.5286044999999999, - "foldLayoutMedianMs": 307.754291, - "gutterMedianMs": null, - "gutterScrollMedianMs": null, - "gutterScrollP95Ms": null, - "nativeInputMedianMs": 29.968375, - "scrollSequenceMs": 23320.388750000002, - "mainLoopMaxGapMs": 4323.491041, - "mainLoopP95GapMs": 2420.506959, - "sampledJavaProcesses": false, - "lspRequested": false, - "mainSamples": 4007, - "gutterSamples": 2559 - } -] \ No newline at end of file From 6454400a012b7433d3c32c36263f59764893e8f6 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 11 Sep 2026 15:56:57 +0800 Subject: [PATCH 4/5] fix(macos): address editor viewport review regressions --- .../Lithe/Views/Editor/CodeEditorView.swift | 95 +++++++--- .../LitheTests/EditorJavaViewportTests.swift | 164 +++++++++++++++++- 2 files changed, 236 insertions(+), 23 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 89def3711..f3094ef7d 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -677,6 +677,8 @@ struct CodeEditorView: NSViewRepresentable { Coordinator( document: document, model: model, + isDarkAppearance: colorScheme == .dark, + colorTheme: settings.colorTheme, markdownScrollPosition: markdownScrollPosition, viewportStore: viewportStore ) @@ -842,8 +844,6 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.debugInlineValueOverlay = DebugInlineValueOverlayController( textView: textView ) - context.coordinator.isDarkAppearance = palette.isDark - context.coordinator.colorTheme = settings.colorTheme context.coordinator.highlight() textView.updateCaretDecorations() context.coordinator.scheduleFoldRefresh(useDefaultImportFold: true) @@ -912,15 +912,7 @@ struct CodeEditorView: NSViewRepresentable { if textView.string != document.text, !textView.hasMarkedText(), !context.coordinator.isApplyingEditorChange { - let selection = textView.selectedRange() - textView.string = document.text - (textView as? CodeTextView)?.rebuildLineIndex() - container.gutter?.refreshLineNumberLayout() - textView.setSelectedRange(NSRange(location: min(selection.location, document.text.utf16.count), length: 0)) - context.coordinator.resetHighlightCache(textChanged: true) - context.coordinator.highlight() - (textView as? CodeTextView)?.updateEditorDecorations() - container.gutter?.needsDisplay = true + context.coordinator.replaceText(document.text) context.coordinator.scheduleFoldRefresh() textChanged = true } @@ -964,8 +956,8 @@ struct CodeEditorView: NSViewRepresentable { var codeVisionOverlay: CodeVisionOverlayController? var debugInlineValueOverlay: DebugInlineValueOverlayController? var isApplyingEditorChange = false - var isDarkAppearance = true - var colorTheme: AppColorTheme = .lithe + var isDarkAppearance: Bool + var colorTheme: AppColorTheme var shouldFocus = true var markdownScrollPosition: Binding? private struct CodeVisionInputKey: Equatable { @@ -979,6 +971,7 @@ struct CodeEditorView: NSViewRepresentable { var appliedNavigationTargetID: UUID? var foldRegions: [JavaFoldRegion] = [] var collapsedFoldIDs: Set = [] + private var collapsedFoldIDsBeforeTextReplacement: Set = [] var implementationMarkers: [JavaImplementationMarker] = [] var lastFindVisible = false var lastFindQuery = "" @@ -1033,11 +1026,15 @@ struct CodeEditorView: NSViewRepresentable { init( document: EditorDocument, model: AppModel, + isDarkAppearance: Bool, + colorTheme: AppColorTheme, markdownScrollPosition: Binding?, viewportStore: EditorViewportStore ) { self.document = document self.model = model + self.isDarkAppearance = isDarkAppearance + self.colorTheme = colorTheme self.markdownScrollPosition = markdownScrollPosition self.viewportStore = viewportStore fileName = document.url.lastPathComponent @@ -1501,6 +1498,32 @@ struct CodeEditorView: NSViewRepresentable { } } + func replaceText(_ source: String) { + guard let textView else { return } + foldRefreshTask?.cancel() + javaMarkerRefreshTask?.cancel() + let selection = textView.selectedRange() + // Retain only the user's fold choices until fresh structure validates + // them. Old offsets must never hide or exclude the replacement text. + collapsedFoldIDsBeforeTextReplacement.formUnion(collapsedFoldIDs) + let hadFoldState = !foldRegions.isEmpty || !collapsedFoldIDs.isEmpty || !implementationMarkers.isEmpty + foldRegions = [] + collapsedFoldIDs = [] + implementationMarkers = [] + textView.string = source + (textView as? CodeTextView)?.rebuildLineIndex() + resetHighlightCache(textChanged: true) + if hadFoldState { + applyFoldState() + } else { + highlight() + } + gutter?.refreshLineNumberLayout() + textView.setSelectedRange(NSRange(location: min(selection.location, source.utf16.count), length: 0)) + (textView as? CodeTextView)?.updateEditorDecorations() + gutter?.needsDisplay = true + } + func primeJavaImportFold(_ region: JavaFoldRegion) { guard fileExtension.lowercased() == "java" else { return } foldRegions = [region] @@ -1509,15 +1532,19 @@ struct CodeEditorView: NSViewRepresentable { } func scheduleFoldRefresh(useDefaultImportFold: Bool = false) { - scheduleJavaNavigationMarkerRefresh() foldRefreshTask?.cancel() + guard fileExtension.lowercased() == "java" else { + clearJavaStructure() + return + } + scheduleJavaNavigationMarkerRefresh() foldRefreshTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(80)) guard !Task.isCancelled, let self, let document = self.document, let textView = self.textView as? CodeTextView else { return } - guard self.fileExtension.lowercased() == "java", let model = self.model else { + guard let model = self.model else { self.clearJavaStructure() return } @@ -1584,6 +1611,8 @@ struct CodeEditorView: NSViewRepresentable { } foldRegions = structure.foldRegions let availableIDs = Set(foldRegions.map(\.id)) + collapsedFoldIDs.formUnion(collapsedFoldIDsBeforeTextReplacement) + collapsedFoldIDsBeforeTextReplacement = [] collapsedFoldIDs.formIntersection(availableIDs) if useDefaultImportFold, let imports = foldRegions.first(where: { $0.kind == .imports }) { @@ -1596,13 +1625,18 @@ struct CodeEditorView: NSViewRepresentable { } private func clearJavaStructure() { - syntaxHighlightState.invalidateText() - highlight() + collapsedFoldIDsBeforeTextReplacement = [] + let isJava = fileExtension.lowercased() == "java" + if isJava { + syntaxHighlightState.invalidateText() + } if !foldRegions.isEmpty || !collapsedFoldIDs.isEmpty || !implementationMarkers.isEmpty { foldRegions = [] collapsedFoldIDs = [] implementationMarkers = [] applyFoldState() + } else if isJava { + highlight() } } @@ -2298,13 +2332,19 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } func applyLineIndexEdit(replacedRange: NSRange, replacement: String) { - // NSTextStorage adjusts temporary attributes through edits. Rebuild them - // if an edit can touch/shift a fold, even if parsing returns equal ranges. - if let last = collapsedRanges.ranges.last, replacedRange.location <= NSMaxRange(last) { + let insertedLength = (replacement as NSString).length + // Equal-length edits outside folds leave their temporary attributes intact. + // Offset-changing edits remain dirty: several edits can shift attributes + // before one parse returns, even if their net change restores old geometry. + let touchesFold = collapsedRanges.contains(replacedRange.location) + || collapsedRanges.intersects(replacedRange) + let shiftsFold = insertedLength != replacedRange.length + && collapsedRanges.ranges.last.map { replacedRange.location < NSMaxRange($0) } == true + if touchesFold || shiftsFold { foldAttributesNeedRefresh = true } if replacement.contains("\n") || replacement.contains("\r") - || !lineIndex.applySingleLineEdit(replacedRange: replacedRange, insertedLength: (replacement as NSString).length) { + || !lineIndex.applySingleLineEdit(replacedRange: replacedRange, insertedLength: insertedLength) { lineIndex = TextLineIndex(source: string as NSString) } } @@ -5672,6 +5712,17 @@ struct HighlightedRangeCache { } func contains(_ location: Int) -> Bool { + let index = firstRangeEnding(after: location) + return index < ranges.count && NSLocationInRange(location, ranges[index]) + } + + func intersects(_ range: NSRange) -> Bool { + guard range.length > 0 else { return false } + let index = firstRangeEnding(after: range.location) + return index < ranges.count && ranges[index].location < NSMaxRange(range) + } + + private func firstRangeEnding(after location: Int) -> Int { var lower = 0 var upper = ranges.count while lower < upper { @@ -5679,7 +5730,7 @@ struct HighlightedRangeCache { if NSMaxRange(ranges[middle]) <= location { lower = middle + 1 } else { upper = middle } } - return lower < ranges.count && NSLocationInRange(location, ranges[lower]) + return lower } mutating func insert(_ range: NSRange) { diff --git a/macos/Tests/LitheTests/EditorJavaViewportTests.swift b/macos/Tests/LitheTests/EditorJavaViewportTests.swift index 2bc114d07..55b4c7089 100644 --- a/macos/Tests/LitheTests/EditorJavaViewportTests.swift +++ b/macos/Tests/LitheTests/EditorJavaViewportTests.swift @@ -189,6 +189,131 @@ struct EditorJavaViewportTests { #expect(storage.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor != before) } + @Test + func fullTextReplacementClearsOldFoldsBeforeFreshStructureArrives() throws { + let source = "start\nhidden\nend" + let (view, layout) = makeTextView(source) + let gutter = LineNumberGutterView(frame: .zero) + try withCoordinator(source: source) { coordinator in + coordinator.textView = view + coordinator.gutter = gutter + let fold = JavaFoldRegion(kind: .imports, startLine: 0, endLine: 2, + hiddenRange: NSRange(location: 6, length: 7)) + coordinator.primeJavaImportFold(fold) + #expect(view.isCharacterHiddenByFold(6)) + + // Inspect the synchronous replacement boundary without delivering any + // Java structure result; unrelated text now occupies the old offsets. + coordinator.replaceText("class New {\nint visible;\n}\n") + + let storage = try #require(view.textStorage) + let full = NSRange(location: 0, length: storage.length) + #expect(coordinator.foldRegions.isEmpty) + #expect(coordinator.collapsedFoldIDs.isEmpty) + #expect(view.unfoldedRanges(in: full) == [full]) + let reference = NSTextStorage(string: view.string) + SyntaxHighlighter.apply(to: reference, font: font, fileExtension: "java", isDark: true, range: full) + for location in 0.. 0) + recorder.attributeEdits = 0 + + // The non-Java branch finishes synchronously; no debounce or external + // analysis is needed to discover that there is no Java state to clear. + coordinator.scheduleFoldRefresh() + #expect(recorder.attributeEdits == 0) + + layout.containerLayoutRequests = 0 + coordinator.replaceText("other\ntext\n") + #expect(layout.containerLayoutRequests == 0) + } + } + + @Test + func initialImportFoldUsesLightColorsBeforeAnyStructureResult() throws { + let source = "import a;\nimport b;\nclass Demo {}\n" + let (view, _) = makeTextView(source) + try withCoordinator(source: source, isDark: false) { coordinator in + coordinator.textView = view + let fold = JavaFoldRegion(kind: .imports, startLine: 0, endLine: 1, + hiddenRange: (source as NSString).range(of: "import b;\n")) + coordinator.primeJavaImportFold(fold) + coordinator.highlight() + let storage = try #require(view.textStorage) + let full = NSRange(location: 0, length: storage.length) + let reference = NSTextStorage(string: source) + SyntaxHighlighter.apply(to: reference, font: font, fileExtension: "java", isDark: false, range: full) + for keyword in ["import", "class"] { + let location = (source as NSString).range(of: keyword).location + #expect(storage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor + == reference.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor) + } + } + } + + @Test(arguments: [0, 5, 13]) + func equalLengthEditsOutsideFoldDoNotForceLayout(location: Int) { + let (view, layout) = makeTextView("start\nhidden\nend") + let fold = JavaFoldRegion(kind: .block, startLine: 0, endLine: 2, + hiddenRange: NSRange(location: 6, length: 7)) + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + let edit = NSRange(location: location, length: 1) + view.textStorage?.replaceCharacters(in: edit, with: "X") + view.applyLineIndexEdit(replacedRange: edit, replacement: "X") + layout.containerLayoutRequests = 0 + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + #expect(layout.containerLayoutRequests == 0) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: 6, + effectiveRange: nil) as? NSColor == .clear) + } + + @Test(arguments: [NSRange(location: 6, length: 1), NSRange(location: 12, length: 1), + NSRange(location: 5, length: 2)]) + func editsTouchingFoldBoundariesStillRefreshAttributes(edit: NSRange) { + let (view, layout) = makeTextView("start\nhidden\nend") + let fold = JavaFoldRegion(kind: .block, startLine: 0, endLine: 2, + hiddenRange: NSRange(location: 6, length: 7)) + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + let replacement = String(repeating: "X", count: edit.length) + view.textStorage?.replaceCharacters(in: edit, with: replacement) + view.applyLineIndexEdit(replacedRange: edit, replacement: replacement) + layout.containerLayoutRequests = 0 + view.updateFolds(regions: [fold], collapsedIDs: [fold.id], onToggle: { _ in }) + #expect(layout.containerLayoutRequests > 0) + #expect(layout.temporaryAttribute(.foregroundColor, atCharacterIndex: 6, + effectiveRange: nil) as? NSColor == .clear) + } + @Test func shiftingAnExistingFoldReplacesItsGeometryAndTemporaryAttributes() { let (view, layout) = makeTextView("start\nhidden\nend") @@ -250,9 +375,46 @@ struct EditorJavaViewportTests { view.rebuildLineIndex() return (view, layout) } + + private func withCoordinator( + source: String, + fileExtension: String = "java", + isDark: Bool = true, + body: (CodeEditorView.Coordinator) throws -> Void + ) rethrows { + let store = ViewportTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services + let model = AppModel(settings: settings, services: services) + let document = EditorDocument(url: URL(fileURLWithPath: "/fixture/Viewport.\(fileExtension)"), + text: source, modificationDate: nil) + let coordinator = CodeEditorView.Coordinator( + document: document, model: model, isDarkAppearance: isDark, colorTheme: .lithe, + markdownScrollPosition: nil, viewportStore: EditorViewportStore() + ) + try withExtendedLifetime((model, document)) { try body(coordinator) } + } +} + +private final class ViewportAttributeRecorder: NSObject, NSTextStorageDelegate { + var attributeEdits = 0 + + func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, changeInLength delta: Int) { + if editedMask.contains(.editedAttributes) { attributeEdits += 1 } + } +} + +private final class ViewportTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } } -@MainActor private final class ViewportLayoutRecorder: NSLayoutManager { var containerLayoutRequests = 0 var requestedLineGlyphs: [Int] = [] From 50070125f2ddce709e13301091a1a1aa62f12d62 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 11 Sep 2026 16:12:31 +0800 Subject: [PATCH 5/5] fix(macos): reset folds for native whole-buffer editor changes --- .../Lithe/Views/Editor/CodeEditorView.swift | 42 +++++++---- .../LitheTests/EditorJavaViewportTests.swift | 75 +++++++++++++++++++ 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index f3094ef7d..48aa71da6 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1300,10 +1300,13 @@ struct CodeEditorView: NSViewRepresentable { guard document?.isReadOnly != true else { return } let codeTextView = textView as? CodeTextView let previousSource = document?.text - if let replacedRange = pendingReplacedRange, let replacement = pendingReplacement { - codeTextView?.applyLineIndexEdit(replacedRange: replacedRange, replacement: replacement) - } else { + let replacesWholeText = pendingReplacedRange == nil || pendingReplacement == nil + || (pendingReplacedRange?.location == 0 && pendingReplacedRange?.length == previousSource?.utf16.count) + if replacesWholeText { codeTextView?.rebuildLineIndex() + invalidateStructureForTextReplacement() + } else if let replacedRange = pendingReplacedRange, let replacement = pendingReplacement { + codeTextView?.applyLineIndexEdit(replacedRange: replacedRange, replacement: replacement) } gutter?.refreshLineNumberLayout() isApplyingEditorChange = true @@ -1335,7 +1338,6 @@ struct CodeEditorView: NSViewRepresentable { } let findReplacedRange = pendingReplacedRange let findInsertedLength = pendingHighlightRange?.length ?? 0 - if pendingHighlightRange == nil { resetHighlightCache(textChanged: true) } highlight( in: pendingHighlightRange, replacedLength: pendingReplacedRange?.length @@ -1500,28 +1502,32 @@ struct CodeEditorView: NSViewRepresentable { func replaceText(_ source: String) { guard let textView else { return } + let selection = textView.selectedRange() + textView.string = source + (textView as? CodeTextView)?.rebuildLineIndex() + invalidateStructureForTextReplacement() + highlight() + gutter?.refreshLineNumberLayout() + textView.setSelectedRange(NSRange(location: min(selection.location, source.utf16.count), length: 0)) + (textView as? CodeTextView)?.updateEditorDecorations() + gutter?.needsDisplay = true + } + + private func invalidateStructureForTextReplacement() { foldRefreshTask?.cancel() javaMarkerRefreshTask?.cancel() - let selection = textView.selectedRange() // Retain only the user's fold choices until fresh structure validates - // them. Old offsets must never hide or exclude the replacement text. + // them. Both model-driven replacements and native whole-buffer edits + // must stop old offsets from hiding or excluding the new text. collapsedFoldIDsBeforeTextReplacement.formUnion(collapsedFoldIDs) let hadFoldState = !foldRegions.isEmpty || !collapsedFoldIDs.isEmpty || !implementationMarkers.isEmpty foldRegions = [] collapsedFoldIDs = [] implementationMarkers = [] - textView.string = source - (textView as? CodeTextView)?.rebuildLineIndex() resetHighlightCache(textChanged: true) if hadFoldState { - applyFoldState() - } else { - highlight() + updateFoldPresentation() } - gutter?.refreshLineNumberLayout() - textView.setSelectedRange(NSRange(location: min(selection.location, source.utf16.count), length: 0)) - (textView as? CodeTextView)?.updateEditorDecorations() - gutter?.needsDisplay = true } func primeJavaImportFold(_ region: JavaFoldRegion) { @@ -1650,6 +1656,11 @@ struct CodeEditorView: NSViewRepresentable { } private func applyFoldState() { + updateFoldPresentation() + highlight() + } + + private func updateFoldPresentation() { (textView as? CodeTextView)?.updateFolds( regions: foldRegions, collapsedIDs: collapsedFoldIDs, @@ -1664,7 +1675,6 @@ struct CodeEditorView: NSViewRepresentable { guard let document else { return } model?.resolveJavaNavigation(marker, in: document.url) } - highlight() scheduleEditorOverlayRelayout() } diff --git a/macos/Tests/LitheTests/EditorJavaViewportTests.swift b/macos/Tests/LitheTests/EditorJavaViewportTests.swift index 55b4c7089..245fd19da 100644 --- a/macos/Tests/LitheTests/EditorJavaViewportTests.swift +++ b/macos/Tests/LitheTests/EditorJavaViewportTests.swift @@ -259,6 +259,81 @@ struct EditorJavaViewportTests { } } + @Test(arguments: ["replaceAll", "paste", "programmatic"]) + func wholeBufferEditorEditsClearFoldsBeforeAnalysis(entry: String) throws { + let source = "import first;\nimport second;\nclass Demo {}\n" + let (view, layout) = makeTextView(source) + try withCoordinator(source: source) { coordinator in + coordinator.textView = view + view.delegate = coordinator + defer { view.delegate = nil } + let fold = JavaFoldRegion(kind: .imports, startLine: 0, endLine: 1, + hiddenRange: (source as NSString).range(of: "import second;\n")) + coordinator.primeJavaImportFold(fold) + let replacement = source.replacingOccurrences(of: "first", with: "firstLong") + switch entry { + case "replaceAll": + view.updateFindMatches(query: "first", options: FindInFileOptions()) + view.replaceAllFindMatches(replacement: "firstLong") + case "paste": + view.insertText(replacement, replacementRange: NSRange(location: 0, length: source.utf16.count)) + default: + view.textStorage?.replaceCharacters(in: NSRange(location: 0, length: source.utf16.count), + with: replacement) + view.didChangeText() + } + #expect(view.string == replacement) + #expect(coordinator.document?.text == replacement) + #expect(coordinator.foldRegions.isEmpty) + #expect(coordinator.collapsedFoldIDs.isEmpty) + let full = NSRange(location: 0, length: replacement.utf16.count) + #expect(view.unfoldedRanges(in: full) == [full]) + for location in 0..