diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 8d765b02fd..a33c24b521 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; }; 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0824A02C5C0C9700A0751E /* SwiftTerm */; }; 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C147C4429A329350089B630 /* OrderedCollections */; }; + 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */; }; 6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */; }; 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6C66C31229D05CDC00DE9ED2 /* GRDB */; }; 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F329CD142C00235D17 /* CollectionConcurrencyKit */; }; 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; }; 6C6BD6F929CD14D100235D17 /* CodeEditKit in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */; }; + 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */; }; 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; }; 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; }; 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; }; @@ -170,6 +172,8 @@ 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, + 6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */, + 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */, 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, @@ -323,6 +327,8 @@ 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, + 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */, + 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -425,9 +431,9 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, + 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1755,6 +1761,14 @@ minimumVersion = 0.2.0; }; }; + 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = exactVersion; + version = 0.14.1; + }; + }; 6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditKit"; @@ -1779,14 +1793,6 @@ version = 1.0.1; }; }; - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = exactVersion; - version = 0.14.1; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1839,6 +1845,10 @@ package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; + 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */; @@ -1862,6 +1872,11 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + productName = CodeEditSourceEditor; + }; 6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = { isa = XCSwiftPackageProductDependency; package = 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cb15291f44..41cbfe6e7e 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/Rearrange", "state" : { - "revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1", - "version" : "1.8.1" + "revision" : "f1d74e1642956f0300756ad8d1d64e9034857bc3", + "version" : "2.0.0" } }, { @@ -248,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", "state" : { - "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", - "version" : "0.9.0" + "revision" : "08ef81eb8620617b55b08868126707ad72bf754f", + "version" : "0.25.0" } }, { @@ -275,8 +275,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextStory", "state" : { - "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", - "version" : "0.9.0" + "revision" : "91df6fc9bd817f9712331a4a3e826f7bdc823e1d", + "version" : "0.9.1" } }, { @@ -284,8 +284,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tree-sitter/tree-sitter", "state" : { - "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", - "version" : "0.23.2" + "revision" : "bf655c0beaf4943573543fa77c58e8006ff34971", + "version" : "0.25.6" } }, { diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift index 7551c0bedc..a15ac9311e 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift @@ -76,6 +76,7 @@ final class CodeEditSplitViewController: NSSplitViewController { .environmentObject(statusBarViewModel) .environmentObject(utilityAreaModel) .environmentObject(taskManager) + .environmentObject(workspace.undoRegistration) } } diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift index 261a4d224e..5d321c647d 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift @@ -29,6 +29,7 @@ extension WorkspaceDocument { @Published var searchResultsCount: Int = 0 /// Stores the user's input, shown when no files are found, and persists across navigation items. @Published var searchQuery: String = "" + @Published var replaceText: String = "" @Published var indexStatus: IndexStatus = .none diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index a47fdbba83..e5aa3251a7 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -45,6 +45,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { var workspaceSettingsManager: CEWorkspaceSettings? var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler() + var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() + @Published var notificationPanel = NotificationPanelViewModel() private var cancellables = Set() diff --git a/CodeEdit/Features/Editor/Models/Editor+History.swift b/CodeEdit/Features/Editor/Models/Editor/Editor+History.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/Editor+History.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor+History.swift diff --git a/CodeEdit/Features/Editor/Models/Editor+TabSwitch.swift b/CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/Editor+TabSwitch.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor+TabSwitch.swift diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor/Editor.swift similarity index 94% rename from CodeEdit/Features/Editor/Models/Editor.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor.swift index d6fcfa767d..eebfb1902d 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor/Editor.swift @@ -55,23 +55,27 @@ final class Editor: ObservableObject, Identifiable { var id = UUID() weak var parent: SplitViewData? + weak var workspace: WorkspaceDocument? init() { self.tabs = [] self.temporaryTab = nil self.parent = nil + self.workspace = nil } init( files: OrderedSet = [], selectedTab: Tab? = nil, temporaryTab: Tab? = nil, - parent: SplitViewData? = nil + parent: SplitViewData? = nil, + workspace: WorkspaceDocument? = nil, ) { self.tabs = [] self.parent = parent + self.workspace = workspace files.forEach { openTab(file: $0) } - self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(file: files.first!)) + self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!)) self.temporaryTab = temporaryTab } @@ -79,10 +83,12 @@ final class Editor: ObservableObject, Identifiable { files: OrderedSet = [], selectedTab: Tab? = nil, temporaryTab: Tab? = nil, - parent: SplitViewData? = nil + parent: SplitViewData? = nil, + workspace: WorkspaceDocument? = nil ) { self.tabs = [] self.parent = parent + self.workspace = workspace files.forEach { openTab(file: $0.file) } self.selectedTab = selectedTab ?? tabs.first self.temporaryTab = temporaryTab @@ -135,7 +141,7 @@ final class Editor: ObservableObject, Identifiable { clearFuture() } if file != selectedTab?.file { - addToHistory(EditorInstance(file: file)) + addToHistory(EditorInstance(workspace: workspace, file: file)) } removeTab(file) if let selectedTab { @@ -165,7 +171,7 @@ final class Editor: ObservableObject, Identifiable { /// - file: the file to open. /// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab. func openTab(file: CEWorkspaceFile, asTemporary: Bool) { - let item = EditorInstance(file: file) + let item = EditorInstance(workspace: workspace, file: file) // Item is already opened in a tab. guard !tabs.contains(item) || !asTemporary else { selectedTab = item @@ -207,7 +213,7 @@ final class Editor: ObservableObject, Identifiable { /// - index: Index where the tab needs to be added. If nil, it is added to the back. /// - fromHistory: Indicates whether the tab has been opened from going back in history. func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) { - let item = Tab(file: file) + let item = Tab(workspace: workspace, file: file) if let index { tabs.insert(item, at: index) } else { diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index 7ec13c53bd..fd11333bb7 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -13,33 +13,105 @@ import CodeEditSourceEditor /// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish /// the user's current location in a file. -class EditorInstance: Hashable { - // Public - +class EditorInstance: ObservableObject, Hashable { /// The file presented in this editor instance. let file: CEWorkspaceFile /// A publisher for the user's current location in a file. - var cursorPositions: AnyPublisher<[CursorPosition], Never> { - cursorSubject.eraseToAnyPublisher() - } + @Published var cursorPositions: [CursorPosition] + @Published var scrollPosition: CGPoint? - // Public TextViewCoordinator APIs + @Published var findText: String? + var findTextSubject: PassthroughSubject - var rangeTranslator: RangeTranslator? + @Published var replaceText: String? + var replaceTextSubject: PassthroughSubject - // Internal Combine subjects + var rangeTranslator: RangeTranslator = RangeTranslator() - private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([]) + private var cancellables: Set = [] - // MARK: - Init, Hashable, Equatable + // MARK: - Init - init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) { + init(workspace: WorkspaceDocument?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) { self.file = file - self.cursorSubject.send(cursorPositions) - self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) + let url = file.url + let editorState = EditorStateRestoration.shared?.restorationState(for: url) + + findText = workspace?.searchState?.searchQuery + findTextSubject = PassthroughSubject() + replaceText = workspace?.searchState?.replaceText + replaceTextSubject = PassthroughSubject() + + self.cursorPositions = ( + cursorPositions ?? editorState?.editorCursorPositions ?? [CursorPosition(line: 1, column: 1)] + ) + self.scrollPosition = editorState?.scrollPosition + + // Setup listeners + + Publishers.CombineLatest( + $cursorPositions.removeDuplicates(), + $scrollPosition + .debounce(for: .seconds(0.1), scheduler: RunLoop.main) // This can trigger *very* often + .removeDuplicates() + ) + .sink { (cursorPositions, scrollPosition) in + EditorStateRestoration.shared?.updateRestorationState( + for: url, + data: .init(cursorPositions: cursorPositions, scrollPosition: scrollPosition ?? .zero) + ) + } + .store(in: &cancellables) + + listenToFindText(workspace: workspace) + listenToReplaceText(workspace: workspace) + } + + // MARK: - Find/Replace Listeners + + func listenToFindText(workspace: WorkspaceDocument?) { + workspace?.searchState?.$searchQuery + .receive(on: RunLoop.main) + .sink { [weak self] newQuery in + if self?.findText != newQuery { + self?.findText = newQuery + } + } + .store(in: &cancellables) + findTextSubject + .receive(on: RunLoop.main) + .sink { [weak workspace, weak self] newFindText in + if let newFindText, workspace?.searchState?.searchQuery != newFindText { + workspace?.searchState?.searchQuery = newFindText + } + self?.findText = workspace?.searchState?.searchQuery + } + .store(in: &cancellables) + } + + func listenToReplaceText(workspace: WorkspaceDocument?) { + workspace?.searchState?.$replaceText + .receive(on: RunLoop.main) + .sink { [weak self] newText in + if self?.replaceText != newText { + self?.replaceText = newText + } + } + .store(in: &cancellables) + replaceTextSubject + .receive(on: RunLoop.main) + .sink { [weak workspace, weak self] newReplaceText in + if let newReplaceText, workspace?.searchState?.replaceText != newReplaceText { + workspace?.searchState?.replaceText = newReplaceText + } + self?.replaceText = workspace?.searchState?.replaceText + } + .store(in: &cancellables) } + // MARK: - Hashable, Equatable + func hash(into hasher: inout Hasher) { hasher.combine(file) } @@ -53,19 +125,17 @@ class EditorInstance: Hashable { /// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range. class RangeTranslator: TextViewCoordinator { private weak var textViewController: TextViewController? - private var cursorSubject: CurrentValueSubject<[CursorPosition], Never> - - init(cursorSubject: CurrentValueSubject<[CursorPosition], Never>) { - self.cursorSubject = cursorSubject - } - func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { - self.cursorSubject.send(controller.cursorPositions) - } + init() { } func prepareCoordinator(controller: TextViewController) { self.textViewController = controller - self.cursorSubject.send(controller.cursorPositions) + } + + func controllerDidAppear(controller: TextViewController) { + if controller.isEditable && controller.isSelectable { + controller.view.window?.makeFirstResponder(controller.textView) + } } func destroy() { diff --git a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift similarity index 90% rename from CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift rename to CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift index 48a8fe1334..88ef128977 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift @@ -14,8 +14,7 @@ extension EditorManager { /// - Parameter workspace: The workspace to retrieve state from. func restoreFromState(_ workspace: WorkspaceDocument) { do { - guard let fileManager = workspace.workspaceFileManager, - let data = workspace.getFromWorkspaceState(.openTabs) as? Data else { + guard let data = workspace.getFromWorkspaceState(.openTabs) as? Data else { return } @@ -35,7 +34,7 @@ extension EditorManager { return } - fixRestoredEditorLayout(state.groups, fileManager: fileManager) + fixRestoredEditorLayout(state.groups, workspace: workspace) self.editorLayout = state.groups self.activeEditor = activeEditor @@ -54,17 +53,17 @@ extension EditorManager { /// - Parameters: /// - group: The tab group to fix. /// - fileManager: The file manager to use to map files. - private func fixRestoredEditorLayout(_ group: EditorLayout, fileManager: CEWorkspaceFileManager) { + private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) { switch group { case let .one(data): - fixEditor(data, fileManager: fileManager) + fixEditor(data, workspace: workspace) case let .vertical(splitData): splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, fileManager: fileManager) + fixRestoredEditorLayout(group, workspace: workspace) } case let .horizontal(splitData): splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, fileManager: fileManager) + fixRestoredEditorLayout(group, workspace: workspace) } } } @@ -88,11 +87,13 @@ extension EditorManager { /// - Parameters: /// - data: The tab group to fix. /// - fileManager: The file manager to use to map files.a - private func fixEditor(_ editor: Editor, fileManager: CEWorkspaceFileManager) { + private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) { + guard let fileManager = workspace.workspaceFileManager else { return } let resolvedTabs = editor .tabs .compactMap({ fileManager.getFile($0.file.url.path(), createIfNotFound: true) }) - .map({ EditorInstance(file: $0) }) + .map({ EditorInstance(workspace: workspace, file: $0) }) + editor.workspace = workspace editor.tabs = OrderedSet(resolvedTabs) if let selectedTab = editor.selectedTab { if let resolvedFile = fileManager.getFile(selectedTab.file.url.path(), createIfNotFound: true) { @@ -215,8 +216,12 @@ extension Editor: Codable { let id = try container.decode(UUID.self, forKey: .id) self.init( files: OrderedSet(fileURLs.map { CEWorkspaceFile(url: $0) }), - selectedTab: selectedTab == nil ? nil : EditorInstance(file: CEWorkspaceFile(url: selectedTab!)), - parent: nil + selectedTab: selectedTab == nil ? nil : EditorInstance( + workspace: nil, + file: CEWorkspaceFile(url: selectedTab!) + ), + parent: nil, + workspace: nil ) self.id = id } diff --git a/CodeEdit/Features/Editor/Models/EditorLayout.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/EditorLayout.swift rename to CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout.swift diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEdit/Features/Editor/Models/EditorManager.swift index 5c60da34b1..4e8eae3493 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEdit/Features/Editor/Models/EditorManager.swift @@ -30,7 +30,7 @@ class EditorManager: ObservableObject { var activeEditorHistory: Deque<() -> Editor?> = [] /// notify listeners whenever tab selection changes on the active editor. - var tabBarTabIdSubject = PassthroughSubject() + var tabBarTabIdSubject = PassthroughSubject() var cancellable: AnyCancellable? // This caching mechanism is a temporary solution and is not optimized @@ -103,7 +103,7 @@ class EditorManager: ObservableObject { cancellable = nil cancellable = activeEditor.$selectedTab .sink { [weak self] tab in - self?.tabBarTabIdSubject.send(tab?.file.id) + self?.tabBarTabIdSubject.send(tab) } } diff --git a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift new file mode 100644 index 0000000000..4b375ac887 --- /dev/null +++ b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift @@ -0,0 +1,138 @@ +// +// EditorStateRestoration.swift +// CodeEdit +// +// Created by Khan Winter on 6/20/25. +// + +import Foundation +import GRDB +import CodeEditSourceEditor +import OSLog + +/// CodeEdit attempts to store and retrieve editor state for open tabs to restore the user's scroll position and +/// cursor positions between sessions. This class manages the storage mechanism to facilitate that feature. +/// +/// This creates a sqlite database in the application support directory named `editor-restoration.db`. +/// +/// To ensure we can query this quickly, this class is shared globally (to avoid having to use a database pool) and +/// all writes and reads are synchronous. +/// +/// # If changes are required +/// +/// Use the database migrator in the initializer for this class, see GRDB's documentation for adding a migration +/// version. **Do not ever** delete migration versions that have made it to a released version of CodeEdit. +final class EditorStateRestoration { + /// Optional here so we can gracefully catch errors. + /// The nice thing is this feature is optional in that if we don't have it available the user's experience is + /// degraded but not catastrophic. + static let shared: EditorStateRestoration? = try? EditorStateRestoration() + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "", + category: "EditorStateRestoration" + ) + + struct StateRestorationRecord: Codable, TableRecord, FetchableRecord, PersistableRecord { + let uri: String + let data: Data + } + + struct StateRestorationData: Codable, Equatable { + // Cursor positions as range values (not row/column!) + let cursorPositions: [Range] + let scrollPositionX: Double + let scrollPositionY: Double + + var scrollPosition: CGPoint { + CGPoint(x: scrollPositionX, y: scrollPositionY) + } + + var editorCursorPositions: [CursorPosition] { + cursorPositions.map { CursorPosition(range: NSRange(start: $0.lowerBound, end: $0.upperBound)) } + } + + init(cursorPositions: [CursorPosition], scrollPosition: CGPoint) { + self.cursorPositions = cursorPositions + .compactMap { $0.range } + .map { $0.location..<($0.location + $0.length) } + self.scrollPositionX = scrollPosition.x + self.scrollPositionY = scrollPosition.y + } + } + + private var databaseQueue: DatabaseQueue? + private var databaseURL: URL + + /// Create a new editor restoration object. Will connect to or create a SQLite db. + /// - Parameter databaseURL: The database URL to use. Must point to a file, not a directory. If left `nil`, will + /// create a new database named `editor-restoration.db` in the application support + /// directory. + init(_ databaseURL: URL? = nil) throws { + self.databaseURL = databaseURL ?? FileManager.default + .homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) + .appending(path: "editor-restoration.db", directoryHint: .notDirectory) + try attemptMigration(retry: true) + } + + func attemptMigration(retry: Bool) throws { + do { + let databaseQueue = try DatabaseQueue(path: self.databaseURL.absolutePath, configuration: .init()) + + var migrator = DatabaseMigrator() + + migrator.registerMigration("Version 0") { + try $0.create(table: "stateRestorationRecord") { table in + table.column("uri", .text).primaryKey().notNull() + table.column("data", .blob).notNull() + } + } + + try migrator.migrate(databaseQueue) + self.databaseQueue = databaseQueue + } catch { + if retry { + // Try to delete the database on failure, might fix a corruption or version error. + try? FileManager.default.removeItem(at: databaseURL) + try attemptMigration(retry: false) + + return // Ignore the original error if we're retrying + } + Self.logger.error("Failed to start database connection: \(error)") + throw error + } + } + + /// Update saved restoration state of a document. + /// - Parameters: + /// - documentUrl: The URL of the document. + /// - data: The data to store for the file, retrieved using ``restorationState(for:)``. + func updateRestorationState(for documentUrl: URL, data: StateRestorationData) { + do { + let serializedData = try JSONEncoder().encode(data) + let dbRow = StateRestorationRecord(uri: documentUrl.absolutePath, data: serializedData) + try databaseQueue?.write { try dbRow.upsert($0) } + } catch { + Self.logger.error("Failed to save editor state: \(error)") + } + } + + /// Find the restoration state for a document. + /// - Parameter documentUrl: The URL of the document. + /// - Returns: Any data saved for this file. + func restorationState(for documentUrl: URL) -> StateRestorationData? { + do { + guard let row = try databaseQueue?.read({ + try StateRestorationRecord.fetchOne($0, key: documentUrl.absolutePath) + }) else { + return nil + } + let decodedData = try JSONDecoder().decode(StateRestorationData.self, from: row.data) + return decodedData + } catch { + Self.logger.error("Failed to find editor state for '\(documentUrl.absolutePath)': \(error)") + } + return nil + } +} diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift new file mode 100644 index 0000000000..5a244cd0a7 --- /dev/null +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -0,0 +1,35 @@ +// +// UndoManagerRegistration.swift +// CodeEdit +// +// Created by Khan Winter on 6/27/25. +// + +import SwiftUI +import CodeEditTextView + +/// Very simple class for registering undo manager for files for a project session. This does not do any saving, it +/// just stores the undo managers in memory and retrieves them as necessary for files. +/// +/// Undo stacks aren't stored on `CEWorkspaceFile` or `CodeFileDocument` because: +/// - `CEWorkspaceFile` can be refreshed and reloaded at any point. +/// - `CodeFileDocument` is released once there are no editors viewing it. +/// Undo stacks need to be retained for the duration of a workspace session, enduring editor closes.. +final class UndoManagerRegistration: ObservableObject { + private var managerMap: [CEWorkspaceFile.ID: CEUndoManager] = [:] + + init() { } + + /// Find or create a new undo manager. + /// - Parameter file: The file to create for. + /// - Returns: The undo manager for the given file. + func manager(forFile file: CEWorkspaceFile) -> CEUndoManager { + if let manager = managerMap[file.id] { + return manager + } else { + let newManager = CEUndoManager() + managerMap[file.id] = newManager + return newManager + } + } +} diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift index b0f3a1baa8..5ba88387be 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift @@ -90,7 +90,7 @@ struct EditorTabView: View { // Only set the `selectedId` when they are not equal to avoid performance issue for now. editorManager.activeEditor = editor if editor.selectedTab?.file != tabFile { - let tabItem = EditorInstance(file: tabFile) + let tabItem = EditorInstance(workspace: workspace, file: tabFile) editor.setSelectedTab(tabFile) editor.clearFuture() editor.addToHistory(tabItem) diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift index fbebd7c4b4..29e539cf14 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarContextMenu.swift @@ -137,7 +137,7 @@ struct EditorTabBarContextMenu: ViewModifier { } func moveToNewSplit(_ edge: Edge) { - let newEditor = Editor(files: [item]) + let newEditor = Editor(files: [item], workspace: workspace) splitEditor(edge, newEditor) tabs.closeTab(file: item) workspace.editorManager?.activeEditor = newEditor diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift index 156983061f..64131b5695 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift @@ -22,6 +22,8 @@ struct EditorTabBarTrailingAccessories: View { @Environment(\.controlActiveState) private var activeState + @EnvironmentObject var workspace: WorkspaceDocument + @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var editor: Editor @@ -97,7 +99,7 @@ struct EditorTabBarTrailingAccessories: View { func split(edge: Edge) { let newEditor: Editor if let tab = editor.selectedTab { - newEditor = .init(files: [tab], temporaryTab: tab) + newEditor = .init(files: [tab], temporaryTab: tab, workspace: workspace) } else { newEditor = .init() } diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index e301b688b7..31dc997ba5 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -14,10 +14,9 @@ import Combine /// CodeFileView is just a wrapper of the `CodeEditor` dependency struct CodeFileView: View { + @ObservedObject private var editorInstance: EditorInstance @ObservedObject private var codeFile: CodeFileDocument - @State private var editorState: SourceEditorState - @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() /// Any coordinators passed to the view. @@ -65,6 +64,8 @@ struct CodeFileView: View { @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject var undoRegistration: UndoManagerRegistration + @ObservedObject private var themeModel: ThemeModel = .shared @State private var treeSitter = TreeSitterClient() @@ -73,21 +74,24 @@ struct CodeFileView: View { private let isEditable: Bool - private let undoManager = CEUndoManager() - - init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { + init( + editorInstance: EditorInstance, + codeFile: CodeFileDocument, + textViewCoordinators: [TextViewCoordinator] = [], + isEditable: Bool = true + ) { + self._editorInstance = .init(wrappedValue: editorInstance) self._codeFile = .init(wrappedValue: codeFile) self.textViewCoordinators = textViewCoordinators + + [editorInstance.rangeTranslator] + [codeFile.contentCoordinator] + [codeFile.languageServerObjects.textCoordinator].compactMap({ $0 }) self.isEditable = isEditable if let openOptions = codeFile.openOptions { codeFile.openOptions = nil - self.editorState = SourceEditorState(cursorPositions: openOptions.cursorPositions) - } else { - self.editorState = SourceEditorState() + editorInstance.cursorPositions = openOptions.cursorPositions } updateHighlightProviders() @@ -99,8 +103,6 @@ struct CodeFileView: View { codeFile.updateChangeCount(.changeDone) } .store(in: &cancellables) - - codeFile.undoManager = self.undoManager } private var currentTheme: Theme { @@ -147,9 +149,26 @@ struct CodeFileView: View { warningCharacters: Set(warningCharacters.characters.keys) ) ), - state: $editorState, + state: Binding( + get: { + SourceEditorState( + cursorPositions: editorInstance.cursorPositions, + scrollPosition: editorInstance.scrollPosition, + findText: editorInstance.findText, + replaceText: editorInstance.replaceText + ) + }, + set: { newState in + editorInstance.cursorPositions = newState.cursorPositions ?? [] + editorInstance.scrollPosition = newState.scrollPosition + editorInstance.findText = newState.findText + editorInstance.findTextSubject.send(newState.findText) + editorInstance.replaceText = newState.replaceText + editorInstance.replaceTextSubject.send(newState.replaceText) + } + ), highlightProviders: highlightProviders, - undoManager: undoManager, + undoManager: undoRegistration.manager(forFile: editorInstance.file), coordinators: textViewCoordinators ) .id(codeFile.fileURL) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index e5d4f9ffc4..e4367dcc0a 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -19,12 +19,15 @@ struct EditorAreaFileView: View { @Environment(\.edgeInsets) private var edgeInsets + var editorInstance: EditorInstance var codeFile: CodeFileDocument - var textViewCoordinators: [TextViewCoordinator] = [] @ViewBuilder var editorAreaFileView: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: codeFile, textViewCoordinators: textViewCoordinators) + CodeFileView( + editorInstance: editorInstance, + codeFile: codeFile + ) } else { NonTextFileView(fileDocument: codeFile) .padding(.top, edgeInsets.top - 1.74) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 96ff8bf210..c617f8dd30 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -25,7 +25,7 @@ struct EditorAreaView: View { @EnvironmentObject private var editorManager: EditorManager - @State var codeFile: CodeFileDocument? + @State var codeFile: (() -> CodeFileDocument?)? @Environment(\.window.value) private var window: NSWindow? @@ -33,7 +33,9 @@ struct EditorAreaView: View { init(editor: Editor, focus: FocusState.Binding) { self.editor = editor self._focus = focus - self.codeFile = editor.selectedTab?.file.fileDocument + if let file = editor.selectedTab?.file.fileDocument { + self.codeFile = { [weak file] in file } + } } var body: some View { @@ -53,29 +55,26 @@ struct EditorAreaView: View { VStack { if let selected = editor.selectedTab { - if let codeFile = codeFile { - EditorAreaFileView( - codeFile: codeFile, - textViewCoordinators: [selected.rangeTranslator].compactMap({ $0 }) - ) - .focusedObject(editor) - .transformEnvironment(\.edgeInsets) { insets in - insets.top += editorInsetAmount - } - .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - _ = handleDrop(providers: providers) - return true - } + if let codeFile = codeFile?() { + EditorAreaFileView(editorInstance: selected, codeFile: codeFile) + .focusedObject(editor) + .transformEnvironment(\.edgeInsets) { insets in + insets.top += editorInsetAmount + } + .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + _ = handleDrop(providers: providers) + return true + } } else { LoadingFileView(selected.file.name) .onAppear { if let file = selected.file.fileDocument { - self.codeFile = file + self.codeFile = { [weak file] in file } } } .onReceive(selected.file.fileDocumentPublisher) { latestValue in - self.codeFile = latestValue + self.codeFile = { [weak latestValue] in latestValue } } } @@ -96,6 +95,12 @@ struct EditorAreaView: View { .safeAreaInset(edge: .top, spacing: 0) { GeometryReader { geometry in let topSafeArea = geometry.safeAreaInsets.top + let fileBinding = Binding { + codeFile?() + } set: { newFile in + codeFile = { [weak newFile] in newFile } + } + VStack(spacing: 0) { if topSafeArea > 0 { Rectangle() @@ -104,7 +109,7 @@ struct EditorAreaView: View { .background(.clear) } if shouldShowTabBar { - EditorTabBarView(hasTopInsets: topSafeArea > 0, codeFile: $codeFile) + EditorTabBarView(hasTopInsets: topSafeArea > 0, codeFile: fileBinding) .id("TabBarView" + editor.id.uuidString) .environmentObject(editor) Divider() @@ -113,7 +118,7 @@ struct EditorAreaView: View { EditorJumpBarView( file: editor.selectedTab?.file, shouldShowTabBar: shouldShowTabBar, - codeFile: $codeFile + codeFile: fileBinding ) { [weak editor] newFile in if let file = editor?.selectedTab, let index = editor?.tabs.firstIndex(of: file) { editor?.openTab(file: newFile, at: index) @@ -141,7 +146,9 @@ struct EditorAreaView: View { } } .onChange(of: editor.selectedTab) { newValue in - codeFile = newValue?.file.fileDocument + if let file = newValue?.file.fileDocument { + codeFile = { [weak file] in file } + } } } @@ -155,9 +162,8 @@ struct EditorAreaView: View { DispatchQueue.main.async { let file = CEWorkspaceFile(url: url) - editor.openTab(file: file) editorManager.activeEditor = editor - focus = editor + editor.openTab(file: file) } } } diff --git a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift index 3bc2a16f8b..d53d1682f8 100644 --- a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift @@ -11,11 +11,24 @@ import SwiftUI /// View that fixes [#1158](https://github.com/CodeEditApp/CodeEdit/issues/1158) /// # Should **not** be used other than in a single file window. struct WindowCodeFileView: View { + @StateObject var editorInstance: EditorInstance + @StateObject var undoRegistration: UndoManagerRegistration = UndoManagerRegistration() var codeFile: CodeFileDocument + init(codeFile: CodeFileDocument) { + self._editorInstance = .init( + wrappedValue: EditorInstance( + workspace: nil, + file: CEWorkspaceFile(url: codeFile.fileURL ?? URL(fileURLWithPath: "")) + ) + ) + self.codeFile = codeFile + } + var body: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: codeFile) + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + .environmentObject(undoRegistration) } else { NonTextFileView(fileDocument: codeFile) } diff --git a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift index f014492aed..393789b09b 100644 --- a/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift +++ b/CodeEdit/Features/NavigatorArea/FindNavigator/FindNavigatorForm.swift @@ -18,7 +18,6 @@ struct FindNavigatorForm: View { } } - @State private var replaceText: String = "" @State private var includesText: String = "" @State private var excludesText: String = "" @State private var scoped: Bool = false @@ -167,7 +166,7 @@ struct FindNavigatorForm: View { if selectedMode[0] == SearchModeModel.Replace { PaneTextField( "With", - text: $replaceText, + text: $state.replaceText, axis: .vertical, leadingAccessories: { Image(systemName: "arrow.2.squarepath") @@ -254,7 +253,7 @@ struct FindNavigatorForm: View { Button { Task { let startTime = Date() - try? await state.findAndReplace(query: state.searchQuery, replacingTerm: replaceText) + try? await state.findAndReplace(query: state.searchQuery, replacingTerm: state.replaceText) print(Date().timeIntervalSince(startTime)) } } label: { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index a7be86bc8e..a072d80c27 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -59,8 +59,8 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { }) .store(in: &cancellables) workspace.editorManager?.tabBarTabIdSubject - .sink { [weak self] itemID in - self?.controller?.updateSelection(itemID: itemID) + .sink { [weak self] editorInstance in + self?.controller?.updateSelection(itemID: editorInstance?.file.id) } .store(in: &cancellables) workspace.$navigatorFilter diff --git a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift index 920bdf2c48..a336df84a9 100644 --- a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift +++ b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift @@ -12,7 +12,8 @@ struct OpenQuicklyPreviewView: View { private let queue = DispatchQueue(label: "app.codeedit.CodeEdit.quickOpen.preview") private let item: CEWorkspaceFile - @ObservedObject var document: CodeFileDocument + @StateObject var editorInstance: EditorInstance + @StateObject var document: CodeFileDocument init(item: CEWorkspaceFile) { self.item = item @@ -21,12 +22,13 @@ struct OpenQuicklyPreviewView: View { withContentsOf: item.url, ofType: item.contentType?.identifier ?? "public.source-code" ) + self._editorInstance = .init(wrappedValue: EditorInstance(workspace: nil, file: item)) self._document = .init(wrappedValue: doc ?? .init()) } var body: some View { if let utType = document.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: document, isEditable: false) + CodeFileView(editorInstance: editorInstance, codeFile: document, isEditable: false) } else { NonTextFileView(fileDocument: document) } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift index a47e851761..bdead53a57 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -61,7 +61,7 @@ struct StatusBarCursorPositionLabel: View { .font(statusBarViewModel.statusBarFont) .foregroundColor(foregroundColor) .lineLimit(1) - .onReceive(editorInstance.cursorPositions) { newValue in + .onReceive(editorInstance.$cursorPositions) { newValue in self.cursorPositions = newValue } } @@ -78,7 +78,7 @@ struct StatusBarCursorPositionLabel: View { /// - Parameter range: The range to query. /// - Returns: The number of lines in the range. func getLines(_ range: NSRange) -> Int { - return editorInstance.rangeTranslator?.linesInRange(range) ?? 0 + return editorInstance.rangeTranslator.linesInRange(range) } /// Create a label string for cursor positions. diff --git a/CodeEdit/Features/WindowCommands/EditorCommands.swift b/CodeEdit/Features/WindowCommands/EditorCommands.swift index 6075452006..e99c8dfa36 100644 --- a/CodeEdit/Features/WindowCommands/EditorCommands.swift +++ b/CodeEdit/Features/WindowCommands/EditorCommands.swift @@ -19,12 +19,12 @@ struct EditorCommands: Commands { CommandMenu("Editor") { Menu("Structure") { Button("Move line up") { - editor?.selectedTab?.rangeTranslator?.moveLinesUp() + editor?.selectedTab?.rangeTranslator.moveLinesUp() } .keyboardShortcut("[", modifiers: [.command, .option]) Button("Move line down") { - editor?.selectedTab?.rangeTranslator?.moveLinesDown() + editor?.selectedTab?.rangeTranslator.moveLinesDown() } .keyboardShortcut("]", modifiers: [.command, .option]) } diff --git a/CodeEdit/WorkspaceView.swift b/CodeEdit/WorkspaceView.swift index d9e2aa1b0e..b52159d1ed 100644 --- a/CodeEdit/WorkspaceView.swift +++ b/CodeEdit/WorkspaceView.swift @@ -44,84 +44,47 @@ struct WorkspaceView: View { VStack { SplitViewReader { proxy in SplitView(axis: .vertical) { - ZStack { - GeometryReader { geo in - EditorLayoutView( - layout: editorManager.isFocusingActiveEditor - ? editorManager.activeEditor.getEditorLayout() ?? editorManager.editorLayout - : editorManager.editorLayout, - focus: $focusedEditor - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: geo.size.height) { newHeight in - editorsHeight = newHeight - } - .onAppear { - editorsHeight = geo.size.height - } - } - } - .frame(minHeight: 170 + 29 + 29) - .collapsable() - .collapsed($utilityAreaViewModel.isMaximized) - .holdingPriority(.init(1)) - Rectangle() - .collapsable() - .collapsed($utilityAreaViewModel.isCollapsed) - .splitViewCanAnimate($utilityAreaViewModel.animateCollapse) - .opacity(0) - .frame(idealHeight: 260) - .frame(minHeight: 100) - .background { - GeometryReader { geo in - Rectangle() - .opacity(0) - .onChange(of: geo.size.height) { newHeight in - drawerHeight = newHeight - } - .onAppear { - drawerHeight = geo.size.height - } - } - } - .accessibilityHidden(true) + editorArea + utilityAreaPlaceholder } .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { - ZStack(alignment: .top) { - UtilityAreaView() - .frame(height: utilityAreaViewModel.isMaximized ? nil : drawerHeight) - .frame(maxHeight: utilityAreaViewModel.isMaximized ? .infinity : nil) - .padding(.top, utilityAreaViewModel.isMaximized ? statusbarHeight + 1 : 0) - .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight + 1) - VStack(spacing: 0) { - StatusBarView(proxy: proxy) - if utilityAreaViewModel.isMaximized { - PanelDivider() - } - } - .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight - statusbarHeight) - } - .accessibilityElement(children: .contain) + utilityArea(proxy: proxy) } .overlay(alignment: .topTrailing) { NotificationPanelView() } + + // MARK: - Tab Focus Listeners + + .onChange(of: editorManager.activeEditor) { newValue in + focusedEditor = newValue + } .onChange(of: focusedEditor) { newValue in - /// update active tab group only if the new one is not the same with it. + /// Update active tab group only if the new one is not the same with it. if let newValue, editorManager.activeEditor != newValue { editorManager.activeEditor = newValue } } - .onChange(of: editorManager.activeEditor) { newValue in - if newValue != focusedEditor { - focusedEditor = newValue - } - } + + // MARK: - Theme Color Scheme + .task { themeModel.colorScheme = colorScheme + } + .onChange(of: colorScheme) { newValue in + themeModel.colorScheme = newValue + if matchAppearance { + themeModel.selectedTheme = newValue == .dark + ? themeModel.selectedDarkTheme + : themeModel.selectedLightTheme + } + } + // MARK: - Source Control + + .task { do { try await sourceControlManager.refreshRemotes() try await sourceControlManager.refreshStashEntries() @@ -132,14 +95,6 @@ struct WorkspaceView: View { ) } } - .onChange(of: colorScheme) { newValue in - themeModel.colorScheme = newValue - if matchAppearance { - themeModel.selectedTheme = newValue == .dark - ? themeModel.selectedDarkTheme - : themeModel.selectedLightTheme - } - } .onChange(of: sourceControlIsEnabled) { newValue in if newValue { Task { @@ -149,17 +104,9 @@ struct WorkspaceView: View { sourceControlManager.currentBranch = nil } } - .onChange(of: focusedEditor) { newValue in - /// Update active tab group only if the new one is not the same with it. - if let newValue, editorManager.activeEditor != newValue { - editorManager.activeEditor = newValue - } - } - .onChange(of: editorManager.activeEditor) { newValue in - if newValue != focusedEditor { - focusedEditor = newValue - } - } + + // MARK: - Window Will Close + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { output in if let window = output.object as? NSWindow, self.window == window { workspace.addToWorkspaceState( @@ -181,6 +128,76 @@ struct WorkspaceView: View { } } + // MARK: - Editor Area + + @ViewBuilder private var editorArea: some View { + ZStack { + GeometryReader { geo in + EditorLayoutView( + layout: editorManager.isFocusingActiveEditor + ? editorManager.activeEditor.getEditorLayout() ?? editorManager.editorLayout + : editorManager.editorLayout, + focus: $focusedEditor + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: geo.size.height) { newHeight in + editorsHeight = newHeight + } + .onAppear { + editorsHeight = geo.size.height + } + } + } + .frame(minHeight: 170 + 29 + 29) + .collapsable() + .collapsed($utilityAreaViewModel.isMaximized) + .holdingPriority(.init(1)) + } + + // MARK: - Utility Area + + @ViewBuilder + private func utilityArea(proxy: SplitViewProxy) -> some View { + ZStack(alignment: .top) { + UtilityAreaView() + .frame(height: utilityAreaViewModel.isMaximized ? nil : drawerHeight) + .frame(maxHeight: utilityAreaViewModel.isMaximized ? .infinity : nil) + .padding(.top, utilityAreaViewModel.isMaximized ? statusbarHeight + 1 : 0) + .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight + 1) + VStack(spacing: 0) { + StatusBarView(proxy: proxy) + if utilityAreaViewModel.isMaximized { + PanelDivider() + } + } + .offset(y: utilityAreaViewModel.isMaximized ? 0 : editorsHeight - statusbarHeight) + } + .accessibilityElement(children: .contain) + } + + @ViewBuilder private var utilityAreaPlaceholder: some View { + Rectangle() + .collapsable() + .collapsed($utilityAreaViewModel.isCollapsed) + .splitViewCanAnimate($utilityAreaViewModel.animateCollapse) + .opacity(0) + .frame(idealHeight: 260) + .frame(minHeight: 100) + .background { + GeometryReader { geo in + Rectangle() + .opacity(0) + .onChange(of: geo.size.height) { newHeight in + drawerHeight = newHeight + } + .onAppear { + drawerHeight = geo.size.height + } + } + } + .accessibilityHidden(true) + } + private func handleDrop(providers: [NSItemProvider]) -> Bool { for provider in providers { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift new file mode 100644 index 0000000000..b2e7d46be0 --- /dev/null +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -0,0 +1,75 @@ +// +// EditorStateRestorationTests.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +import Testing +import Foundation +@testable import CodeEdit + +@Suite(.serialized) +struct EditorStateRestorationTests { + @Test + func createsDatabase() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + _ = try EditorStateRestoration(url) + #expect(FileManager.default.fileExists(atPath: url.path(percentEncoded: false))) + } + } + + @Test + func savesAndRetrievesStateForFile() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + let restoration = try EditorStateRestoration(url) + + // Update some state + restoration.updateRestorationState( + for: dir.appending(path: "file.txt"), + data: .init(cursorPositions: [], scrollPosition: .zero) + ) + + // Retrieve it + #expect( + restoration.restorationState(for: dir.appending(path: "file.txt")) + == EditorStateRestoration.StateRestorationData(cursorPositions: [], scrollPosition: .zero) + ) + } + } + + @Test + func savesScrollPosition() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + let restoration = try EditorStateRestoration(url) + + // Update some state + restoration.updateRestorationState( + for: dir.appending(path: "file.txt"), + data: .init(cursorPositions: [], scrollPosition: CGPoint(x: 100, y: 100)) + ) + + // Retrieve it + #expect( + restoration.restorationState(for: dir.appending(path: "file.txt")) + == EditorStateRestoration.StateRestorationData( + cursorPositions: [], + scrollPosition: CGPoint(x: 100, y: 100) + ) + ) + } + } + + @Test + func clearsCorruptedDatabase() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + try "bad data".write(to: url, atomically: true, encoding: .utf8) + // This will throw if it can't connect to the database. + _ = try EditorStateRestoration(url) + } + } +} diff --git a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift new file mode 100644 index 0000000000..cfa9619aaf --- /dev/null +++ b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift @@ -0,0 +1,36 @@ +// +// UndoManagerRegistrationTests.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +@testable import CodeEdit +import Testing +import Foundation +import CodeEditTextView + +@MainActor +@Suite +struct UndoManagerRegistrationTests { + let registrar = UndoManagerRegistration() + let file = CEWorkspaceFile(url: URL(filePath: "/fake/dir/file.txt")) + let textView = TextView(string: "hello world") + + @Test + func newUndoManager() { + let manager = registrar.manager(forFile: file) + #expect(manager.canUndo == false) + } + + @Test + func undoManagersRetained() throws { + let manager = registrar.manager(forFile: file) + textView.setUndoManager(manager) + manager.registerMutation(.init(insert: "hello", at: 0, limit: 11)) + + let sameManager = registrar.manager(forFile: file) + #expect(manager === sameManager) + #expect(sameManager.canUndo) + } +} diff --git a/CodeEditTests/Utils/withTempDir.swift b/CodeEditTests/Utils/withTempDir.swift new file mode 100644 index 0000000000..99a49fee4e --- /dev/null +++ b/CodeEditTests/Utils/withTempDir.swift @@ -0,0 +1,48 @@ +// +// withTempDir.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +import Foundation + +func withTempDir(_ test: (URL) async throws -> Void) async throws { + let tempDirURL = try createAndClearDir() + do { + try await test(tempDirURL) + } catch { + try clearDir(tempDirURL) + throw error + } + try clearDir(tempDirURL) +} + +func withTempDir(_ test: (URL) throws -> Void) throws { + let tempDirURL = try createAndClearDir() + do { + try test(tempDirURL) + } catch { + try clearDir(tempDirURL) + throw error + } + try clearDir(tempDirURL) +} + +private func createAndClearDir() throws -> URL { + let tempDirURL = FileManager.default.temporaryDirectory + .appending(path: "CodeEditTestDirectory", directoryHint: .isDirectory) + + // If it exists, delete it before the test + try clearDir(tempDirURL) + + try FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true) + + return tempDirURL +} + +private func clearDir(_ url: URL) throws { + if FileManager.default.fileExists(atPath: url.absoluteURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: url) + } +}