From d0d1f76615d89946784d7fe12b0f2536149d1f87 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:01:40 -0500 Subject: [PATCH 01/18] Restore Editor Position & Cursors --- CodeEdit.xcodeproj/project.pbxproj | 24 ++-- .../xcshareddata/swiftpm/Package.resolved | 28 +--- .../Models/{ => Editor}/Editor+History.swift | 0 .../{ => Editor}/Editor+TabSwitch.swift | 0 .../Editor/Models/{ => Editor}/Editor.swift | 0 .../Editor/Models/EditorInstance.swift | 53 ++++---- .../EditorLayout+StateRestoration.swift | 0 .../{ => EditorLayout}/EditorLayout.swift | 0 .../Models/EditorStateRestoration.swift | 124 ++++++++++++++++++ .../Features/Editor/Views/CodeFileView.swift | 86 ++++++++---- .../Editor/Views/EditorAreaFileView.swift | 7 +- .../Editor/Views/EditorAreaView.swift | 23 ++-- .../Editor/Views/WindowCodeFileView.swift | 12 +- .../Views/OpenQuicklyPreviewView.swift | 6 +- .../Models/TextEditingSettings.swift | 15 ++- .../StatusBarCursorPositionLabel.swift | 4 +- 16 files changed, 275 insertions(+), 107 deletions(-) rename CodeEdit/Features/Editor/Models/{ => Editor}/Editor+History.swift (100%) rename CodeEdit/Features/Editor/Models/{ => Editor}/Editor+TabSwitch.swift (100%) rename CodeEdit/Features/Editor/Models/{ => Editor}/Editor.swift (100%) rename CodeEdit/Features/Editor/Models/{ => EditorLayout}/EditorLayout+StateRestoration.swift (100%) rename CodeEdit/Features/Editor/Models/{ => EditorLayout}/EditorLayout.swift (100%) create mode 100644 CodeEdit/Features/Editor/Models/EditorStateRestoration.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index bc0e0ae3e5..f6b428b004 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 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 */; }; @@ -170,6 +171,7 @@ 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, + 6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */, 6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */, 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, @@ -323,6 +325,7 @@ 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, + 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -425,9 +428,9 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, + 6C315FC62E05E33D0011BFC5 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1634,6 +1637,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 6C315FC62E05E33D0011BFC5 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CodeEditSourceEditor; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -1779,14 +1789,6 @@ version = 1.0.1; }; }; - 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = exactVersion; - version = 0.13.2; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1839,6 +1841,10 @@ package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; + 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5889948339..eb60cde3fe 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "caf7678c3c52812febb80907a6a451d5e91a20058bbe45d250d7234c51299e91", + "originHash" : "3c158a035d67f9ecf8312d6614a5b859085ebbb640a018dfec6ef364a8a69340", "pins" : [ { "identity" : "aboutwindow", @@ -37,15 +37,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", - "state" : { - "revision" : "30eb8a8cf3b291c91da04cfbc6683bee643b86a6", - "version" : "0.13.2" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -55,15 +46,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" - } - }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", @@ -248,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", "state" : { - "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", - "version" : "0.9.0" + "revision" : "08ef81eb8620617b55b08868126707ad72bf754f", + "version" : "0.25.0" } }, { @@ -284,8 +266,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/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 100% rename from CodeEdit/Features/Editor/Models/Editor.swift rename to CodeEdit/Features/Editor/Models/Editor/Editor.swift diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index f8aeb8ebc6..7ced0a57aa 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -13,31 +13,44 @@ 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() - } - - // Public TextViewCoordinator APIs - - var rangeTranslator: RangeTranslator? + @Published var cursorPositions: [CursorPosition] = [] + @Published var scrollPosition: CGPoint? + @Published var findText: String? - // Internal Combine subjects + var rangeTranslator: RangeTranslator = RangeTranslator() - private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([]) + private var cancellables: Set = [] // MARK: - Init, Hashable, Equatable - init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) { + init(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) + + self.cursorPositions = cursorPositions ?? editorState?.editorCursorPositions ?? [] + self.scrollPosition = editorState?.scrollPosition + + // Setup listeners + + Publishers.CombineLatest( + $cursorPositions.removeDuplicates(), + $scrollPosition + .debounce(for: .seconds(0.5), 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) } func hash(into hasher: inout Hasher) { @@ -53,19 +66,11 @@ 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 destroy() { diff --git a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift rename to CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift 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/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorStateRestoration.swift new file mode 100644 index 0000000000..7a9a19cc30 --- /dev/null +++ b/CodeEdit/Features/Editor/Models/EditorStateRestoration.swift @@ -0,0 +1,124 @@ +// +// 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 { + // 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 init() throws { + let databaseURL: URL = FileManager.default + .homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) + .appending(path: "editor-restoration.db", directoryHint: .notDirectory) + + do { + self.databaseQueue = try DatabaseQueue(path: 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) + } catch { + // Try to delete the database on failure, might fix a corruption or version error. + try? FileManager.default.removeItem(at: databaseURL) + 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/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index ef01751319..dc902962d8 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -14,11 +14,10 @@ import Combine /// CodeFileView is just a wrapper of the `CodeEditor` dependency struct CodeFileView: View { + @ObservedObject private var editorInstance: EditorInstance @ObservedObject private var codeFile: CodeFileDocument - /// The current cursor positions in the view - @State private var cursorPositions: [CursorPosition] = [] - + @State private var editorState: SourceEditorState = .init(cursorPositions: [.init(line: 1, column: 1)]) @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() /// Any coordinators passed to the view. @@ -48,6 +47,8 @@ struct CodeFileView: View { var bracketEmphasis @AppSettings(\.textEditing.useSystemCursor) var useSystemCursor + @AppSettings(\.textEditing.showGutter) + var showGutter @AppSettings(\.textEditing.showMinimap) var showMinimap @AppSettings(\.textEditing.reformatAtColumn) @@ -68,17 +69,24 @@ struct CodeFileView: View { 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.cursorPositions = openOptions.cursorPositions + self.editorState.cursorPositions = openOptions.cursorPositions } updateHighlightProviders() @@ -105,7 +113,7 @@ struct CodeFileView: View { } .store(in: &cancellables) - codeFile.undoManager = self.undoManager.manager + codeFile.undoManager = self.undoManager } private var currentTheme: Theme { @@ -118,30 +126,56 @@ struct CodeFileView: View { private var edgeInsets var body: some View { - CodeEditSourceEditor( + SourceEditor( codeFile.content ?? NSTextStorage(), language: codeFile.getLanguage(), - theme: currentTheme.editor.editorTheme, - font: font, - tabWidth: codeFile.defaultTabWidth ?? defaultTabWidth, - indentOption: (codeFile.indentOption ?? indentOption).textViewOption(), - lineHeight: lineHeightMultiple, - wrapLines: codeFile.wrapLines ?? wrapLinesToEditorWidth, - editorOverscroll: overscroll.overscrollPercentage, - cursorPositions: $cursorPositions, - useThemeBackground: useThemeBackground, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: currentTheme.editor.editorTheme, + useThemeBackground: useThemeBackground, + font: font, + lineHeightMultiple: lineHeightMultiple, + letterSpacing: letterSpacing, + wrapLines: wrapLinesToEditorWidth, + useSystemCursor: useSystemCursor, + tabWidth: defaultTabWidth, + bracketPairEmphasis: getBracketPairEmphasis() + ), + behavior: .init( + isEditable: isEditable, + indentOption: indentOption.textViewOption(), + reformatAtColumn: reformatAtColumn + ), + layout: .init( + editorOverscroll: overscroll.overscrollPercentage, + contentInsets: edgeInsets.nsEdgeInsets, + additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) + ), + peripherals: .init( + showGutter: showGutter, + showMinimap: showMinimap, + showReformattingGuide: showReformattingGuide, + invisibleCharactersConfiguration: .empty, + warningCharacters: [] + ) + ), + state: Binding( + get: { + SourceEditorState( + cursorPositions: editorInstance.cursorPositions, + scrollPosition: editorInstance.scrollPosition, + findText: editorInstance.findText + ) + }, + set: { newState in + editorInstance.cursorPositions = newState.cursorPositions ?? [] + editorInstance.scrollPosition = newState.scrollPosition + editorInstance.findText = newState.findText + } + ), highlightProviders: highlightProviders, - contentInsets: edgeInsets.nsEdgeInsets, - additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0), - isEditable: isEditable, - letterSpacing: letterSpacing, - bracketPairEmphasis: getBracketPairEmphasis(), - useSystemCursor: useSystemCursor, undoManager: undoManager, - coordinators: textViewCoordinators, - showMinimap: showMinimap, - reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + coordinators: textViewCoordinators ) .id(codeFile.fileURL) .background { 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..fd50327077 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -54,19 +54,16 @@ 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 - } + 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 { diff --git a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift index 3bc2a16f8b..b315a9b203 100644 --- a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift @@ -11,11 +11,21 @@ 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 var codeFile: CodeFileDocument + init(codeFile: CodeFileDocument) { + self._editorInstance = .init( + wrappedValue: EditorInstance( + 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) } else { NonTextFileView(fileDocument: codeFile) } diff --git a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift index 920bdf2c48..55b08b0737 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(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/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index ba54a47257..9b8bbfc0e5 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -28,6 +28,7 @@ extension SettingsData { "Enable type-over completion", "Bracket Pair Emphasis", "Bracket Pair Highlight", + "Show Gutter", "Show Minimap", "Reformat at Column", "Show Reformatting Guide", @@ -73,6 +74,9 @@ extension SettingsData { /// Use the system cursor for the source editor. var useSystemCursor: Bool = true + /// Toggle the gutter in the editor. + var showGutter: Bool = true + /// Toggle the minimap in the editor. var showMinimap: Bool = true @@ -130,6 +134,7 @@ extension SettingsData { self.useSystemCursor = false } + self.showGutter = try container.decodeIfPresent(Bool.self, forKey: .showGutter) ?? true self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true self.reformatAtColumn = try container.decodeIfPresent(Int.self, forKey: .reformatAtColumn) ?? 80 self.showReformattingGuide = try container.decodeIfPresent( @@ -171,13 +176,13 @@ extension SettingsData { } ) - mgr.addCommand( - name: "Toggle Minimap", - title: "Toggle Minimap", - id: "prefs.text_editing.toggle_minimap" - ) { + mgr.addCommand(name: "Toggle Minimap", title: "Toggle Minimap", id: "prefs.text_editing.toggle_minimap") { Settings[\.textEditing].showMinimap.toggle() } + + mgr.addCommand(name: "Toggle Gutter", title: "Toggle Gutter", id: "prefs.text_editing.toggle_gutter") { + Settings[\.textEditing].showGutter.toggle() + } } struct IndentOption: Codable, Hashable { 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. From 6cac1e4a283b3bc1448774c46f2413c6190b3d65 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:17:29 -0500 Subject: [PATCH 02/18] Default Cursor Positions --- .../xcshareddata/swiftpm/Package.resolved | 11 ++++++++++- CodeEdit/Features/Editor/Models/EditorInstance.swift | 8 +++++--- CodeEdit/Features/Editor/Views/CodeFileView.swift | 3 +-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index eb60cde3fe..6c974f5d4b 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3c158a035d67f9ecf8312d6614a5b859085ebbb640a018dfec6ef364a8a69340", + "originHash" : "009e1551c2694bea3a9d2dbceddd8e00773fef8a671e74aec625abd3b334d910", "pins" : [ { "identity" : "aboutwindow", @@ -46,6 +46,15 @@ "version" : "0.2.3" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "df485cb63e163c9bdc68ec0617c113d301368da6", + "version" : "0.11.3" + } + }, { "identity" : "collectionconcurrencykit", "kind" : "remoteSourceControl", diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index 7ced0a57aa..b15186fc9b 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -18,7 +18,7 @@ class EditorInstance: ObservableObject, Hashable { let file: CEWorkspaceFile /// A publisher for the user's current location in a file. - @Published var cursorPositions: [CursorPosition] = [] + @Published var cursorPositions: [CursorPosition] @Published var scrollPosition: CGPoint? @Published var findText: String? @@ -33,7 +33,9 @@ class EditorInstance: ObservableObject, Hashable { let url = file.url let editorState = EditorStateRestoration.shared?.restorationState(for: url) - self.cursorPositions = cursorPositions ?? editorState?.editorCursorPositions ?? [] + self.cursorPositions = ( + cursorPositions ?? editorState?.editorCursorPositions ?? [CursorPosition(line: 1, column: 1)] + ) self.scrollPosition = editorState?.scrollPosition // Setup listeners @@ -41,7 +43,7 @@ class EditorInstance: ObservableObject, Hashable { Publishers.CombineLatest( $cursorPositions.removeDuplicates(), $scrollPosition - .debounce(for: .seconds(0.5), scheduler: RunLoop.main) // This can trigger *very* often + .debounce(for: .seconds(0.1), scheduler: RunLoop.main) // This can trigger *very* often .removeDuplicates() ) .sink { (cursorPositions, scrollPosition) in diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index dc902962d8..7e1f04b7bb 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -17,7 +17,6 @@ struct CodeFileView: View { @ObservedObject private var editorInstance: EditorInstance @ObservedObject private var codeFile: CodeFileDocument - @State private var editorState: SourceEditorState = .init(cursorPositions: [.init(line: 1, column: 1)]) @State private var treeSitterClient: TreeSitterClient = TreeSitterClient() /// Any coordinators passed to the view. @@ -86,7 +85,7 @@ struct CodeFileView: View { if let openOptions = codeFile.openOptions { codeFile.openOptions = nil - self.editorState.cursorPositions = openOptions.cursorPositions + editorInstance.cursorPositions = openOptions.cursorPositions } updateHighlightProviders() From 95ff7b962779bc17a1a91534bb130b25fa2eadb7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:47:22 -0500 Subject: [PATCH 03/18] Focus Editor on Tab Change --- .../Editor/Models/EditorInstance.swift | 6 + .../Editor/Models/EditorManager.swift | 4 +- .../Editor/Views/EditorAreaView.swift | 25 ++- .../ProjectNavigatorOutlineView.swift | 4 +- CodeEdit/WorkspaceView.swift | 179 ++++++++++-------- 5 files changed, 125 insertions(+), 93 deletions(-) diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index b15186fc9b..9107726afa 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -75,6 +75,12 @@ class EditorInstance: ObservableObject, Hashable { self.textViewController = controller } + func controllerDidAppear(controller: TextViewController) { + if controller.isEditable && controller.isSelectable { + controller.view.window?.makeFirstResponder(controller.textView) + } + } + func destroy() { self.textViewController = nil } 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/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index fd50327077..99c8d5d908 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,7 +55,7 @@ struct EditorAreaView: View { VStack { if let selected = editor.selectedTab { - if let codeFile = codeFile { + if let codeFile = codeFile?() { EditorAreaFileView(editorInstance: selected, codeFile: codeFile) .focusedObject(editor) .transformEnvironment(\.edgeInsets) { insets in @@ -68,11 +70,11 @@ struct EditorAreaView: View { 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 } } } @@ -93,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() @@ -138,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 } + } } } @@ -152,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/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/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 From c118350f4c7f6c4c675df8446355e1bbafb09fc3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:00:30 -0500 Subject: [PATCH 04/18] Sync Find & Replace Text --- .../WorkspaceDocument+SearchState.swift | 1 + .../Editor/Models/Editor/Editor.swift | 18 ++++-- .../Editor/Models/EditorInstance.swift | 61 ++++++++++++++++++- .../EditorLayout+StateRestoration.swift | 27 ++++---- .../TabBar/Tabs/Tab/EditorTabView.swift | 2 +- .../Views/EditorTabBarContextMenu.swift | 2 +- .../EditorTabBarTrailingAccessories.swift | 4 +- .../Features/Editor/Views/CodeFileView.swift | 6 +- .../Editor/Views/EditorAreaView.swift | 4 +- .../Editor/Views/WindowCodeFileView.swift | 1 + .../FindNavigator/FindNavigatorForm.swift | 5 +- .../Views/OpenQuicklyPreviewView.swift | 2 +- 12 files changed, 104 insertions(+), 29 deletions(-) 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/Editor/Models/Editor/Editor.swift b/CodeEdit/Features/Editor/Models/Editor/Editor.swift index d6fcfa767d..eebfb1902d 100644 --- a/CodeEdit/Features/Editor/Models/Editor/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 9107726afa..724a057227 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -20,19 +20,29 @@ class EditorInstance: ObservableObject, Hashable { /// A publisher for the user's current location in a file. @Published var cursorPositions: [CursorPosition] @Published var scrollPosition: CGPoint? + @Published var findText: String? + var findTextSubject: PassthroughSubject + + @Published var replaceText: String? + var replaceTextSubject: PassthroughSubject var rangeTranslator: RangeTranslator = RangeTranslator() private var cancellables: Set = [] - // MARK: - Init, Hashable, Equatable + // MARK: - Init - init(file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) { + init(workspace: WorkspaceDocument?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) { self.file = file 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)] ) @@ -53,8 +63,55 @@ class EditorInstance: ObservableObject, Hashable { ) } .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) } diff --git a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift index 48a8fe1334..88ef128977 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout/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/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 7e1f04b7bb..22ee8dc52d 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -163,13 +163,17 @@ struct CodeFileView: View { SourceEditorState( cursorPositions: editorInstance.cursorPositions, scrollPosition: editorInstance.scrollPosition, - findText: editorInstance.findText + 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, diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 99c8d5d908..c617f8dd30 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -109,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() @@ -118,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) diff --git a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift index b315a9b203..acf15a2e41 100644 --- a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift @@ -17,6 +17,7 @@ struct WindowCodeFileView: View { init(codeFile: CodeFileDocument) { self._editorInstance = .init( wrappedValue: EditorInstance( + workspace: nil, file: CEWorkspaceFile(url: codeFile.fileURL ?? URL(fileURLWithPath: "")) ) ) 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/OpenQuickly/Views/OpenQuicklyPreviewView.swift b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift index 55b08b0737..a336df84a9 100644 --- a/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift +++ b/CodeEdit/Features/OpenQuickly/Views/OpenQuicklyPreviewView.swift @@ -22,7 +22,7 @@ struct OpenQuicklyPreviewView: View { withContentsOf: item.url, ofType: item.contentType?.identifier ?? "public.source-code" ) - self._editorInstance = .init(wrappedValue: EditorInstance(file: item)) + self._editorInstance = .init(wrappedValue: EditorInstance(workspace: nil, file: item)) self._document = .init(wrappedValue: doc ?? .init()) } From 9b7a26eac0fe08668f39889f0fa4596cf61fb135 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 26 Jun 2025 13:29:40 -0500 Subject: [PATCH 05/18] Share CodeFileDocument Undo Stack --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- CodeEdit/Features/Editor/Views/CodeFileView.swift | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6c974f5d4b..d807464586 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "df485cb63e163c9bdc68ec0617c113d301368da6", - "version" : "0.11.3" + "revision" : "80fb8842a232cdb04a9b9487da8f266f62066e75", + "version" : "0.11.5" } }, { diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 920a897d00..01fcdd4576 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -66,7 +66,7 @@ struct CodeFileView: View { private let isEditable: Bool - private let undoManager = CEUndoManager() + private let undoManager: CEUndoManager init( editorInstance: EditorInstance, @@ -88,6 +88,15 @@ struct CodeFileView: View { editorInstance.cursorPositions = openOptions.cursorPositions } + // Share the undo manager for the document between editors + if let undoManager = codeFile.undoManager as? CEUndoManager { + self.undoManager = undoManager + } else { + let undoManager = CEUndoManager() + codeFile.undoManager = undoManager + self.undoManager = undoManager + } + updateHighlightProviders() codeFile @@ -97,8 +106,6 @@ struct CodeFileView: View { codeFile.updateChangeCount(.changeDone) } .store(in: &cancellables) - - codeFile.undoManager = self.undoManager.manager } private var currentTheme: Theme { From dd6cae8b98ebb68f1ac5c3b2134a79d56533ddc3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:29:20 -0500 Subject: [PATCH 06/18] Add Undo Registration --- .../CodeEditSplitViewController.swift | 1 + .../WorkspaceDocument/WorkspaceDocument.swift | 2 ++ .../EditorStateRestoration.swift | 0 .../Restoration/UndoManagerRegistration.swift | 27 +++++++++++++++++++ .../Features/Editor/Views/CodeFileView.swift | 16 +++-------- .../Editor/Views/WindowCodeFileView.swift | 2 ++ 6 files changed, 36 insertions(+), 12 deletions(-) rename CodeEdit/Features/Editor/Models/{ => Restoration}/EditorStateRestoration.swift (100%) create mode 100644 CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift 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.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/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift similarity index 100% rename from CodeEdit/Features/Editor/Models/EditorStateRestoration.swift rename to CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift new file mode 100644 index 0000000000..183458893e --- /dev/null +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -0,0 +1,27 @@ +// +// 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. +final class UndoManagerRegistration: ObservableObject { + private var managerMap: [CEWorkspaceFile.ID: CEUndoManager] = [:] + + init() { } + + 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/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 01fcdd4576..b9277f22ac 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -58,6 +58,9 @@ struct CodeFileView: View { @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject + var undoRegistration: UndoManagerRegistration + @ObservedObject private var themeModel: ThemeModel = .shared @State private var treeSitter = TreeSitterClient() @@ -66,8 +69,6 @@ struct CodeFileView: View { private let isEditable: Bool - private let undoManager: CEUndoManager - init( editorInstance: EditorInstance, codeFile: CodeFileDocument, @@ -88,15 +89,6 @@ struct CodeFileView: View { editorInstance.cursorPositions = openOptions.cursorPositions } - // Share the undo manager for the document between editors - if let undoManager = codeFile.undoManager as? CEUndoManager { - self.undoManager = undoManager - } else { - let undoManager = CEUndoManager() - codeFile.undoManager = undoManager - self.undoManager = undoManager - } - updateHighlightProviders() codeFile @@ -170,7 +162,7 @@ struct CodeFileView: View { } ), highlightProviders: highlightProviders, - undoManager: undoManager, + undoManager: undoRegistration.manager(forFile: editorInstance.file), coordinators: textViewCoordinators ) .id(codeFile.fileURL) diff --git a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift index acf15a2e41..d53d1682f8 100644 --- a/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/WindowCodeFileView.swift @@ -12,6 +12,7 @@ import SwiftUI /// # 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) { @@ -27,6 +28,7 @@ struct WindowCodeFileView: View { var body: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + .environmentObject(undoRegistration) } else { NonTextFileView(fileDocument: codeFile) } From 7fef786f003635c2dcf9e36b1841f2463985012d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:00:40 -0500 Subject: [PATCH 07/18] Update CESE Version --- CodeEdit.xcodeproj/project.pbxproj | 25 +++++++++++++------ .../xcshareddata/swiftpm/Package.resolved | 23 +++++++++++------ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index f6b428b004..04a2f7989a 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 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 */; }; @@ -171,6 +172,7 @@ 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 */, @@ -326,6 +328,7 @@ 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */, + 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -430,7 +433,7 @@ 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, - 6C315FC62E05E33D0011BFC5 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, + 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1637,13 +1640,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 6C315FC62E05E33D0011BFC5 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../CodeEditSourceEditor; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -1765,6 +1761,14 @@ minimumVersion = 0.2.0; }; }; + 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.14.0; + }; + }; 6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditKit"; @@ -1868,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 d807464586..a6a715baa8 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "009e1551c2694bea3a9d2dbceddd8e00773fef8a671e74aec625abd3b334d910", + "originHash" : "3cb552b5e2e31b91b281091225c5b3ea09f287f41b8e0cccdf51671a9f974d6a", "pins" : [ { "identity" : "aboutwindow", @@ -37,6 +37,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeeditsourceeditor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", + "state" : { + "revision" : "8a47aa4d3969a5e3bd372ce6c3d49ae3232883dd", + "version" : "0.14.0" + } + }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -51,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "80fb8842a232cdb04a9b9487da8f266f62066e75", - "version" : "0.11.5" + "revision" : "d65c2a4b23a52f69d0b3a113124d7434c7af07fa", + "version" : "0.11.6" } }, { @@ -159,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/Rearrange", "state" : { - "revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1", - "version" : "1.8.1" + "revision" : "f1d74e1642956f0300756ad8d1d64e9034857bc3", + "version" : "2.0.0" } }, { @@ -266,8 +275,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextStory", "state" : { - "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", - "version" : "0.9.0" + "revision" : "91df6fc9bd817f9712331a4a3e826f7bdc823e1d", + "version" : "0.9.1" } }, { From 28646bdbcb75e1db3268e05f94e3e52296608f19 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:01:07 -0500 Subject: [PATCH 08/18] Disable Folding Ribbon (until setting is added) --- CodeEdit/Features/Editor/Views/CodeFileView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index b9277f22ac..7ef4d0b228 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -139,6 +139,7 @@ struct CodeFileView: View { showGutter: showGutter, showMinimap: showMinimap, showReformattingGuide: showReformattingGuide, + showFoldingRibbon: false, invisibleCharactersConfiguration: .empty, warningCharacters: [] ) From cc3a125336938764281052afa5ae0a62d7be368f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:02:38 -0500 Subject: [PATCH 09/18] Document `UndoManagerRegistration` Further --- .../Models/Restoration/UndoManagerRegistration.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift index 183458893e..e18ae3d12a 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -10,11 +10,19 @@ 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 From 13ed249d26486367a5a6d9daf51ad9aa9ac6d0b1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:05:15 -0500 Subject: [PATCH 10/18] Retry On Database Failure --- .../Restoration/EditorStateRestoration.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift index 7a9a19cc30..6b87b1eb1b 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift @@ -69,9 +69,12 @@ final class EditorStateRestoration { .appending(path: "Library/Application Support/CodeEdit", directoryHint: .isDirectory) .appending(path: "editor-restoration.db", directoryHint: .notDirectory) - do { - self.databaseQueue = try DatabaseQueue(path: databaseURL.absolutePath, configuration: .init()) + self.databaseQueue = try DatabaseQueue(path: databaseURL.absolutePath, configuration: .init()) + try attemptMigration(retry: true) + } + private func attemptMigration(retry: Bool) throws { + do { var migrator = DatabaseMigrator() migrator.registerMigration("Version 0") { @@ -83,8 +86,15 @@ final class EditorStateRestoration { try migrator.migrate(databaseQueue) } catch { - // Try to delete the database on failure, might fix a corruption or version error. - try? FileManager.default.removeItem(at: databaseURL) + if retry { + // Try to delete the database on failure, might fix a corruption or version error. + try? FileManager.default.removeItem(at: databaseURL) + // This will recreate the db file if necessary + self.databaseQueue = try DatabaseQueue(path: databaseURL.absolutePath, configuration: .init()) + try attemptMigration(retry: false) + + return // Ignore the original error if we're retrying + } Self.logger.error("Failed to start database connection: \(error)") throw error } From b2210cc7f59f24e04772ead5f17e66eaa5a0a05a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:25:37 -0500 Subject: [PATCH 11/18] Add State Restoration Tests, DB Recovery Steps --- .../Restoration/EditorStateRestoration.swift | 26 +++++---- .../Restoration/UndoManagerRegistration.swift | 2 +- .../Features/Editor/Views/CodeFileView.swift | 3 +- .../Editor/EditorStateRestorationTests.swift | 49 +++++++++++++++++ CodeEditTests/Utils/withTempDir.swift | 53 +++++++++++++++++++ 5 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 CodeEditTests/Features/Editor/EditorStateRestorationTests.swift create mode 100644 CodeEditTests/Utils/withTempDir.swift diff --git a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift index 6b87b1eb1b..3c81c10f83 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift @@ -61,20 +61,25 @@ final class EditorStateRestoration { } } - private var databaseQueue: DatabaseQueue - - private init() throws { - let databaseURL: URL = FileManager.default + 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) - - self.databaseQueue = try DatabaseQueue(path: databaseURL.absolutePath, configuration: .init()) try attemptMigration(retry: true) } - private func attemptMigration(retry: Bool) throws { + func attemptMigration(retry: Bool) throws { do { + let databaseQueue = try DatabaseQueue(path: self.databaseURL.absolutePath, configuration: .init()) + var migrator = DatabaseMigrator() migrator.registerMigration("Version 0") { @@ -85,12 +90,11 @@ final class EditorStateRestoration { } 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) - // This will recreate the db file if necessary - self.databaseQueue = try DatabaseQueue(path: databaseURL.absolutePath, configuration: .init()) try attemptMigration(retry: false) return // Ignore the original error if we're retrying @@ -108,7 +112,7 @@ final class EditorStateRestoration { do { let serializedData = try JSONEncoder().encode(data) let dbRow = StateRestorationRecord(uri: documentUrl.absolutePath, data: serializedData) - try databaseQueue.write { try dbRow.upsert($0) } + try databaseQueue?.write { try dbRow.upsert($0) } } catch { Self.logger.error("Failed to save editor state: \(error)") } @@ -119,7 +123,7 @@ final class EditorStateRestoration { /// - Returns: Any data saved for this file. func restorationState(for documentUrl: URL) -> StateRestorationData? { do { - guard let row = try databaseQueue.read({ + guard let row = try databaseQueue?.read({ try StateRestorationRecord.fetchOne($0, key: documentUrl.absolutePath) }) else { return nil diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift index e18ae3d12a..5a244cd0a7 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -19,7 +19,7 @@ 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. diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 7ef4d0b228..d8158e6f3a 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -58,8 +58,7 @@ struct CodeFileView: View { @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject - var undoRegistration: UndoManagerRegistration + @EnvironmentObject var undoRegistration: UndoManagerRegistration @ObservedObject private var themeModel: ThemeModel = .shared diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift new file mode 100644 index 0000000000..6519fd988a --- /dev/null +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -0,0 +1,49 @@ +// +// 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 savesAndRetrievesState() 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")) != nil) + } + } + + @Test + func clearsCorruptedDatabase() throws { + try withTempDir { dir in + let url = dir.appending(path: "database.db") + try "bad SQLITE data HAHAHA".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/Utils/withTempDir.swift b/CodeEditTests/Utils/withTempDir.swift new file mode 100644 index 0000000000..1933217f12 --- /dev/null +++ b/CodeEditTests/Utils/withTempDir.swift @@ -0,0 +1,53 @@ +// +// 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 = try FileManager.default.url( + for: .developerApplicationDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .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) + } +} From 014e4b4287c3cf5ec02dea6477b979879de7f4c3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:31:19 -0500 Subject: [PATCH 12/18] Add UndoManagerRegistration Tests --- .../Editor/UndoManagerRegistrationTests.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift diff --git a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift new file mode 100644 index 0000000000..11f66e43d1 --- /dev/null +++ b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift @@ -0,0 +1,35 @@ +// +// UndoManagerRegistrationTests.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +@testable import CodeEdit +import Testing +import Foundation +import CodeEditTextView + +@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) + } +} From e570dcf8be9910df83cfcc59a313723ed47bf3b4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:45:43 -0500 Subject: [PATCH 13/18] Revert Some Changes --- CodeEdit/Features/Editor/Views/CodeFileView.swift | 4 +--- .../Models/TextEditingSettings.swift | 14 +++++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index d8158e6f3a..ac8c93edb4 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -46,8 +46,6 @@ struct CodeFileView: View { var bracketEmphasis @AppSettings(\.textEditing.useSystemCursor) var useSystemCursor - @AppSettings(\.textEditing.showGutter) - var showGutter @AppSettings(\.textEditing.showMinimap) var showMinimap @AppSettings(\.textEditing.reformatAtColumn) @@ -135,7 +133,7 @@ struct CodeFileView: View { additionalTextInsets: NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) ), peripherals: .init( - showGutter: showGutter, + showGutter: true, showMinimap: showMinimap, showReformattingGuide: showReformattingGuide, showFoldingRibbon: false, diff --git a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift index 9b8bbfc0e5..6771d2ec61 100644 --- a/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift +++ b/CodeEdit/Features/Settings/Pages/TextEditingSettings/Models/TextEditingSettings.swift @@ -74,9 +74,6 @@ extension SettingsData { /// Use the system cursor for the source editor. var useSystemCursor: Bool = true - /// Toggle the gutter in the editor. - var showGutter: Bool = true - /// Toggle the minimap in the editor. var showMinimap: Bool = true @@ -134,7 +131,6 @@ extension SettingsData { self.useSystemCursor = false } - self.showGutter = try container.decodeIfPresent(Bool.self, forKey: .showGutter) ?? true self.showMinimap = try container.decodeIfPresent(Bool.self, forKey: .showMinimap) ?? true self.reformatAtColumn = try container.decodeIfPresent(Int.self, forKey: .reformatAtColumn) ?? 80 self.showReformattingGuide = try container.decodeIfPresent( @@ -176,13 +172,13 @@ extension SettingsData { } ) - mgr.addCommand(name: "Toggle Minimap", title: "Toggle Minimap", id: "prefs.text_editing.toggle_minimap") { + mgr.addCommand( + name: "Toggle Minimap", + title: "Toggle Minimap", + id: "prefs.text_editing.toggle_minimap" + ) { Settings[\.textEditing].showMinimap.toggle() } - - mgr.addCommand(name: "Toggle Gutter", title: "Toggle Gutter", id: "prefs.text_editing.toggle_gutter") { - Settings[\.textEditing].showGutter.toggle() - } } struct IndentOption: Codable, Hashable { From 8306cf23d629f067dc8caf1a964d777eaa8ba029 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:48:09 -0500 Subject: [PATCH 14/18] Update EditorStateRestorationTests.swift --- CodeEditTests/Features/Editor/EditorStateRestorationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift index 6519fd988a..184c1707a0 100644 --- a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -41,7 +41,7 @@ struct EditorStateRestorationTests { func clearsCorruptedDatabase() throws { try withTempDir { dir in let url = dir.appending(path: "database.db") - try "bad SQLITE data HAHAHA".write(to: url, atomically: true, encoding: .utf8) + try "bad data".write(to: url, atomically: true, encoding: .utf8) // This will throw if it can't connect to the database. _ = try EditorStateRestoration(url) } From 08adf15a031e1cf62705782043957fee50aef8aa Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:57:53 -0500 Subject: [PATCH 15/18] MainActor the test --- CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift index 11f66e43d1..cfa9619aaf 100644 --- a/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift +++ b/CodeEditTests/Features/Editor/UndoManagerRegistrationTests.swift @@ -10,6 +10,7 @@ import Testing import Foundation import CodeEditTextView +@MainActor @Suite struct UndoManagerRegistrationTests { let registrar = UndoManagerRegistration() From f72e4619f8e2844903f2d61e87ac834e2535bb7a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:11:44 -0500 Subject: [PATCH 16/18] Bump CESE (bugfix), Add Test For Scroll Position --- CodeEdit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 ++-- .../Restoration/EditorStateRestoration.swift | 2 +- .../Editor/EditorStateRestorationTests.swift | 30 +++++++++++++++++-- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 8501951cf0..ce2a4d14be 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -1766,7 +1766,7 @@ repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { kind = exactVersion; - version = 0.14.0; + version = 0.14.1; }; }; 6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = { diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a6a715baa8..41cbfe6e7e 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3cb552b5e2e31b91b281091225c5b3ea09f287f41b8e0cccdf51671a9f974d6a", + "originHash" : "caf7678c3c52812febb80907a6a451d5e91a20058bbe45d250d7234c51299e91", "pins" : [ { "identity" : "aboutwindow", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", "state" : { - "revision" : "8a47aa4d3969a5e3bd372ce6c3d49ae3232883dd", - "version" : "0.14.0" + "revision" : "afc57523b05c209496a221655c2171c0624b51d3", + "version" : "0.14.1" } }, { diff --git a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift index 3c81c10f83..4b375ac887 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/EditorStateRestoration.swift @@ -38,7 +38,7 @@ final class EditorStateRestoration { let data: Data } - struct StateRestorationData: Codable { + struct StateRestorationData: Codable, Equatable { // Cursor positions as range values (not row/column!) let cursorPositions: [Range] let scrollPositionX: Double diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift index 184c1707a0..b2e7d46be0 100644 --- a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -21,7 +21,7 @@ struct EditorStateRestorationTests { } @Test - func savesAndRetrievesState() throws { + func savesAndRetrievesStateForFile() throws { try withTempDir { dir in let url = dir.appending(path: "database.db") let restoration = try EditorStateRestoration(url) @@ -33,7 +33,33 @@ struct EditorStateRestorationTests { ) // Retrieve it - #expect(restoration.restorationState(for: dir.appending(path: "file.txt")) != nil) + #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) + ) + ) } } From 21f8034e93174299c82f56bf13df721e2b455bf7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:50:43 -0500 Subject: [PATCH 17/18] Small Temp Dir Test Fix --- CodeEditTests/Utils/withTempDir.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/CodeEditTests/Utils/withTempDir.swift b/CodeEditTests/Utils/withTempDir.swift index 1933217f12..99a49fee4e 100644 --- a/CodeEditTests/Utils/withTempDir.swift +++ b/CodeEditTests/Utils/withTempDir.swift @@ -30,12 +30,7 @@ func withTempDir(_ test: (URL) throws -> Void) throws { } private func createAndClearDir() throws -> URL { - let tempDirURL = try FileManager.default.url( - for: .developerApplicationDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) + let tempDirURL = FileManager.default.temporaryDirectory .appending(path: "CodeEditTestDirectory", directoryHint: .isDirectory) // If it exists, delete it before the test From 664de492871e98ca2e66810d70c752207c7ccff3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Jul 2025 12:03:20 -0500 Subject: [PATCH 18/18] Finish Merge From Main --- CodeEdit/Features/WindowCommands/EditorCommands.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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]) }