native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #205 from supabitapp/sbertix/layout-restoration

Add terminal layout persistence and restoration

authored by

Stefano Bertagno and committed by
GitHub
562977d0 c29ee5a5

+573 -10
+24
supacode/App/supacodeApp.swift
··· 32 32 @MainActor 33 33 final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { 34 34 var appStore: StoreOf<AppFeature>? 35 + var terminalManager: WorktreeTerminalManager? 36 + 37 + func applicationWillTerminate(_ notification: Notification) { 38 + terminalManager?.saveAllLayoutSnapshots() 39 + } 35 40 36 41 func applicationDidFinishLaunching(_ notification: Notification) { 37 42 // Disable press-and-hold accent menu so that key repeat works in the terminal. ··· 127 132 let shortcuts = GhosttyShortcutManager(runtime: runtime) 128 133 _ghosttyShortcuts = State(initialValue: shortcuts) 129 134 let terminalManager = WorktreeTerminalManager(runtime: runtime) 135 + // Always persist layouts regardless of `restoreTerminalLayoutEnabled`, so enabling 136 + // the setting retroactively restores the most recent session. 137 + terminalManager.saveLayoutSnapshot = { worktreeID, snapshot in 138 + @Shared(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:] 139 + $layouts.withLock { dict in 140 + if let snapshot { 141 + dict[worktreeID] = snapshot 142 + } else { 143 + dict.removeValue(forKey: worktreeID) 144 + } 145 + } 146 + } 147 + terminalManager.loadLayoutSnapshot = { worktreeID in 148 + @SharedReader(.settingsFile) var settingsFile 149 + guard settingsFile.global.restoreTerminalLayoutEnabled else { return nil } 150 + @SharedReader(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:] 151 + return layouts[worktreeID] 152 + } 130 153 _terminalManager = State(initialValue: terminalManager) 131 154 let worktreeInfoWatcher = WorktreeInfoWatcherManager() 132 155 _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) ··· 157 180 } 158 181 _store = State(initialValue: appStore) 159 182 appDelegate.appStore = appStore 183 + appDelegate.terminalManager = terminalManager 160 184 } 161 185 162 186 var body: some Scene {
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 21 21 var copyUntrackedOnWorktreeCreate: Bool 22 22 var pullRequestMergeStrategy: PullRequestMergeStrategy 23 23 var terminalThemeSyncEnabled: Bool 24 + var restoreTerminalLayoutEnabled: Bool 24 25 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 25 26 26 27 static let `default` = GlobalSettings( ··· 45 46 copyUntrackedOnWorktreeCreate: false, 46 47 pullRequestMergeStrategy: .merge, 47 48 terminalThemeSyncEnabled: false, 49 + restoreTerminalLayoutEnabled: false, 48 50 defaultWorktreeBaseDirectoryPath: nil, 49 51 shortcutOverrides: [:] 50 52 ) ··· 71 73 copyUntrackedOnWorktreeCreate: Bool = false, 72 74 pullRequestMergeStrategy: PullRequestMergeStrategy = .merge, 73 75 terminalThemeSyncEnabled: Bool = false, 76 + restoreTerminalLayoutEnabled: Bool = false, 74 77 defaultWorktreeBaseDirectoryPath: String? = nil, 75 78 shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] 76 79 ) { ··· 95 98 self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate 96 99 self.pullRequestMergeStrategy = pullRequestMergeStrategy 97 100 self.terminalThemeSyncEnabled = terminalThemeSyncEnabled 101 + self.restoreTerminalLayoutEnabled = restoreTerminalLayoutEnabled 98 102 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 99 103 self.shortcutOverrides = shortcutOverrides 100 104 } ··· 158 162 terminalThemeSyncEnabled = 159 163 try container.decodeIfPresent(Bool.self, forKey: .terminalThemeSyncEnabled) 160 164 ?? Self.default.terminalThemeSyncEnabled 165 + restoreTerminalLayoutEnabled = 166 + try container.decodeIfPresent(Bool.self, forKey: .restoreTerminalLayoutEnabled) 167 + ?? Self.default.restoreTerminalLayoutEnabled 161 168 defaultWorktreeBaseDirectoryPath = 162 169 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 163 170 ?? Self.default.defaultWorktreeBaseDirectoryPath
+4
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 27 27 var copyUntrackedOnWorktreeCreate: Bool 28 28 var pullRequestMergeStrategy: PullRequestMergeStrategy 29 29 var terminalThemeSyncEnabled: Bool 30 + var restoreTerminalLayoutEnabled: Bool 30 31 var defaultWorktreeBaseDirectoryPath: String 31 32 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 32 33 // nil = settings window closed, non-nil = open to this section. ··· 59 60 copyUntrackedOnWorktreeCreate = settings.copyUntrackedOnWorktreeCreate 60 61 pullRequestMergeStrategy = settings.pullRequestMergeStrategy 61 62 terminalThemeSyncEnabled = settings.terminalThemeSyncEnabled 63 + restoreTerminalLayoutEnabled = settings.restoreTerminalLayoutEnabled 62 64 shortcutOverrides = settings.shortcutOverrides 63 65 defaultWorktreeBaseDirectoryPath = 64 66 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" ··· 87 89 copyUntrackedOnWorktreeCreate: copyUntrackedOnWorktreeCreate, 88 90 pullRequestMergeStrategy: pullRequestMergeStrategy, 89 91 terminalThemeSyncEnabled: terminalThemeSyncEnabled, 92 + restoreTerminalLayoutEnabled: restoreTerminalLayoutEnabled, 90 93 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 91 94 defaultWorktreeBaseDirectoryPath 92 95 ), ··· 170 173 state.copyUntrackedOnWorktreeCreate = normalizedSettings.copyUntrackedOnWorktreeCreate 171 174 state.pullRequestMergeStrategy = normalizedSettings.pullRequestMergeStrategy 172 175 state.terminalThemeSyncEnabled = normalizedSettings.terminalThemeSyncEnabled 176 + state.restoreTerminalLayoutEnabled = normalizedSettings.restoreTerminalLayoutEnabled 173 177 state.shortcutOverrides = normalizedSettings.shortcutOverrides 174 178 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 175 179 state.syncGlobalDefaults(from: normalizedSettings)
+5
supacode/Features/Settings/Views/AppearanceSettingsView.swift
··· 41 41 isOn: $store.confirmBeforeQuit 42 42 ) 43 43 .help("Ask before quitting Supacode") 44 + Toggle(isOn: $store.restoreTerminalLayoutEnabled) { 45 + Text("Restore Terminal Layout") 46 + Text("Reopen tabs, splits, and working directories from your last session.") 47 + } 48 + .help("Restore tabs and splits when reopening a worktree") 44 49 } 45 50 Section("Editor") { 46 51 Picker(
+65
supacode/Features/Terminal/BusinessLogic/LayoutsPersistenceKey.swift
··· 1 + import Dependencies 2 + import Foundation 3 + import Sharing 4 + 5 + nonisolated struct LayoutsKeyID: Hashable, Sendable {} 6 + 7 + nonisolated struct LayoutsKey: SharedKey { 8 + private static let logger = SupaLogger("Layouts") 9 + 10 + var id: LayoutsKeyID { LayoutsKeyID() } 11 + 12 + func load( 13 + context _: LoadContext<[String: TerminalLayoutSnapshot]>, 14 + continuation: LoadContinuation<[String: TerminalLayoutSnapshot]> 15 + ) { 16 + @Dependency(\.settingsFileStorage) var storage 17 + let data: Data 18 + do { 19 + data = try storage.load(SupacodePaths.layoutsURL) 20 + } catch { 21 + // File does not exist yet — expected on first run. 22 + continuation.resumeReturningInitialValue() 23 + return 24 + } 25 + do { 26 + let layouts = try JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data) 27 + continuation.resume(returning: layouts) 28 + } catch { 29 + Self.logger.warning( 30 + "Failed to decode layouts from \(SupacodePaths.layoutsURL.path(percentEncoded: false)): \(error)" 31 + ) 32 + continuation.resumeReturningInitialValue() 33 + } 34 + } 35 + 36 + func subscribe( 37 + context _: LoadContext<[String: TerminalLayoutSnapshot]>, 38 + subscriber _: SharedSubscriber<[String: TerminalLayoutSnapshot]> 39 + ) -> SharedSubscription { 40 + SharedSubscription {} 41 + } 42 + 43 + func save( 44 + _ value: [String: TerminalLayoutSnapshot], 45 + context _: SaveContext, 46 + continuation: SaveContinuation 47 + ) { 48 + @Dependency(\.settingsFileStorage) var storage 49 + do { 50 + let encoder = JSONEncoder() 51 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 52 + let data = try encoder.encode(value) 53 + try storage.save(data, SupacodePaths.layoutsURL) 54 + continuation.resume() 55 + } catch { 56 + continuation.resume(throwing: error) 57 + } 58 + } 59 + } 60 + 61 + nonisolated extension SharedReaderKey where Self == LayoutsKey.Default { 62 + static var layouts: Self { 63 + Self[LayoutsKey(), default: [:]] 64 + } 65 + }
+28 -3
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 13 13 private var eventContinuation: AsyncStream<TerminalClient.Event>.Continuation? 14 14 private var pendingEvents: [TerminalClient.Event] = [] 15 15 var selectedWorktreeID: Worktree.ID? 16 + var saveLayoutSnapshot: ((Worktree.ID, TerminalLayoutSnapshot?) -> Void)? 17 + var loadLayoutSnapshot: ((Worktree.ID) -> TerminalLayoutSnapshot?)? 16 18 17 19 init(runtime: GhosttyRuntime) { 18 20 self.runtime = runtime ··· 96 98 guard id != selectedWorktreeID else { return } 97 99 if let previousID = selectedWorktreeID, let previousState = states[previousID] { 98 100 previousState.setAllSurfacesOccluded() 101 + saveLayoutSnapshot?(previousID, previousState.captureLayoutSnapshot()) 99 102 } 100 103 selectedWorktreeID = id 101 104 terminalLogger.info("Selected worktree \(id ?? "nil")") ··· 131 134 if runSetupScriptIfNew() { 132 135 existing.enableSetupScriptIfNeeded() 133 136 } 137 + // Reload snapshot if the state has no tabs (e.g., setting was just enabled). 138 + if existing.tabManager.tabs.isEmpty, 139 + existing.pendingLayoutSnapshot == nil, 140 + !existing.needsSetupScript() 141 + { 142 + existing.pendingLayoutSnapshot = loadLayoutSnapshot?(worktree.id) 143 + } 134 144 return existing 135 145 } 136 146 let runSetupScript = runSetupScriptIfNew() ··· 139 149 worktree: worktree, 140 150 runSetupScript: runSetupScript 141 151 ) 152 + // Load saved layout snapshot for restoration (skip when a setup script is pending). 153 + if !runSetupScript { 154 + state.pendingLayoutSnapshot = loadLayoutSnapshot?(worktree.id) 155 + } 142 156 state.setNotificationsEnabled(notificationsEnabled) 143 157 state.isSelected = { [weak self] in 144 158 self?.selectedWorktreeID == worktree.id ··· 205 219 } 206 220 207 221 func prune(keeping worktreeIDs: Set<Worktree.ID>) { 208 - var removed: [WorktreeTerminalState] = [] 222 + var removed: [(Worktree.ID, WorktreeTerminalState)] = [] 209 223 for (id, state) in states where !worktreeIDs.contains(id) { 210 - removed.append(state) 224 + removed.append((id, state)) 211 225 } 212 - for state in removed { 226 + for (id, state) in removed { 227 + saveLayoutSnapshot?(id, state.captureLayoutSnapshot()) 213 228 state.closeAllSurfaces() 214 229 } 215 230 if !removed.isEmpty { ··· 241 256 242 257 func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { 243 258 states[worktreeID]?.hasUnseenNotification == true 259 + } 260 + 261 + func saveAllLayoutSnapshots() { 262 + guard let saveLayoutSnapshot else { 263 + assertionFailure("saveLayoutSnapshot closure not configured.") 264 + return 265 + } 266 + for (id, state) in states { 267 + saveLayoutSnapshot(id, state.captureLayoutSnapshot()) 268 + } 244 269 } 245 270 246 271 func surfaceBackgroundOpacity() -> Double {
+4 -4
supacode/Features/Terminal/Models/SplitTree.swift
··· 98 98 root?.find(id: id) 99 99 } 100 100 101 - func inserting(view: ViewType, at anchor: ViewType, direction: NewDirection) throws -> Self { 101 + func inserting(view: ViewType, at anchor: ViewType, direction: NewDirection, ratio: Double = 0.5) throws -> Self { 102 102 guard let root else { throw SplitError.viewNotFound } 103 103 return .init( 104 - root: try root.inserting(view: view, at: anchor, direction: direction), 104 + root: try root.inserting(view: view, at: anchor, direction: direction, ratio: ratio), 105 105 zoomed: nil 106 106 ) 107 107 } ··· 397 397 } 398 398 } 399 399 400 - func inserting(view: ViewType, at anchor: ViewType, direction: NewDirection) throws -> Self { 400 + func inserting(view: ViewType, at anchor: ViewType, direction: NewDirection, ratio: Double = 0.5) throws -> Self { 401 401 guard let path = path(to: .leaf(view: anchor)) else { 402 402 throw SplitError.viewNotFound 403 403 } ··· 424 424 let newSplit: Node = .split( 425 425 .init( 426 426 direction: splitDirection, 427 - ratio: 0.5, 427 + ratio: ratio, 428 428 left: newViewOnLeft ? newNode : existingNode, 429 429 right: newViewOnLeft ? existingNode : newNode 430 430 ))
+57
supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift
··· 1 + import Foundation 2 + 3 + struct TerminalLayoutSnapshot: Codable, Equatable, Sendable { 4 + let tabs: [TabSnapshot] 5 + let selectedTabIndex: Int 6 + 7 + struct TabSnapshot: Codable, Equatable, Sendable { 8 + let title: String 9 + let icon: String? 10 + let tintColor: TerminalTabTintColor? 11 + let layout: LayoutNode 12 + let focusedLeafIndex: Int 13 + } 14 + 15 + indirect enum LayoutNode: Codable, Equatable, Sendable { 16 + case leaf(SurfaceSnapshot) 17 + case split(SplitSnapshot) 18 + } 19 + 20 + struct SplitSnapshot: Codable, Equatable, Sendable { 21 + let direction: SplitDirection 22 + let ratio: Double 23 + let left: LayoutNode 24 + let right: LayoutNode 25 + } 26 + 27 + struct SurfaceSnapshot: Codable, Equatable, Sendable { 28 + let workingDirectory: String? 29 + } 30 + 31 + enum SplitDirection: String, Codable, Equatable, Sendable { 32 + case horizontal 33 + case vertical 34 + } 35 + } 36 + 37 + extension TerminalLayoutSnapshot.LayoutNode { 38 + /// The leftmost leaf in the subtree. 39 + var firstLeaf: TerminalLayoutSnapshot.SurfaceSnapshot { 40 + switch self { 41 + case .leaf(let surface): 42 + return surface 43 + case .split(let split): 44 + return split.left.firstLeaf 45 + } 46 + } 47 + 48 + /// The number of leaves in the subtree. 49 + var leafCount: Int { 50 + switch self { 51 + case .leaf: 52 + return 1 53 + case .split(let split): 54 + return split.left.leafCount + split.right.leafCount 55 + } 56 + } 57 + }
+1 -1
supacode/Features/Terminal/Models/TerminalTabID.swift
··· 1 1 import Foundation 2 2 3 - struct TerminalTabID: Hashable, Identifiable, Sendable { 3 + struct TerminalTabID: Hashable, Identifiable, Codable, Sendable { 4 4 let rawValue: UUID 5 5 6 6 init() {
+1 -1
supacode/Features/Terminal/Models/TerminalTabTintColor.swift
··· 2 2 3 3 /// Color token for terminal tab tint indicators, used in place of 4 4 /// `Color` so that `TerminalTabItem` can remain `Equatable` and `Sendable`. 5 - enum TerminalTabTintColor: Hashable, Sendable { 5 + enum TerminalTabTintColor: String, Codable, Hashable, Sendable { 6 6 case green 7 7 case orange 8 8 case red
+196 -1
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 6 6 import Sharing 7 7 8 8 private let blockingScriptLogger = SupaLogger("BlockingScript") 9 + private let layoutLogger = SupaLogger("Layout") 9 10 10 11 @MainActor 11 12 @Observable ··· 29 30 private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] 30 31 private var pendingSetupScript: Bool 31 32 private var isEnsuringInitialTab = false 33 + @ObservationIgnored var pendingLayoutSnapshot: TerminalLayoutSnapshot? 32 34 private var lastReportedTaskStatus: WorktreeTaskStatus? 33 35 private var lastEmittedFocusSurfaceId: UUID? 34 36 private var lastWindowIsKey: Bool? ··· 72 74 guard tabManager.tabs.isEmpty else { return } 73 75 guard !isEnsuringInitialTab else { return } 74 76 isEnsuringInitialTab = true 77 + 78 + if let snapshot = pendingLayoutSnapshot { 79 + pendingLayoutSnapshot = nil 80 + restoreFromSnapshot(snapshot, focusing: focusing) 81 + isEnsuringInitialTab = false 82 + return 83 + } 84 + 75 85 Task { 76 86 let setupScript: String? 77 87 if pendingSetupScript { ··· 586 596 emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 587 597 } 588 598 599 + // MARK: - Layout Snapshot 600 + 601 + func captureLayoutSnapshot() -> TerminalLayoutSnapshot? { 602 + guard !tabManager.tabs.isEmpty else { return nil } 603 + var tabSnapshots: [TerminalLayoutSnapshot.TabSnapshot] = [] 604 + for tab in tabManager.tabs { 605 + guard let tree = trees[tab.id], let root = tree.root else { 606 + layoutLogger.warning("Skipping tab \(tab.id.rawValue) during snapshot capture (no tree)") 607 + continue 608 + } 609 + let layout = captureLayoutNode(root) 610 + let leaves = root.leaves() 611 + let focusedId = focusedSurfaceIdByTab[tab.id] 612 + let focusedLeafIndex = 613 + focusedId.flatMap { id in 614 + leaves.firstIndex(where: { $0.id == id }) 615 + } ?? 0 616 + // Detect blocking-script tabs by their locked title or tint color and normalize to default state. 617 + let isBlockingScriptTab = tab.isTitleLocked || tab.tintColor != nil 618 + tabSnapshots.append( 619 + TerminalLayoutSnapshot.TabSnapshot( 620 + title: tab.title, 621 + icon: isBlockingScriptTab ? nil : tab.icon, 622 + tintColor: isBlockingScriptTab ? nil : tab.tintColor, 623 + layout: layout, 624 + focusedLeafIndex: focusedLeafIndex 625 + ) 626 + ) 627 + } 628 + guard !tabSnapshots.isEmpty else { return nil } 629 + let selectedIndex = 630 + tabManager.selectedTabId.flatMap { id in 631 + tabManager.tabs.firstIndex(where: { $0.id == id }) 632 + } ?? 0 633 + return TerminalLayoutSnapshot(tabs: tabSnapshots, selectedTabIndex: selectedIndex) 634 + } 635 + 636 + private func captureLayoutNode( 637 + _ node: SplitTree<GhosttySurfaceView>.Node 638 + ) -> TerminalLayoutSnapshot.LayoutNode { 639 + switch node { 640 + case .leaf(let view): 641 + return .leaf( 642 + TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: view.bridge.state.pwd) 643 + ) 644 + case .split(let split): 645 + let direction: TerminalLayoutSnapshot.SplitDirection = 646 + switch split.direction { 647 + case .horizontal: .horizontal 648 + case .vertical: .vertical 649 + } 650 + return .split( 651 + TerminalLayoutSnapshot.SplitSnapshot( 652 + direction: direction, 653 + ratio: split.ratio, 654 + left: captureLayoutNode(split.left), 655 + right: captureLayoutNode(split.right) 656 + ) 657 + ) 658 + } 659 + } 660 + 661 + private func restoreFromSnapshot(_ snapshot: TerminalLayoutSnapshot, focusing: Bool) { 662 + guard !snapshot.tabs.isEmpty else { 663 + layoutLogger.warning("Attempted to restore empty layout snapshot, skipping restoration.") 664 + return 665 + } 666 + 667 + // Skip setup script when restoring a saved layout. 668 + pendingSetupScript = false 669 + 670 + for (index, tabSnapshot) in snapshot.tabs.enumerated() { 671 + let firstLeafPwd = tabSnapshot.layout.firstLeaf.workingDirectory 672 + let workingDir = firstLeafPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } 673 + let context: ghostty_surface_context_e = 674 + index == 0 ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB 675 + let tabId = tabManager.createTab( 676 + title: tabSnapshot.title, 677 + icon: tabSnapshot.icon, 678 + isTitleLocked: false, 679 + tintColor: tabSnapshot.tintColor 680 + ) 681 + let surface = createSurface( 682 + tabId: tabId, 683 + initialInput: nil, 684 + workingDirectoryOverride: workingDir, 685 + inheritingFromSurfaceId: nil, 686 + context: context 687 + ) 688 + let tree = SplitTree(view: surface) 689 + trees[tabId] = tree 690 + focusedSurfaceIdByTab[tabId] = surface.id 691 + tabIsRunningById[tabId] = false 692 + 693 + // Recursively restore splits. 694 + restoreLayoutNode(tabSnapshot.layout, anchor: surface, tabId: tabId) 695 + 696 + // Log if partial restoration produced fewer panes than expected. 697 + let leaves = trees[tabId]?.root?.leaves() ?? [] 698 + let expectedLeaves = tabSnapshot.layout.leafCount 699 + if leaves.count != expectedLeaves { 700 + layoutLogger.warning( 701 + "Partial restore for tab '\(tabSnapshot.title)': expected \(expectedLeaves) panes, got \(leaves.count)" 702 + ) 703 + } 704 + 705 + // Focus the correct leaf. 706 + let focusedIndex = max(0, min(tabSnapshot.focusedLeafIndex, leaves.count - 1)) 707 + if focusedIndex < leaves.count { 708 + focusedSurfaceIdByTab[tabId] = leaves[focusedIndex].id 709 + } 710 + 711 + onTabCreated?() 712 + } 713 + 714 + // Select the correct tab. 715 + let selectedIndex = max(0, min(snapshot.selectedTabIndex, tabManager.tabs.count - 1)) 716 + if selectedIndex < tabManager.tabs.count { 717 + let selectedTab = tabManager.tabs[selectedIndex] 718 + tabManager.selectTab(selectedTab.id) 719 + if focusing { 720 + focusSurface(in: selectedTab.id) 721 + } 722 + } 723 + } 724 + 725 + private func restoreLayoutNode( 726 + _ node: TerminalLayoutSnapshot.LayoutNode, 727 + anchor: GhosttySurfaceView, 728 + tabId: TerminalTabID 729 + ) { 730 + guard case .split(let split) = node else { return } 731 + 732 + // Create the right child by splitting the anchor. 733 + let rightPwd = split.right.firstLeaf.workingDirectory 734 + let rightWorkingDir = rightPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } 735 + let direction: SplitTree<GhosttySurfaceView>.NewDirection = 736 + split.direction == .horizontal ? .right : .down 737 + 738 + guard 739 + let newSurface = createRestorationSplit( 740 + at: anchor, 741 + direction: direction, 742 + ratio: split.ratio, 743 + workingDirectory: rightWorkingDir, 744 + tabId: tabId 745 + ) 746 + else { 747 + layoutLogger.warning("Skipping subtree restoration for tab \(tabId.rawValue)") 748 + return 749 + } 750 + 751 + // Recurse into left and right subtrees. 752 + restoreLayoutNode(split.left, anchor: anchor, tabId: tabId) 753 + restoreLayoutNode(split.right, anchor: newSurface, tabId: tabId) 754 + } 755 + 756 + private func createRestorationSplit( 757 + at anchor: GhosttySurfaceView, 758 + direction: SplitTree<GhosttySurfaceView>.NewDirection, 759 + ratio: Double, 760 + workingDirectory: URL?, 761 + tabId: TerminalTabID 762 + ) -> GhosttySurfaceView? { 763 + guard var tree = trees[tabId] else { return nil } 764 + let newSurface = createSurface( 765 + tabId: tabId, 766 + initialInput: nil, 767 + workingDirectoryOverride: workingDirectory, 768 + inheritingFromSurfaceId: anchor.id, 769 + context: GHOSTTY_SURFACE_CONTEXT_SPLIT 770 + ) 771 + do { 772 + tree = try tree.inserting(view: newSurface, at: anchor, direction: direction, ratio: ratio) 773 + trees[tabId] = tree 774 + return newSurface 775 + } catch { 776 + layoutLogger.warning("Failed to restore split for tab \(tabId.rawValue): \(error)") 777 + newSurface.closeSurface() 778 + surfaces.removeValue(forKey: newSurface.id) 779 + return nil 780 + } 781 + } 782 + 589 783 func needsSetupScript() -> Bool { 590 784 pendingSetupScript 591 785 } ··· 692 886 private func createSurface( 693 887 tabId: TerminalTabID, 694 888 initialInput: String?, 889 + workingDirectoryOverride: URL? = nil, 695 890 inheritingFromSurfaceId: UUID?, 696 891 context: ghostty_surface_context_e 697 892 ) -> GhosttySurfaceView { 698 893 let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) 699 894 let view = GhosttySurfaceView( 700 895 runtime: runtime, 701 - workingDirectory: inherited.workingDirectory ?? worktree.workingDirectory, 896 + workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, 702 897 initialInput: initialInput, 703 898 fontSize: inherited.fontSize, 704 899 context: context
+4
supacode/Support/SupacodePaths.swift
··· 77 77 .path(percentEncoded: false) 78 78 } 79 79 80 + static var layoutsURL: URL { 81 + baseDirectory.appending(path: "layouts.json", directoryHint: .notDirectory) 82 + } 83 + 80 84 static var settingsURL: URL { 81 85 baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) 82 86 }
+15
supacodeTests/SettingsFeatureTests.swift
··· 308 308 #expect(settingsFile.global.pullRequestMergeStrategy == .squash) 309 309 } 310 310 311 + @Test(.dependencies) func toggleRestoreTerminalLayoutPersists() async { 312 + @Shared(.settingsFile) var settingsFile 313 + $settingsFile.withLock { $0.global = .default } 314 + 315 + let store = TestStore(initialState: SettingsFeature.State()) { 316 + SettingsFeature() 317 + } 318 + 319 + await store.send(.binding(.set(\.restoreTerminalLayoutEnabled, true))) { 320 + $0.restoreTerminalLayoutEnabled = true 321 + } 322 + await store.receive(\.delegate.settingsChanged) 323 + #expect(settingsFile.global.restoreTerminalLayoutEnabled == true) 324 + } 325 + 311 326 // MARK: - Sorted repositories. 312 327 313 328 @Test(.dependencies) func repositoriesChangedSortsByNameCaseInsensitive() async {
+100
supacodeTests/TerminalLayoutSnapshotTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + struct TerminalLayoutSnapshotTests { 7 + @Test func codableRoundTrip() throws { 8 + let snapshot = TerminalLayoutSnapshot( 9 + tabs: [ 10 + TerminalLayoutSnapshot.TabSnapshot( 11 + title: "main 1", 12 + icon: "terminal", 13 + tintColor: nil, 14 + layout: .split( 15 + TerminalLayoutSnapshot.SplitSnapshot( 16 + direction: .horizontal, 17 + ratio: 0.7, 18 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/Users/test/project")), 19 + right: .split( 20 + TerminalLayoutSnapshot.SplitSnapshot( 21 + direction: .vertical, 22 + ratio: 0.4, 23 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/tmp")), 24 + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)) 25 + ) 26 + ) 27 + ) 28 + ), 29 + focusedLeafIndex: 1 30 + ), 31 + TerminalLayoutSnapshot.TabSnapshot( 32 + title: "main 2", 33 + icon: nil, 34 + tintColor: nil, 35 + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/Users/test")), 36 + focusedLeafIndex: 0 37 + ), 38 + ], 39 + selectedTabIndex: 0 40 + ) 41 + 42 + let encoder = JSONEncoder() 43 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 44 + let data = try encoder.encode(snapshot) 45 + let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: data) 46 + #expect(decoded == snapshot) 47 + } 48 + 49 + @Test func firstLeafReturnsLeftmost() { 50 + let node: TerminalLayoutSnapshot.LayoutNode = .split( 51 + TerminalLayoutSnapshot.SplitSnapshot( 52 + direction: .horizontal, 53 + ratio: 0.5, 54 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/first")), 55 + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/second")) 56 + ) 57 + ) 58 + #expect(node.firstLeaf.workingDirectory == "/first") 59 + } 60 + 61 + @Test func leafCountCountsAllLeaves() { 62 + let node: TerminalLayoutSnapshot.LayoutNode = .split( 63 + TerminalLayoutSnapshot.SplitSnapshot( 64 + direction: .horizontal, 65 + ratio: 0.5, 66 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)), 67 + right: .split( 68 + TerminalLayoutSnapshot.SplitSnapshot( 69 + direction: .vertical, 70 + ratio: 0.5, 71 + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)), 72 + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: nil)) 73 + ) 74 + ) 75 + ) 76 + ) 77 + #expect(node.leafCount == 3) 78 + } 79 + 80 + @Test func singleLeafLayout() throws { 81 + let snapshot = TerminalLayoutSnapshot( 82 + tabs: [ 83 + TerminalLayoutSnapshot.TabSnapshot( 84 + title: "tab", 85 + icon: nil, 86 + tintColor: nil, 87 + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/home")), 88 + focusedLeafIndex: 0 89 + ), 90 + ], 91 + selectedTabIndex: 0 92 + ) 93 + 94 + let data = try JSONEncoder().encode(snapshot) 95 + let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: data) 96 + #expect(decoded.tabs.count == 1) 97 + #expect(decoded.tabs[0].layout.firstLeaf.workingDirectory == "/home") 98 + #expect(decoded.tabs[0].layout.leafCount == 1) 99 + } 100 + }
+62
supacodeTests/WorktreeTerminalManagerTests.swift
··· 5 5 6 6 @MainActor 7 7 struct WorktreeTerminalManagerTests { 8 + @Test func reusesExistingStateAndReloadsSnapshotAfterRestoreIsEnabled() { 9 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 10 + let worktree = makeWorktree() 11 + let snapshot = makeLayoutSnapshot() 12 + var restoreEnabled = false 13 + 14 + manager.loadLayoutSnapshot = { _ in 15 + guard restoreEnabled else { return nil } 16 + return snapshot 17 + } 18 + 19 + let initialState = manager.state(for: worktree) 20 + #expect(initialState.pendingLayoutSnapshot == nil) 21 + 22 + restoreEnabled = true 23 + 24 + let reusedState = manager.state(for: worktree) 25 + #expect(reusedState === initialState) 26 + #expect(reusedState.pendingLayoutSnapshot == snapshot) 27 + } 28 + 29 + @Test func reusingExistingStateDoesNotReloadSnapshotWhenSetupScriptBecomesPending() { 30 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 31 + let worktree = makeWorktree() 32 + let snapshot = makeLayoutSnapshot() 33 + var restoreEnabled = false 34 + 35 + manager.loadLayoutSnapshot = { _ in 36 + guard restoreEnabled else { return nil } 37 + return snapshot 38 + } 39 + 40 + let initialState = manager.state(for: worktree) 41 + #expect(initialState.pendingLayoutSnapshot == nil) 42 + 43 + restoreEnabled = true 44 + 45 + let reusedState = manager.state(for: worktree) { true } 46 + #expect(reusedState === initialState) 47 + #expect(reusedState.needsSetupScript()) 48 + #expect(reusedState.pendingLayoutSnapshot == nil) 49 + } 50 + 8 51 @Test func buffersEventsUntilStreamCreated() async { 9 52 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 10 53 let worktree = makeWorktree() ··· 634 677 title: "Title", 635 678 body: "Body", 636 679 isRead: isRead 680 + ) 681 + } 682 + 683 + private func makeLayoutSnapshot() -> TerminalLayoutSnapshot { 684 + TerminalLayoutSnapshot( 685 + tabs: [ 686 + TerminalLayoutSnapshot.TabSnapshot( 687 + title: "Terminal 1", 688 + icon: nil, 689 + tintColor: nil, 690 + layout: .leaf( 691 + TerminalLayoutSnapshot.SurfaceSnapshot( 692 + workingDirectory: "/tmp/repo/wt-1" 693 + ) 694 + ), 695 + focusedLeafIndex: 0 696 + ), 697 + ], 698 + selectedTabIndex: 0 637 699 ) 638 700 } 639 701 }