Skip to content

Editor Restoration #2078

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jul 10, 2025
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions CodeEdit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; };
6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0824A02C5C0C9700A0751E /* SwiftTerm */; };
6C147C4529A329350089B630 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C147C4429A329350089B630 /* OrderedCollections */; };
6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */; };
6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */; };
6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6C66C31229D05CDC00DE9ED2 /* GRDB */; };
6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F329CD142C00235D17 /* CollectionConcurrencyKit */; };
6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; };
6C6BD6F929CD14D100235D17 /* CodeEditKit in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */; };
6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */; };
6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; };
6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; };
6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; };
Expand Down Expand Up @@ -170,6 +172,8 @@
58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */,
6C147C4529A329350089B630 /* OrderedCollections in Frameworks */,
6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */,
6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */,
6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */,
6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */,
6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */,
6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */,
Expand Down Expand Up @@ -323,6 +327,8 @@
6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */,
5EACE6212DF4BF08005E08B8 /* WelcomeWindow */,
5E4485602DF600D9008BBE69 /* AboutWindow */,
6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */,
6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */,
);
productName = CodeEdit;
productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */;
Expand Down Expand Up @@ -425,9 +431,9 @@
303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */,
6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */,
5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */,
6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
);
preferredProjectObjectVersion = 55;
productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */;
Expand Down Expand Up @@ -1755,6 +1761,14 @@
minimumVersion = 0.2.0;
};
};
6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor";
requirement = {
kind = exactVersion;
version = 0.14.1;
};
};
6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/CodeEditApp/CodeEditKit";
Expand All @@ -1779,14 +1793,6 @@
version = 1.0.1;
};
};
6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor";
requirement = {
kind = exactVersion;
version = 0.14.0;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand Down Expand Up @@ -1839,6 +1845,10 @@
package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = OrderedCollections;
};
6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
Expand All @@ -1862,6 +1872,11 @@
isa = XCSwiftPackageProductDependency;
productName = CodeEditSourceEditor;
};
6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = {
isa = XCSwiftPackageProductDependency;
package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
productName = CodeEditSourceEditor;
};
6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = {
isa = XCSwiftPackageProductDependency;
package = 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ final class CodeEditSplitViewController: NSSplitViewController {
.environmentObject(statusBarViewModel)
.environmentObject(utilityAreaModel)
.environmentObject(taskManager)
.environmentObject(workspace.undoRegistration)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,34 +55,40 @@ 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<CEWorkspaceFile> = [],
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
}

init(
files: OrderedSet<Tab> = [],
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
116 changes: 93 additions & 23 deletions CodeEdit/Features/Editor/Models/EditorInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,105 @@ import CodeEditSourceEditor

/// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish
/// the user's current location in a file.
class EditorInstance: Hashable {
// Public

class EditorInstance: ObservableObject, Hashable {
/// The file presented in this editor instance.
let file: CEWorkspaceFile

/// A publisher for the user's current location in a file.
var cursorPositions: AnyPublisher<[CursorPosition], Never> {
cursorSubject.eraseToAnyPublisher()
}
@Published var cursorPositions: [CursorPosition]
@Published var scrollPosition: CGPoint?

// Public TextViewCoordinator APIs
@Published var findText: String?
var findTextSubject: PassthroughSubject<String?, Never>

var rangeTranslator: RangeTranslator?
@Published var replaceText: String?
var replaceTextSubject: PassthroughSubject<String?, Never>

// Internal Combine subjects
var rangeTranslator: RangeTranslator = RangeTranslator()

private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([])
private var cancellables: Set<AnyCancellable> = []

// MARK: - Init, Hashable, Equatable
// MARK: - Init

init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) {
init(workspace: WorkspaceDocument?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) {
self.file = file
self.cursorSubject.send(cursorPositions)
self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject)
let url = file.url
let editorState = EditorStateRestoration.shared?.restorationState(for: url)

findText = workspace?.searchState?.searchQuery
findTextSubject = PassthroughSubject()
replaceText = workspace?.searchState?.replaceText
replaceTextSubject = PassthroughSubject()

self.cursorPositions = (
cursorPositions ?? editorState?.editorCursorPositions ?? [CursorPosition(line: 1, column: 1)]
)
self.scrollPosition = editorState?.scrollPosition

// Setup listeners

Publishers.CombineLatest(
$cursorPositions.removeDuplicates(),
$scrollPosition
.debounce(for: .seconds(0.1), scheduler: RunLoop.main) // This can trigger *very* often
.removeDuplicates()
)
.sink { (cursorPositions, scrollPosition) in
EditorStateRestoration.shared?.updateRestorationState(
for: url,
data: .init(cursorPositions: cursorPositions, scrollPosition: scrollPosition ?? .zero)
)
}
.store(in: &cancellables)

listenToFindText(workspace: workspace)
listenToReplaceText(workspace: workspace)
}

// MARK: - Find/Replace Listeners

func listenToFindText(workspace: WorkspaceDocument?) {
workspace?.searchState?.$searchQuery
.receive(on: RunLoop.main)
.sink { [weak self] newQuery in
if self?.findText != newQuery {
self?.findText = newQuery
}
}
.store(in: &cancellables)
findTextSubject
.receive(on: RunLoop.main)
.sink { [weak workspace, weak self] newFindText in
if let newFindText, workspace?.searchState?.searchQuery != newFindText {
workspace?.searchState?.searchQuery = newFindText
}
self?.findText = workspace?.searchState?.searchQuery
}
.store(in: &cancellables)
}

func listenToReplaceText(workspace: WorkspaceDocument?) {
workspace?.searchState?.$replaceText
.receive(on: RunLoop.main)
.sink { [weak self] newText in
if self?.replaceText != newText {
self?.replaceText = newText
}
}
.store(in: &cancellables)
replaceTextSubject
.receive(on: RunLoop.main)
.sink { [weak workspace, weak self] newReplaceText in
if let newReplaceText, workspace?.searchState?.replaceText != newReplaceText {
workspace?.searchState?.replaceText = newReplaceText
}
self?.replaceText = workspace?.searchState?.replaceText
}
.store(in: &cancellables)
}

// MARK: - Hashable, Equatable

func hash(into hasher: inout Hasher) {
hasher.combine(file)
}
Expand All @@ -53,19 +125,17 @@ class EditorInstance: Hashable {
/// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range.
class RangeTranslator: TextViewCoordinator {
private weak var textViewController: TextViewController?
private var cursorSubject: CurrentValueSubject<[CursorPosition], Never>

init(cursorSubject: CurrentValueSubject<[CursorPosition], Never>) {
self.cursorSubject = cursorSubject
}

func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) {
self.cursorSubject.send(controller.cursorPositions)
}
init() { }

func prepareCoordinator(controller: TextViewController) {
self.textViewController = controller
self.cursorSubject.send(controller.cursorPositions)
}

func controllerDidAppear(controller: TextViewController) {
if controller.isEditable && controller.isSelectable {
controller.view.window?.makeFirstResponder(controller.textView)
}
}

func destroy() {
Expand Down
Loading