From 01828ef557cf3dbb6276bdef4839da29cb43123c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:50:12 -0600 Subject: [PATCH 1/3] Fix editor split bug --- .../EditorLayout+StateRestoration.swift | 76 ++++++++++++ .../Features/Editor/Models/EditorLayout.swift | 26 ++++- .../Editor/Models/EditorManager.swift | 109 ++++++++---------- .../EditorTabBarLeadingAccessories.swift | 27 ++--- 4 files changed, 164 insertions(+), 74 deletions(-) diff --git a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift index 31adaf9149..f7d73d756d 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift @@ -9,6 +9,82 @@ import Foundation import SwiftUI import OrderedCollections +extension EditorManager { + /// Restores the tab manager from a captured state obtained using `saveRestorationState` + /// - Parameter workspace: The workspace to retrieve state from. + func restoreFromState(_ workspace: WorkspaceDocument) { + guard let fileManager = workspace.workspaceFileManager, + let data = workspace.getFromWorkspaceState(.openTabs) as? Data, + let state = try? JSONDecoder().decode(EditorRestorationState.self, from: data) else { + return + } + + guard !state.groups.isEmpty else { + logger.warning("Empty Editor State found, restoring to clean editor state.") + return + } + + fixRestoredEditorLayout(state.groups, fileManager: fileManager) + self.editorLayout = state.groups + self.activeEditor = activeEditor + switchToActiveEditor() + } + + /// Fix any hanging files after restoring from saved state. + /// + /// After decoding the state, we're left with `CEWorkspaceFile`s that don't exist in the file manager + /// so this function maps all those to 'real' files. Works recursively on all the tab groups. + /// - Parameters: + /// - group: The tab group to fix. + /// - fileManager: The file manager to use to map files. + private func fixRestoredEditorLayout(_ group: EditorLayout, fileManager: CEWorkspaceFileManager) { + switch group { + case let .one(data): + fixEditor(data, fileManager: fileManager) + case let .vertical(splitData): + splitData.editorLayouts.forEach { group in + fixRestoredEditorLayout(group, fileManager: fileManager) + } + case let .horizontal(splitData): + splitData.editorLayouts.forEach { group in + fixRestoredEditorLayout(group, fileManager: fileManager) + } + } + } + + private func findEditorLayout(group: EditorLayout, searchFor id: UUID) -> 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 + case let .horizontal(splitData): + return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first + } + } + + /// Fixes any hanging files after restoring from saved state. + /// - Parameters: + /// - data: The tab group to fix. + /// - fileManager: The file manager to use to map files.a + private func fixEditor(_ editor: Editor, fileManager: CEWorkspaceFileManager) { + editor.tabs = OrderedSet(editor.tabs.compactMap { fileManager.getFile($0.url.path, createIfNotFound: true) }) + if let selectedTab = editor.selectedTab { + editor.selectedTab = fileManager.getFile(selectedTab.url.path, createIfNotFound: true) + } + } + + func saveRestorationState(_ workspace: WorkspaceDocument) { + if let data = try? JSONEncoder().encode( + EditorRestorationState(focus: activeEditor, groups: editorLayout) + ) { + workspace.addToWorkspaceState(key: .openTabs, value: data) + } else { + workspace.addToWorkspaceState(key: .openTabs, value: nil) + } + } +} + struct EditorRestorationState: Codable { var focus: Editor var groups: EditorLayout diff --git a/CodeEdit/Features/Editor/Models/EditorLayout.swift b/CodeEdit/Features/Editor/Models/EditorLayout.swift index 6fa75de65f..06435d4548 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout.swift +++ b/CodeEdit/Features/Editor/Models/EditorLayout.swift @@ -7,7 +7,7 @@ import Foundation -enum EditorLayout { +enum EditorLayout: Equatable { case one(Editor) case vertical(SplitViewData) case horizontal(SplitViewData) @@ -71,4 +71,28 @@ enum EditorLayout { } } } + + var isEmpty: Bool { + switch self { + case .one: + return false + case .vertical(let splitViewData), .horizontal(let splitViewData): + return splitViewData.editorLayouts.allSatisfy { editorLayout in + editorLayout.isEmpty + } + } + } + + static func == (lhs: EditorLayout, rhs: EditorLayout) -> Bool { + switch (lhs, rhs) { + case let (.one(lhs), .one(rhs)): + return lhs == rhs + case let (.vertical(lhs), .vertical(rhs)): + return lhs.editorLayouts == rhs.editorLayouts + case let (.horizontal(lhs), .horizontal(rhs)): + return lhs.editorLayouts == rhs.editorLayouts + default: + return false + } + } } diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEdit/Features/Editor/Models/EditorManager.swift index e6ff743a1d..2c11561e77 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEdit/Features/Editor/Models/EditorManager.swift @@ -9,8 +9,11 @@ import Combine import Foundation import DequeModule import OrderedCollections +import os class EditorManager: ObservableObject { + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "EditorManager") + /// The complete editor layout. @Published var editorLayout: EditorLayout @@ -33,6 +36,8 @@ class EditorManager: ObservableObject { var tabBarTabIdSubject = PassthroughSubject() var cancellable: AnyCancellable? + // MARK: - Init + init() { let tab = Editor() self.activeEditor = tab @@ -41,11 +46,25 @@ class EditorManager: ObservableObject { self.isFocusingActiveEditor = false switchToActiveEditor() } + + /// Initializes the editor manager's state to the "initial" state. + /// + /// Functionally identical to the initializer for this class. + func initCleanState() { + let tab = Editor() + self.activeEditor = tab + self.activeEditorHistory.prepend { [weak tab] in tab } + self.editorLayout = .horizontal(.init(.horizontal, editorLayouts: [.one(tab)])) + self.isFocusingActiveEditor = false + switchToActiveEditor() + } /// Flattens the splitviews. func flatten() { if case .horizontal(let data) = editorLayout { data.flatten() + } else if case .vertical(let data) = editorLayout { + data.flatten() } } @@ -68,74 +87,48 @@ class EditorManager: ObservableObject { } } - /// Restores the tab manager from a captured state obtained using `saveRestorationState` - /// - Parameter workspace: The workspace to retrieve state from. - func restoreFromState(_ workspace: WorkspaceDocument) { - guard let fileManager = workspace.workspaceFileManager, - let data = workspace.getFromWorkspaceState(.openTabs) as? Data, - let state = try? JSONDecoder().decode(EditorRestorationState.self, from: data) else { - return - } - fixRestoredEditorLayout(state.groups, fileManager: fileManager) - self.editorLayout = state.groups - self.activeEditor = findEditorLayout( - group: state.groups, - searchFor: state.focus.id - ) ?? editorLayout.findSomeEditor()! - switchToActiveEditor() - } + // MARK: - Close Editor - /// Fix any hanging files after restoring from saved state. - /// - /// After decoding the state, we're left with `CEWorkspaceFile`s that don't exist in the file manager - /// so this function maps all those to 'real' files. Works recursively on all the tab groups. - /// - Parameters: - /// - group: The tab group to fix. - /// - fileManager: The file manager to use to map files. - private func fixRestoredEditorLayout(_ group: EditorLayout, fileManager: CEWorkspaceFileManager) { - switch group { - case let .one(data): - fixEditor(data, fileManager: fileManager) - case let .vertical(splitData): - splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, fileManager: fileManager) - } - case let .horizontal(splitData): - splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, fileManager: fileManager) - } + /// Close an editor and fix editor manager state, updating active editor, etc. + /// - Parameter editor: The editor to close + func closeEditor(_ editor: Editor) { + editor.close() + if activeEditor == editor { + setNewActiveEditor(excluding: editor) } + + flatten() + objectWillChange.send() } - private func findEditorLayout(group: EditorLayout, searchFor id: UUID) -> 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 - case let .horizontal(splitData): - return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first + /// Set a new active editor. + /// - Parameter editor: The editor to exclude. + func setNewActiveEditor(excluding editor: Editor) { + activeEditorHistory.removeAll { $0() == nil || $0() == editor } + if activeEditorHistory.isEmpty { + activeEditor = findSomeEditor(excluding: editor) + } else { + activeEditor = activeEditorHistory.removeFirst()()! } } - /// Fixes any hanging files after restoring from saved state. - /// - Parameters: - /// - data: The tab group to fix. - /// - fileManager: The file manager to use to map files.a - private func fixEditor(_ editor: Editor, fileManager: CEWorkspaceFileManager) { - editor.tabs = OrderedSet(editor.tabs.compactMap { fileManager.getFile($0.url.path, createIfNotFound: true) }) - if let selectedTab = editor.selectedTab { - editor.selectedTab = fileManager.getFile(selectedTab.url.path, createIfNotFound: true) + /// Find some editor, or if one cannot be found set up the editor manager with a clean state. + /// - Parameter editor: The editor to exclude. + /// - Returns: Some editor, order is not guaranteed. + func findSomeEditor(excluding editor: Editor) -> Editor { + guard let someEditor = editorLayout.findSomeEditor(except: editor) else { + initCleanState() + return activeEditor } + return someEditor } - func saveRestorationState(_ workspace: WorkspaceDocument) { - if let data = try? JSONEncoder().encode( - EditorRestorationState(focus: activeEditor, groups: editorLayout) - ) { - workspace.addToWorkspaceState(key: .openTabs, value: data) - } else { - workspace.addToWorkspaceState(key: .openTabs, value: nil) + // MARK: - Focus + + func toggleFocusingEditor(from editor: Editor) { + if !isFocusingActiveEditor { + activeEditor = editor } + isFocusingActiveEditor.toggle() } } diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarLeadingAccessories.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarLeadingAccessories.swift index 52be854aaf..597bc5ccbd 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarLeadingAccessories.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarLeadingAccessories.swift @@ -15,25 +15,19 @@ struct EditorTabBarLeadingAccessories: View { @EnvironmentObject private var editor: Editor + @State private var otherEditor: Editor? + @AppSettings(\.general.tabBarStyle) var tabBarStyle var body: some View { HStack(spacing: 0) { - if let otherGroup = editorManager.editorLayout.findSomeEditor(except: editor) { + if let otherEditor { EditorTabBarAccessoryIcon( icon: .init(systemName: "multiply"), action: { [weak editor] in - editor?.close() - if editorManager.activeEditor == editor { - editorManager.activeEditorHistory.removeAll { $0() == nil || $0() == editor } - if editorManager.activeEditorHistory.isEmpty { - editorManager.activeEditor = otherGroup - } else { - editorManager.activeEditor = editorManager.activeEditorHistory.removeFirst()()! - } - } - editorManager.flatten() + guard let editor else { return } + editorManager.closeEditor(editor) } ) .help("Close this Editor") @@ -48,10 +42,7 @@ struct EditorTabBarLeadingAccessories: View { ), isActive: editorManager.isFocusingActiveEditor, action: { - if !editorManager.isFocusingActiveEditor { - editorManager.activeEditor = editor - } - editorManager.isFocusingActiveEditor.toggle() + editorManager.toggleFocusingEditor(from: editor) } ) .help( @@ -137,6 +128,12 @@ struct EditorTabBarLeadingAccessories: View { EditorTabBarAccessoryNativeBackground(dividerAt: .trailing) } } + .onAppear { + otherEditor = editorManager.editorLayout.findSomeEditor(except: editor) + } + .onReceive(editorManager.objectWillChange) { _ in + otherEditor = editorManager.editorLayout.findSomeEditor(except: editor) + } } } From 7b352ccfd6f8703055f961b565be7393e6c41156 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 8 Jan 2024 17:03:26 -0600 Subject: [PATCH 2/3] Create Clean State if Empty Found --- .../Features/Editor/Models/EditorLayout+StateRestoration.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift index f7d73d756d..9087da7689 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/EditorLayout+StateRestoration.swift @@ -21,6 +21,7 @@ extension EditorManager { guard !state.groups.isEmpty else { logger.warning("Empty Editor State found, restoring to clean editor state.") + initCleanState() return } From 3c9b9532eb828b5371a618d042c8d6fa4f3c6971 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 8 Jan 2024 22:42:57 -0600 Subject: [PATCH 3/3] Fix Linter --- CodeEdit/Features/Editor/Models/EditorManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/Editor/Models/EditorManager.swift b/CodeEdit/Features/Editor/Models/EditorManager.swift index 2c11561e77..6dea0ae9e3 100644 --- a/CodeEdit/Features/Editor/Models/EditorManager.swift +++ b/CodeEdit/Features/Editor/Models/EditorManager.swift @@ -46,7 +46,7 @@ class EditorManager: ObservableObject { self.isFocusingActiveEditor = false switchToActiveEditor() } - + /// Initializes the editor manager's state to the "initial" state. /// /// Functionally identical to the initializer for this class.