Skip to content

Reflect External File Changes #2075

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
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor
return true
}

/// Loads the ``fileDocument`` property with a new ``CodeFileDocument`` and registers it with the shared
/// ``CodeEditDocumentController``.
func loadCodeFile() throws {
let codeFile = try CodeFileDocument(contentsOf: resolvedURL, ofType: contentType?.identifier ?? "")
CodeEditDocumentController.shared.addDocument(codeFile)
self.fileDocument = codeFile
}

// MARK: Statics
/// The default `FileManager` instance
static let fileManager = FileManager.default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import CodeEditTextView
import CodeEditLanguages
import Combine
import OSLog
import TextStory

enum CodeFileError: Error {
case failedToDecode
Expand Down Expand Up @@ -161,15 +162,39 @@ final class CodeFileDocument: NSDocument, ObservableObject {
convertedString: &nsString,
usedLossyConversion: nil
)
if let validEncoding = FileEncoding(rawEncoding), let nsString {
self.sourceEncoding = validEncoding
self.content = NSTextStorage(string: nsString as String)
} else {
guard let validEncoding = FileEncoding(rawEncoding), let nsString else {
Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)")
return
}
self.sourceEncoding = validEncoding
if let content {
registerContentChangeUndo(fileURL: fileURL, nsString: nsString, content: content)
content.mutableString.setString(nsString as String)
} else {
self.content = NSTextStorage(string: nsString as String)
}
NotificationCenter.default.post(name: Self.didOpenNotification, object: self)
}

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

// MARK: - Autosave

/// Triggered when change occurred
Expand Down Expand Up @@ -217,6 +242,43 @@ final class CodeFileDocument: NSDocument, ObservableObject {
}
}

// MARK: - External Changes

/// Handle the notification that the represented file item changed.
///
/// We check if a file has been modified and can be read again to display to the user.
/// To determine if a file has changed, we check the modification date. If it's different from the stored one,
/// we continue.
/// To determine if we can reload the file, we check if the document has outstanding edits. If not, we reload the
/// file.
override func presentedItemDidChange() {
if fileModificationDate != getModificationDate() {
guard isDocumentEdited else {
fileModificationDate = getModificationDate()
if let fileURL, let fileType {
// This blocks the presented item thread intentionally. If we don't wait, we'll receive more updates
// that the file has changed and we'll end up dispatching multiple reads.
// The presented item thread expects this operation to by synchronous anyways.
DispatchQueue.main.asyncAndWait {
try? self.read(from: fileURL, ofType: fileType)
}
}
return
}
}

super.presentedItemDidChange()
}

/// Helper to find the last modified date of the represented file item.
///
/// Different from `NSDocument.fileModificationDate`. This returns the *current* modification date, whereas the
/// alternative stores the date that existed when we last read the file.
private func getModificationDate() -> Date? {
guard let path = fileURL?.absolutePath else { return nil }
return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date
}

// MARK: - Close

override func close() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
)
}

workspaceFileManager?.addObserver(undoRegistration)
editorManager?.restoreFromState(self)
utilityAreaModel?.restoreFromState(self)
}
Expand Down
22 changes: 10 additions & 12 deletions CodeEdit/Features/Editor/Models/Editor/Editor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ final class Editor: ObservableObject, Identifiable {
selectedTab: Tab? = nil,
temporaryTab: Tab? = nil,
parent: SplitViewData? = nil,
workspace: WorkspaceDocument? = nil,
workspace: WorkspaceDocument? = nil
) {
self.tabs = []
self.parent = parent
self.workspace = workspace
files.forEach { openTab(file: $0) }
// If we open the files without a valid workspace, we risk creating a file we lose track of but stays in memory
if workspace != nil {
files.forEach { openTab(file: $0) }
} else {
self.tabs = OrderedSet(files.map { EditorInstance(workspace: workspace, file: $0) })
}
self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!))
self.temporaryTab = temporaryTab
}
Expand Down Expand Up @@ -237,18 +241,12 @@ final class Editor: ObservableObject, Identifiable {
}

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

let contentType = item.file.resolvedURL.contentType
let codeFile = try CodeFileDocument(
for: item.file.url,
withContentsOf: item.file.resolvedURL,
ofType: contentType?.identifier ?? ""
)
item.file.fileDocument = codeFile
CodeEditDocumentController.shared.addDocument(codeFile)
try item.file.loadCodeFile()
}

/// Check if tab can be closed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ extension EditorManager {
return
}

fixRestoredEditorLayout(state.groups, workspace: workspace)
try fixRestoredEditorLayout(state.groups, workspace: workspace)

self.editorLayout = state.groups
self.activeEditor = activeEditor
Expand All @@ -53,29 +53,29 @@ extension EditorManager {
/// - Parameters:
/// - group: The tab group to fix.
/// - fileManager: The file manager to use to map files.
private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) {
private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) throws {
switch group {
case let .one(data):
fixEditor(data, workspace: workspace)
try fixEditor(data, workspace: workspace)
case let .vertical(splitData):
splitData.editorLayouts.forEach { group in
fixRestoredEditorLayout(group, workspace: workspace)
try splitData.editorLayouts.forEach { group in
try fixRestoredEditorLayout(group, workspace: workspace)
}
case let .horizontal(splitData):
splitData.editorLayouts.forEach { group in
fixRestoredEditorLayout(group, workspace: workspace)
try splitData.editorLayouts.forEach { group in
try fixRestoredEditorLayout(group, workspace: workspace)
}
}
}

private func findEditorLayout(group: EditorLayout, searchFor id: UUID) -> Editor? {
private func findEditorLayout(group: EditorLayout, searchFor id: UUID) throws -> Editor? {
switch group {
case let .one(data):
return data.id == id ? data : nil
case let .vertical(splitData):
return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first
return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first
case let .horizontal(splitData):
return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first
return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first
}
}

Expand All @@ -87,16 +87,25 @@ extension EditorManager {
/// - Parameters:
/// - data: The tab group to fix.
/// - fileManager: The file manager to use to map files.a
private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) {
private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) throws {
guard let fileManager = workspace.workspaceFileManager else { return }
let resolvedTabs = editor
.tabs
.compactMap({ fileManager.getFile($0.file.url.path(), createIfNotFound: true) })
.compactMap({ fileManager.getFile($0.file.url.path(percentEncoded: false), createIfNotFound: true) })
.map({ EditorInstance(workspace: workspace, file: $0) })

for tab in resolvedTabs {
try tab.file.loadCodeFile()
}

editor.workspace = workspace
editor.tabs = OrderedSet(resolvedTabs)

if let selectedTab = editor.selectedTab {
if let resolvedFile = fileManager.getFile(selectedTab.file.url.path(), createIfNotFound: true) {
if let resolvedFile = fileManager.getFile(
selectedTab.file.url.path(percentEncoded: false),
createIfNotFound: true
) {
editor.setSelectedTab(resolvedFile)
} else {
editor.setSelectedTab(nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,48 @@ import CodeEditTextView
/// - `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] = [:]
private var managerMap: [String: 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] {
manager(forFile: file.url)
}

/// Find or create a new undo manager.
/// - Parameter path: The path of the file to create for.
/// - Returns: The undo manager for the given file.
func manager(forFile path: URL) -> CEUndoManager {
if let manager = managerMap[path.absolutePath] {
return manager
} else {
let newManager = CEUndoManager()
managerMap[file.id] = newManager
managerMap[path.absolutePath] = newManager
return newManager
}
}

/// Find or create a new undo manager.
/// - Parameter path: The path of the file to create for.
/// - Returns: The undo manager for the given file.
func managerIfExists(forFile path: URL) -> CEUndoManager? {
managerMap[path.absolutePath]
}
}

extension UndoManagerRegistration: CEWorkspaceFileManagerObserver {
/// Managers need to be cleared when the following is true:
/// - The file is not open in any editors
/// - The file is updated externally
///
/// To handle this?
/// - When we receive a file update, if the file is not open in any editors we clear the undo stack
func fileManagerUpdated(updatedItems: Set<CEWorkspaceFile>) {
for file in updatedItems where file.fileDocument == nil {
managerMap.removeValue(forKey: file.url.absolutePath)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ struct EditorTabBarTrailingAccessories: View {
Toggle(
"Wrap Lines",
isOn: Binding(
get: { codeFile.wrapLines ?? wrapLinesToEditorWidth },
set: {
codeFile.wrapLines = $0
get: { [weak codeFile] in codeFile?.wrapLines ?? wrapLinesToEditorWidth },
set: { [weak codeFile] in
codeFile?.wrapLines = $0
}
)
)
Expand Down
7 changes: 4 additions & 3 deletions CodeEdit/Features/Editor/Views/CodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ struct CodeFileView: View {
codeFile
.contentCoordinator
.textUpdatePublisher
.sink { _ in
codeFile.updateChangeCount(.changeDone)
.sink { [weak codeFile] _ in
codeFile?.updateChangeCount(.changeDone)
}
.store(in: &cancellables)
}
Expand Down Expand Up @@ -171,7 +171,8 @@ struct CodeFileView: View {
undoManager: undoRegistration.manager(forFile: editorInstance.file),
coordinators: textViewCoordinators
)
.id(codeFile.fileURL)
// This view needs to refresh when the codefile changes. The file URL is too stable.
.id(ObjectIdentifier(codeFile))
.background {
if colorScheme == .dark {
EffectView(.underPageBackground)
Expand Down
Loading