Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -132,6 +134,7 @@ struct LitheApp: App {
.preferredColorScheme(settings.themePreference.preferredColorScheme)
.task {
memoryUsageMonitor.start()
frameRateMonitor.start()
}
}
.defaultSize(
Expand Down
24 changes: 24 additions & 0 deletions Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 5 additions & 2 deletions Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
56 changes: 33 additions & 23 deletions Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,25 @@ final class AppModel: ObservableObject, Identifiable {
/// Replace in Project 面板的搜索选项(Preserve Case、文件掩码等)。
@Published var projectReplaceOptions = ProjectSearchOptions.default
@Published var selectedProjectReplacementPaths: Set<String> = []
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 }
Expand Down Expand Up @@ -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] = [:]
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
Expand All @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions Sources/Lithe/Models/Editor/EditorChromeModel.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
22 changes: 22 additions & 0 deletions Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift
Original file line number Diff line number Diff line change
@@ -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] ?? []
}
}
25 changes: 22 additions & 3 deletions Sources/Lithe/Models/Editor/EditorDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
}
Expand All @@ -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 }
Expand Down
11 changes: 8 additions & 3 deletions Sources/Lithe/Models/Workspace/ProjectSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading