Skip to content

Commit eb14077

Browse files
authored
Save UI state per Workspace (#984)
* Initial Workspace State logic * Default width, test fix. * snapWidth fix, remember open tabs. * Save/Restore active tab to state. * Save tab reorder state * Save navigator and inspector state * Save debug drawer state * debug drawer height fix * Bug fixes * Fix linter * Rebase fix
1 parent de40742 commit eb14077

File tree

9 files changed

+200
-59
lines changed

9 files changed

+200
-59
lines changed

CodeEdit/Features/Documents/Controllers/CodeEditSplitViewController.swift

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ private extension CGFloat {
1515
}
1616

1717
final class CodeEditSplitViewController: NSSplitViewController {
18+
private var workspace: WorkspaceDocument
19+
private let widthStateName: String = "\(String(describing: CodeEditSplitViewController.self))-Width"
20+
private let isNavigatorCollapsedStateName: String
21+
= "\(String(describing: CodeEditSplitViewController.self))-IsNavigatorCollapsed"
22+
private let isInspectorCollapsedStateName: String
23+
= "\(String(describing: CodeEditSplitViewController.self))-IsInspectorCollapsed"
24+
private var setWidthFromState = false
25+
1826
// Properties
1927
private(set) var isSnapped: Bool = false {
2028
willSet {
@@ -29,7 +37,8 @@ final class CodeEditSplitViewController: NSSplitViewController {
2937

3038
// MARK: - Initialization
3139

32-
init(feedbackPerformer: NSHapticFeedbackPerformer) {
40+
init(workspace: WorkspaceDocument, feedbackPerformer: NSHapticFeedbackPerformer) {
41+
self.workspace = workspace
3342
self.feedbackPerformer = feedbackPerformer
3443
super.init(nibName: nil, bundle: nil)
3544
}
@@ -39,10 +48,26 @@ final class CodeEditSplitViewController: NSSplitViewController {
3948
fatalError("init(coder:) has not been implemented")
4049
}
4150

42-
// TODO: Set user preferences width if it is not the snap width
43-
// override func viewWillAppear() {
44-
// super.viewWillAppear()
45-
// }
51+
override func viewWillAppear() {
52+
super.viewWillAppear()
53+
let width = workspace.getFromWorkspaceState(key: self.widthStateName) as? CGFloat
54+
splitView.setPosition(width ?? .snapWidth, ofDividerAt: .zero)
55+
setWidthFromState = true
56+
57+
if let firstSplitView = splitViewItems.first {
58+
firstSplitView.isCollapsed = workspace.getFromWorkspaceState(
59+
key: isNavigatorCollapsedStateName
60+
) as? Bool ?? false
61+
}
62+
63+
if let lastSplitView = splitViewItems.last {
64+
lastSplitView.isCollapsed = workspace.getFromWorkspaceState(
65+
key: isInspectorCollapsedStateName
66+
) as? Bool ?? true
67+
}
68+
69+
self.insertToolbarItemIfNeeded()
70+
}
4671

4772
// MARK: - NSSplitViewDelegate
4873

@@ -78,6 +103,28 @@ final class CodeEditSplitViewController: NSSplitViewController {
78103
return proposedPosition
79104
}
80105

106+
override func splitViewDidResizeSubviews(_ notification: Notification) {
107+
guard let resizedDivider = notification.userInfo?["NSSplitViewDividerIndex"] as? Int else {
108+
return
109+
}
110+
111+
if resizedDivider == 0 {
112+
let panel = splitView.subviews[0]
113+
let width = panel.frame.size.width
114+
if width > 0 && setWidthFromState {
115+
workspace.addToWorkspaceState(key: self.widthStateName, value: width)
116+
}
117+
}
118+
}
119+
120+
func saveNavigatorCollapsedState(isCollapsed: Bool) {
121+
workspace.addToWorkspaceState(key: isNavigatorCollapsedStateName, value: isCollapsed)
122+
}
123+
124+
func saveInspectorCollapsedState(isCollapsed: Bool) {
125+
workspace.addToWorkspaceState(key: isInspectorCollapsedStateName, value: isCollapsed)
126+
}
127+
81128
/// Quick fix for list tracking separator needing to be added again after closing,
82129
/// then opening the inspector with a drag.
83130
private func insertToolbarItemIfNeeded() {

CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate {
6868

6969
private func setupSplitView(with workspace: WorkspaceDocument) {
7070
let feedbackPerformer = NSHapticFeedbackManager.defaultPerformer
71-
let splitVC = CodeEditSplitViewController(feedbackPerformer: feedbackPerformer)
71+
let splitVC = CodeEditSplitViewController(workspace: workspace, feedbackPerformer: feedbackPerformer)
7272

7373
let navigatorView = NavigatorSidebarView(workspace: workspace)
7474
let navigator = NSSplitViewItem(
@@ -215,6 +215,9 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate {
215215
@objc func toggleFirstPanel() {
216216
guard let firstSplitView = splitViewController.splitViewItems.first else { return }
217217
firstSplitView.animator().isCollapsed.toggle()
218+
if let codeEditSplitVC = splitViewController as? CodeEditSplitViewController {
219+
codeEditSplitVC.saveNavigatorCollapsedState(isCollapsed: firstSplitView.isCollapsed)
220+
}
218221
}
219222

220223
@objc func toggleLastPanel() {
@@ -225,6 +228,9 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate {
225228
} else {
226229
window?.toolbar?.insertItem(withItemIdentifier: .itemListTrackingSeparator, at: 4)
227230
}
231+
if let codeEditSplitVC = splitViewController as? CodeEditSplitViewController {
232+
codeEditSplitVC.saveInspectorCollapsedState(isCollapsed: lastSplitView.isCollapsed)
233+
}
228234
}
229235

230236
private func getSelectedCodeFile() -> CodeFileDocument? {

CodeEdit/Features/Documents/WorkspaceDocument.swift

Lines changed: 88 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,27 @@ import CodeEditKit
2222
@Published var selectionState: WorkspaceSelectionState = .init()
2323
@Published var fileItems: [WorkspaceClient.FileItem] = []
2424

25+
var workspaceState: [String: Any] {
26+
get {
27+
let key = "workspaceState-\(self.fileURL?.absoluteString ?? "")"
28+
return UserDefaults.standard.object(forKey: key) as? [String: Any] ?? [:]
29+
}
30+
set {
31+
let key = "workspaceState-\(self.fileURL?.absoluteString ?? "")"
32+
UserDefaults.standard.set(newValue, forKey: key)
33+
}
34+
}
35+
2536
var statusBarModel: StatusBarViewModel?
2637
var searchState: SearchState?
2738
var quickOpenViewModel: QuickOpenViewModel?
2839
var commandsPaletteState: CommandPaletteViewModel?
2940
var listenerModel: WorkspaceNotificationModel = .init()
41+
3042
private var cancellables = Set<AnyCancellable>()
43+
private let openTabsStateName: String = "\(String(describing: WorkspaceDocument.self))-OpenTabs"
44+
private let activeTabStateName: String = "\(String(describing: WorkspaceDocument.self))-ActiveTab"
45+
private var openedTabsFromState = false
3146

3247
@Published var targets: [Target] = []
3348

@@ -36,6 +51,14 @@ import CodeEditKit
3651
NotificationCenter.default.removeObserver(self)
3752
}
3853

54+
func getFromWorkspaceState(key: String) -> Any? {
55+
return workspaceState[key]
56+
}
57+
58+
func addToWorkspaceState(key: String, value: Any) {
59+
workspaceState.updateValue(value, forKey: key)
60+
}
61+
3962
// MARK: Open Tabs
4063
/// Opens new tab
4164
/// - Parameter item: any item which can be represented as a tab
@@ -147,6 +170,30 @@ import CodeEditKit
147170
closeTabs(items: range)
148171
}
149172

173+
/// Switched the active tab to current tab
174+
/// - Parameter item: tab item that is now active.
175+
func switchedTab(item: TabBarItemRepresentable) {
176+
selectionState.selectedId = item.tabID
177+
guard let fileItem = item as? WorkspaceClient.FileItem else { return }
178+
self.addToWorkspaceState(key: activeTabStateName, value: fileItem.url.absoluteString)
179+
}
180+
181+
/// Tabs reordered
182+
/// - Parameter openedTabs: reordered tabs
183+
func reorderedTabs(openedTabs: [TabBarItemID]) {
184+
selectionState.openedTabs = openedTabs
185+
186+
if openedTabsFromState {
187+
var openTabsInState: [String] = []
188+
for openTabId in openedTabs {
189+
guard let item = selectionState.getItemByTab(id: openTabId) as? WorkspaceClient.FileItem
190+
else { continue }
191+
openTabsInState.append(item.url.absoluteString)
192+
}
193+
self.addToWorkspaceState(key: openTabsStateName, value: openTabsInState)
194+
}
195+
}
196+
150197
/// Closes an open temporary tab, does not save the temporary tab's file.
151198
/// Removes the tab item from `openedCodeFiles`, `openedExtensions`, and `openFileItems`.
152199
private func closeTemporaryTab() {
@@ -196,6 +243,14 @@ import CodeEditKit
196243
selectionState.openedCodeFiles.removeValue(forKey: item)
197244
selectionState.openFileItems.remove(at: openFileItemIndex)
198245
removeTab(id: item.tabID)
246+
247+
if openedTabsFromState {
248+
var openTabsInState = self.getFromWorkspaceState(key: openTabsStateName) as? [String] ?? []
249+
if let index = openTabsInState.firstIndex(of: item.url.absoluteString) {
250+
openTabsInState.remove(at: index)
251+
self.addToWorkspaceState(key: openTabsStateName, value: openTabsInState)
252+
}
253+
}
199254
}
200255

201256
private func closeExtensionTab(item: Plugin) {
@@ -209,8 +264,19 @@ import CodeEditKit
209264
@objc func convertTemporaryTab() {
210265
if selectionState.selectedId == selectionState.temporaryTab &&
211266
selectionState.temporaryTab != nil {
267+
let item = selectionState.getItemByTab(id: selectionState.temporaryTab!)
212268
selectionState.previousTemporaryTab = selectionState.temporaryTab
213269
selectionState.temporaryTab = nil
270+
271+
guard let file = item as? WorkspaceClient.FileItem else { return }
272+
273+
if openedTabsFromState && item != nil {
274+
var openTabsInState = self.getFromWorkspaceState(key: openTabsStateName) as? [String] ?? []
275+
if !openTabsInState.contains(file.url.absoluteString) {
276+
openTabsInState.append(file.url.absoluteString)
277+
self.addToWorkspaceState(key: openTabsStateName, value: openTabsInState)
278+
}
279+
}
214280
}
215281
}
216282

@@ -269,6 +335,27 @@ import CodeEditKit
269335
windowController.shouldCascadeWindows = false
270336
windowController.window?.setFrameAutosaveName(self.fileURL?.absoluteString ?? "Untitled")
271337
self.addWindowController(windowController)
338+
339+
var activeTabID: TabBarItemID?
340+
var activeTabInState = self.getFromWorkspaceState(key: activeTabStateName) as? String ?? ""
341+
var openTabsInState = self.getFromWorkspaceState(key: openTabsStateName) as? [String] ?? []
342+
for openTab in openTabsInState {
343+
let tabUrl = URL(string: openTab)!
344+
if FileManager.default.fileExists(atPath: tabUrl.path) {
345+
let item = WorkspaceClient.FileItem(url: tabUrl)
346+
self.openTab(item: item)
347+
self.convertTemporaryTab()
348+
if activeTabInState == openTab {
349+
activeTabID = item.tabID
350+
}
351+
}
352+
}
353+
354+
if activeTabID != nil {
355+
selectionState.selectedId = activeTabID
356+
}
357+
358+
self.openedTabsFromState = true
272359
}
273360

274361
// MARK: Set Up Workspace
@@ -282,7 +369,7 @@ import CodeEditKit
282369
self.searchState = .init(self)
283370
self.quickOpenViewModel = .init(fileURL: url)
284371
self.commandsPaletteState = .init()
285-
self.statusBarModel = .init(workspaceURL: url)
372+
self.statusBarModel = .init(workspace: self, workspaceURL: url)
286373

287374
NotificationCenter.default.addObserver(
288375
self,
@@ -292,26 +379,10 @@ import CodeEditKit
292379
)
293380
}
294381

295-
/// Retrieves selection state from UserDefaults using SHA256 hash of project path as key
296-
/// - Throws: `DecodingError.dataCorrupted` error if retrived data from UserDefaults is not decodable
297-
/// - Returns: retrived state from UserDefaults or default state if not found
298-
private func readSelectionState() throws -> WorkspaceSelectionState {
299-
guard let path = fileURL?.path,
300-
let data = UserDefaults.standard.value(forKey: path.sha256()) as? Data else { return selectionState }
301-
let state = try PropertyListDecoder().decode(WorkspaceSelectionState.self, from: data)
302-
return state
303-
}
304-
305382
override func read(from url: URL, ofType typeName: String) throws {
306383
try initWorkspaceState(url)
307384

308385
// Initialize Workspace
309-
do {
310-
selectionState = try readSelectionState()
311-
} catch {
312-
Swift.print("couldn't retrieve selection state from user defaults")
313-
}
314-
315386
workspaceClient?
316387
.getFiles
317388
.sink { [weak self] files in
@@ -355,22 +426,7 @@ import CodeEditKit
355426

356427
// MARK: Close Workspace
357428

358-
/// Saves selection state to UserDefaults using SHA256 hash of project path as key
359-
/// - Throws: `EncodingError.invalidValue` error if sellection state is not encodable
360-
private func saveSelectionState() throws {
361-
guard let path = fileURL?.path else { return }
362-
let hash = path.sha256()
363-
let data = try PropertyListEncoder().encode(selectionState)
364-
UserDefaults.standard.set(data, forKey: hash)
365-
}
366-
367429
override func close() {
368-
do {
369-
try saveSelectionState()
370-
} catch {
371-
Swift.print("couldn't save selection state from user defaults")
372-
}
373-
374430
selectionState.selectedId = nil
375431
selectionState.openedCodeFiles.removeAll()
376432

CodeEdit/Features/StatusBar/ViewModels/StatusBarViewModel.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import SwiftUI
1212
/// A model class to host and manage data for the ``StatusBarView``
1313
///
1414
class StatusBarViewModel: ObservableObject {
15+
private let isStatusBarDrawerCollapsedStateName: String
16+
= "\(String(describing: StatusBarViewModel.self))-IsStatusBarDrawerCollapsed"
17+
private let statusBarDrawerHeightStateName: String
18+
= "\(String(describing: StatusBarViewModel.self))-StatusBarDrawerHeight"
1519

1620
// TODO: Implement logic for updating values
1721
// TODO: Add @Published vars for indentation, encoding, linebreak
@@ -54,6 +58,8 @@ class StatusBarViewModel: ObservableObject {
5458
/// Returns the font for status bar items to use
5559
private(set) var toolbarFont: Font = .system(size: 11)
5660

61+
private(set) var workspace: WorkspaceDocument
62+
5763
/// The base URL of the workspace
5864
private(set) var workspaceURL: URL
5965

@@ -69,7 +75,27 @@ class StatusBarViewModel: ObservableObject {
6975

7076
/// Initialize with a GitClient
7177
/// - Parameter workspaceURL: the current workspace URL
72-
init(workspaceURL: URL) {
78+
init(workspace: WorkspaceDocument, workspaceURL: URL) {
79+
self.workspace = workspace
7380
self.workspaceURL = workspaceURL
81+
82+
var currentHeight = workspace.getFromWorkspaceState(key: statusBarDrawerHeightStateName) as? Double
83+
?? self.standardHeight
84+
if currentHeight == 0 {
85+
currentHeight = self.standardHeight
86+
}
87+
88+
self.isExpanded = workspace.getFromWorkspaceState(key: isStatusBarDrawerCollapsedStateName) as? Bool ?? false
89+
if self.isExpanded {
90+
self.currentHeight = currentHeight
91+
}
92+
}
93+
94+
func saveIsExpandedToState() {
95+
self.workspace.addToWorkspaceState(key: isStatusBarDrawerCollapsedStateName, value: self.isExpanded)
96+
}
97+
98+
func saveHeightToState(height: Double) {
99+
self.workspace.addToWorkspaceState(key: statusBarDrawerHeightStateName, value: height)
74100
}
75101
}

0 commit comments

Comments
 (0)