Skip to content

Commit 4ce9be0

Browse files
Editor Restoration (#2078)
### Description Implements editor restoration, helping ease the user experience for longer editing tasks spanning multiple files. Users expect opened tabs to retain the scroll position, cursors, and undo stack. To facilitate this, we receive a stream of state updates from the editor and save them to a SQLite DB. When a file is opened again, the state is restored from that DB. Ideally this is transparent to the user. However, there is one very slight UX flaw that this doesn't address: highlighting. When a user restores an editor there can be a flash as the highlighter works to do syntax highlighting on the newly opened editor. Some potential future directions: - The text storage object that outlives editors does retain color information, if we could avoid wiping that color information when an editor is loaded we could avoid that slight delay. - To do that, we need to store that state with the storage object somehow. Either through a custom text attribute (could be finicky) or maybe by subclassing NSTextStorage and adding a flag. > [!NOTE] > This includes a mechanism for sharing undo stacks for editors during a workspace's lifetime. As @austincondiff mentioned on discord, it should add a undo frame when a file is updated externally. This PR notably does not include that change. I'll be adding it in #2075 ### Related Issues * closes #2057 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/6a9ff5ea-743e-4183-83c9-b774915b0c35
1 parent 666d33b commit 4ce9be0

30 files changed

+685
-192
lines changed

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121
6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; };
2222
6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0824A02C5C0C9700A0751E /* SwiftTerm */; };
2323
6C147C4529A329350089B630 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C147C4429A329350089B630 /* OrderedCollections */; };
24+
6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */; };
2425
6C4E37FC2C73E00700AEE7B5 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */; };
2526
6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 6C66C31229D05CDC00DE9ED2 /* GRDB */; };
2627
6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F329CD142C00235D17 /* CollectionConcurrencyKit */; };
2728
6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; };
2829
6C6BD6F929CD14D100235D17 /* CodeEditKit in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 6C6BD6F729CD14D100235D17 /* CodeEditKit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
2930
6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */; };
31+
6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */; };
3032
6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; };
3133
6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; };
3234
6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; };
@@ -170,6 +172,8 @@
170172
58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */,
171173
6C147C4529A329350089B630 /* OrderedCollections in Frameworks */,
172174
6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */,
175+
6C76D6D42E15B91E00EF52C3 /* CodeEditSourceEditor in Frameworks */,
176+
6C315FC82E05E33D0011BFC5 /* CodeEditSourceEditor in Frameworks */,
173177
6CC00A8B2CBEF150004E8134 /* CodeEditSourceEditor in Frameworks */,
174178
6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */,
175179
6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */,
@@ -323,6 +327,8 @@
323327
6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */,
324328
5EACE6212DF4BF08005E08B8 /* WelcomeWindow */,
325329
5E4485602DF600D9008BBE69 /* AboutWindow */,
330+
6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */,
331+
6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */,
326332
);
327333
productName = CodeEdit;
328334
productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */;
@@ -425,9 +431,9 @@
425431
303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */,
426432
6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
427433
6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
428-
6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
429434
5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */,
430435
5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */,
436+
6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
431437
);
432438
preferredProjectObjectVersion = 55;
433439
productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */;
@@ -1755,6 +1761,14 @@
17551761
minimumVersion = 0.2.0;
17561762
};
17571763
};
1764+
6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
1765+
isa = XCRemoteSwiftPackageReference;
1766+
repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor";
1767+
requirement = {
1768+
kind = exactVersion;
1769+
version = 0.14.1;
1770+
};
1771+
};
17581772
6C85BB3E2C2105ED00EB5DEF /* XCRemoteSwiftPackageReference "CodeEditKit" */ = {
17591773
isa = XCRemoteSwiftPackageReference;
17601774
repositoryURL = "https://github.com/CodeEditApp/CodeEditKit";
@@ -1779,14 +1793,6 @@
17791793
version = 1.0.1;
17801794
};
17811795
};
1782-
6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = {
1783-
isa = XCRemoteSwiftPackageReference;
1784-
repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor";
1785-
requirement = {
1786-
kind = exactVersion;
1787-
version = 0.14.1;
1788-
};
1789-
};
17901796
/* End XCRemoteSwiftPackageReference section */
17911797

17921798
/* Begin XCSwiftPackageProductDependency section */
@@ -1839,6 +1845,10 @@
18391845
package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */;
18401846
productName = OrderedCollections;
18411847
};
1848+
6C315FC72E05E33D0011BFC5 /* CodeEditSourceEditor */ = {
1849+
isa = XCSwiftPackageProductDependency;
1850+
productName = CodeEditSourceEditor;
1851+
};
18421852
6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = {
18431853
isa = XCSwiftPackageProductDependency;
18441854
package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
@@ -1862,6 +1872,11 @@
18621872
isa = XCSwiftPackageProductDependency;
18631873
productName = CodeEditSourceEditor;
18641874
};
1875+
6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */ = {
1876+
isa = XCSwiftPackageProductDependency;
1877+
package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
1878+
productName = CodeEditSourceEditor;
1879+
};
18651880
6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = {
18661881
isa = XCSwiftPackageProductDependency;
18671882
package = 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */;

CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ final class CodeEditSplitViewController: NSSplitViewController {
7676
.environmentObject(statusBarViewModel)
7777
.environmentObject(utilityAreaModel)
7878
.environmentObject(taskManager)
79+
.environmentObject(workspace.undoRegistration)
7980
}
8081
}
8182

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ extension WorkspaceDocument {
2929
@Published var searchResultsCount: Int = 0
3030
/// Stores the user's input, shown when no files are found, and persists across navigation items.
3131
@Published var searchQuery: String = ""
32+
@Published var replaceText: String = ""
3233

3334
@Published var indexStatus: IndexStatus = .none
3435

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
4545
var workspaceSettingsManager: CEWorkspaceSettings?
4646
var taskNotificationHandler: TaskNotificationHandler = TaskNotificationHandler()
4747

48+
var undoRegistration: UndoManagerRegistration = UndoManagerRegistration()
49+
4850
@Published var notificationPanel = NotificationPanelViewModel()
4951
private var cancellables = Set<AnyCancellable>()
5052

CodeEdit/Features/Editor/Models/Editor.swift renamed to CodeEdit/Features/Editor/Models/Editor/Editor.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,34 +55,40 @@ final class Editor: ObservableObject, Identifiable {
5555
var id = UUID()
5656

5757
weak var parent: SplitViewData?
58+
weak var workspace: WorkspaceDocument?
5859

5960
init() {
6061
self.tabs = []
6162
self.temporaryTab = nil
6263
self.parent = nil
64+
self.workspace = nil
6365
}
6466

6567
init(
6668
files: OrderedSet<CEWorkspaceFile> = [],
6769
selectedTab: Tab? = nil,
6870
temporaryTab: Tab? = nil,
69-
parent: SplitViewData? = nil
71+
parent: SplitViewData? = nil,
72+
workspace: WorkspaceDocument? = nil,
7073
) {
7174
self.tabs = []
7275
self.parent = parent
76+
self.workspace = workspace
7377
files.forEach { openTab(file: $0) }
74-
self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(file: files.first!))
78+
self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!))
7579
self.temporaryTab = temporaryTab
7680
}
7781

7882
init(
7983
files: OrderedSet<Tab> = [],
8084
selectedTab: Tab? = nil,
8185
temporaryTab: Tab? = nil,
82-
parent: SplitViewData? = nil
86+
parent: SplitViewData? = nil,
87+
workspace: WorkspaceDocument? = nil
8388
) {
8489
self.tabs = []
8590
self.parent = parent
91+
self.workspace = workspace
8692
files.forEach { openTab(file: $0.file) }
8793
self.selectedTab = selectedTab ?? tabs.first
8894
self.temporaryTab = temporaryTab
@@ -135,7 +141,7 @@ final class Editor: ObservableObject, Identifiable {
135141
clearFuture()
136142
}
137143
if file != selectedTab?.file {
138-
addToHistory(EditorInstance(file: file))
144+
addToHistory(EditorInstance(workspace: workspace, file: file))
139145
}
140146
removeTab(file)
141147
if let selectedTab {
@@ -165,7 +171,7 @@ final class Editor: ObservableObject, Identifiable {
165171
/// - file: the file to open.
166172
/// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab.
167173
func openTab(file: CEWorkspaceFile, asTemporary: Bool) {
168-
let item = EditorInstance(file: file)
174+
let item = EditorInstance(workspace: workspace, file: file)
169175
// Item is already opened in a tab.
170176
guard !tabs.contains(item) || !asTemporary else {
171177
selectedTab = item
@@ -207,7 +213,7 @@ final class Editor: ObservableObject, Identifiable {
207213
/// - index: Index where the tab needs to be added. If nil, it is added to the back.
208214
/// - fromHistory: Indicates whether the tab has been opened from going back in history.
209215
func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) {
210-
let item = Tab(file: file)
216+
let item = Tab(workspace: workspace, file: file)
211217
if let index {
212218
tabs.insert(item, at: index)
213219
} else {

CodeEdit/Features/Editor/Models/EditorInstance.swift

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,105 @@ import CodeEditSourceEditor
1313

1414
/// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish
1515
/// the user's current location in a file.
16-
class EditorInstance: Hashable {
17-
// Public
18-
16+
class EditorInstance: ObservableObject, Hashable {
1917
/// The file presented in this editor instance.
2018
let file: CEWorkspaceFile
2119

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

27-
// Public TextViewCoordinator APIs
24+
@Published var findText: String?
25+
var findTextSubject: PassthroughSubject<String?, Never>
2826

29-
var rangeTranslator: RangeTranslator?
27+
@Published var replaceText: String?
28+
var replaceTextSubject: PassthroughSubject<String?, Never>
3029

31-
// Internal Combine subjects
30+
var rangeTranslator: RangeTranslator = RangeTranslator()
3231

33-
private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([])
32+
private var cancellables: Set<AnyCancellable> = []
3433

35-
// MARK: - Init, Hashable, Equatable
34+
// MARK: - Init
3635

37-
init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) {
36+
init(workspace: WorkspaceDocument?, file: CEWorkspaceFile, cursorPositions: [CursorPosition]? = nil) {
3837
self.file = file
39-
self.cursorSubject.send(cursorPositions)
40-
self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject)
38+
let url = file.url
39+
let editorState = EditorStateRestoration.shared?.restorationState(for: url)
40+
41+
findText = workspace?.searchState?.searchQuery
42+
findTextSubject = PassthroughSubject()
43+
replaceText = workspace?.searchState?.replaceText
44+
replaceTextSubject = PassthroughSubject()
45+
46+
self.cursorPositions = (
47+
cursorPositions ?? editorState?.editorCursorPositions ?? [CursorPosition(line: 1, column: 1)]
48+
)
49+
self.scrollPosition = editorState?.scrollPosition
50+
51+
// Setup listeners
52+
53+
Publishers.CombineLatest(
54+
$cursorPositions.removeDuplicates(),
55+
$scrollPosition
56+
.debounce(for: .seconds(0.1), scheduler: RunLoop.main) // This can trigger *very* often
57+
.removeDuplicates()
58+
)
59+
.sink { (cursorPositions, scrollPosition) in
60+
EditorStateRestoration.shared?.updateRestorationState(
61+
for: url,
62+
data: .init(cursorPositions: cursorPositions, scrollPosition: scrollPosition ?? .zero)
63+
)
64+
}
65+
.store(in: &cancellables)
66+
67+
listenToFindText(workspace: workspace)
68+
listenToReplaceText(workspace: workspace)
69+
}
70+
71+
// MARK: - Find/Replace Listeners
72+
73+
func listenToFindText(workspace: WorkspaceDocument?) {
74+
workspace?.searchState?.$searchQuery
75+
.receive(on: RunLoop.main)
76+
.sink { [weak self] newQuery in
77+
if self?.findText != newQuery {
78+
self?.findText = newQuery
79+
}
80+
}
81+
.store(in: &cancellables)
82+
findTextSubject
83+
.receive(on: RunLoop.main)
84+
.sink { [weak workspace, weak self] newFindText in
85+
if let newFindText, workspace?.searchState?.searchQuery != newFindText {
86+
workspace?.searchState?.searchQuery = newFindText
87+
}
88+
self?.findText = workspace?.searchState?.searchQuery
89+
}
90+
.store(in: &cancellables)
91+
}
92+
93+
func listenToReplaceText(workspace: WorkspaceDocument?) {
94+
workspace?.searchState?.$replaceText
95+
.receive(on: RunLoop.main)
96+
.sink { [weak self] newText in
97+
if self?.replaceText != newText {
98+
self?.replaceText = newText
99+
}
100+
}
101+
.store(in: &cancellables)
102+
replaceTextSubject
103+
.receive(on: RunLoop.main)
104+
.sink { [weak workspace, weak self] newReplaceText in
105+
if let newReplaceText, workspace?.searchState?.replaceText != newReplaceText {
106+
workspace?.searchState?.replaceText = newReplaceText
107+
}
108+
self?.replaceText = workspace?.searchState?.replaceText
109+
}
110+
.store(in: &cancellables)
41111
}
42112

113+
// MARK: - Hashable, Equatable
114+
43115
func hash(into hasher: inout Hasher) {
44116
hasher.combine(file)
45117
}
@@ -53,19 +125,17 @@ class EditorInstance: Hashable {
53125
/// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range.
54126
class RangeTranslator: TextViewCoordinator {
55127
private weak var textViewController: TextViewController?
56-
private var cursorSubject: CurrentValueSubject<[CursorPosition], Never>
57-
58-
init(cursorSubject: CurrentValueSubject<[CursorPosition], Never>) {
59-
self.cursorSubject = cursorSubject
60-
}
61128

62-
func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) {
63-
self.cursorSubject.send(controller.cursorPositions)
64-
}
129+
init() { }
65130

66131
func prepareCoordinator(controller: TextViewController) {
67132
self.textViewController = controller
68-
self.cursorSubject.send(controller.cursorPositions)
133+
}
134+
135+
func controllerDidAppear(controller: TextViewController) {
136+
if controller.isEditable && controller.isSelectable {
137+
controller.view.window?.makeFirstResponder(controller.textView)
138+
}
69139
}
70140

71141
func destroy() {

0 commit comments

Comments
 (0)