Skip to content

Commit 61a5e67

Browse files
Reflect External File Changes (#2075)
### Description Implements reflecting external changes in open files. If a file has no unsaved changes, and is notified that the contents changed, it reloads the contents. `NSDocument` already handles asking the user if they want to overwrite the contents of the file or use the disk version if the user attempts to save to a file that has been updated since they edited the file. When a successful reload happens, the `CodeFileDocument` will also register a new undo frame with the new contents. This allows the user to undo changes that are reflected in the editor. When a file is updated externally and there are no open editors, the cached undo stack is cleared. #### Detailed changes: - Override `CodeFileDocument.presentedItemDidChange` to handle notifications about file changes. - If the file's current modification date != the last recorded one, we assume it has been changed on disk. - If `isDocumentEdited` is false, we block the calling thread and reload the document. - In `CodeFileDocument.read`, we check to see if the file has already been read into the `NSTextStorage` object. - If it has, we replace the contents with the new contents. This will be reflected in any open editors. Replacing the object would lose any subscribers (like the text view and syntax highlighting). - We also register the undo frame here. - Centralized the code that loads `CodeFileDocument`s into `CEWorkspaceFile`. Previously it was in a few different places. - Added `UndoManagerRestoration` to receive file system events. It uses these to invalidate undo stacks where necessary. - Updated `CodeFileView` to use the file document's object ID as SwiftUI's identifier. This fixed a bug where a document was sometimes 'cached' by the document controller and meant SwiftUI didn't load up a new view for the new document. This ID is stable while a file is open in an editor. - Fixed ~3 memory leaks related to `CodeFileDocument` that were causing the file to not reload when it was closed and opened again. - The changes to `EditorLayout+StateRestoration.swift` are all related to this. - Any changes where I just added a `[weak codeFile]` are similarly related. ### Related Issues * closes #1826 ### 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 Demo of new behavior. I first undo modifications to the open file, then make some modifications. The changes are added to the current undo stack. I cause a lint again and then undo all the changes and close the file. I then make more external changes (invalidating the undo stack) and open the file, where Command-Z no longer has any effect because of the changes that happened while the file wasn't opened. https://github.com/user-attachments/assets/b80acdcd-faf0-4b8a-88d2-8ebadfa9c7ff
1 parent cfbca15 commit 61a5e67

File tree

14 files changed

+268
-111
lines changed

14 files changed

+268
-111
lines changed

CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,14 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor
266266
return true
267267
}
268268

269+
/// Loads the ``fileDocument`` property with a new ``CodeFileDocument`` and registers it with the shared
270+
/// ``CodeEditDocumentController``.
271+
func loadCodeFile() throws {
272+
let codeFile = try CodeFileDocument(contentsOf: resolvedURL, ofType: contentType?.identifier ?? "")
273+
CodeEditDocumentController.shared.addDocument(codeFile)
274+
self.fileDocument = codeFile
275+
}
276+
269277
// MARK: Statics
270278
/// The default `FileManager` instance
271279
static let fileManager = FileManager.default

CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import CodeEditTextView
1414
import CodeEditLanguages
1515
import Combine
1616
import OSLog
17+
import TextStory
1718

1819
enum CodeFileError: Error {
1920
case failedToDecode
@@ -161,15 +162,39 @@ final class CodeFileDocument: NSDocument, ObservableObject {
161162
convertedString: &nsString,
162163
usedLossyConversion: nil
163164
)
164-
if let validEncoding = FileEncoding(rawEncoding), let nsString {
165-
self.sourceEncoding = validEncoding
166-
self.content = NSTextStorage(string: nsString as String)
167-
} else {
165+
guard let validEncoding = FileEncoding(rawEncoding), let nsString else {
168166
Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)")
167+
return
168+
}
169+
self.sourceEncoding = validEncoding
170+
if let content {
171+
registerContentChangeUndo(fileURL: fileURL, nsString: nsString, content: content)
172+
content.mutableString.setString(nsString as String)
173+
} else {
174+
self.content = NSTextStorage(string: nsString as String)
169175
}
170176
NotificationCenter.default.post(name: Self.didOpenNotification, object: self)
171177
}
172178

179+
/// If this file is already open and being tracked by an undo manager, we register an undo mutation
180+
/// of the entire contents. This allows the user to undo changes that occurred outside of CodeEdit
181+
/// while the file was displayed in CodeEdit.
182+
///
183+
/// - Note: This is inefficient memory-wise. We could do a diff of the file and only register the
184+
/// mutations that would recreate the diff. However, that would instead be CPU intensive.
185+
/// Tradeoffs.
186+
private func registerContentChangeUndo(fileURL: URL?, nsString: NSString, content: NSTextStorage) {
187+
guard let fileURL else { return }
188+
// If there's an undo manager, register a mutation replacing the entire contents.
189+
let mutation = TextMutation(
190+
string: nsString as String,
191+
range: NSRange(location: 0, length: content.length),
192+
limit: content.length
193+
)
194+
let undoManager = self.findWorkspace()?.undoRegistration.managerIfExists(forFile: fileURL)
195+
undoManager?.registerMutation(mutation)
196+
}
197+
173198
// MARK: - Autosave
174199

175200
/// Triggered when change occurred
@@ -217,6 +242,43 @@ final class CodeFileDocument: NSDocument, ObservableObject {
217242
}
218243
}
219244

245+
// MARK: - External Changes
246+
247+
/// Handle the notification that the represented file item changed.
248+
///
249+
/// We check if a file has been modified and can be read again to display to the user.
250+
/// To determine if a file has changed, we check the modification date. If it's different from the stored one,
251+
/// we continue.
252+
/// To determine if we can reload the file, we check if the document has outstanding edits. If not, we reload the
253+
/// file.
254+
override func presentedItemDidChange() {
255+
if fileModificationDate != getModificationDate() {
256+
guard isDocumentEdited else {
257+
fileModificationDate = getModificationDate()
258+
if let fileURL, let fileType {
259+
// This blocks the presented item thread intentionally. If we don't wait, we'll receive more updates
260+
// that the file has changed and we'll end up dispatching multiple reads.
261+
// The presented item thread expects this operation to by synchronous anyways.
262+
DispatchQueue.main.asyncAndWait {
263+
try? self.read(from: fileURL, ofType: fileType)
264+
}
265+
}
266+
return
267+
}
268+
}
269+
270+
super.presentedItemDidChange()
271+
}
272+
273+
/// Helper to find the last modified date of the represented file item.
274+
///
275+
/// Different from `NSDocument.fileModificationDate`. This returns the *current* modification date, whereas the
276+
/// alternative stores the date that existed when we last read the file.
277+
private func getModificationDate() -> Date? {
278+
guard let path = fileURL?.absolutePath else { return nil }
279+
return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date
280+
}
281+
220282
// MARK: - Close
221283

222284
override func close() {

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
165165
}
166166
self.taskNotificationHandler.workspaceURL = url
167167

168+
workspaceFileManager?.addObserver(undoRegistration)
168169
editorManager?.restoreFromState(self)
169170
utilityAreaModel?.restoreFromState(self)
170171
}

CodeEdit/Features/Editor/Models/Editor/Editor.swift

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,16 @@ final class Editor: ObservableObject, Identifiable {
6969
selectedTab: Tab? = nil,
7070
temporaryTab: Tab? = nil,
7171
parent: SplitViewData? = nil,
72-
workspace: WorkspaceDocument? = nil,
72+
workspace: WorkspaceDocument? = nil
7373
) {
74-
self.tabs = []
7574
self.parent = parent
7675
self.workspace = workspace
77-
files.forEach { openTab(file: $0) }
76+
// If we open the files without a valid workspace, we risk creating a file we lose track of but stays in memory
77+
if workspace != nil {
78+
files.forEach { openTab(file: $0) }
79+
} else {
80+
self.tabs = OrderedSet(files.map { EditorInstance(workspace: workspace, file: $0) })
81+
}
7882
self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!))
7983
self.temporaryTab = temporaryTab
8084
}
@@ -237,18 +241,12 @@ final class Editor: ObservableObject, Identifiable {
237241
}
238242

239243
private func openFile(item: Tab) throws {
240-
guard item.file.fileDocument == nil else {
244+
// If this isn't attached to a workspace, loading a new NSDocument will cause a loose document we can't close
245+
guard item.file.fileDocument == nil && workspace != nil else {
241246
return
242247
}
243248

244-
let contentType = item.file.resolvedURL.contentType
245-
let codeFile = try CodeFileDocument(
246-
for: item.file.url,
247-
withContentsOf: item.file.resolvedURL,
248-
ofType: contentType?.identifier ?? ""
249-
)
250-
item.file.fileDocument = codeFile
251-
CodeEditDocumentController.shared.addDocument(codeFile)
249+
try item.file.loadCodeFile()
252250
}
253251

254252
/// Check if tab can be closed

CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension EditorManager {
3434
return
3535
}
3636

37-
fixRestoredEditorLayout(state.groups, workspace: workspace)
37+
try fixRestoredEditorLayout(state.groups, workspace: workspace)
3838

3939
self.editorLayout = state.groups
4040
self.activeEditor = activeEditor
@@ -53,29 +53,29 @@ extension EditorManager {
5353
/// - Parameters:
5454
/// - group: The tab group to fix.
5555
/// - fileManager: The file manager to use to map files.
56-
private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) {
56+
private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) throws {
5757
switch group {
5858
case let .one(data):
59-
fixEditor(data, workspace: workspace)
59+
try fixEditor(data, workspace: workspace)
6060
case let .vertical(splitData):
61-
splitData.editorLayouts.forEach { group in
62-
fixRestoredEditorLayout(group, workspace: workspace)
61+
try splitData.editorLayouts.forEach { group in
62+
try fixRestoredEditorLayout(group, workspace: workspace)
6363
}
6464
case let .horizontal(splitData):
65-
splitData.editorLayouts.forEach { group in
66-
fixRestoredEditorLayout(group, workspace: workspace)
65+
try splitData.editorLayouts.forEach { group in
66+
try fixRestoredEditorLayout(group, workspace: workspace)
6767
}
6868
}
6969
}
7070

71-
private func findEditorLayout(group: EditorLayout, searchFor id: UUID) -> Editor? {
71+
private func findEditorLayout(group: EditorLayout, searchFor id: UUID) throws -> Editor? {
7272
switch group {
7373
case let .one(data):
7474
return data.id == id ? data : nil
7575
case let .vertical(splitData):
76-
return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first
76+
return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first
7777
case let .horizontal(splitData):
78-
return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first
78+
return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first
7979
}
8080
}
8181

@@ -87,16 +87,25 @@ extension EditorManager {
8787
/// - Parameters:
8888
/// - data: The tab group to fix.
8989
/// - fileManager: The file manager to use to map files.a
90-
private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) {
90+
private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) throws {
9191
guard let fileManager = workspace.workspaceFileManager else { return }
9292
let resolvedTabs = editor
9393
.tabs
94-
.compactMap({ fileManager.getFile($0.file.url.path(), createIfNotFound: true) })
94+
.compactMap({ fileManager.getFile($0.file.url.path(percentEncoded: false), createIfNotFound: true) })
9595
.map({ EditorInstance(workspace: workspace, file: $0) })
96+
97+
for tab in resolvedTabs {
98+
try tab.file.loadCodeFile()
99+
}
100+
96101
editor.workspace = workspace
97102
editor.tabs = OrderedSet(resolvedTabs)
103+
98104
if let selectedTab = editor.selectedTab {
99-
if let resolvedFile = fileManager.getFile(selectedTab.file.url.path(), createIfNotFound: true) {
105+
if let resolvedFile = fileManager.getFile(
106+
selectedTab.file.url.path(percentEncoded: false),
107+
createIfNotFound: true
108+
) {
100109
editor.setSelectedTab(resolvedFile)
101110
} else {
102111
editor.setSelectedTab(nil)

CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,48 @@ import CodeEditTextView
1616
/// - `CodeFileDocument` is released once there are no editors viewing it.
1717
/// Undo stacks need to be retained for the duration of a workspace session, enduring editor closes..
1818
final class UndoManagerRegistration: ObservableObject {
19-
private var managerMap: [CEWorkspaceFile.ID: CEUndoManager] = [:]
19+
private var managerMap: [String: CEUndoManager] = [:]
2020

2121
init() { }
2222

2323
/// Find or create a new undo manager.
2424
/// - Parameter file: The file to create for.
2525
/// - Returns: The undo manager for the given file.
2626
func manager(forFile file: CEWorkspaceFile) -> CEUndoManager {
27-
if let manager = managerMap[file.id] {
27+
manager(forFile: file.url)
28+
}
29+
30+
/// Find or create a new undo manager.
31+
/// - Parameter path: The path of the file to create for.
32+
/// - Returns: The undo manager for the given file.
33+
func manager(forFile path: URL) -> CEUndoManager {
34+
if let manager = managerMap[path.absolutePath] {
2835
return manager
2936
} else {
3037
let newManager = CEUndoManager()
31-
managerMap[file.id] = newManager
38+
managerMap[path.absolutePath] = newManager
3239
return newManager
3340
}
3441
}
42+
43+
/// Find or create a new undo manager.
44+
/// - Parameter path: The path of the file to create for.
45+
/// - Returns: The undo manager for the given file.
46+
func managerIfExists(forFile path: URL) -> CEUndoManager? {
47+
managerMap[path.absolutePath]
48+
}
49+
}
50+
51+
extension UndoManagerRegistration: CEWorkspaceFileManagerObserver {
52+
/// Managers need to be cleared when the following is true:
53+
/// - The file is not open in any editors
54+
/// - The file is updated externally
55+
///
56+
/// To handle this?
57+
/// - When we receive a file update, if the file is not open in any editors we clear the undo stack
58+
func fileManagerUpdated(updatedItems: Set<CEWorkspaceFile>) {
59+
for file in updatedItems where file.fileDocument == nil {
60+
managerMap.removeValue(forKey: file.url.absolutePath)
61+
}
62+
}
3563
}

CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ struct EditorTabBarTrailingAccessories: View {
5959
Toggle(
6060
"Wrap Lines",
6161
isOn: Binding(
62-
get: { codeFile.wrapLines ?? wrapLinesToEditorWidth },
63-
set: {
64-
codeFile.wrapLines = $0
62+
get: { [weak codeFile] in codeFile?.wrapLines ?? wrapLinesToEditorWidth },
63+
set: { [weak codeFile] in
64+
codeFile?.wrapLines = $0
6565
}
6666
)
6767
)

CodeEdit/Features/Editor/Views/CodeFileView.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ struct CodeFileView: View {
9999
codeFile
100100
.contentCoordinator
101101
.textUpdatePublisher
102-
.sink { _ in
103-
codeFile.updateChangeCount(.changeDone)
102+
.sink { [weak codeFile] _ in
103+
codeFile?.updateChangeCount(.changeDone)
104104
}
105105
.store(in: &cancellables)
106106
}
@@ -171,7 +171,8 @@ struct CodeFileView: View {
171171
undoManager: undoRegistration.manager(forFile: editorInstance.file),
172172
coordinators: textViewCoordinators
173173
)
174-
.id(codeFile.fileURL)
174+
// This view needs to refresh when the codefile changes. The file URL is too stable.
175+
.id(ObjectIdentifier(codeFile))
175176
.background {
176177
if colorScheme == .dark {
177178
EffectView(.underPageBackground)

0 commit comments

Comments
 (0)