diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index b5d01dfc..1add71c3 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -60,6 +60,7 @@ struct LitheApp: App { @StateObject private var settings: AppSettings @StateObject private var projectSessions: ProjectSessionManager @StateObject private var memoryUsageMonitor: MemoryUsageMonitor + @StateObject private var frameRateMonitor = FrameRateMonitor() @StateObject private var updateChecker = UpdateChecker() init() { @@ -122,6 +123,7 @@ struct LitheApp: App { .environmentObject(projectSessions) .environmentObject(settings) .environmentObject(memoryUsageMonitor) + .environmentObject(frameRateMonitor) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) // SwiftUI does not consistently re-resolve every existing @@ -132,6 +134,7 @@ struct LitheApp: App { .preferredColorScheme(settings.themePreference.preferredColorScheme) .task { memoryUsageMonitor.start() + frameRateMonitor.start() } } .defaultSize( diff --git a/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift b/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift index 61cf7624..3ec1a2db 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift @@ -1,6 +1,30 @@ import Foundation +struct LanguageSessionChromeSignature: Equatable { + var features: [String: LanguageServerFeatureSet] + var states: [String: LanguageServerSessionState] + var infos: [String: LanguageServerInfo] +} + extension AppModel { + func handleLanguageSessionChange() { + refreshEditorDiagnosticsStore() + let signature = LanguageSessionChromeSignature( + features: languageToolingSessionsIfActive?.languageServerFeatures ?? [:], + states: languageToolingSessionsIfActive?.languageServerStates ?? [:], + infos: languageToolingSessionsIfActive?.languageServerInfos ?? [:] + ) + guard signature != languageSessionChromeSignature else { return } + languageSessionChromeSignature = signature + scheduleObjectWillChangeRelay() + } + + func refreshEditorDiagnosticsStore() { + editorDiagnosticsStore.replace( + EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) + ) + } + func refreshCodeVision(for fileURL: URL) async { let normalizedURL = fileURL.standardizedFileURL guard normalizedURL.pathExtension.lowercased() == "java", diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 0f8b0548..0dd2aa30 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -49,16 +49,19 @@ extension AppModel { var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } var gitChanges: [GitChange] { gitFeatureIfActive?.gitChanges ?? [] } + var gitTreeStatusProjection: GitTreeStatusProjection { + gitFeatureIfActive?.gitTreeStatus ?? GitTreeStatusProjection(changes: []) + } func gitChange(for url: URL) -> GitChange? { guard let root = gitRepositoryRoot, let relativePath = workspaceRelativePath(for: url, root: root) else { return nil } - return GitTreeStatusProjection(changes: gitChanges).change(relativePath: relativePath) + return gitFeatureIfActive?.gitTreeStatus.change(relativePath: relativePath) } func gitTreeStatus(for url: URL, isDirectory: Bool) -> GitChangeKind? { guard let root = gitRepositoryRoot, let relativePath = workspaceRelativePath(for: url, root: root) else { return nil } - return GitTreeStatusProjection(changes: gitChanges).kind( + return gitFeatureIfActive?.gitTreeStatus.kind( relativePath: relativePath, isDirectory: isDirectory ) diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 8ea31ae7..332dff63 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -68,14 +68,25 @@ final class AppModel: ObservableObject, Identifiable { /// Replace in Project 面板的搜索选项(Preserve Case、文件掩码等)。 @Published var projectReplaceOptions = ProjectSearchOptions.default @Published var selectedProjectReplacementPaths: Set = [] + let editorChrome = EditorChromeModel() + let editorDiagnosticsStore = EditorDiagnosticsStore() /// 编辑器当前选中的单行文本,供 Find/Replace in Files 预填查询词。 - @Published var editorSelectedText = "" + var editorSelectedText: String { + get { editorChrome.selectedText } + set { editorChrome.update(selectedText: newValue) } + } /// 递增令牌:搜索侧栏观察它来把焦点移回输入框。 @Published var searchSidebarFocusRequest = 0 - @Published var isFindBarVisible = false - @Published var findBarQuery = "" - @Published private(set) var findMatchCount = 0 - @Published private(set) var currentFindMatchIndex = 0 + var isFindBarVisible: Bool { + get { editorChrome.isFindBarVisible } + set { editorChrome.setFindBarVisible(newValue) } + } + var findBarQuery: String { + get { editorChrome.findBarQuery } + set { editorChrome.setFindBarQuery(newValue) } + } + var findMatchCount: Int { editorChrome.findMatchCount } + var currentFindMatchIndex: Int { editorChrome.currentFindMatchIndex } var projectItemEditRequest: ProjectItemEditRequest? { get { workspaceFeature.projectItemEditRequest } set { workspaceFeature.projectItemEditRequest = newValue } @@ -108,7 +119,10 @@ final class AppModel: ObservableObject, Identifiable { @Published var languageNavigationLocations: [LanguageNavigationLocation] = [] @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions @Published var isLoadingLanguageNavigation = false - @Published var editorCaret: EditorCaret? + var editorCaret: EditorCaret? { + get { editorChrome.caret } + set { editorChrome.update(caret: newValue) } + } @Published var editorNavigationTarget: EditorNavigationTarget? let navigationHistoryFeature: NavigationHistoryFeatureModel var virtualDocumentProviderIDs: [URL: String] = [:] @@ -244,8 +258,9 @@ final class AppModel: ObservableObject, Identifiable { return combined } var editorDiagnostics: [URL: [EditorDiagnostic]] { - EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) + editorDiagnosticsStore.diagnosticsByURL } + var languageSessionChromeSignature: LanguageSessionChromeSignature? private var workspaceFeatureObservation: AnyCancellable? private var githubFeatureObservation: AnyCancellable? private var runtimeFeatureObservation: AnyCancellable? @@ -623,6 +638,7 @@ final class AppModel: ObservableObject, Identifiable { self?.scheduleObjectWillChangeRelay() } springFeatureObservation = springFeature.objectWillChange.sink { [weak self] _ in + self?.refreshEditorDiagnosticsStore() self?.scheduleObjectWillChangeRelay() } fileVisibilityRulesObserverID = settings.addFileVisibilityRulesObserver { [weak self] in @@ -959,7 +975,8 @@ final class AppModel: ObservableObject, Identifiable { isRunVisible = false isTestsVisible = false isDebugVisible = false - editorCaret = nil + editorChrome.reset() + editorDiagnosticsStore.reset() editorNavigationTarget = nil navigationHistoryFeature.reset() virtualDocumentProviderIDs.removeAll() @@ -1022,10 +1039,8 @@ final class AppModel: ObservableObject, Identifiable { projectReplaceQuery = "" projectReplaceText = "" selectedProjectReplacementPaths = [] - isFindBarVisible = false - findBarQuery = "" - findMatchCount = 0 - currentFindMatchIndex = 0 + editorChrome.resetFindBar() + editorDiagnosticsStore.reset() projectHistoryFeatureIfActive?.reset() workspaceFeature.reset() gitFeatureIfActive?.reset() @@ -1048,7 +1063,7 @@ final class AppModel: ObservableObject, Identifiable { genericDebugFeatureIfActive?.reset() javaFeature.stop() springFeature.reset() - editorCaret = nil + editorChrome.reset() editorNavigationTarget = nil navigationHistoryFeature.reset() virtualDocumentProviderIDs.removeAll() @@ -1338,7 +1353,7 @@ final class AppModel: ObservableObject, Identifiable { guard let capability = value as? LitheLanguageIntelligenceModule.LanguageIntelligenceCapability else { return } self.cacheModuleCapability(capability, id: .languageIntelligence, moduleID: .languageIntelligence) self.observeModuleFeature(.languageIntelligence, observation: capability.sessions.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() + self?.handleLanguageSessionChange() }) capability.tools.onCandidatesChanged = { [weak self] providerID in guard let self, @@ -1391,14 +1406,11 @@ final class AppModel: ObservableObject, Identifiable { func showFindBar() { guard activeDocument != nil else { return } - isFindBarVisible = true + editorChrome.setFindBarVisible(true) } func hideFindBar() { - isFindBarVisible = false - findBarQuery = "" - findMatchCount = 0 - currentFindMatchIndex = 0 + editorChrome.resetFindBar() NotificationCenter.default.post(name: .litheFindDismiss, object: nil) } @@ -1411,7 +1423,7 @@ final class AppModel: ObservableObject, Identifiable { } func setFindBarQuery(_ query: String) { - findBarQuery = query + editorChrome.setFindBarQuery(query) NotificationCenter.default.post( name: .litheFindQueryChanged, object: nil, @@ -1428,9 +1440,7 @@ final class AppModel: ObservableObject, Identifiable { } func updateFindState(currentIndex: Int, count: Int) { - guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } - findMatchCount = count - currentFindMatchIndex = currentIndex + editorChrome.updateFindState(currentIndex: currentIndex, count: count) } func commitStagedChanges() async { diff --git a/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/Sources/Lithe/Models/Editor/EditorChromeModel.swift new file mode 100644 index 00000000..9e5e7609 --- /dev/null +++ b/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -0,0 +1,54 @@ +import Combine +import Foundation + +/// Caret, selection, and Find in File chrome. These values change on arrow +/// keys and query keystrokes, so they live off `AppModel` and must not +/// republish the workbench tree. +@MainActor +final class EditorChromeModel: ObservableObject { + @Published private(set) var caret: EditorCaret? + @Published private(set) var selectedText = "" + @Published private(set) var isFindBarVisible = false + @Published private(set) var findBarQuery = "" + private(set) var findMatchCount = 0 + private(set) var currentFindMatchIndex = 0 + + func update(caret: EditorCaret?) { + guard self.caret != caret else { return } + self.caret = caret + } + + func update(selectedText: String) { + guard self.selectedText != selectedText else { return } + self.selectedText = selectedText + } + + func setFindBarVisible(_ isVisible: Bool) { + guard isFindBarVisible != isVisible else { return } + isFindBarVisible = isVisible + } + + func setFindBarQuery(_ query: String) { + guard findBarQuery != query else { return } + findBarQuery = query + } + + func updateFindState(currentIndex: Int, count: Int) { + guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } + objectWillChange.send() + currentFindMatchIndex = currentIndex + findMatchCount = count + } + + func resetFindBar() { + setFindBarVisible(false) + setFindBarQuery("") + updateFindState(currentIndex: 0, count: 0) + } + + func reset() { + update(caret: nil) + update(selectedText: "") + resetFindBar() + } +} diff --git a/Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift b/Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift new file mode 100644 index 00000000..54445c6d --- /dev/null +++ b/Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift @@ -0,0 +1,22 @@ +import Combine +import Foundation + +/// Latest editor diagnostics for the open workspace. Language-server publish +/// storms stay on this object so the workbench tree does not rebuild. +@MainActor +final class EditorDiagnosticsStore: ObservableObject { + @Published private(set) var diagnosticsByURL: [URL: [EditorDiagnostic]] = [:] + + func replace(_ diagnostics: [URL: [EditorDiagnostic]]) { + guard diagnosticsByURL != diagnostics else { return } + diagnosticsByURL = diagnostics + } + + func reset() { + replace([:]) + } + + func diagnostics(for url: URL) -> [EditorDiagnostic] { + diagnosticsByURL[url.standardizedFileURL] ?? [] + } +} diff --git a/Sources/Lithe/Models/Editor/EditorDocument.swift b/Sources/Lithe/Models/Editor/EditorDocument.swift index 83bf418a..5be65e60 100644 --- a/Sources/Lithe/Models/Editor/EditorDocument.swift +++ b/Sources/Lithe/Models/Editor/EditorDocument.swift @@ -17,7 +17,11 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable private(set) var url: URL let isReadOnly: Bool let displayPath: String? - @Published var text: String + private var storedText: String + var text: String { + get { storedText } + set { replaceText(newValue, publish: true) } + } @Published private(set) var savedText: String @Published var hasExternalConflict = false private(set) var lastKnownModificationDate: Date? @@ -32,7 +36,7 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable self.url = url self.isReadOnly = isReadOnly self.displayPath = displayPath - self.text = text + self.storedText = text self.savedText = text self.lastKnownModificationDate = modificationDate } @@ -41,7 +45,22 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable displayPath?.split(separator: "/").last.map(String.init) ?? url.lastPathComponent } - var isDirty: Bool { text != savedText } + var isDirty: Bool { storedText != savedText } + + /// Keep the live NSTextView buffer in sync without waking SwiftUI on every + /// already-dirty keystroke. The first edit still publishes so the tab dirty + /// mark can appear. + func applyLiveEditorText(_ newText: String) { + replaceText(newText, publish: isDirty != (newText != savedText)) + } + + private func replaceText(_ newText: String, publish: Bool) { + guard storedText != newText else { return } + if publish { + objectWillChange.send() + } + storedText = newText + } func save() throws { guard !isReadOnly else { throw DocumentError.readOnly } diff --git a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift index 51f39b11..5ca8e659 100644 --- a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift +++ b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift @@ -185,9 +185,14 @@ final class ProjectSessionManager: ObservableObject { self.removeClosedSession(model) } ) - modelObservations[model.id] = model.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - } + // Only workspace open/close should wake the window chrome. Relaying + // every AppModel tick rebuilds every mounted project session. + modelObservations[model.id] = model.$workspaceURL + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } } private func removeClosedSession(_ model: AppModel) { diff --git a/Sources/Lithe/Services/Monitoring/FrameRateMonitor.swift b/Sources/Lithe/Services/Monitoring/FrameRateMonitor.swift new file mode 100644 index 00000000..6780727d --- /dev/null +++ b/Sources/Lithe/Services/Monitoring/FrameRateMonitor.swift @@ -0,0 +1,81 @@ +import Combine +import CoreVideo +import Foundation +import QuartzCore + +/// Counts vsync callbacks that reach the main thread so a hitch shows up as a +/// lower FPS. The workbench must not observe this object; only the status-bar +/// label should subscribe. +@MainActor +final class FrameRateMonitor: ObservableObject { + private(set) var framesPerSecond = 0 + + var framesPerSecondText: String { + "\(framesPerSecond) FPS" + } + + private var displayLink: CVDisplayLink? + private var framesInWindow: UInt64 = 0 + private var windowStartedAt: CFTimeInterval? + private var lastAcceptedFrameAt: CFTimeInterval? + private var displayedFramesPerSecond = -1 + private let sampleWindow: CFTimeInterval + + init(sampleWindow: TimeInterval = 0.5) { + self.sampleWindow = sampleWindow + } + + deinit { + if let displayLink { + CVDisplayLinkStop(displayLink) + } + } + + func start() { + guard displayLink == nil else { return } + windowStartedAt = CACurrentMediaTime() + lastAcceptedFrameAt = nil + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link else { return } + displayLink = link + let context = Unmanaged.passUnretained(self).toOpaque() + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, context in + guard let context else { return kCVReturnSuccess } + Task { @MainActor in + Unmanaged.fromOpaque(context).takeUnretainedValue() + .recordFrame(at: CACurrentMediaTime()) + } + return kCVReturnSuccess + }, context) + CVDisplayLinkStart(link) + } + + #if DEBUG + func recordFrameForTesting(at mediaTime: TimeInterval) { + recordFrame(at: mediaTime) + } + #endif + + /// Display-link callbacks can pile up behind a hitch. Collapse ticks that + /// land in the same frame so a stall is not hidden by a later burst. + private func recordFrame(at mediaTime: CFTimeInterval) { + if let lastAcceptedFrameAt, mediaTime - lastAcceptedFrameAt < 0.008 { + return + } + lastAcceptedFrameAt = mediaTime + let windowStart = windowStartedAt ?? mediaTime + windowStartedAt = windowStart + framesInWindow += 1 + let elapsed = mediaTime - windowStart + guard elapsed >= sampleWindow else { return } + + let fps = Int((Double(framesInWindow) / elapsed).rounded()) + framesInWindow = 0 + windowStartedAt = mediaTime + guard fps != displayedFramesPerSecond else { return } + displayedFramesPerSecond = fps + framesPerSecond = fps + objectWillChange.send() + } +} diff --git a/Sources/Lithe/Views/App/RootView.swift b/Sources/Lithe/Views/App/RootView.swift index ccde8c35..2b5c5f42 100644 --- a/Sources/Lithe/Views/App/RootView.swift +++ b/Sources/Lithe/Views/App/RootView.swift @@ -6,8 +6,6 @@ enum LitheWindowID { } struct RootView: View { - @Environment(\.openWindow) private var openWindow - @EnvironmentObject private var model: AppModel @EnvironmentObject private var projectSessions: ProjectSessionManager @EnvironmentObject private var updateChecker: UpdateChecker @State private var didStartAutomaticUpdateCheck = false @@ -15,33 +13,18 @@ struct RootView: View { var body: some View { ZStack { ForEach(projectSessions.sessions) { session in - projectContent(for: session) - .opacity(session.id == projectSessions.activeSessionID ? 1 : 0) - .allowsHitTesting(session.id == projectSessions.activeSessionID) - .accessibilityHidden(session.id != projectSessions.activeSessionID) - .zIndex(session.id == projectSessions.activeSessionID ? 1 : 0) + ProjectSessionContent( + session: session, + isActive: session.id == projectSessions.activeSessionID + ) } + ActiveSessionChrome() } .frame( minWidth: windowLayout.minimumContentSize.width, minHeight: windowLayout.minimumContentSize.height ) .background(LitheTheme.window) - .background( - WindowCloseGuard( - projectSessions: projectSessions, - layout: windowLayout, - title: windowTitle - ) - ) - .onReceive(model.$isSettingsPresented) { isPresented in - guard isPresented else { return } - openWindow(id: LitheWindowID.settings) - } - .sheet(isPresented: $model.isCloneRepositoryPresented) { - CloneRepositoryView() - .environmentObject(model) - } .sheet(item: $projectSessions.pendingProjectOpen) { request in OpenProjectLocationDialog(request: request) { placement, doNotAskAgain in projectSessions.resolvePendingOpen( @@ -51,14 +34,6 @@ struct RootView: View { ) } } - .sheet(item: $model.localHistoryRequest) { request in - LocalHistoryView(request: request) - .environmentObject(model) - } - .sheet(item: $model.projectLocalHistoryRequest) { request in - ProjectLocalHistoryView(request: request) - .environmentObject(model) - } .alert(item: $updateChecker.notice) { notice in switch notice.action { case .install: @@ -115,8 +90,27 @@ struct RootView: View { } } - @ViewBuilder - private func projectContent(for session: AppModel) -> some View { + private var windowLayout: LitheWindowLayout { + projectSessions.activeModel.workspaceURL == nil ? .welcome : .workspace + } + + private var updatePromptPresented: Binding { + Binding( + get: { updateChecker.updatePrompt != nil }, + set: { isPresented in + if !isPresented { + updateChecker.dismissUpdatePrompt() + } + } + ) + } +} + +private struct ProjectSessionContent: View { + @ObservedObject var session: AppModel + let isActive: Bool + + var body: some View { Group { if session.workspaceURL == nil { WelcomeView() @@ -126,21 +120,52 @@ struct RootView: View { } } .environmentObject(session) + .environmentObject(session.editorChrome) + .environmentObject(session.editorDiagnosticsStore) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .accessibilityHidden(!isActive) + .zIndex(isActive ? 1 : 0) } +} - private var updatePromptPresented: Binding { - Binding( - get: { updateChecker.updatePrompt != nil }, - set: { isPresented in - if !isPresented { - updateChecker.dismissUpdatePrompt() - } +private struct ActiveSessionChrome: View { + @Environment(\.openWindow) private var openWindow + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var projectSessions: ProjectSessionManager + + var body: some View { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + .background( + WindowCloseGuard( + projectSessions: projectSessions, + layout: windowLayout, + title: windowTitle + ) + ) + .onReceive(model.$isSettingsPresented) { isPresented in + guard isPresented else { return } + openWindow(id: LitheWindowID.settings) + } + .sheet(isPresented: $model.isCloneRepositoryPresented) { + CloneRepositoryView() + .environmentObject(model) + } + .sheet(item: $model.localHistoryRequest) { request in + LocalHistoryView(request: request) + .environmentObject(model) + } + .sheet(item: $model.projectLocalHistoryRequest) { request in + ProjectLocalHistoryView(request: request) + .environmentObject(model) } - ) } private var windowLayout: LitheWindowLayout { - projectSessions.activeModel.workspaceURL == nil ? .welcome : .workspace + model.workspaceURL == nil ? .welcome : .workspace } private var windowTitle: String? { diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift index 2efdf418..d27a90aa 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -58,6 +58,8 @@ fileprivate struct CodeEditorPalette { struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @EnvironmentObject private var diagnosticsStore: EditorDiagnosticsStore @EnvironmentObject private var settings: AppSettings @ObservedObject var document: EditorDocument var debugService: JavaDebugFeatureModel? @@ -221,31 +223,32 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.shouldFocus = shouldFocus context.coordinator.markdownScrollPosition = markdownScrollPosition if let scrollView = container.scrollView { - scrollView.backgroundColor = palette.background + if appearanceChanged { + scrollView.backgroundColor = palette.background + } context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) } context.coordinator.isDarkAppearance = palette.isDark context.coordinator.colorTheme = settings.colorTheme context.coordinator.requestInitialFocusIfNeeded() - textView.font = LitheTheme.editorFont(size: settings.editorFontSize) - textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle - if let codeTextView = textView as? CodeTextView { - codeTextView.applyAppearance(palette) - codeTextView.indentationWidth = settings.tabWidth - codeTextView.languageServerFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] - codeTextView.isLanguageNavigationEnabled = !codeTextView.languageServerFeatures.intersection([ - .definition, .references, .implementation - ]).isEmpty - codeTextView.isLanguageIntelligenceEnabled = !codeTextView.languageServerFeatures.intersection([ - .hover, .completion, .rename, .formatting, .codeActions - ]).isEmpty - } - container.gutter?.applyAppearance(palette) - textView.isEditable = !document.isReadOnly - textView.isSelectable = true + + let languageFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] + let fontSize = settings.editorFontSize + let tabWidth = settings.tabWidth + let chromeChanged = context.coordinator.applyEditorChromeIfNeeded( + fontSize: fontSize, + tabWidth: tabWidth, + languageFeatures: languageFeatures, + isReadOnly: document.isReadOnly, + palette: palette, + textView: textView, + gutter: container.gutter + ) + // Keep IME marked text (for example, an active Chinese pinyin // composition) in the NSTextView until the input method commits it. + var textChanged = false if textView.string != document.text, !textView.hasMarkedText(), !context.coordinator.isApplyingEditorChange { @@ -256,17 +259,27 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.highlight() (textView as? CodeTextView)?.updateEditorDecorations() container.gutter?.needsDisplay = true + textChanged = true } if appearanceChanged { context.coordinator.highlight() (textView as? CodeTextView)?.updateEditorDecorations() + } else if chromeChanged, !textChanged { + (textView as? CodeTextView)?.updateEditorDecorations() } context.coordinator.updateCodeVisionAndBlame() context.coordinator.updateGitLineChanges() context.coordinator.updateDiagnostics() context.coordinator.applyNavigationTargetIfNeeded() if let codeTextView = textView as? CodeTextView { - codeTextView.syncFindState(isVisible: model.isFindBarVisible, query: model.findBarQuery) + let findVisible = chrome.isFindBarVisible + let findQuery = chrome.findBarQuery + if context.coordinator.lastFindVisible != findVisible + || context.coordinator.lastFindQuery != findQuery { + context.coordinator.lastFindVisible = findVisible + context.coordinator.lastFindQuery = findQuery + codeTextView.syncFindState(isVisible: findVisible, query: findQuery) + } } context.coordinator.applySynchronizedMarkdownScrollIfNeeded(to: container.scrollView) } @@ -290,6 +303,26 @@ struct CodeEditorView: NSViewRepresentable { var appliedNavigationTargetID: UUID? var foldRegions: [JavaFoldRegion] = [] var collapsedFoldIDs: Set = [] + var lastFindVisible = false + var lastFindQuery = "" + private var pendingHighlightRange: NSRange? + private var pendingReplacedRange: NSRange? + private var pendingReplacement: String? + private var foldRefreshTask: Task? + private var decorationRefreshTask: Task? + private var documentChangeTask: Task? + private var remainingHighlightTask: Task? + private var appliedFontSize: CGFloat? + private var appliedTabWidth: Int? + private var appliedLanguageFeatures: LanguageServerFeatureSet? + private var appliedReadOnly: Bool? + private var appliedCodeVisionHints: [JavaCodeVisionHint]? + private var appliedInlayHints: [JavaInlayHint]? + private var appliedBlameVisible = false + private var appliedBlameLines: [GitBlameLine] = [] + private var appliedDebugBreakpointLines = Set() + private var appliedGitMarkers: [GitLineChangeMarker]? + private var appliedDiagnostics: [EditorDiagnostic]? private var markdownImagePasteMonitor: Any? private weak var markdownScrollView: NSScrollView? private var markdownScrollObserver: NSObjectProtocol? @@ -311,6 +344,10 @@ struct CodeEditorView: NSViewRepresentable { } deinit { + foldRefreshTask?.cancel() + decorationRefreshTask?.cancel() + documentChangeTask?.cancel() + remainingHighlightTask?.cancel() if let markdownImagePasteMonitor { NSEvent.removeMonitor(markdownImagePasteMonitor) } @@ -468,44 +505,194 @@ struct CodeEditorView: NSViewRepresentable { return true } + func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { + let inserted = replacementString ?? "" + pendingReplacement = inserted + pendingReplacedRange = affectedCharRange + pendingHighlightRange = NSRange(location: affectedCharRange.location, length: (inserted as NSString).length) + return true + } + func textDidChange(_ notification: Notification) { guard let textView else { return } guard document?.isReadOnly != true else { return } - (textView as? CodeTextView)?.rebuildLineIndex() + let codeTextView = textView as? CodeTextView + if let replacedRange = pendingReplacedRange, let replacement = pendingReplacement { + codeTextView?.applyLineIndexEdit(replacedRange: replacedRange, replacement: replacement) + } else { + codeTextView?.rebuildLineIndex() + } isApplyingEditorChange = true - document?.text = textView.string + document?.applyLiveEditorText(textView.string) if let document { - model?.documentDidChange(document) + scheduleDocumentChange(document) } - highlight() - let codeTextView = textView as? CodeTextView - if let codeTextView, let model, model.isFindBarVisible, !model.findBarQuery.isEmpty { - // 先按新文本重算匹配再统一刷新装饰,避免旧 range 越界 - codeTextView.updateFindMatches(query: model.findBarQuery) + highlight(in: pendingHighlightRange) + let findReplacedRange = pendingReplacedRange + let findInsertedLength = pendingHighlightRange?.length ?? 0 + pendingHighlightRange = nil + pendingReplacedRange = nil + pendingReplacement = nil + if let codeTextView, + let findReplacedRange, + model?.editorChrome.isFindBarVisible == true, + let query = model?.editorChrome.findBarQuery, + !query.isEmpty { + codeTextView.applyFindEdit( + replacedRange: findReplacedRange, + insertedLength: findInsertedLength, + query: query + ) + codeTextView.updateCaretDecorations() + } else if model?.editorChrome.isFindBarVisible == true, + !(model?.editorChrome.findBarQuery.isEmpty ?? true) { + scheduleDecorationRefresh() } else { - codeTextView?.updateEditorDecorations() + codeTextView?.updateCaretDecorations() + scheduleDecorationRefresh() } - refreshFoldRegions(useDefaultImportFold: false) + scheduleFoldRefresh() gutter?.needsDisplay = true isApplyingEditorChange = false updateCaret() } func textViewDidChangeSelection(_ notification: Notification) { - (textView as? CodeTextView)?.updateEditorDecorations() + // Typing already refreshed caret chrome in textDidChange. A second + // full pass here is what dropped the frame rate into the 30s. + guard !isApplyingEditorChange else { return } + (textView as? CodeTextView)?.updateCaretDecorations() textView?.needsDisplay = true gutter?.needsDisplay = true updateCaret() } - func highlight() { + fileprivate func applyEditorChromeIfNeeded( + fontSize: CGFloat, + tabWidth: Int, + languageFeatures: LanguageServerFeatureSet, + isReadOnly: Bool, + palette: CodeEditorPalette, + textView: NSTextView, + gutter: LineNumberGutterView? + ) -> Bool { + var changed = false + if appliedFontSize != fontSize { + textView.font = LitheTheme.editorFont(size: fontSize) + textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle + appliedFontSize = fontSize + changed = true + } + if let codeTextView = textView as? CodeTextView { + codeTextView.applyAppearance(palette) + if appliedTabWidth != tabWidth { + codeTextView.indentationWidth = tabWidth + appliedTabWidth = tabWidth + changed = true + } + if appliedLanguageFeatures != languageFeatures { + codeTextView.languageServerFeatures = languageFeatures + codeTextView.isLanguageNavigationEnabled = !languageFeatures.intersection([ + .definition, .references, .implementation + ]).isEmpty + codeTextView.isLanguageIntelligenceEnabled = !languageFeatures.intersection([ + .hover, .completion, .rename, .formatting, .codeActions + ]).isEmpty + appliedLanguageFeatures = languageFeatures + changed = true + } + } + gutter?.applyAppearance(palette) + if appliedReadOnly != isReadOnly { + textView.isEditable = !isReadOnly + textView.isSelectable = true + appliedReadOnly = isReadOnly + changed = true + } + return changed + } + + func highlight(in editedRange: NSRange? = nil) { guard let textView, let textStorage = textView.textStorage else { return } + if let editedRange { + SyntaxHighlighter.apply( + to: textStorage, + font: textView.font ?? LitheTheme.editorFont(size: 13), + fileExtension: fileExtension, + isDark: isDarkAppearance, + range: editedRange + ) + return + } + let visible = (textView as? CodeTextView)?.visibleCharacterRange() + ?? NSRange(location: 0, length: min(8_192, textStorage.length)) SyntaxHighlighter.apply( to: textStorage, font: textView.font ?? LitheTheme.editorFont(size: 13), fileExtension: fileExtension, - isDark: isDarkAppearance + isDark: isDarkAppearance, + range: visible ) + scheduleRemainingHighlight(skipping: visible) + } + + func scheduleRemainingHighlight(skipping alreadyColored: NSRange) { + remainingHighlightTask?.cancel() + remainingHighlightTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled, let self, let textView = self.textView, + let storage = textView.textStorage else { return } + let font = textView.font ?? LitheTheme.editorFont(size: 13) + let chunk = 16_384 + var location = 0 + while location < storage.length { + if Task.isCancelled { return } + let length = min(chunk, storage.length - location) + let range = NSRange(location: location, length: length) + if NSIntersectionRange(range, alreadyColored) != range { + SyntaxHighlighter.apply( + to: storage, + font: font, + fileExtension: self.fileExtension, + isDark: self.isDarkAppearance, + range: range + ) + } + location += chunk + await Task.yield() + } + } + } + + func scheduleFoldRefresh() { + foldRefreshTask?.cancel() + foldRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(80)) + guard !Task.isCancelled, let self else { return } + self.refreshFoldRegions(useDefaultImportFold: false) + } + } + + func scheduleDecorationRefresh() { + decorationRefreshTask?.cancel() + decorationRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(80)) + guard !Task.isCancelled, let self, let textView = self.textView as? CodeTextView else { return } + if let model = self.model, model.isFindBarVisible, !model.findBarQuery.isEmpty { + textView.updateFindMatches(query: model.findBarQuery) + } else { + textView.updateEditorDecorations() + } + } + } + + func scheduleDocumentChange(_ document: EditorDocument) { + documentChangeTask?.cancel() + documentChangeTask = Task { @MainActor [weak self, weak document] in + try? await Task.sleep(for: .milliseconds(80)) + guard !Task.isCancelled, let document else { return } + self?.model?.documentDidChange(document) + } } func refreshFoldRegions(useDefaultImportFold: Bool) { @@ -569,19 +756,26 @@ struct CodeEditorView: NSViewRepresentable { guard let document, let model else { return } let url = document.url.standardizedFileURL let hints = model.settings.showCodeVision ? model.javaCodeVisionHints[url] ?? [] : [] - codeVisionOverlay?.update( - hints: hints, - onUsages: { [weak model] hint in model?.findUsages(for: hint, in: url) }, - onImplementations: { [weak model] hint in - model?.findJavaImplementations( - line: hint.line, - utf16Column: hint.utf16Column, - in: url - ) - }, - onAuthor: { [weak model] in model?.showBlame(for: url) } - ) - inlayHintOverlay?.update(hints: model.javaInlayHints[url] ?? []) + if appliedCodeVisionHints != hints { + appliedCodeVisionHints = hints + codeVisionOverlay?.update( + hints: hints, + onUsages: { [weak model] hint in model?.findUsages(for: hint, in: url) }, + onImplementations: { [weak model] hint in + model?.findJavaImplementations( + line: hint.line, + utf16Column: hint.utf16Column, + in: url + ) + }, + onAuthor: { [weak model] in model?.showBlame(for: url) } + ) + } + let inlayHints = model.javaInlayHints[url] ?? [] + if appliedInlayHints != inlayHints { + appliedInlayHints = inlayHints + inlayHintOverlay?.update(hints: inlayHints) + } let isBlameVisible = model.blameVisibleURL == url let blameLines = model.gitBlameLines[url] ?? [] @@ -592,12 +786,19 @@ struct CodeEditorView: NSViewRepresentable { $0.fileURL.standardizedFileURL == url }.map(\.line) let debugBreakpointLines = Set(javaBreakpointLines + genericBreakpointLines) - container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : 52 - gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in - Task { await model?.showGitCommit(blame.commitHash) } - } - gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in - model?.toggleDebugBreakpoint(fileURL: url, line: line) + if appliedBlameVisible != isBlameVisible + || appliedBlameLines != blameLines + || appliedDebugBreakpointLines != debugBreakpointLines { + appliedBlameVisible = isBlameVisible + appliedBlameLines = blameLines + appliedDebugBreakpointLines = debugBreakpointLines + container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : 52 + gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in + Task { await model?.showGitCommit(blame.commitHash) } + } + gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in + model?.toggleDebugBreakpoint(fileURL: url, line: line) + } } } @@ -606,6 +807,8 @@ struct CodeEditorView: NSViewRepresentable { let url = document.url.standardizedFileURL if let markers = model.gitLineChangeMarkers(for: url) { isLoadingGitLineChanges = false + guard appliedGitMarkers != markers else { return } + appliedGitMarkers = markers let change = model.gitChange(for: url) gutter.updateGitLineChanges( markers, @@ -627,7 +830,10 @@ struct CodeEditorView: NSViewRepresentable { return } - gutter.updateGitLineChanges([], onShow: { _ in }) + if appliedGitMarkers != [] { + appliedGitMarkers = [] + gutter.updateGitLineChanges([], onShow: { _ in }) + } guard !isLoadingGitLineChanges else { return } isLoadingGitLineChanges = true Task { @MainActor [weak self, weak model] in @@ -637,11 +843,12 @@ struct CodeEditorView: NSViewRepresentable { } func updateDiagnostics() { - guard let document, let model, + guard let document, let textView = textView as? CodeTextView else { return } - textView.updateDiagnostics( - model.editorDiagnostics[document.url.standardizedFileURL] ?? [] - ) + let diagnostics = model?.editorDiagnosticsStore.diagnostics(for: document.url) ?? [] + guard appliedDiagnostics != diagnostics else { return } + appliedDiagnostics = diagnostics + textView.updateDiagnostics(diagnostics) } func applyNavigationTargetIfNeeded() { @@ -671,12 +878,21 @@ struct CodeEditorView: NSViewRepresentable { let text = textView.string as NSString updateSelectedText(in: text, range: textView.selectedRange()) let location = min(textView.selectedRange().location, text.length) - let prefix = text.substring(to: location) as NSString - var line = 0 - var lineStart = 0 - for index in 0.. Bool { + let replacedEnd = NSMaxRange(replacedRange) + if starts.contains(where: { $0 > replacedRange.location && $0 <= replacedEnd }) { + return false + } + let delta = insertedLength - replacedRange.length + guard delta != 0 else { return true } + textLength = max(0, textLength + delta) + for index in starts.indices where starts[index] > replacedRange.location { + starts[index] += delta + } + return true + } + var lineCount: Int { guard textLength > 0, starts.last == textLength else { return starts.count } return max(1, starts.count - 1) @@ -780,6 +1012,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var findMatchRanges: [NSRange] = [] private var currentFindMatchIndex = 0 private var lastReportedFindState: (index: Int, count: Int)? + private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? @@ -856,6 +1089,88 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { lineIndex = TextLineIndex(source: string as NSString) } + func applyLineIndexEdit(replacedRange: NSRange, replacement: String) { + if replacement.contains("\n") || replacement.contains("\r") + || !lineIndex.applySingleLineEdit(replacedRange: replacedRange, insertedLength: (replacement as NSString).length) { + rebuildLineIndex() + } + } + + func visibleCharacterRange() -> NSRange? { + guard let layoutManager, + let textContainer, + let scrollView = enclosingScrollView else { return nil } + let visibleRect = scrollView.documentVisibleRect + let textContainerVisibleRect = NSRect( + x: visibleRect.minX - textContainerOrigin.x, + y: visibleRect.minY - textContainerOrigin.y, + width: visibleRect.width, + height: visibleRect.height + ) + let glyphRange = layoutManager.glyphRange( + forBoundingRect: textContainerVisibleRect, + in: textContainer + ) + guard glyphRange.length > 0 else { return nil } + return layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + } + + #if DEBUG + var currentFindMatchCountForTesting: Int { findMatchRanges.count } + var findMatchLocationsForTesting: [Int] { findMatchRanges.map(\.location) } + #endif + + func applyFindEdit(replacedRange: NSRange, insertedLength: Int, query: String) { + guard !query.isEmpty else { + clearFindHighlights() + return + } + let source = string as NSString + let delta = insertedLength - replacedRange.length + let replacedEnd = NSMaxRange(replacedRange) + findMatchRanges = findMatchRanges.compactMap { range in + if NSMaxRange(range) <= replacedRange.location { return range } + if range.location >= replacedEnd { + return NSRange(location: range.location + delta, length: range.length) + } + return nil + } + let safeLocation = min(replacedRange.location, max(0, source.length - 1)) + let lineRange = source.length == 0 + ? NSRange(location: 0, length: 0) + : source.lineRange(for: NSRange(location: safeLocation, length: 0)) + let searchEnd = min(source.length, max(NSMaxRange(lineRange), replacedRange.location + insertedLength)) + let searchRange = NSRange( + location: lineRange.location, + length: max(0, searchEnd - lineRange.location) + ) + findMatchRanges.removeAll { range in + NSIntersectionRange(range, searchRange).length > 0 + || (range.location >= searchRange.location && range.location < NSMaxRange(searchRange)) + } + if searchRange.length > 0, !query.isEmpty { + var cursor = searchRange + while cursor.length > 0 { + let found = source.range( + of: query, + options: [.caseInsensitive, .diacriticInsensitive], + range: cursor + ) + if found.location == NSNotFound { break } + findMatchRanges.append(found) + let nextLocation = NSMaxRange(found) + cursor = NSRange(location: nextLocation, length: NSMaxRange(searchRange) - nextLocation) + } + findMatchRanges.sort { $0.location < $1.location } + } + currentFindMatchIndex = min(currentFindMatchIndex, max(0, findMatchRanges.count - 1)) + applyFindHighlights() + reportFindState( + index: findMatchRanges.isEmpty ? -1 : currentFindMatchIndex, + count: findMatchRanges.count + ) + } + func characterOffset(forLine targetLine: Int, in _: NSString) -> Int { lineIndex.characterOffset(forLine: targetLine) } @@ -873,6 +1188,53 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { updateEditorDecorations() } + func updateCaretDecorations() { + guard let layoutManager else { return } + let fullLength = (string as NSString).length + for range in lastCaretBackgroundRanges where NSMaxRange(range) <= fullLength { + layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: range) + } + lastCaretBackgroundRanges = [] + guard fullLength > 0 else { return } + + let source = string as NSString + let caret = min(selectedRange().location, source.length) + let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) + layoutManager.addTemporaryAttribute( + .backgroundColor, + value: currentLineColor, + forCharacterRange: lineRange + ) + lastCaretBackgroundRanges.append(lineRange) + + for range in matchingBracketRanges(in: source, caret: caret) { + layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) + lastCaretBackgroundRanges.append(range) + } + + if isLanguageNavigationEnabled, + let symbol = identifier(at: caret, in: source), + let scope = enclosingCodeScope(at: caret, in: source) { + let escaped = NSRegularExpression.escapedPattern(for: symbol.text) + if let expression = try? NSRegularExpression(pattern: "\\b\(escaped)\\b") { + expression.enumerateMatches(in: string, range: scope) { [weak layoutManager] match, _, _ in + guard let match else { return } + layoutManager?.addTemporaryAttribute( + .backgroundColor, + value: self.symbolColor, + forCharacterRange: match.range + ) + self.lastCaretBackgroundRanges.append(match.range) + } + } + } + + if !findMatchRanges.isEmpty { + applyFindHighlights() + } + applyLinkHighlight() + } + func updateEditorDecorations() { guard let layoutManager else { return } let fullRange = NSRange(location: 0, length: string.utf16.count) @@ -887,6 +1249,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return } + lastCaretBackgroundRanges = [] let source = string as NSString let caret = min(selectedRange().location, source.length) let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) @@ -895,9 +1258,11 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { value: currentLineColor, forCharacterRange: lineRange ) + lastCaretBackgroundRanges.append(lineRange) for range in matchingBracketRanges(in: source, caret: caret) { layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) + lastCaretBackgroundRanges.append(range) } if isLanguageNavigationEnabled, @@ -912,6 +1277,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { value: self.symbolColor, forCharacterRange: match.range ) + self.lastCaretBackgroundRanges.append(match.range) } } } @@ -2806,9 +3172,37 @@ private final class ClosureButton: NSButton { @MainActor private enum SyntaxHighlighter { - static func apply(to storage: NSTextStorage, font: NSFont, fileExtension: String, isDark: Bool) { + private static let keywordExpression = try! NSRegularExpression( + pattern: #"\b(class|struct|enum|protocol|extension|func|let|var|if|else|guard|switch|case|for|while|return|throw|throws|try|catch|async|await|public|private|internal|protected|static|final|new|import|package|interface|implements|extends|void|boolean|int|long|const|function|def|in|from|as|true|false|null|nil|self|this)\b"# + ) + private static let annotationExpression = try! NSRegularExpression( + pattern: #"@[A-Za-z_][A-Za-z0-9_]*"# + ) + private static let typeExpression = try! NSRegularExpression( + pattern: #"\b[A-Z][A-Za-z0-9_]*\b"# + ) + private static let numberExpression = try! NSRegularExpression( + pattern: #"\b\d+(?:\.\d+)?\b"# + ) + private static let stringExpression = try! NSRegularExpression( + pattern: #"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"# + ) + private static let commentExpression = try! NSRegularExpression( + pattern: #"//.*$|#.*$|/\*[\s\S]*?\*/"#, + options: [.anchorsMatchLines] + ) + + static func apply( + to storage: NSTextStorage, + font: NSFont, + fileExtension: String, + isDark: Bool, + range: NSRange? = nil + ) { let fullRange = NSRange(location: 0, length: storage.length) guard fullRange.length > 0 else { return } + let target = expandedRange(range, in: storage.string as NSString, limit: fullRange) + guard target.length > 0 else { return } let palette = CodeEditorPalette(isDark: isDark, theme: LitheTheme.activeTheme) storage.beginEditing() @@ -2817,28 +3211,51 @@ private enum SyntaxHighlighter { .paragraphStyle: LitheTheme.editorParagraphStyle, .ligature: 0, .foregroundColor: palette.text - ], range: fullRange) - - apply(pattern: #"\b(class|struct|enum|protocol|extension|func|let|var|if|else|guard|switch|case|for|while|return|throw|throws|try|catch|async|await|public|private|internal|protected|static|final|new|import|package|interface|implements|extends|void|boolean|int|long|const|function|def|in|from|as|true|false|null|nil|self|this)\b"#, color: palette.keyword, storage: storage) - apply(pattern: #"@[A-Za-z_][A-Za-z0-9_]*"#, color: palette.annotation, storage: storage) - apply(pattern: #"\b[A-Z][A-Za-z0-9_]*\b"#, color: palette.type, storage: storage) - apply(pattern: #"\b\d+(?:\.\d+)?\b"#, color: palette.number, storage: storage) - apply(pattern: #"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"#, color: palette.string, storage: storage) - apply(pattern: #"//.*$|#.*$|/\*[\s\S]*?\*/"#, options: [.anchorsMatchLines], color: palette.comment, storage: storage) + ], range: target) + + apply(keywordExpression, color: palette.keyword, storage: storage, range: target) + apply(annotationExpression, color: palette.annotation, storage: storage, range: target) + apply(typeExpression, color: palette.type, storage: storage, range: target) + apply(numberExpression, color: palette.number, storage: storage, range: target) + apply(stringExpression, color: palette.string, storage: storage, range: target) + apply(commentExpression, color: palette.comment, storage: storage, range: target) storage.endEditing() } private static func apply( - pattern: String, - options: NSRegularExpression.Options = [], + _ expression: NSRegularExpression, color: NSColor, - storage: NSTextStorage + storage: NSTextStorage, + range: NSRange ) { - guard let expression = try? NSRegularExpression(pattern: pattern, options: options) else { return } - let range = NSRange(location: 0, length: storage.length) expression.enumerateMatches(in: storage.string, range: range) { match, _, _ in guard let match else { return } storage.addAttribute(.foregroundColor, value: color, range: match.range) } } + + /// Re-color the edited lines plus a small pad so a token that crosses the + /// caret, or a nearby block comment, is not left half-styled. + private static func expandedRange(_ range: NSRange?, in source: NSString, limit: NSRange) -> NSRange { + guard let range else { return limit } + let safe = NSIntersectionRange(range, limit) + guard source.length > 0 else { return safe } + let startLine = source.lineRange(for: NSRange(location: safe.location, length: 0)) + let endIndex = max(safe.location, NSMaxRange(safe) > 0 ? NSMaxRange(safe) - 1 : 0) + let endLine = source.lineRange(for: NSRange(location: min(endIndex, source.length - 1), length: 0)) + var combined = NSUnionRange(startLine, endLine) + if combined.location > 0 { + combined = NSUnionRange( + source.lineRange(for: NSRange(location: combined.location - 1, length: 0)), + combined + ) + } + if NSMaxRange(combined) < source.length { + combined = NSUnionRange( + combined, + source.lineRange(for: NSRange(location: NSMaxRange(combined), length: 0)) + ) + } + return NSIntersectionRange(combined, limit) + } } diff --git a/Sources/Lithe/Views/Editor/EditorAreaView.swift b/Sources/Lithe/Views/Editor/EditorAreaView.swift index 058f86db..f830ded8 100644 --- a/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -255,11 +255,7 @@ struct EditorAreaView: View { size: 13 ) editorTabTitle(document) - if document.isDirty { - Circle() - .fill(LitheTheme.primaryText) - .frame(width: 6, height: 6) - } + EditorTabDirtyIndicator(document: document) } .foregroundStyle(model.activeDocumentID == document.id ? LitheTheme.primaryText : LitheTheme.secondaryText) .padding(.leading, 11) @@ -641,12 +637,7 @@ struct EditorAreaView: View { ) -> some View { codeEditor(document, markdownScrollPosition: markdownScrollPosition) .overlay(alignment: .top) { - if model.isFindBarVisible { - FindBarView() - .padding(.top, 10) - .padding(.horizontal, 12) - .transition(.move(edge: .top).combined(with: .opacity)) - } + FindBarOverlay() } } @@ -751,3 +742,28 @@ private struct EditorTabDropDelegate: DropDelegate { ) } } + +private struct EditorTabDirtyIndicator: View { + @ObservedObject var document: EditorDocument + + var body: some View { + if document.isDirty { + Circle() + .fill(LitheTheme.primaryText) + .frame(width: 6, height: 6) + } + } +} + +private struct FindBarOverlay: View { + @EnvironmentObject private var chrome: EditorChromeModel + + var body: some View { + if chrome.isFindBarVisible { + FindBarView() + .padding(.top, 10) + .padding(.horizontal, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/Sources/Lithe/Views/Editor/FindBarView.swift b/Sources/Lithe/Views/Editor/FindBarView.swift index 4302e433..8f03a6ab 100644 --- a/Sources/Lithe/Views/Editor/FindBarView.swift +++ b/Sources/Lithe/Views/Editor/FindBarView.swift @@ -3,11 +3,12 @@ import SwiftUI /// 编辑器内的单文件查找栏:实时高亮、上/下一个、Esc 关闭。 struct FindBarView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel @FocusState private var focused: Bool private var queryBinding: Binding { Binding( - get: { model.findBarQuery }, + get: { chrome.findBarQuery }, set: { model.setFindBarQuery($0) } ) } @@ -43,7 +44,7 @@ struct FindBarView: View { } .litheIconButton() .foregroundStyle(LitheTheme.secondaryText) - .disabled(model.findMatchCount == 0) + .disabled(chrome.findMatchCount == 0) .help("Previous match (Shift+Return)") Button { @@ -53,7 +54,7 @@ struct FindBarView: View { } .litheIconButton() .foregroundStyle(LitheTheme.secondaryText) - .disabled(model.findMatchCount == 0) + .disabled(chrome.findMatchCount == 0) .help("Next match (Return)") Button { @@ -76,8 +77,8 @@ struct FindBarView: View { } private var matchLabel: String { - guard model.findMatchCount > 0 else { return "" } - let current = max(0, model.currentFindMatchIndex + 1) - return "\(current)/\(model.findMatchCount)" + guard chrome.findMatchCount > 0 else { return "" } + let current = max(0, chrome.currentFindMatchIndex + 1) + return "\(current)/\(chrome.findMatchCount)" } } diff --git a/Sources/Lithe/Views/Language/JavaProblemsView.swift b/Sources/Lithe/Views/Language/JavaProblemsView.swift index 6a6652c2..11e9d4b7 100644 --- a/Sources/Lithe/Views/Language/JavaProblemsView.swift +++ b/Sources/Lithe/Views/Language/JavaProblemsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ProblemsView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var diagnosticsStore: EditorDiagnosticsStore @State private var severityFilter = Set(DiagnosticSeverity.allCases) var body: some View { @@ -80,7 +81,7 @@ struct ProblemsView: View { } private var allDiagnostics: [EditorDiagnostic] { - model.editorDiagnostics.values + diagnosticsStore.diagnosticsByURL.values .flatMap { $0 } .sorted { let left = model.relativePath(for: $0.fileURL) diff --git a/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift new file mode 100644 index 00000000..1dc9a766 --- /dev/null +++ b/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct EditorCaretPositionLabel: View { + @ObservedObject var chrome: EditorChromeModel + + var body: some View { + Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") + .monospacedDigit() + } +} + +struct MemoryUsageStatusView: View { + @EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor + @State private var isMemoryUsagePopoverPresented = false + + var body: some View { + Button { + isMemoryUsagePopoverPresented.toggle() + } label: { + Label { + HStack(spacing: 4) { + Text("Total \(memoryUsageMonitor.totalText)") + Text("·") + Text("Lithe \(memoryUsageMonitor.litheText)") + } + .monospacedDigit() + } icon: { + Image(systemName: "memorychip") + } + } + .buttonStyle(.plain) + .lithePointer() + .help( + Text( + "Total managed memory: \(memoryUsageMonitor.totalText)\n" + + "Lithe: \(memoryUsageMonitor.litheText) · LSP: \(memoryUsageMonitor.lspText) · Services: \(memoryUsageMonitor.serviceText)" + ) + ) + .popover(isPresented: $isMemoryUsagePopoverPresented, arrowEdge: .top) { + memoryUsagePopover + } + .onChange(of: isMemoryUsagePopoverPresented) { isPresented in + memoryUsageMonitor.setDetailedUsageVisible(isPresented) + } + } + + private var memoryUsagePopover: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "memorychip") + .foregroundStyle(LitheTheme.accent) + Text("Managed Memory") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 8) + Button { + isMemoryUsagePopoverPresented = false + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + } + .litheIconButton() + .help("Close") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + VStack(spacing: 0) { + memoryMetric("Lithe", value: memoryUsageMonitor.litheText) + memoryMetric( + "Language servers", + value: memoryUsageMonitor.languageServerProcessCount == 0 + ? String(localized: "Not running") + : memoryUsageMonitor.lspText + ) + memoryMetric( + "Running services", + value: memoryUsageMonitor.serviceProcessCount == 0 + ? String(localized: "Not running") + : memoryUsageMonitor.serviceText + ) + memoryMetric("Total", value: memoryUsageMonitor.totalText) + memoryMetric("Average total", value: memoryUsageMonitor.averageText) + memoryMetric("Peak total", value: memoryUsageMonitor.peakText) + memoryMetric("Runtime", value: memoryUsageMonitor.runtimeText) + memoryMetric("Sample interval", value: memoryUsageMonitor.samplingIntervalText) + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + HStack(alignment: .top, spacing: 6) { + Image(systemName: "info.circle") + Text("Resident memory of Lithe and its managed process trees") + .fixedSize(horizontal: false, vertical: true) + } + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(12) + } + .frame(width: 280) + .background(LitheTheme.popupBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func memoryMetric(_ title: String, value: String) -> some View { + HStack(spacing: 8) { + Text(LocalizedStringKey(title)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 8) + Text(value) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .monospacedDigit() + } + .frame(minHeight: 27) + } +} + +struct FrameRateStatusView: View { + @EnvironmentObject private var frameRateMonitor: FrameRateMonitor + + var body: some View { + Label { + Text(frameRateMonitor.framesPerSecondText) + .monospacedDigit() + } icon: { + Image(systemName: "speedometer") + } + .help("Frames rendered per second") + .accessibilityLabel(Text("\(frameRateMonitor.framesPerSecond) frames per second")) + } +} diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 048c4dd7..9e5ddab0 100644 --- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -15,7 +15,6 @@ struct WorkbenchView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var projectSessions: ProjectSessionManager @EnvironmentObject private var settings: AppSettings - @EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor @Environment(\.accessibilityReduceMotion) private var reduceMotion @StateObject private var linuxDoWebSession = LinuxDoAnonymousWebSession() @State private var sidebarWidth: CGFloat = 320 @@ -29,7 +28,6 @@ struct WorkbenchView: View { @State private var isCheckoutRevisionPresented = false @State private var pendingTopBarPushReference: GitReference? @State private var isProjectSwitcherPresented = false - @State private var isMemoryUsagePopoverPresented = false @State private var isPluginPanelPresented = false @State private var didRestoreLayout = false @State private var hoveredProjectTabID: UUID? @@ -898,7 +896,7 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { - caretPosition + EditorCaretPositionLabel(chrome: model.editorChrome) Text("UTF-8") Text("\(settings.tabWidth) spaces") Button { @@ -911,24 +909,21 @@ struct WorkbenchView: View { .help(LocalizedStringKey( model.activeDocument?.isReadOnly == true ? "Read-only document" : "Save" )) - memoryStatus + MemoryUsageStatusView() + FrameRateStatusView() gitStatus } } private var compactStatusItems: some View { HStack(spacing: 10) { - caretPosition - memoryStatus + EditorCaretPositionLabel(chrome: model.editorChrome) + MemoryUsageStatusView() + FrameRateStatusView() gitStatus } } - private var caretPosition: some View { - Text(model.editorCaret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") - .monospacedDigit() - } - private var gitStatus: some View { HStack(spacing: 7) { if model.isReferencesVisible { @@ -940,116 +935,6 @@ struct WorkbenchView: View { } } - private var memoryStatus: some View { - Button { - isMemoryUsagePopoverPresented.toggle() - } label: { - Label { - HStack(spacing: 4) { - Text("Total \(memoryUsageMonitor.totalText)") - Text("·") - Text("Lithe \(memoryUsageMonitor.litheText)") - } - .monospacedDigit() - } icon: { - Image(systemName: "memorychip") - } - } - .buttonStyle(.plain) - .lithePointer() - .help( - Text( - "Total managed memory: \(memoryUsageMonitor.totalText)\n" + - "Lithe: \(memoryUsageMonitor.litheText) · LSP: \(memoryUsageMonitor.lspText) · Services: \(memoryUsageMonitor.serviceText)" - ) - ) - .popover(isPresented: $isMemoryUsagePopoverPresented, arrowEdge: .top) { - memoryUsagePopover - } - .onChange(of: isMemoryUsagePopoverPresented) { isPresented in - memoryUsageMonitor.setDetailedUsageVisible(isPresented) - } - } - - private var memoryUsagePopover: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Image(systemName: "memorychip") - .foregroundStyle(LitheTheme.accent) - Text("Managed Memory") - .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Spacer(minLength: 8) - Button { - isMemoryUsagePopoverPresented = false - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .semibold)) - } - .litheIconButton() - .help("Close") - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - VStack(spacing: 0) { - memoryMetric("Lithe", value: memoryUsageMonitor.litheText) - memoryMetric( - "Language servers", - value: memoryUsageMonitor.languageServerProcessCount == 0 - ? String(localized: "Not running") - : memoryUsageMonitor.lspText - ) - memoryMetric( - "Running services", - value: memoryUsageMonitor.serviceProcessCount == 0 - ? String(localized: "Not running") - : memoryUsageMonitor.serviceText - ) - memoryMetric("Total", value: memoryUsageMonitor.totalText) - memoryMetric("Average total", value: memoryUsageMonitor.averageText) - memoryMetric("Peak total", value: memoryUsageMonitor.peakText) - memoryMetric("Runtime", value: memoryUsageMonitor.runtimeText) - memoryMetric("Sample interval", value: memoryUsageMonitor.samplingIntervalText) - } - .padding(.horizontal, 12) - .padding(.vertical, 5) - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - HStack(alignment: .top, spacing: 6) { - Image(systemName: "info.circle") - Text("Resident memory of Lithe and its managed process trees") - .fixedSize(horizontal: false, vertical: true) - } - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(12) - } - .frame(width: 280) - .background(LitheTheme.popupBackground) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - private func memoryMetric(_ title: String, value: String) -> some View { - HStack(spacing: 8) { - Text(LocalizedStringKey(title)) - .foregroundStyle(LitheTheme.secondaryText) - Spacer(minLength: 8) - Text(value) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .monospacedDigit() - } - .frame(minHeight: 27) - } - private var projectInitials: String { let words = model.projectName.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) let initials = words.prefix(2).compactMap(\.first) diff --git a/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index 20d8fe0f..5fb7e1e3 100644 --- a/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -1,3 +1,4 @@ +import LitheGitModule import SwiftUI struct ProjectSidebarView: View { @@ -22,13 +23,18 @@ struct ProjectSidebarView: View { GeometryReader { geometry in ScrollView([.vertical, .horizontal]) { LazyVStack(alignment: .leading, spacing: 1) { - FileNodeRow( - node: root, - depth: 0, + ProjectFileTreeContent( + root: root, availableWidth: geometry.size.width, activeDocumentURL: model.activeDocument?.url, + gitStatus: ProjectGitStatusSnapshot( + repositoryRoot: model.gitRepositoryRoot, + projection: model.gitTreeStatusProjection + ), + actions: ProjectTreeActions(model: model), expandedDirectoryPaths: $expandedDirectoryPaths ) + .equatable() } .padding(.vertical, 5) .frame( @@ -150,14 +156,119 @@ struct ProjectSidebarView: View { } } +private struct ProjectGitStatusSnapshot: Equatable { + let repositoryRoot: URL? + let projection: GitTreeStatusProjection + + func kind(for url: URL, isDirectory: Bool) -> GitChangeKind? { + guard let repositoryRoot, + let relative = Self.relativePath(for: url, root: repositoryRoot) else { return nil } + return projection.kind(relativePath: relative, isDirectory: isDirectory) + } + + func change(for url: URL) -> GitChange? { + guard let repositoryRoot, + let relative = Self.relativePath(for: url, root: repositoryRoot) else { return nil } + return projection.change(relativePath: relative) + } + + private static func relativePath(for url: URL, root: URL) -> String? { + let normalizedRoot = root.standardizedFileURL.path + let normalizedPath = url.standardizedFileURL.path + guard normalizedPath.hasPrefix(normalizedRoot + "/") else { return nil } + return String(normalizedPath.dropFirst(normalizedRoot.count + 1)) + } +} + +private final class ProjectTreeActions: @unchecked Sendable { + private let model: AppModel + + init(model: AppModel) { + self.model = model + } + + // Button and context-menu closures are not MainActor-isolated under the + // Swift 6 test/release check. Keep these methods synchronous and hop. + nonisolated func openFile(_ url: URL) { + Task { @MainActor in self.model.openFile(url) } + } + nonisolated func requestCreateFile(_ url: URL) { + Task { @MainActor in self.model.requestCreateFile(in: url) } + } + nonisolated func requestCreateDirectory(_ url: URL) { + Task { @MainActor in self.model.requestCreateDirectory(in: url) } + } + nonisolated func revealInFinder(_ url: URL) { + Task { @MainActor in self.model.revealProjectItemInFinder(url) } + } + nonisolated func copyPath(_ url: URL, relative: Bool) { + Task { @MainActor in self.model.copyProjectItemPath(url, relative: relative) } + } + nonisolated func duplicate(_ url: URL) { + Task { await self.model.duplicateProjectItem(at: url) } + } + nonisolated func requestRename(_ url: URL) { + Task { @MainActor in self.model.requestRenameProjectItem(at: url) } + } + nonisolated func requestDelete(_ url: URL, _ isDirectory: Bool) { + Task { @MainActor in + self.model.requestDeleteProjectItem(at: url, isDirectory: isDirectory) + } + } + nonisolated func refreshWorkspace() { + Task { await self.model.refreshWorkspace() } + } + nonisolated func showGitDirectoryDiff(_ url: URL) { + Task { await self.model.showGitDirectoryDiff(for: url) } + } + nonisolated func selectChange(_ change: GitChange) { + Task { @MainActor in self.model.selectChange(change) } + } + nonisolated func showLocalHistory(_ url: URL) { + Task { @MainActor in self.model.showLocalHistory(for: url) } + } + func javaIconKind(_ url: URL) async -> LitheIconKind? { + await model.javaIconKind(for: url) + } +} + +private struct ProjectFileTreeContent: View, Equatable { + let root: FileNode + let availableWidth: CGFloat + let activeDocumentURL: URL? + let gitStatus: ProjectGitStatusSnapshot + let actions: ProjectTreeActions + @Binding var expandedDirectoryPaths: Set + + static func == (lhs: ProjectFileTreeContent, rhs: ProjectFileTreeContent) -> Bool { + lhs.root == rhs.root + && lhs.availableWidth == rhs.availableWidth + && lhs.activeDocumentURL == rhs.activeDocumentURL + && lhs.gitStatus == rhs.gitStatus + } + + var body: some View { + FileNodeRow( + node: root, + depth: 0, + availableWidth: availableWidth, + activeDocumentURL: activeDocumentURL, + gitStatus: gitStatus, + actions: actions, + expandedDirectoryPaths: $expandedDirectoryPaths + ) + } +} + private struct FileNodeRow: View { private static let horizontalInset: CGFloat = 10 - @EnvironmentObject private var model: AppModel let node: FileNode let depth: Int let availableWidth: CGFloat let activeDocumentURL: URL? + let gitStatus: ProjectGitStatusSnapshot + let actions: ProjectTreeActions @Binding var expandedDirectoryPaths: Set @State private var resolvedJavaIconKind: LitheIconKind? @@ -182,6 +293,8 @@ private struct FileNodeRow: View { depth: depth + 1, availableWidth: availableWidth, activeDocumentURL: activeDocumentURL, + gitStatus: gitStatus, + actions: actions, expandedDirectoryPaths: $expandedDirectoryPaths ) } @@ -236,7 +349,7 @@ private struct FileNodeRow: View { private var fileRow: some View { Button { - model.openFile(node.url) + actions.openFile(node.url) } label: { HStack(spacing: 6) { Color.clear.frame(width: 10) @@ -249,7 +362,7 @@ private struct FileNodeRow: View { .truncationMode(.middle) .layoutPriority(1) Spacer(minLength: 4) - if let status = model.gitChange(for: node.url) { + if let status = gitStatus.change(for: node.url) { Text(status.displayStatus) .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundStyle(gitStatusColor ?? LitheTheme.secondaryText) @@ -275,56 +388,56 @@ private struct FileNodeRow: View { .contextMenu { fileContextMenu } .task(id: node.url.standardizedFileURL.path) { guard node.url.pathExtension.lowercased() == "java" else { return } - resolvedJavaIconKind = await model.javaIconKind(for: node.url) + resolvedJavaIconKind = await actions.javaIconKind(node.url) } } @ViewBuilder private var directoryContextMenu: some View { - if model.gitTreeStatus(for: node.url, isDirectory: true) != nil { + if gitStatus.kind(for: node.url, isDirectory: true) != nil { Button("Show Git Diff") { - Task { await model.showGitDirectoryDiff(for: node.url) } + actions.showGitDirectoryDiff(node.url) } Divider() } Button("New File…") { - model.requestCreateFile(in: node.url) + actions.requestCreateFile(node.url) } Button("New Directory…") { - model.requestCreateDirectory(in: node.url) + actions.requestCreateDirectory(node.url) } Divider() Button("Show in Finder") { - model.revealProjectItemInFinder(node.url) + actions.revealInFinder(node.url) } Button("Copy Path") { - model.copyProjectItemPath(node.url, relative: false) + actions.copyPath(node.url, relative: false) } Button("Copy Relative Path") { - model.copyProjectItemPath(node.url, relative: true) + actions.copyPath(node.url, relative: true) } if depth > 0 { Divider() Button("Duplicate") { - Task { await model.duplicateProjectItem(at: node.url) } + actions.duplicate(node.url) } Button("Rename…") { - model.requestRenameProjectItem(at: node.url) + actions.requestRename(node.url) } Button("Move to Trash", role: .destructive) { - model.requestDeleteProjectItem(at: node.url, isDirectory: true) + actions.requestDelete(node.url, true) } } Divider() Button("Refresh") { - Task { await model.refreshWorkspace() } + actions.refreshWorkspace() } } @@ -332,12 +445,12 @@ private struct FileNodeRow: View { private var fileContextMenu: some View { Group { Button("Open") { - model.openFile(node.url) + actions.openFile(node.url) } - if let change = model.gitChange(for: node.url) { + if let change = gitStatus.change(for: node.url) { Button("Show Git Diff") { - model.selectChange(change) + actions.selectChange(change) } } } @@ -346,16 +459,16 @@ private struct FileNodeRow: View { Group { Button("Duplicate") { - Task { await model.duplicateProjectItem(at: node.url) } + actions.duplicate(node.url) } Button("Rename…") { - model.requestRenameProjectItem(at: node.url) + actions.requestRename(node.url) } Button("Local History…") { - model.showLocalHistory(for: node.url) + actions.showLocalHistory(node.url) } Button("Move to Trash", role: .destructive) { - model.requestDeleteProjectItem(at: node.url, isDirectory: false) + actions.requestDelete(node.url, false) } } @@ -363,19 +476,19 @@ private struct FileNodeRow: View { Group { Button("Show in Finder") { - model.revealProjectItemInFinder(node.url) + actions.revealInFinder(node.url) } Button("Copy Path") { - model.copyProjectItemPath(node.url, relative: false) + actions.copyPath(node.url, relative: false) } Button("Copy Relative Path") { - model.copyProjectItemPath(node.url, relative: true) + actions.copyPath(node.url, relative: true) } } } private var gitStatusColor: Color? { - guard let kind = model.gitTreeStatus(for: node.url, isDirectory: node.isDirectory) else { + guard let kind = gitStatus.kind(for: node.url, isDirectory: node.isDirectory) else { return nil } switch kind { diff --git a/Sources/LitheGitModule/Application/GitFeatureModel.swift b/Sources/LitheGitModule/Application/GitFeatureModel.swift index 0b200a1d..7d75b0b7 100644 --- a/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -7,7 +7,10 @@ import LitheModuleAPI /// in AppModel. Git command construction and parsing remain in GitService/Core. @MainActor package final class GitFeatureModel: ObservableObject { - @Published package private(set) var gitChanges: [GitChange] = [] + @Published package private(set) var gitChanges: [GitChange] = [] { + didSet { gitTreeStatus = GitTreeStatusProjection(changes: gitChanges) } + } + package private(set) var gitTreeStatus = GitTreeStatusProjection(changes: []) @Published private var pendingStagingStates: [GitChange.ID: Bool] = [:] @Published package private(set) var gitStashes: [GitStash] = [] @Published package private(set) var gitShelves: [GitShelfEntry] = [] diff --git a/Sources/LitheGitModule/Models/GitModels.swift b/Sources/LitheGitModule/Models/GitModels.swift index 333edd1d..0c891521 100644 --- a/Sources/LitheGitModule/Models/GitModels.swift +++ b/Sources/LitheGitModule/Models/GitModels.swift @@ -431,31 +431,56 @@ package enum GitChangeKind: String, Sendable { /// Projects repository-relative Git changes onto file and directory rows. /// Directory status uses the most urgent descendant state so conflicts and /// deletions are never hidden behind a lower-priority modification. -package struct GitTreeStatusProjection: Sendable { - private let changes: [GitChange] +package struct GitTreeStatusProjection: Equatable, Sendable { + private let changesByPath: [String: GitChange] + private let directoryKinds: [String: GitChangeKind] package init(changes: [GitChange]) { - self.changes = changes + var changesByPath: [String: GitChange] = [:] + var directoryKinds: [String: GitChangeKind] = [:] + for change in changes { + let path = Self.normalized(change.path) + if changesByPath[path] == nil { + changesByPath[path] = change + } + var remainder = path + while let slash = remainder.lastIndex(of: "/") { + remainder = String(remainder[.. Self.priority(current) { + directoryKinds[remainder] = change.kind + } + } else { + directoryKinds[remainder] = change.kind + } + } + if !path.isEmpty { + if let current = directoryKinds[""] { + if Self.priority(change.kind) > Self.priority(current) { + directoryKinds[""] = change.kind + } + } else { + directoryKinds[""] = change.kind + } + } + } + self.changesByPath = changesByPath + self.directoryKinds = directoryKinds } package func change(relativePath: String) -> GitChange? { - let normalized = Self.normalized(relativePath) - return changes.first { Self.normalized($0.path) == normalized } + changesByPath[Self.normalized(relativePath)] } package func kind(relativePath: String, isDirectory: Bool) -> GitChangeKind? { let normalized = Self.normalized(relativePath) if !isDirectory { - return change(relativePath: normalized)?.kind + return changesByPath[normalized]?.kind } - let prefix = normalized.isEmpty ? "" : normalized + "/" - return changes - .filter { normalized.isEmpty || Self.normalized($0.path).hasPrefix(prefix) } - .map(\.kind) - .max { priority($0) < priority($1) } + return directoryKinds[normalized] } - private func priority(_ kind: GitChangeKind) -> Int { + private static func priority(_ kind: GitChangeKind) -> Int { switch kind { case .modified: 0 case .copied: 1 diff --git a/Tests/LitheGitModuleTests/GitModuleTests.swift b/Tests/LitheGitModuleTests/GitModuleTests.swift index a4596bf5..04766a71 100644 --- a/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -37,6 +37,7 @@ struct GitModuleTests { #expect(projection.kind(relativePath: "Sources/Feature", isDirectory: true) == .conflicted) #expect(projection.kind(relativePath: "Sources", isDirectory: true) == .conflicted) #expect(projection.kind(relativePath: "Tests", isDirectory: true) == nil) + #expect(projection.kind(relativePath: "", isDirectory: true) == .conflicted) } @Test diff --git a/Tests/LitheTests/EditorChromeModelTests.swift b/Tests/LitheTests/EditorChromeModelTests.swift new file mode 100644 index 00000000..797e17d9 --- /dev/null +++ b/Tests/LitheTests/EditorChromeModelTests.swift @@ -0,0 +1,75 @@ +import Combine +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct EditorChromeModelTests { + @Test + func unchangedCaretAndSelectionDoNotPublish() { + let chrome = EditorChromeModel() + let caret = EditorCaret( + url: URL(fileURLWithPath: "/workspace/App.java"), + line: 3, + utf16Column: 8 + ) + chrome.update(caret: caret) + chrome.update(selectedText: "name") + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.update(caret: caret) + chrome.update(selectedText: "name") + #expect(publishCount == 0) + + chrome.update(caret: EditorCaret(url: caret.url, line: 4, utf16Column: 0)) + #expect(publishCount == 1) + chrome.update(selectedText: "") + #expect(publishCount == 2) + } + + @Test + func resetClearsCaretAndSelectionOnceEach() { + let chrome = EditorChromeModel() + chrome.update( + caret: EditorCaret(url: URL(fileURLWithPath: "/workspace/App.java"), line: 0, utf16Column: 0) + ) + chrome.update(selectedText: "foo") + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.reset() + #expect(chrome.caret == nil) + #expect(chrome.selectedText.isEmpty) + #expect(publishCount == 2) + + chrome.reset() + #expect(publishCount == 2) + } + + @Test + func findBarUpdatesDoNotPublishUnchangedValues() { + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setFindBarQuery("foo") + chrome.updateFindState(currentIndex: 1, count: 3) + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.setFindBarVisible(true) + chrome.setFindBarQuery("foo") + chrome.updateFindState(currentIndex: 1, count: 3) + #expect(publishCount == 0) + + chrome.setFindBarQuery("bar") + #expect(publishCount == 1) + chrome.updateFindState(currentIndex: 0, count: 2) + #expect(publishCount == 2) + } +} diff --git a/Tests/LitheTests/EditorDiagnosticsStoreTests.swift b/Tests/LitheTests/EditorDiagnosticsStoreTests.swift new file mode 100644 index 00000000..18dc5903 --- /dev/null +++ b/Tests/LitheTests/EditorDiagnosticsStoreTests.swift @@ -0,0 +1,39 @@ +import Combine +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct EditorDiagnosticsStoreTests { + @Test + func unchangedDiagnosticsDoNotPublish() { + let store = EditorDiagnosticsStore() + let url = URL(fileURLWithPath: "/workspace/App.java") + let diagnostic = EditorDiagnostic( + id: "d1", + fileURL: url, + line: 1, + utf16Column: 0, + endLine: 1, + endUTF16Column: 4, + severity: .warning, + message: "unused", + source: nil, + code: nil, + tags: [], + relatedInformation: [] + ) + store.replace([url: [diagnostic]]) + + var publishCount = 0 + let observation = store.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + store.replace([url: [diagnostic]]) + #expect(publishCount == 0) + + store.replace([:]) + #expect(publishCount == 1) + #expect(store.diagnostics(for: url).isEmpty) + } +} diff --git a/Tests/LitheTests/FrameRateMonitorTests.swift b/Tests/LitheTests/FrameRateMonitorTests.swift new file mode 100644 index 00000000..3c056a5d --- /dev/null +++ b/Tests/LitheTests/FrameRateMonitorTests.swift @@ -0,0 +1,37 @@ +import Combine +import Testing +@testable import Lithe + +@MainActor +struct FrameRateMonitorTests { + @Test + func publishesWhenDisplayedIntegerChangesAndIgnoresRepeats() { + let monitor = FrameRateMonitor(sampleWindow: 0.5) + monitor.recordFrameForTesting(at: 0) + monitor.recordFrameForTesting(at: 0.5) + #expect(monitor.framesPerSecond == 4) + #expect(monitor.framesPerSecondText == "4 FPS") + + var publishCount = 0 + let observation = monitor.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + monitor.recordFrameForTesting(at: 1.0) + #expect(monitor.framesPerSecond == 2) + #expect(publishCount == 1) + + monitor.recordFrameForTesting(at: 1.5) + #expect(monitor.framesPerSecond == 2) + #expect(publishCount == 1) + } + + @Test + func coalescesBurstsAfterAHitch() { + let monitor = FrameRateMonitor(sampleWindow: 0.5) + monitor.recordFrameForTesting(at: 0) + monitor.recordFrameForTesting(at: 0.001) + monitor.recordFrameForTesting(at: 0.002) + monitor.recordFrameForTesting(at: 0.5) + #expect(monitor.framesPerSecond == 4) + } +} diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 3434c789..36764438 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import CoreServices import Foundation @testable import LitheDatabaseModule @@ -2119,6 +2120,38 @@ struct LitheCoreLogicTests { #expect(updates.first?.count == 0) } + @Test + @MainActor + func codeEditorLineIndexKeepsLineNumbersAfterSingleLineEdit() { + let textView = CodeTextView(frame: .zero) + textView.string = "one\ntwo\nthree" + textView.rebuildLineIndex() + #expect(textView.lineNumber(at: 4, in: textView.string as NSString) == 1) + + textView.string = "oneX\ntwo\nthree" + textView.applyLineIndexEdit(replacedRange: NSRange(location: 3, length: 0), replacement: "X") + let source = textView.string as NSString + #expect(textView.lineNumber(at: 0, in: source) == 0) + #expect(textView.lineNumber(at: 5, in: source) == 1) + #expect(textView.lineNumber(at: 9, in: source) == 2) + #expect(textView.characterOffset(forLine: 2, in: source) == 9) + } + + @Test + @MainActor + func codeEditorShiftsFindMatchesAcrossASingleLineEdit() { + let textView = CodeTextView(frame: .zero) + textView.string = "alpha beta alpha" + textView.rebuildLineIndex() + textView.updateFindMatches(query: "alpha") + #expect(textView.currentFindMatchCountForTesting == 2) + + textView.string = "Xalpha beta alpha" + textView.applyFindEdit(replacedRange: NSRange(location: 0, length: 0), insertedLength: 1, query: "alpha") + #expect(textView.currentFindMatchCountForTesting == 2) + #expect(textView.findMatchLocationsForTesting == [1, 12]) + } + @Test @MainActor func codeEditorReportsEachFindStateOnlyOnce() { @@ -2592,6 +2625,34 @@ struct EditorDocumentTests { #expect(try String(contentsOf: url, encoding: .utf8) == "after") } + @Test + @MainActor + func liveEditorTextPublishesOnlyWhenDirtyStateChanges() { + let document = EditorDocument( + url: URL(fileURLWithPath: "/tmp/live-editor.txt"), + text: "before", + modificationDate: nil + ) + var publishCount = 0 + let observation = document.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + document.applyLiveEditorText("before") + #expect(publishCount == 0) + + document.applyLiveEditorText("after") + #expect(document.isDirty) + #expect(publishCount == 1) + + document.applyLiveEditorText("after more") + #expect(document.isDirty) + #expect(publishCount == 1) + + document.applyLiveEditorText("before") + #expect(!document.isDirty) + #expect(publishCount == 2) + } + @Test func readOnlyDocumentRejectsSave() { let url = FileManager.default.temporaryDirectory