Skip to content

Commit 7bf5518

Browse files
committed
feat: sidebar width and visibility per workspace
1 parent bf9154d commit 7bf5518

69 files changed

Lines changed: 1061 additions & 784 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
branches: [main]
88

99
env:
10-
DEVELOPER_DIR: /Applications/Xcode_16.3.app/Contents/Developer
10+
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
1111

1212
concurrency:
1313
group: ci-${{ github.ref }}
@@ -88,7 +88,7 @@ jobs:
8888
- name: Cache ironmark build
8989
uses: actions/cache@v5
9090
with:
91-
path: Vendor/ironmark/target
91+
path: Vendor/ironmark/target-macos15
9292
key: ironmark-macos15-${{ hashFiles('Vendor/ironmark/Cargo.lock', 'Vendor/ironmark/Cargo.toml') }}
9393
restore-keys: |
9494
ironmark-macos15-
@@ -153,7 +153,7 @@ jobs:
153153
- name: Cache ironmark build
154154
uses: actions/cache@v5
155155
with:
156-
path: Vendor/ironmark/target
156+
path: Vendor/ironmark/target-macos15
157157
key: ironmark-release-macos15-${{ hashFiles('Vendor/ironmark/Cargo.lock', 'Vendor/ironmark/Cargo.toml') }}
158158
restore-keys: |
159159
ironmark-release-macos15-

Boo/App/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public enum BooMain {
5050

5151
// Watch system appearance changes for auto-theme
5252
appearanceObserver = NSApp.observe(\.effectiveAppearance) { _, _ in
53-
AppSettings.shared.applySystemAppearance()
53+
Task { @MainActor in AppSettings.shared.applySystemAppearance() }
5454
}
5555
AppSettings.shared.applySystemAppearance()
5656

Boo/App/MainWindowController+Workspace.swift

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,13 @@ extension MainWindowController {
112112
appState.ensureUniquePaneIDsAcrossWorkspaces()
113113
}
114114

115-
func persistActiveWorkspaceSidebarState() {
116-
activeWorkspace?.normalizePaneState()
117-
sidebarController.persistLiveState()
115+
func persistActiveWorkspaceSidebarState(for workspace: Workspace? = nil) {
116+
let target = workspace ?? activeWorkspace
117+
target?.normalizePaneState()
118+
sidebarController.persistLiveState(for: target)
119+
debugLog(
120+
"[Sidebar] persistActiveWorkspaceSidebarState ws=\(target?.id.uuidString.prefix(8) ?? "nil") → sidebarState=\(String(describing: target?.sidebarState))"
121+
)
118122
}
119123

120124
/// Shared close-workspace logic used by both toolbar and workspace bar delegates.
@@ -203,12 +207,18 @@ extension MainWindowController {
203207
}
204208

205209
func saveSession() {
210+
cancelPendingSidebarStateSave()
206211
for paneView in paneViews.values {
207212
paneView.persistContentStateToModel()
208213
}
209214
normalizeWorkspaceState()
210215
persistActiveWorkspaceSidebarState()
211216
savePluginStateForActiveTab()
217+
for (i, ws) in appState.workspaces.enumerated() {
218+
debugLog(
219+
"[Sidebar] saveSession ws[\(i)] id=\(ws.id.uuidString.prefix(8)) sidebarState=visible:\(String(describing: ws.sidebarState.isVisible)) width:\(ws.sidebarState.width ?? -1)"
220+
)
221+
}
212222
SessionStore.save(appState: appState)
213223
}
214224

@@ -339,14 +349,15 @@ extension MainWindowController {
339349
}
340350
}
341351

342-
if activeWorkspace != nil {
343-
persistActiveWorkspaceSidebarState()
352+
appState.setActiveWorkspace(index)
353+
guard let workspace = activeWorkspace else { return }
354+
355+
// Skip self-activation (startup restore): isVisible is uninitialized and would overwrite the loaded state.
356+
if let prev = previousWorkspace, prev.id != workspace.id {
357+
persistActiveWorkspaceSidebarState(for: prev)
344358
}
345359

346360
savePluginStateForActiveTab()
347-
348-
appState.setActiveWorkspace(index)
349-
guard let workspace = activeWorkspace else { return }
350361
workspace.normalizePaneState()
351362
debugLog(
352363
"[WorkspaceSwitch] activate fromWorkspace=\(previousWorkspace?.id.uuidString ?? "none") toWorkspace=\(workspace.id.uuidString) targetPane=\(workspace.activePaneID.uuidString)"
@@ -369,7 +380,13 @@ extension MainWindowController {
369380
let cwd = workspace.pane(for: workspace.activePaneID)?.activeTab?.workingDirectory ?? workspace.folderPath
370381
bridge.switchContext(paneID: workspace.activePaneID, workspaceID: workspace.id, workingDirectory: cwd)
371382

372-
sidebarController.applyRestoredState(sidebarController.resolveEffectiveSidebarState(for: workspace))
383+
let restoredSidebarState = sidebarController.resolveEffectiveSidebarState(for: workspace)
384+
debugLog(
385+
"[Sidebar] activateWorkspace → resolvedState for ws=\(workspace.id.uuidString.prefix(8)) visible=\(String(describing: restoredSidebarState.isVisible)) width=\(restoredSidebarState.width ?? -1) storedState=\(String(describing: workspace.sidebarState))"
386+
)
387+
sidebarController.suppressSidebarStateSync += 1
388+
let activationGen = sidebarController.beginActivation()
389+
sidebarController.applyRestoredState(restoredSidebarState)
373390

374391
refreshToolbar()
375392
// isRemoteSidebar/activeRemoteSession are now derived from bridge state,
@@ -379,15 +396,17 @@ extension MainWindowController {
379396
// Rebuild split container with the workspace's tree
380397
renderedWorkspaceID = workspace.id
381398
splitContainer.update(tree: workspace.splitTree)
399+
sidebarController.suppressSidebarStateSync -= 1
382400

383401
DispatchQueue.main.async { [weak self] in
384402
guard let self, let ws = self.activeWorkspace else { return }
403+
// splitContainer.update can reset the divider; re-apply after layout settles.
404+
debugLog(
405+
"[Sidebar] async restoreActiveWorkspaceWidth for ws=\(ws.id.uuidString.prefix(8)) stored=\(String(describing: ws.sidebarState))"
406+
)
407+
self.sidebarController.restoreActiveWorkspaceWidth(ifGeneration: activationGen)
385408

386-
// Iterate over the WORKSPACE'S PANES (data model), not paneViews, to ensure all panes
387-
// have PaneViews created even if they're not yet in the view cache.
388409
for (_, pane) in ws.panes {
389-
// Get or create the PaneView for this pane - this triggers
390-
// splitContainer(self:paneViewFor:) which creates and caches it.
391410
if let pv = self.paneViews[pane.id] {
392411
// Ensure every pane has a session
393412
if pv.currentTerminalView == nil {

Boo/App/MainWindowController.swift

Lines changed: 88 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ class ThemedSplitView: NSSplitView {
8080
}
8181
}
8282

83-
@MainActor class MainWindowController: NSWindowController, SplitContainerDelegate, NSSplitViewDelegate, NSWindowDelegate {
83+
@MainActor class MainWindowController: NSWindowController, SplitContainerDelegate, NSSplitViewDelegate, NSWindowDelegate
84+
{
8485
let appState = AppState()
8586

8687
let toolbar = ToolbarView(frame: .zero)
@@ -190,6 +191,11 @@ class ThemedSplitView: NSSplitView {
190191
set { coordinator?.sidebarSectionOrder = newValue }
191192
}
192193

194+
func cancelPendingSidebarStateSave() {
195+
sidebarStateSaveTimer?.invalidate()
196+
sidebarStateSaveTimer = nil
197+
}
198+
193199
func performWhileIgnoringSidebarLayoutSettingsRefresh<T>(_ body: () -> T) -> T {
194200
sidebarSettingsRefreshSuppressionDepth += 1
195201
defer { sidebarSettingsRefreshSuppressionDepth -= 1 }
@@ -466,108 +472,105 @@ class ThemedSplitView: NSSplitView {
466472
settingsObserver = NotificationCenter.default.addObserver(
467473
forName: .settingsChanged, object: nil, queue: .main
468474
) { [weak self] notification in
469-
guard let self, self.window?.isVisible == true else { return }
470-
let topic = (notification.userInfo?["topic"] as? String).flatMap(SettingsTopic.init(rawValue:))
471-
472-
// Broadcast settings/theme changes to socket subscribers
473-
if let t = topic {
474-
BooSocketServer.shared.emitSettingsChanged(topic: t.rawValue)
475-
}
476-
477-
// Theme changes: refresh chrome colors, pane backgrounds, sidebar, status bar
478-
if topic == nil || topic == .theme {
479-
MainActor.assumeIsolated { AppStore.shared.refreshTheme() }
480-
let theme = AppSettings.shared.theme
481-
BooSocketServer.shared.emitThemeChanged(name: theme.name, isDark: theme.isDark)
482-
self.window?.backgroundColor = theme.chromeBg
483-
self.window?.appearance = NSAppearance(named: theme.isDark ? .darkAqua : .aqua)
484-
self.sidebarContainer.layer?.backgroundColor = theme.sidebarBg.cgColor
485-
self.splitContainer.layer?.backgroundColor = theme.background.nsColor.cgColor
486-
self.mainSplitView.needsDisplay = true
487-
self.toolbar.needsDisplay = true
488-
self.statusBar.needsDisplay = true
489-
for (_, pv) in self.paneViews {
490-
pv.layer?.backgroundColor = theme.background.nsColor.cgColor
491-
pv.needsLayout = true
492-
pv.needsDisplay = true
493-
}
494-
// Rebuild plugin sidebar to pick up new theme
495-
if let ctx = MainActor.assumeIsolated({ self.pluginRegistry.lastContext }) {
496-
self.cachedDetailViews.removeAll()
497-
self.rebuildSidebarTabs(context: ctx)
475+
let topicString = notification.userInfo?["topic"] as? String
476+
MainActor.assumeIsolated {
477+
guard let self, self.window?.isVisible == true else { return }
478+
let topic = topicString.flatMap(SettingsTopic.init(rawValue:))
479+
480+
// Broadcast settings/theme changes to socket subscribers
481+
if let t = topic {
482+
BooSocketServer.shared.emitSettingsChanged(topic: t.rawValue)
498483
}
499-
}
500-
501-
// Status bar changes
502-
if topic == nil || topic == .statusBar {
503-
self.statusBar.needsDisplay = true
504-
}
505484

506-
// Layout changes: sidebar/workspace bar position, density
507-
if topic == nil || topic == .layout {
508-
if self.isIgnoringSidebarLayoutSettingsRefresh {
509-
return
510-
}
511-
self.statusBarHeightConstraint?.constant = DensityMetrics.current.statusBarHeight
512-
self.statusBar.needsDisplay = true
513-
let newSidebarPos = AppSettings.shared.sidebarPosition
514-
if newSidebarPos != self.currentSidebarPosition {
515-
self.currentSidebarPosition = newSidebarPos
516-
MainActor.assumeIsolated {
517-
self.sidebarController.position = newSidebarPos
518-
}
519-
self.rebuildSidebarLayout()
520-
}
521-
let newWsBarPos = AppSettings.shared.workspaceBarPosition
522-
if newWsBarPos != self.currentWorkspaceBarPosition {
523-
self.currentWorkspaceBarPosition = newWsBarPos
524-
self.rebuildWorkspaceBarLayout()
525-
}
526-
let newTabOverflow = AppSettings.shared.tabOverflowMode
527-
if newTabOverflow != self.currentTabOverflowMode {
528-
self.currentTabOverflowMode = newTabOverflow
485+
// Theme changes: refresh chrome colors, pane backgrounds, sidebar, status bar
486+
if topic == nil || topic == .theme {
487+
AppStore.shared.refreshTheme()
488+
let theme = AppSettings.shared.theme
489+
BooSocketServer.shared.emitThemeChanged(name: theme.name, isDark: theme.isDark)
490+
self.window?.backgroundColor = theme.chromeBg
491+
self.window?.appearance = NSAppearance(named: theme.isDark ? .darkAqua : .aqua)
492+
self.sidebarContainer.layer?.backgroundColor = theme.sidebarBg.cgColor
493+
self.splitContainer.layer?.backgroundColor = theme.background.nsColor.cgColor
494+
self.mainSplitView.needsDisplay = true
495+
self.toolbar.needsDisplay = true
496+
self.statusBar.needsDisplay = true
529497
for (_, pv) in self.paneViews {
498+
pv.layer?.backgroundColor = theme.background.nsColor.cgColor
530499
pv.needsLayout = true
531500
pv.needsDisplay = true
532501
}
502+
// Rebuild plugin sidebar to pick up new theme
503+
if let ctx = self.pluginRegistry.lastContext {
504+
self.cachedDetailViews.removeAll()
505+
self.rebuildSidebarTabs(context: ctx)
506+
}
533507
}
534-
// Tab bar position changed — swap constraints and re-install content views
535-
self.applySidebarTabBarPositionConstraints()
536-
if let ctx = MainActor.assumeIsolated({ self.pluginRegistry.lastContext }) {
537-
self.cachedDetailViews.removeAll()
538-
self.removeAllPluginContent()
539-
self.rebuildSidebarTabs(context: ctx)
508+
509+
// Status bar changes
510+
if topic == nil || topic == .statusBar {
511+
self.statusBar.needsDisplay = true
540512
}
541-
MainActor.assumeIsolated {
513+
514+
// Layout changes: sidebar/workspace bar position, density
515+
if topic == nil || topic == .layout {
516+
if self.isIgnoringSidebarLayoutSettingsRefresh {
517+
return
518+
}
519+
self.statusBarHeightConstraint?.constant = DensityMetrics.current.statusBarHeight
520+
self.statusBar.needsDisplay = true
521+
let newSidebarPos = AppSettings.shared.sidebarPosition
522+
if newSidebarPos != self.currentSidebarPosition {
523+
self.currentSidebarPosition = newSidebarPos
524+
self.sidebarController.position = newSidebarPos
525+
self.rebuildSidebarLayout()
526+
}
527+
let newWsBarPos = AppSettings.shared.workspaceBarPosition
528+
if newWsBarPos != self.currentWorkspaceBarPosition {
529+
self.currentWorkspaceBarPosition = newWsBarPos
530+
self.rebuildWorkspaceBarLayout()
531+
}
532+
let newTabOverflow = AppSettings.shared.tabOverflowMode
533+
if newTabOverflow != self.currentTabOverflowMode {
534+
self.currentTabOverflowMode = newTabOverflow
535+
for (_, pv) in self.paneViews {
536+
pv.needsLayout = true
537+
pv.needsDisplay = true
538+
}
539+
}
540+
// Tab bar position changed — swap constraints and re-install content views
541+
self.applySidebarTabBarPositionConstraints()
542+
if let ctx = self.pluginRegistry.lastContext {
543+
self.cachedDetailViews.removeAll()
544+
self.removeAllPluginContent()
545+
self.rebuildSidebarTabs(context: ctx)
546+
}
542547
self.sidebarController.persistLiveState()
543548
self.sidebarController.applyRestoredState(self.sidebarController.resolveEffectiveSidebarState())
544549
}
545-
}
546550

547-
// Plugin changes: deactivate any now-disabled plugins and run a fresh cycle.
548-
if topic == .plugins {
549-
let disabled = AppSettings.shared.disabledPluginIDsSet
550-
// Deactivate plugins that just got disabled
551-
MainActor.assumeIsolated {
551+
// Plugin changes: deactivate any now-disabled plugins and run a fresh cycle.
552+
if topic == .plugins {
553+
let disabled = AppSettings.shared.disabledPluginIDsSet
554+
// Deactivate plugins that just got disabled
552555
for plugin in self.pluginRegistry.plugins where disabled.contains(plugin.pluginID) {
553556
self.pluginRegistry.deactivatePlugin(plugin.pluginID)
554557
self.cachedDetailViews.removeValue(forKey: plugin.pluginID)
555558
}
556559
self.pluginRegistry.clearChangeDetection()
557560
}
558-
}
559561

560-
// Explorer/plugin/font changes: refresh sidebar
561-
if topic == nil || topic == .explorer || topic == .sidebarFont || topic == .plugins {
562-
if topic == .sidebarFont, let ctx = MainActor.assumeIsolated({ self.pluginRegistry.lastContext }) {
563-
if self.sidebarVisible {
564-
self.cachedDetailViews.removeAll()
565-
self.rebuildSidebarTabs(context: ctx)
562+
// Explorer/plugin/font changes: refresh sidebar
563+
if topic == nil || topic == .explorer || topic == .sidebarFont || topic == .plugins {
564+
if topic == .sidebarFont, let ctx = self.pluginRegistry.lastContext {
565+
if self.sidebarVisible {
566+
self.cachedDetailViews.removeAll()
567+
self.rebuildSidebarTabs(context: ctx)
568+
}
569+
} else {
570+
self.runPluginCycle(reason: .focusChanged)
566571
}
567-
} else {
568-
self.runPluginCycle(reason: .focusChanged)
569572
}
570-
}
573+
} // end MainActor.assumeIsolated
571574
}
572575

573576
ghosttyActionObserver = NotificationCenter.default.addObserver(
@@ -588,7 +591,7 @@ class ThemedSplitView: NSSplitView {
588591
guard let self,
589592
let url = notification.userInfo?["url"] as? URL
590593
else { return }
591-
self.handleOpenTab(.browser(url: url))
594+
MainActor.assumeIsolated { self.handleOpenTab(.browser(url: url)) }
592595
}
593596
}
594597

@@ -680,7 +683,7 @@ class ThemedSplitView: NSSplitView {
680683
}
681684
self.splitRatioSaveTimer?.invalidate()
682685
self.splitRatioSaveTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
683-
self?.saveSession()
686+
MainActor.assumeIsolated { self?.saveSession() }
684687
}
685688
}
686689

@@ -1164,7 +1167,7 @@ class ThemedSplitView: NSSplitView {
11641167

11651168
sidebarStateSaveTimer?.invalidate()
11661169
sidebarStateSaveTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in
1167-
self?.saveSession()
1170+
MainActor.assumeIsolated { self?.saveSession() }
11681171
}
11691172
}
11701173

0 commit comments

Comments
 (0)