diff --git a/supacode.xcodeproj/project.pbxproj b/supacode.xcodeproj/project.pbxproj index 08919afb..ece195aa 100644 --- a/supacode.xcodeproj/project.pbxproj +++ b/supacode.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 886A649F771240E8A5AC792D /* Dependencies in Frameworks */ = {isa = PBXBuildFile; productRef = 8D787DCF45744283A83F226F /* Dependencies */; }; C8B33B9AFE69E738ABFA08BE /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 497B582F42253CBC8FBF101F /* ComposableArchitecture */; }; - D61414532F1F9799007DF542 /* Bonsplit in Frameworks */ = {isa = PBXBuildFile; productRef = D61414522F1F9799007DF542 /* Bonsplit */; }; D64162B02F23CAF100260CA3 /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = D64162AD2F23CAF100260CA3 /* ghostty */; }; D64162B12F23CAF100260CA3 /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = D64162AE2F23CAF100260CA3 /* terminfo */; }; D64162B22F23CAF100260CA3 /* git-wt in Resources */ = {isa = PBXBuildFile; fileRef = D64162AF2F23CAF100260CA3 /* git-wt */; }; @@ -74,7 +73,6 @@ 886A649F771240E8A5AC792D /* Dependencies in Frameworks */, D6A1CB312F1F4ABF004FDABD /* GhosttyKit.xcframework in Frameworks */, D661760B2F250A60000FC27C /* CasePaths in Frameworks */, - D61414532F1F9799007DF542 /* Bonsplit in Frameworks */, D6A1CB362F1F4ACB004FDABD /* Carbon.framework in Frameworks */, DFE570C142184CC387C1BC78 /* Sparkle in Frameworks */, ); @@ -141,7 +139,6 @@ ); name = supacode; packageProductDependencies = ( - D61414522F1F9799007DF542 /* Bonsplit */, 8D787DCF45744283A83F226F /* Dependencies */, B5698573F7354B14B940EA60 /* Sparkle */, 497B582F42253CBC8FBF101F /* ComposableArchitecture */, @@ -203,7 +200,6 @@ mainGroup = D69CE0412F1F378200584C57; minimizedProjectReferenceProxies = 1; packageReferences = ( - D61414512F1F9799007DF542 /* XCRemoteSwiftPackageReference "bonsplit" */, 911B4AAD84AF4B34BE232133 /* XCRemoteSwiftPackageReference "swift-case-paths" */, 8E10C2184F9342AC850013F5 /* XCRemoteSwiftPackageReference "Sparkle" */, 46ECCA2394D141A89BE74516 /* XCRemoteSwiftPackageReference "swift-dependencies" */, @@ -605,14 +601,6 @@ minimumVersion = 1.0.0; }; }; - D61414512F1F9799007DF542 /* XCRemoteSwiftPackageReference "bonsplit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/khoi/bonsplit.git"; - requirement = { - branch = main; - kind = branch; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -631,11 +619,6 @@ package = 8E10C2184F9342AC850013F5 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - D61414522F1F9799007DF542 /* Bonsplit */ = { - isa = XCSwiftPackageProductDependency; - package = D61414512F1F9799007DF542 /* XCRemoteSwiftPackageReference "bonsplit" */; - productName = Bonsplit; - }; D661760A2F250A60000FC27C /* CasePaths */ = { isa = XCSwiftPackageProductDependency; package = 911B4AAD84AF4B34BE232133 /* XCRemoteSwiftPackageReference "swift-case-paths" */; diff --git a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a4fb5fc5..54b1d6de 100644 --- a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "originHash" : "43322c6a49f021170260ea4d20825af757f2bd058aed86f9c6aaa57371d0e286", "pins" : [ - { - "identity" : "bonsplit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/khoi/bonsplit.git", - "state" : { - "branch" : "main", - "revision" : "5ff46b1f9e7d9a78250bad5b52d5794ff3f8c033" - } - }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index f42c5cae..66fc4506 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -53,8 +53,8 @@ struct SupacodeApp: App { AppFeature() } withDependencies: { values in values.terminalClient = TerminalClient( - createTab: { worktree, pane in - terminalManager.createTab(in: worktree, pane: pane) + createTab: { worktree in + terminalManager.createTab(in: worktree) }, closeFocusedTab: { worktree in terminalManager.closeFocusedTab(in: worktree) diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index e932a991..977479ae 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -1,8 +1,7 @@ -import Bonsplit import ComposableArchitecture struct TerminalClient { - var createTab: @MainActor @Sendable (Worktree, PaneID?) -> TabID? + var createTab: @MainActor @Sendable (Worktree) -> Void var closeFocusedTab: @MainActor @Sendable (Worktree) -> Bool var closeFocusedSurface: @MainActor @Sendable (Worktree) -> Bool var prune: @MainActor @Sendable (Set) -> Void @@ -10,9 +9,7 @@ struct TerminalClient { extension TerminalClient: DependencyKey { static let liveValue = TerminalClient( - createTab: { _, _ in - fatalError("TerminalClient.createTab not configured") - }, + createTab: { _ in fatalError("TerminalClient.createTab not configured") }, closeFocusedTab: { _ in fatalError("TerminalClient.closeFocusedTab not configured") }, @@ -25,7 +22,7 @@ extension TerminalClient: DependencyKey { ) static let testValue = TerminalClient( - createTab: { _, _ in nil }, + createTab: { _ in }, closeFocusedTab: { _ in false }, closeFocusedSurface: { _ in false }, prune: { _ in } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index b01c54cb..a1a2de82 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -126,7 +126,7 @@ struct AppFeature { return .none } return .run { _ in - _ = await terminalClient.createTab(worktree, nil) + await terminalClient.createTab(worktree) } case .closeTab: diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 93ad8738..1dcddc3c 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1,4 +1,3 @@ -import Bonsplit import Observation @MainActor @@ -28,10 +27,9 @@ final class WorktreeTerminalManager { return state } - @discardableResult - func createTab(in worktree: Worktree, pane: PaneID? = nil) -> TabID? { + func createTab(in worktree: Worktree) { let state = state(for: worktree) - return state.createTab(in: pane) + _ = state.createTab() } @discardableResult diff --git a/supacode/Features/Terminal/Models/TerminalTabID.swift b/supacode/Features/Terminal/Models/TerminalTabID.swift new file mode 100644 index 00000000..a1df1ab2 --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalTabID.swift @@ -0,0 +1,15 @@ +import Foundation + +struct TerminalTabID: Hashable, Identifiable, Sendable { + let rawValue: UUID + + init() { + rawValue = UUID() + } + + init(rawValue: UUID) { + self.rawValue = rawValue + } + + var id: UUID { rawValue } +} diff --git a/supacode/Features/Terminal/Models/TerminalTabItem.swift b/supacode/Features/Terminal/Models/TerminalTabItem.swift new file mode 100644 index 00000000..4afc7db0 --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalTabItem.swift @@ -0,0 +1,20 @@ +import Foundation + +struct TerminalTabItem: Identifiable, Equatable, Sendable { + let id: TerminalTabID + var title: String + var icon: String? + var isDirty: Bool + + init( + id: TerminalTabID = TerminalTabID(), + title: String, + icon: String?, + isDirty: Bool = false + ) { + self.id = id + self.title = title + self.icon = icon + self.isDirty = isDirty + } +} diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift new file mode 100644 index 00000000..a689fe10 --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -0,0 +1,75 @@ +import Observation + +@MainActor +@Observable +final class TerminalTabManager { + var tabs: [TerminalTabItem] = [] + var selectedTabId: TerminalTabID? + + func createTab(title: String, icon: String?) -> TerminalTabID { + let tab = TerminalTabItem(title: title, icon: icon) + if let selectedTabId, + let selectedIndex = tabs.firstIndex(where: { $0.id == selectedTabId }) + { + tabs.insert(tab, at: selectedIndex + 1) + } else { + tabs.append(tab) + } + selectedTabId = tab.id + return tab.id + } + + func selectTab(_ id: TerminalTabID) { + guard tabs.contains(where: { $0.id == id }) else { return } + selectedTabId = id + } + + func updateTitle(_ id: TerminalTabID, title: String) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + tabs[index].title = title + } + + func updateDirty(_ id: TerminalTabID, isDirty: Bool) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + tabs[index].isDirty = isDirty + } + + func reorderTabs(_ orderedIds: [TerminalTabID]) { + let existingIds = Set(tabs.map(\.id)) + let incomingIds = Set(orderedIds) + guard existingIds == incomingIds else { return } + let map = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + tabs = orderedIds.compactMap { map[$0] } + } + + func closeTab(_ id: TerminalTabID) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + tabs.remove(at: index) + guard selectedTabId == id else { return } + if index > 0 { + selectedTabId = tabs[index - 1].id + } else if !tabs.isEmpty { + selectedTabId = tabs[0].id + } else { + selectedTabId = nil + } + } + + func closeOthers(keeping id: TerminalTabID) { + tabs = tabs.filter { $0.id == id } + selectedTabId = tabs.first?.id + } + + func closeToRight(of id: TerminalTabID) { + guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } + tabs = Array(tabs.prefix(index + 1)) + if let selectedTabId, !tabs.contains(where: { $0.id == selectedTabId }) { + self.selectedTabId = tabs.last?.id + } + } + + func closeAll() { + tabs.removeAll() + selectedTabId = nil + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 07d5aa5f..c298f2b3 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1,4 +1,3 @@ -import Bonsplit import CoreGraphics import Foundation import GhosttyKit @@ -6,37 +5,26 @@ import Observation @MainActor @Observable -final class WorktreeTerminalState: BonsplitDelegate { - let controller: BonsplitController +final class WorktreeTerminalState { + let tabManager: TerminalTabManager private let runtime: GhosttyRuntime private let worktree: Worktree private let settingsStorage = RepositorySettingsStorage() - private var trees: [TabID: SplitTree] = [:] + private var trees: [TerminalTabID: SplitTree] = [:] private var surfaces: [UUID: GhosttySurfaceView] = [:] - private var focusedSurfaceIdByTab: [TabID: UUID] = [:] - private var tabIsRunningById: [TabID: Bool] = [:] + private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] + private var tabIsRunningById: [TerminalTabID: Bool] = [:] private var pendingSetupScript: Bool init(runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false) { self.runtime = runtime self.worktree = worktree - pendingSetupScript = runSetupScript - let configuration = BonsplitConfiguration( - allowSplits: false, - allowCloseTabs: true, - allowCloseLastPane: false, - allowTabReordering: true, - allowCrossPaneTabMove: false, - autoCloseEmptyPanes: false, - contentViewLifecycle: .keepAllAlive, - newTabPosition: .current - ) - controller = BonsplitController(configuration: configuration) - controller.delegate = self + self.pendingSetupScript = runSetupScript + self.tabManager = TerminalTabManager() } var focusedTaskStatus: WorktreeTaskStatus { - guard let tabId = focusedTabId() else { return .idle } + guard let tabId = tabManager.selectedTabId else { return .idle } if tabIsRunningById[tabId] == true { return .running } @@ -44,58 +32,48 @@ final class WorktreeTerminalState: BonsplitDelegate { } func ensureInitialTab() { - let tabIds = controller.allTabIds - if tabIds.isEmpty { - _ = createTab(in: nil) - return - } - if tabIds.count == 1, let tabId = tabIds.first, let tab = controller.tab(tabId), - tab.title == "Welcome" - { - let title = "\(worktree.name) \(nextTabIndex())" - controller.updateTab(tabId, title: title, icon: "terminal") + if tabManager.tabs.isEmpty { + _ = createTab() } } @discardableResult - func createTab(in pane: PaneID?) -> TabID? { + func createTab() -> TerminalTabID? { let title = "\(worktree.name) \(nextTabIndex())" - guard - let tabId = controller.createTab( - title: title, - icon: "terminal", - inPane: pane - ) - else { - return nil + let tabId = tabManager.createTab(title: title, icon: "terminal") + let resolvedInput = setupScriptInput(shouldRun: pendingSetupScript) + if pendingSetupScript { + pendingSetupScript = false } - controller.selectTab(tabId) - let tree = splitTree(for: tabId) + let tree = splitTree(for: tabId, initialInput: resolvedInput) + tabIsRunningById[tabId] = false if let surface = tree.root?.leftmostLeaf() { focusSurface(surface, in: tabId) } return tabId } + func selectTab(_ tabId: TerminalTabID) { + tabManager.selectTab(tabId) + focusSurface(in: tabId) + } + + func focusSelectedTab() { + guard let tabId = tabManager.selectedTabId else { return } + focusSurface(in: tabId) + } + @discardableResult func closeFocusedTab() -> Bool { - guard let paneId = controller.focusedPaneId, - let tab = controller.selectedTab(inPane: paneId) - else { - return false - } - let closed = controller.closeTab(tab.id) - if closed, let nextTab = controller.selectedTab(inPane: paneId) { - controller.selectTab(nextTab.id) - } - return closed + guard let tabId = tabManager.selectedTabId else { return false } + closeTab(tabId) + return true } @discardableResult func closeFocusedSurface() -> Bool { - guard let paneId = controller.focusedPaneId, - let tab = controller.selectedTab(inPane: paneId), - let focusedId = focusedSurfaceIdByTab[tab.id], + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] else { return false @@ -104,7 +82,40 @@ final class WorktreeTerminalState: BonsplitDelegate { return true } - func splitTree(for tabId: TabID, initialInput: String? = nil) -> SplitTree { + func closeTab(_ tabId: TerminalTabID) { + removeTree(for: tabId) + tabManager.closeTab(tabId) + if let selected = tabManager.selectedTabId { + focusSurface(in: selected) + } + } + + func closeOtherTabs(keeping tabId: TerminalTabID) { + let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } + for id in ids { + closeTab(id) + } + } + + func closeTabsToRight(of tabId: TerminalTabID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let ids = tabManager.tabs.dropFirst(index + 1).map(\.id) + for id in ids { + closeTab(id) + } + } + + func closeAllTabs() { + let ids = tabManager.tabs.map(\.id) + for id in ids { + closeTab(id) + } + } + + func splitTree( + for tabId: TerminalTabID, + initialInput: String? = nil + ) -> SplitTree { if let existing = trees[tabId] { return existing } @@ -183,7 +194,7 @@ final class WorktreeTerminalState: BonsplitDelegate { } } - func performSplitOperation(_ operation: TerminalSplitTreeView.Operation, in tabId: TabID) { + func performSplitOperation(_ operation: TerminalSplitTreeView.Operation, in tabId: TerminalTabID) { guard var tree = trees[tabId] else { return } switch operation { @@ -220,20 +231,6 @@ final class WorktreeTerminalState: BonsplitDelegate { } } - private func setupScriptInput(shouldRun: Bool) -> String? { - guard shouldRun else { return nil } - let settings = settingsStorage.load(for: worktree.repositoryRootURL) - let script = settings.setupScript - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - return nil - } - if script.hasSuffix("\n") { - return script - } - return "\(script)\n" - } - func closeAllSurfaces() { for surface in surfaces.values { surface.closeSurface() @@ -242,26 +239,24 @@ final class WorktreeTerminalState: BonsplitDelegate { trees.removeAll() focusedSurfaceIdByTab.removeAll() tabIsRunningById.removeAll() + tabManager.closeAll() } - func splitTabBar( - _ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID - ) { - removeTree(for: tabId) - } - - func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Tab, inPane pane: PaneID) { - let tree = splitTree(for: tab.id) - if let focusedId = focusedSurfaceIdByTab[tab.id], let focused = surfaces[focusedId] { - focusSurface(focused, in: tab.id) - return + private func setupScriptInput(shouldRun: Bool) -> String? { + guard shouldRun else { return nil } + let settings = settingsStorage.load(for: worktree.repositoryRootURL) + let script = settings.setupScript + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return nil } - if let surface = tree.root?.leftmostLeaf() { - focusSurface(surface, in: tab.id) + if script.hasSuffix("\n") { + return script } + return "\(script)\n" } - private func createSurface(tabId: TabID, initialInput: String?) -> GhosttySurfaceView { + private func createSurface(tabId: TerminalTabID, initialInput: String?) -> GhosttySurfaceView { let view = GhosttySurfaceView( runtime: runtime, workingDirectory: worktree.workingDirectory, @@ -270,59 +265,70 @@ final class WorktreeTerminalState: BonsplitDelegate { view.bridge.onTitleChange = { [weak self, weak view] title in guard let self, let view else { return } if self.focusedSurfaceIdByTab[tabId] == view.id { - self.controller.updateTab(tabId, title: title, icon: "terminal") + self.tabManager.updateTitle(tabId, title: title) } } view.bridge.onSplitAction = { [weak self, weak view] action in guard let self, let view else { return false } return self.performSplitAction(action, for: view.id) } - view.bridge.onNewTab = { [weak self, weak view] in - guard let self, let view else { return false } - return self.handleNewTabRequest(from: view) + view.bridge.onNewTab = { [weak self] in + guard let self else { return false } + return self.createTab() != nil } - view.bridge.onCloseTab = { [weak self, weak view] mode in - guard let self, let view else { return false } - return self.handleCloseTabRequest(from: view, mode: mode) + view.bridge.onCloseTab = { [weak self] _ in + guard let self else { return false } + self.closeTab(tabId) + return true } view.bridge.onGotoTab = { [weak self] target in guard let self else { return false } return self.handleGotoTabRequest(target) } - view.bridge.onProgressReport = { [weak self, weak view] _ in - guard let self, let view else { return } - self.handleProgressReport(for: view.id) + view.bridge.onProgressReport = { [weak self] _ in + guard let self else { return } + self.updateRunningState(for: tabId) } view.bridge.onCloseRequest = { [weak self, weak view] processAlive in guard let self, let view else { return } self.handleCloseRequest(for: view, processAlive: processAlive) } view.onFocusChange = { [weak self, weak view] focused in - guard let self, let view else { return } - guard focused else { return } + guard let self, let view, focused else { return } self.focusedSurfaceIdByTab[tabId] = view.id + self.tabManager.selectTab(tabId) self.updateTabTitle(for: tabId) } surfaces[view.id] = view return view } - private func updateTabTitle(for tabId: TabID) { + private func updateTabTitle(for tabId: TerminalTabID) { guard let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId] + let surface = surfaces[focusedId], + let title = surface.bridge.state.title else { return } - if let title = surface.bridge.state.title { - controller.updateTab(tabId, title: title, icon: "terminal") + tabManager.updateTitle(tabId, title: title) + } + + private func focusSurface(in tabId: TerminalTabID) { + if let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] { + focusSurface(surface, in: tabId) + return + } + let tree = splitTree(for: tabId) + if let surface = tree.root?.leftmostLeaf() { + focusSurface(surface, in: tabId) } } - private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TabID) { + private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { focusedSurfaceIdByTab[tabId] = surface.id surface.requestFocus() updateTabTitle(for: tabId) } - private func removeTree(for tabId: TabID) { + private func removeTree(for tabId: TerminalTabID) { guard let tree = trees.removeValue(forKey: tabId) else { return } for surface in tree.leaves() { surface.closeSurface() @@ -332,31 +338,20 @@ final class WorktreeTerminalState: BonsplitDelegate { tabIsRunningById.removeValue(forKey: tabId) } - private func tabId(containing surfaceId: UUID) -> TabID? { + private func tabId(containing surfaceId: UUID) -> TerminalTabID? { for (tabId, tree) in trees where tree.find(id: surfaceId) != nil { return tabId } return nil } - private func focusedTabId() -> TabID? { - guard let paneId = controller.focusedPaneId, - let tab = controller.selectedTab(inPane: paneId) - else { return nil } - return tab.id - } - - private func handleProgressReport(for surfaceId: UUID) { - guard let tabId = tabId(containing: surfaceId) else { return } - updateRunningState(for: tabId) - } - - private func updateRunningState(for tabId: TabID) { + private func updateRunningState(for tabId: TerminalTabID) { guard let tree = trees[tabId] else { return } let isRunningNow = tree.leaves().contains { surface in isRunningProgressState(surface.bridge.state.progressState) } tabIsRunningById[tabId] = isRunningNow + tabManager.updateDirty(tabId, isDirty: isRunningNow) } private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { @@ -420,7 +415,7 @@ final class WorktreeTerminalState: BonsplitDelegate { } } - private func handleCloseRequest(for view: GhosttySurfaceView, processAlive: Bool) { + private func handleCloseRequest(for view: GhosttySurfaceView, processAlive _: Bool) { guard surfaces[view.id] != nil else { return } guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { view.closeSurface() @@ -438,7 +433,7 @@ final class WorktreeTerminalState: BonsplitDelegate { if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) - controller.closeTab(tabId) + tabManager.closeTab(tabId) return } trees[tabId] = newTree @@ -452,27 +447,12 @@ final class WorktreeTerminalState: BonsplitDelegate { } } - private func handleNewTabRequest(from view: GhosttySurfaceView) -> Bool { - let paneId = controller.focusedPaneId - return createTab(in: paneId) != nil - } - - private func handleCloseTabRequest( - from view: GhosttySurfaceView, - mode: ghostty_action_close_tab_mode_e - ) -> Bool { - _ = view - _ = mode - return closeFocusedTab() - } - private func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { - guard let paneId = controller.focusedPaneId else { return false } - let tabs = controller.tabs(inPane: paneId) + let tabs = tabManager.tabs guard !tabs.isEmpty else { return false } let raw = Int(target.rawValue) - let selectedIndex = controller.selectedTab(inPane: paneId).flatMap { selected in - tabs.firstIndex { $0.id == selected.id } + let selectedIndex = tabManager.selectedTabId.flatMap { selected in + tabs.firstIndex { $0.id == selected } } let targetIndex: Int if raw <= 0 { @@ -491,7 +471,7 @@ final class WorktreeTerminalState: BonsplitDelegate { } else { targetIndex = min(raw - 1, tabs.count - 1) } - controller.selectTab(tabs[targetIndex].id) + selectTab(tabs[targetIndex].id) return true } @@ -513,10 +493,9 @@ final class WorktreeTerminalState: BonsplitDelegate { private func nextTabIndex() -> Int { let prefix = "\(worktree.name) " var maxIndex = 0 - for tabId in controller.allTabIds { - guard let title = controller.tab(tabId)?.title else { continue } - guard title.hasPrefix(prefix) else { continue } - let suffix = title.dropFirst(prefix.count) + for tab in tabManager.tabs { + guard tab.title.hasPrefix(prefix) else { continue } + let suffix = tab.title.dropFirst(prefix.count) guard let value = Int(suffix) else { continue } maxIndex = max(maxIndex, value) } diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift new file mode 100644 index 00000000..67c6f139 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarColors.swift @@ -0,0 +1,40 @@ +import AppKit +import SwiftUI + +enum TerminalTabBarColors { + static var barBackground: Color { + Color(nsColor: .windowBackgroundColor) + } + + static var activeTabBackground: Color { + Color(nsColor: .controlBackgroundColor) + } + + static var hoveredTabBackground: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.5) + } + + static var inactiveTabBackground: Color { + .clear + } + + static var activeText: Color { + Color(nsColor: .labelColor) + } + + static var inactiveText: Color { + Color(nsColor: .secondaryLabelColor) + } + + static var separator: Color { + Color(nsColor: .separatorColor) + } + + static var dropIndicator: Color { + Color.accentColor + } + + static var dirtyIndicator: Color { + Color(nsColor: .labelColor).opacity(0.6) + } +} diff --git a/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift new file mode 100644 index 00000000..aa428dac --- /dev/null +++ b/supacode/Features/Terminal/TabBar/TerminalTabBarMetrics.swift @@ -0,0 +1,29 @@ +import CoreGraphics + +enum TerminalTabBarMetrics { + static let barHeight: CGFloat = 33 + static let barPadding: CGFloat = 0 + static let tabHeight: CGFloat = 32 + static let tabMinWidth: CGFloat = 140 + static let tabMaxWidth: CGFloat = 220 + static let tabCornerRadius: CGFloat = 0 + static let tabSpacing: CGFloat = 0 + static let tabHorizontalPadding: CGFloat = 12 + static let contentSpacing: CGFloat = 6 + static let contentTrailingSpacing: CGFloat = 4 + static let activeIndicatorHeight: CGFloat = 2 + static let activeTabOffset: CGFloat = 0.5 + static let activeTabBottomPadding: CGFloat = 1 + static let closeButtonSize: CGFloat = 16 + static let closeIconSize: CGFloat = 9 + static let dirtyIndicatorSize: CGFloat = 8 + static let overflowShadowWidth: CGFloat = 24 + static let dropIndicatorWidth: CGFloat = 2 + static let dropIndicatorHeight: CGFloat = 20 + static let hoverAnimationDuration: Double = 0.1 + static let closeAnimationDuration: Double = 0.2 + static let fadeAnimationDuration: Double = 0.15 + static let selectionAnimationDuration: Double = 0.15 + static let reorderAnimationDuration: Double = 0.3 + static let reorderAnimationBounce: Double = 0.15 +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalPressTrackingButtonStyle.swift b/supacode/Features/Terminal/TabBar/Views/TerminalPressTrackingButtonStyle.swift new file mode 100644 index 00000000..767d4441 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalPressTrackingButtonStyle.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct TerminalPressTrackingButtonStyle: ButtonStyle { + @Binding private var isPressed: Bool + + init(isPressed: Binding) { + self._isPressed = isPressed + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .onChange(of: configuration.isPressed) { _, pressed in + isPressed = pressed + } + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift new file mode 100644 index 00000000..8a13cdb5 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBackground.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct TerminalTabBackground: View { + var isActive: Bool + var isPressing: Bool + var isDragging: Bool + var isHovering: Bool + + var body: some View { + ZStack(alignment: .top) { + if isActive { + TerminalTabBarColors.activeTabBackground + } else if isHovering || isPressing || isDragging { + TerminalTabBarColors.hoveredTabBackground + } else { + TerminalTabBarColors.inactiveTabBackground + } + + if isActive { + Rectangle() + .fill(Color.accentColor) + .frame(height: TerminalTabBarMetrics.activeIndicatorHeight) + } + + if !isActive { + VStack(spacing: 0) { + Rectangle() + .fill(TerminalTabBarColors.separator) + .frame(height: 1) + Spacer(minLength: 0) + Rectangle() + .fill(TerminalTabBarColors.separator) + .frame(height: 1) + } + } + } + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift new file mode 100644 index 00000000..da9a1dd1 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarBackground.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct TerminalTabBarBackground: View { + @Environment(\.controlActiveState) + private var activeState + + var body: some View { + Rectangle() + .fill( + activeState == .inactive + ? TerminalTabBarColors.barBackground.opacity(0.95) + : TerminalTabBarColors.barBackground + ) + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift new file mode 100644 index 00000000..1ab511c5 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct TerminalTabBarTrailingAccessories: View { + let createTab: () -> Void + + @Environment(GhosttyShortcutManager.self) + private var ghosttyShortcuts + + var body: some View { + Button("New Terminal", systemImage: "plus") { + createTab() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .help(helpText("New Terminal", shortcut: ghosttyShortcuts.display(for: "new_tab"))) + .frame(height: TerminalTabBarMetrics.barHeight) + .padding(.trailing, 8) + } + + private func helpText(_ title: String, shortcut: String?) -> String { + guard let shortcut else { return "\(title) (no shortcut)" } + return "\(title) (\(shortcut))" + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift new file mode 100644 index 00000000..e681142e --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct TerminalTabBarView: View { + @Bindable var manager: TerminalTabManager + let createTab: () -> Void + let closeTab: (TerminalTabID) -> Void + let closeOthers: (TerminalTabID) -> Void + let closeToRight: (TerminalTabID) -> Void + let closeAll: () -> Void + @Environment(\.controlActiveState) + private var activeState + + var body: some View { + HStack(spacing: 0) { + TerminalTabsView( + manager: manager, + closeTab: closeTab, + closeOthers: closeOthers, + closeToRight: closeToRight, + closeAll: closeAll + ) + Spacer(minLength: 0) + TerminalTabBarTrailingAccessories(createTab: createTab) + } + .frame(height: TerminalTabBarMetrics.barHeight) + .background(TerminalTabBarBackground()) + .saturation(activeState == .inactive ? 0 : 1) + .clipped() + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabButtonStyle.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabButtonStyle.swift new file mode 100644 index 00000000..c3bff419 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabButtonStyle.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct TerminalTabButtonStyle: ButtonStyle { + @Binding private var isPressing: Bool + + init(isPressing: Binding) { + self._isPressing = isPressing + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .onChange(of: configuration.isPressed) { _, isPressed in + isPressing = isPressed + } + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift new file mode 100644 index 00000000..e9bced27 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct TerminalTabCloseButton: View { + var isHoveringTab: Bool + var isDragging: Bool + var closeAction: () -> Void + @Binding var closeButtonGestureActive: Bool + @Binding var isHoveringClose: Bool + + @Environment(GhosttyShortcutManager.self) + private var ghosttyShortcuts + + @State private var isPressing = false + + var body: some View { + let showClose = (isHoveringTab || isHoveringClose) && !isDragging + Button("Close Tab", systemImage: "xmark") { + closeAction() + } + .labelStyle(.iconOnly) + .buttonStyle(TerminalPressTrackingButtonStyle(isPressed: $isPressing)) + .font(.system(size: TerminalTabBarMetrics.closeIconSize)) + .bold() + .foregroundStyle( + isHoveringClose ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText + ) + .frame(width: TerminalTabBarMetrics.closeButtonSize, height: TerminalTabBarMetrics.closeButtonSize) + .background( + TerminalTabCloseButtonBackground(isPressing: isPressing, isHoveringClose: isHoveringClose) + ) + .clipShape(.circle) + .contentShape(.rect) + .onHover { hovering in + isHoveringClose = hovering + } + .onChange(of: isPressing) { _, pressed in + closeButtonGestureActive = pressed + } + .help(helpText("Close Tab", shortcut: ghosttyShortcuts.display(for: "close_tab"))) + .opacity(showClose ? 1 : 0) + .allowsHitTesting(showClose) + .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHoveringTab) + } + + private func helpText(_ title: String, shortcut: String?) -> String { + guard let shortcut else { return "\(title) (no shortcut)" } + return "\(title) (\(shortcut))" + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift new file mode 100644 index 00000000..e9c420f3 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButtonBackground.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct TerminalTabCloseButtonBackground: View { + let isPressing: Bool + let isHoveringClose: Bool + + var body: some View { + Circle() + .fill(backgroundColor) + } + + private var backgroundColor: Color { + if isPressing { + return TerminalTabBarColors.hoveredTabBackground + } + if isHoveringClose { + return TerminalTabBarColors.hoveredTabBackground + } + return .clear + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift new file mode 100644 index 00000000..63ea2876 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContentStack.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct TerminalTabContentStack: View { + let tabs: [TerminalTabItem] + let selectedTabId: TerminalTabID + let content: (TerminalTabID) -> Content + + init( + tabs: [TerminalTabItem], + selectedTabId: TerminalTabID, + @ViewBuilder content: @escaping (TerminalTabID) -> Content + ) { + self.tabs = tabs + self.selectedTabId = selectedTabId + self.content = content + } + + var body: some View { + ZStack { + ForEach(tabs) { tab in + content(tab.id) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(tab.id == selectedTabId ? 1 : 0) + .allowsHitTesting(tab.id == selectedTabId) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift new file mode 100644 index 00000000..74f10811 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenu.swift @@ -0,0 +1,50 @@ +import SwiftUI + +extension View { + func terminalTabContextMenu( + tabId: TerminalTabID, + tabs: [TerminalTabItem], + actions: TerminalTabContextMenuActions + ) -> some View { + modifier( + TerminalTabContextMenu( + tabId: tabId, + tabs: tabs, + actions: actions + ) + ) + } +} + +struct TerminalTabContextMenu: ViewModifier { + let tabId: TerminalTabID + let tabs: [TerminalTabItem] + let actions: TerminalTabContextMenuActions + + func body(content: Content) -> some View { + content.contextMenu { + Button("Close Tab") { + actions.closeTab(tabId) + } + + Button("Close Other Tabs") { + actions.closeOthers(tabId) + } + .disabled(tabs.count <= 1) + + Button("Close Tabs to the Right") { + actions.closeToRight(tabId) + } + .disabled(isLastTab) + + Button("Close All") { + actions.closeAll() + } + } + } + + private var isLastTab: Bool { + guard let last = tabs.last else { return true } + return last.id == tabId + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift new file mode 100644 index 00000000..93885f98 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabContextMenuActions.swift @@ -0,0 +1,6 @@ +struct TerminalTabContextMenuActions { + let closeTab: (TerminalTabID) -> Void + let closeOthers: (TerminalTabID) -> Void + let closeToRight: (TerminalTabID) -> Void + let closeAll: () -> Void +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift new file mode 100644 index 00000000..9e317f41 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabDivider.swift @@ -0,0 +1,10 @@ +import SwiftUI + +struct TerminalTabDivider: View { + var body: some View { + Rectangle() + .frame(width: 1) + .frame(height: TerminalTabBarMetrics.tabHeight) + .foregroundStyle(TerminalTabBarColors.separator) + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift new file mode 100644 index 00000000..e82b1118 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct TerminalTabLabelView: View { + let tab: TerminalTabItem + let isActive: Bool + let isHoveringTab: Bool + let isDragging: Bool + let closeAction: () -> Void + @Binding var closeButtonGestureActive: Bool + @Binding var isHoveringClose: Bool + + var body: some View { + HStack(spacing: TerminalTabBarMetrics.contentSpacing) { + if let icon = tab.icon { + Image(systemName: icon) + .imageScale(.small) + .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) + .accessibilityHidden(true) + } + Text(tab.title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) + Spacer(minLength: TerminalTabBarMetrics.contentTrailingSpacing) + ZStack { + if tab.isDirty && !isHoveringTab && !isHoveringClose { + Circle() + .fill(TerminalTabBarColors.dirtyIndicator) + .frame( + width: TerminalTabBarMetrics.dirtyIndicatorSize, + height: TerminalTabBarMetrics.dirtyIndicatorSize + ) + } + TerminalTabCloseButton( + isHoveringTab: isHoveringTab, + isDragging: isDragging, + closeAction: closeAction, + closeButtonGestureActive: $closeButtonGestureActive, + isHoveringClose: $isHoveringClose + ) + } + } + .frame(maxHeight: .infinity) + .padding(.horizontal, TerminalTabBarMetrics.tabHorizontalPadding) + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabMeasurementView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabMeasurementView.swift new file mode 100644 index 00000000..b1d0a473 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabMeasurementView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct TerminalTabMeasurementView: View { + let tabId: TerminalTabID + let onFrameChange: (TerminalTabID, CGRect) -> Void + + var body: some View { + GeometryReader { proxy in + Color.clear + .onAppear { + onFrameChange(tabId, proxy.frame(in: .global)) + } + .onChange(of: proxy.frame(in: .global)) { _, newFrame in + onFrameChange(tabId, newFrame) + } + } + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift new file mode 100644 index 00000000..a55c53ea --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct TerminalTabView: View { + let tab: TerminalTabItem + let isActive: Bool + let isDragging: Bool + let fixedWidth: CGFloat? + let onSelect: () -> Void + let onClose: () -> Void + @Binding var closeButtonGestureActive: Bool + + @State private var isHovering = false + @State private var isHoveringClose = false + @State private var isPressing = false + + var body: some View { + Button(action: onSelect) { + TerminalTabLabelView( + tab: tab, + isActive: isActive, + isHoveringTab: isHovering, + isDragging: isDragging, + closeAction: onClose, + closeButtonGestureActive: $closeButtonGestureActive, + isHoveringClose: $isHoveringClose + ) + } + .buttonStyle(TerminalTabButtonStyle(isPressing: $isPressing)) + .background { + TerminalTabBackground( + isActive: isActive, + isPressing: isPressing, + isDragging: isDragging, + isHovering: isHovering + ) + .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHovering) + } + .frame( + minWidth: TerminalTabBarMetrics.tabMinWidth, + maxWidth: TerminalTabBarMetrics.tabMaxWidth, + minHeight: TerminalTabBarMetrics.tabHeight, + maxHeight: TerminalTabBarMetrics.tabHeight + ) + .frame(width: fixedWidth) + .padding(.bottom, isActive ? TerminalTabBarMetrics.activeTabBottomPadding : 0) + .offset(y: isActive ? TerminalTabBarMetrics.activeTabOffset : 0) + .clipShape(.rect(cornerRadius: TerminalTabBarMetrics.tabCornerRadius)) + .contentShape(.rect) + .onHover { hovering in + isHovering = hovering + } + .zIndex(isActive ? 2 : (isDragging ? 3 : 0)) + .help("Open tab \(tab.title) (no shortcut)") + .accessibilityLabel(tab.title) + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift new file mode 100644 index 00000000..f942a904 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsOverflowShadow.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct TerminalTabsOverflowShadow: View { + var width: CGFloat + var startPoint: UnitPoint + var endPoint: UnitPoint + + @Environment(\.controlActiveState) + private var activeState + + var body: some View { + Rectangle() + .frame(maxHeight: .infinity) + .frame(width: width) + .foregroundStyle(.clear) + .background( + LinearGradient( + gradient: Gradient(colors: gradientColors), + startPoint: startPoint, + endPoint: endPoint + ) + .opacity(activeState == .inactive ? 0.95 : 1) + ) + .allowsHitTesting(false) + } + + private var gradientColors: [Color] { + [ + TerminalTabBarColors.barBackground, + TerminalTabBarColors.barBackground.opacity(0), + ] + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift new file mode 100644 index 00000000..cbbff909 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct TerminalTabsRowView: View { + @Bindable var manager: TerminalTabManager + @Binding var openedTabs: [TerminalTabID] + @Binding var tabLocations: [TerminalTabID: CGRect] + @Binding var draggingTabId: TerminalTabID? + @Binding var draggingStartLocation: CGFloat? + @Binding var closeButtonGestureActive: Bool + let fixedTabWidth: CGFloat? + let closeTab: (TerminalTabID) -> Void + let closeOthers: (TerminalTabID) -> Void + let closeToRight: (TerminalTabID) -> Void + let closeAll: () -> Void + let scrollReader: ScrollViewProxy + + @State private var dropTargetIndex: Int? + @State private var rowFrame: CGRect = .zero + + var body: some View { + ZStack(alignment: .topLeading) { + HStack(alignment: .center, spacing: TerminalTabBarMetrics.tabSpacing) { + ForEach(openedTabs.enumerated(), id: \.element) { index, id in + if let item = manager.tabs.first(where: { $0.id == id }) { + TerminalTabView( + tab: item, + isActive: manager.selectedTabId == id, + isDragging: draggingTabId == id, + fixedWidth: fixedTabWidth, + onSelect: { + manager.selectTab(id) + }, + onClose: { + closeTab(id) + }, + closeButtonGestureActive: $closeButtonGestureActive + ) + .background( + TerminalTabMeasurementView( + tabId: id, + onFrameChange: { tabId, rect in + tabLocations[tabId] = rect + } + ) + ) + .simultaneousGesture(makeTabDragGesture(id: id)) + .terminalTabContextMenu( + tabId: id, + tabs: manager.tabs, + actions: TerminalTabContextMenuActions( + closeTab: closeTab, + closeOthers: closeOthers, + closeToRight: closeToRight, + closeAll: closeAll + ) + ) + .id(id) + + if index < openedTabs.count - 1 { + TerminalTabDivider() + } + } + } + } + if let offsetX = dropIndicatorOffsetX() { + Capsule() + .fill(TerminalTabBarColors.dropIndicator) + .frame( + width: TerminalTabBarMetrics.dropIndicatorWidth, + height: TerminalTabBarMetrics.dropIndicatorHeight + ) + .offset( + x: offsetX - (TerminalTabBarMetrics.dropIndicatorWidth / 2), + y: (TerminalTabBarMetrics.tabHeight - TerminalTabBarMetrics.dropIndicatorHeight) / 2 + ) + .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: offsetX) + } + } + .background( + GeometryReader { proxy in + Color.clear + .onAppear { + rowFrame = proxy.frame(in: .global) + } + .onChange(of: proxy.frame(in: .global)) { _, newFrame in + rowFrame = newFrame + } + } + ) + .onAppear { + openedTabs = manager.tabs.map(\.id) + if let selectedId = manager.selectedTabId { + scrollReader.scrollTo(selectedId) + } + } + .onChange(of: manager.tabs) { _, newValue in + let newIds = newValue.map(\.id) + if openedTabs.count == newIds.count { + openedTabs = newIds + } else { + withAnimation(.easeOut(duration: TerminalTabBarMetrics.closeAnimationDuration)) { + openedTabs = newIds + } + } + Task { @MainActor in + try? await Task.sleep(for: .seconds(TerminalTabBarMetrics.closeAnimationDuration)) + if let selectedId = manager.selectedTabId { + withAnimation { + scrollReader.scrollTo(selectedId) + } + } + } + } + .onChange(of: manager.selectedTabId) { _, newValue in + if let newValue { + withAnimation { + scrollReader.scrollTo(newValue) + } + } + } + .frame(height: TerminalTabBarMetrics.barHeight) + } + + private func makeTabDragGesture(id: TerminalTabID) -> some Gesture { + DragGesture(minimumDistance: 2, coordinateSpace: .global) + .onChanged { value in + if closeButtonGestureActive { + return + } + + if draggingTabId != id { + draggingTabId = id + draggingStartLocation = value.startLocation.x + } + + guard draggingStartLocation != nil, + openedTabs.contains(id) + else { return } + + let currentLocation = value.location.x + updateDropTarget(at: currentLocation) + } + .onEnded { _ in + let draggedId = draggingTabId + let targetIndex = dropTargetIndex + draggingStartLocation = nil + dropTargetIndex = nil + draggingTabId = nil + guard let draggedId, + let targetIndex, + let sourceIndex = openedTabs.firstIndex(of: draggedId) + else { return } + var newOrder = openedTabs + newOrder.remove(at: sourceIndex) + let adjustedIndex = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex + let safeIndex = min(max(0, adjustedIndex), newOrder.count) + newOrder.insert(draggedId, at: safeIndex) + withAnimation(.spring( + duration: TerminalTabBarMetrics.reorderAnimationDuration, + bounce: TerminalTabBarMetrics.reorderAnimationBounce + )) { + openedTabs = newOrder + } + manager.reorderTabs(newOrder) + } + } + + private func updateDropTarget(at locationX: CGFloat) { + guard draggingTabId != nil else { + dropTargetIndex = nil + return + } + let orderedFrames: [(index: Int, frame: CGRect)] = openedTabs.enumerated().compactMap { + guard let frame = tabLocations[$0.element] else { return nil } + return (index: $0.offset, frame: frame) + } + guard !orderedFrames.isEmpty else { + dropTargetIndex = nil + return + } + for entry in orderedFrames where locationX < entry.frame.midX { + dropTargetIndex = entry.index + return + } + dropTargetIndex = orderedFrames.count + } + + private func dropIndicatorOffsetX() -> CGFloat? { + guard let dropTargetIndex, !openedTabs.isEmpty else { return nil } + let lastIndex = openedTabs.count - 1 + if dropTargetIndex <= 0 { + guard let firstFrame = tabLocations[openedTabs[0]] else { return nil } + return firstFrame.minX - rowFrame.minX + } + if dropTargetIndex > lastIndex { + guard let lastFrame = tabLocations[openedTabs[lastIndex]] else { return nil } + return lastFrame.maxX - rowFrame.minX + } + guard let frame = tabLocations[openedTabs[dropTargetIndex]] else { return nil } + return frame.minX - rowFrame.minX + } +} diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift new file mode 100644 index 00000000..80803c48 --- /dev/null +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +struct TerminalTabsView: View { + @Bindable var manager: TerminalTabManager + let closeTab: (TerminalTabID) -> Void + let closeOthers: (TerminalTabID) -> Void + let closeToRight: (TerminalTabID) -> Void + let closeAll: () -> Void + + @State private var draggingTabId: TerminalTabID? + @State private var draggingStartLocation: CGFloat? + @State private var openedTabs: [TerminalTabID] = [] + @State private var tabLocations: [TerminalTabID: CGRect] = [:] + @State private var closeButtonGestureActive = false + @State private var scrollOffset: CGFloat = 0 + @State private var contentWidth: CGFloat = 0 + @State private var containerWidth: CGFloat = 0 + + var body: some View { + GeometryReader { geometryProxy in + ScrollViewReader { scrollReader in + ScrollView(.horizontal) { + TerminalTabsRowView( + manager: manager, + openedTabs: $openedTabs, + tabLocations: $tabLocations, + draggingTabId: $draggingTabId, + draggingStartLocation: $draggingStartLocation, + closeButtonGestureActive: $closeButtonGestureActive, + fixedTabWidth: effectiveTabWidth, + closeTab: closeTab, + closeOthers: closeOthers, + closeToRight: closeToRight, + closeAll: closeAll, + scrollReader: scrollReader + ) + .padding(.horizontal, TerminalTabBarMetrics.barPadding) + .background( + GeometryReader { contentGeo in + Color.clear + .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in + scrollOffset = -newFrame.minX + contentWidth = newFrame.width + } + .onAppear { + let frame = contentGeo.frame(in: .named("tabScroll")) + scrollOffset = -frame.minX + contentWidth = frame.width + } + } + ) + } + .scrollIndicators(.hidden) + .coordinateSpace(name: "tabScroll") + .onAppear { + containerWidth = geometryProxy.size.width + if let selectedId = manager.selectedTabId { + scrollReader.scrollTo(selectedId, anchor: .center) + } + } + .onChange(of: geometryProxy.size.width) { _, newWidth in + containerWidth = newWidth + } + .onChange(of: manager.selectedTabId) { _, newTabId in + if let tabId = newTabId { + withAnimation(.easeInOut(duration: TerminalTabBarMetrics.selectionAnimationDuration)) { + scrollReader.scrollTo(tabId, anchor: .center) + } + } + } + } + .overlay(alignment: .leading) { + TerminalTabsOverflowShadow( + width: TerminalTabBarMetrics.overflowShadowWidth, + startPoint: .leading, + endPoint: .trailing + ) + .opacity(canScrollLeft ? 1 : 0) + .animation(.easeInOut(duration: TerminalTabBarMetrics.fadeAnimationDuration), value: canScrollLeft) + } + .overlay(alignment: .trailing) { + TerminalTabsOverflowShadow( + width: TerminalTabBarMetrics.overflowShadowWidth, + startPoint: .trailing, + endPoint: .leading + ) + .opacity(canScrollRight ? 1 : 0) + .animation(.easeInOut(duration: TerminalTabBarMetrics.fadeAnimationDuration), value: canScrollRight) + } + } + } + + private var canScrollLeft: Bool { + scrollOffset > 1 + } + + private var canScrollRight: Bool { + contentWidth > containerWidth && scrollOffset < contentWidth - containerWidth - 1 + } + + private var effectiveTabWidth: CGFloat? { + let count = manager.tabs.count + guard containerWidth > 0, count > 0 else { return nil } + let perTab = containerWidth / CGFloat(count) + return min( + TerminalTabBarMetrics.tabMaxWidth, + max(TerminalTabBarMetrics.tabMinWidth, perTab) + ) + } +} diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 98661b3e..4336c93a 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -1,4 +1,3 @@ -import Bonsplit import SwiftUI struct WorktreeTerminalTabsView: View { @@ -6,41 +5,42 @@ struct WorktreeTerminalTabsView: View { let manager: WorktreeTerminalManager let shouldRunSetupScript: Bool let createTab: () -> Void - @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } - let newTabShortcut = ghosttyShortcuts.display(for: "new_tab") - ZStack(alignment: .topLeading) { - BonsplitView( - controller: state.controller, - content: { tab, _ in - TerminalSplitTreeView(tree: state.splitTree(for: tab.id)) { operation in - state.performSplitOperation(operation, in: tab.id) - } + VStack(spacing: 0) { + TerminalTabBarView( + manager: state.tabManager, + createTab: createTab, + closeTab: { tabId in + state.closeTab(tabId) + }, + closeOthers: { tabId in + state.closeOtherTabs(keeping: tabId) }, - emptyPane: { _ in - EmptyTerminalPaneView(message: "No terminals open") + closeToRight: { tabId in + state.closeTabsToRight(of: tabId) + }, + closeAll: { + state.closeAllTabs() } ) - .overlay(alignment: .topTrailing) { - Button("New Terminal", systemImage: "plus") { - createTab() + if let selectedId = state.tabManager.selectedTabId { + TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in + TerminalSplitTreeView(tree: state.splitTree(for: tabId)) { operation in + state.performSplitOperation(operation, in: tabId) + } } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .help(helpText("New Terminal", shortcut: newTabShortcut)) - .frame(height: state.controller.configuration.appearance.tabBarHeight) - .padding(.trailing) + } else { + EmptyTerminalPaneView(message: "No terminals open") } } .onAppear { state.ensureInitialTab() + state.focusSelectedTab() + } + .onChange(of: state.tabManager.selectedTabId) { _, _ in + state.focusSelectedTab() } - } - - private func helpText(_ title: String, shortcut: String?) -> String { - guard let shortcut else { return "\(title) (no shortcut)" } - return "\(title) (\(shortcut))" } } diff --git a/supacodeTests/TerminalTabManagerTests.swift b/supacodeTests/TerminalTabManagerTests.swift new file mode 100644 index 00000000..9992bb62 --- /dev/null +++ b/supacodeTests/TerminalTabManagerTests.swift @@ -0,0 +1,66 @@ +import Testing + +@testable import supacode + +@MainActor +struct TerminalTabManagerTests { + @Test func createTabInsertsAfterSelection() { + let manager = TerminalTabManager() + let first = manager.createTab(title: "one", icon: nil) + let second = manager.createTab(title: "two", icon: nil) + manager.selectTab(first) + let third = manager.createTab(title: "three", icon: nil) + let ids = manager.tabs.map(\.id) + #expect(ids == [first, third, second]) + } + + @Test func closeTabSelectsAdjacent() { + let manager = TerminalTabManager() + let first = manager.createTab(title: "one", icon: nil) + let second = manager.createTab(title: "two", icon: nil) + let third = manager.createTab(title: "three", icon: nil) + manager.selectTab(second) + manager.closeTab(second) + #expect(manager.tabs.map(\.id) == [first, third]) + #expect(manager.selectedTabId == first) + } + + @Test func closeToRightRemovesTrailingTabs() { + let manager = TerminalTabManager() + let first = manager.createTab(title: "one", icon: nil) + let second = manager.createTab(title: "two", icon: nil) + let third = manager.createTab(title: "three", icon: nil) + manager.closeToRight(of: second) + #expect(manager.tabs.map(\.id) == [first, second]) + #expect(manager.tabs.contains { $0.id == third } == false) + } + + @Test func closeOthersLeavesSingleTab() { + let manager = TerminalTabManager() + let first = manager.createTab(title: "one", icon: nil) + let second = manager.createTab(title: "two", icon: nil) + _ = manager.createTab(title: "three", icon: nil) + manager.closeOthers(keeping: second) + #expect(manager.tabs.map(\.id) == [second]) + #expect(manager.selectedTabId == second) + #expect(manager.tabs.contains { $0.id == first } == false) + } + + @Test func reorderTabsUsesProvidedOrder() { + let manager = TerminalTabManager() + let first = manager.createTab(title: "one", icon: nil) + let second = manager.createTab(title: "two", icon: nil) + let third = manager.createTab(title: "three", icon: nil) + manager.reorderTabs([third, first, second]) + #expect(manager.tabs.map(\.id) == [third, first, second]) + } + + @Test func updateDirtyUpdatesTabState() { + let manager = TerminalTabManager() + let tabId = manager.createTab(title: "one", icon: nil) + manager.updateDirty(tabId, isDirty: true) + #expect(manager.tabs.first?.isDirty == true) + manager.updateDirty(tabId, isDirty: false) + #expect(manager.tabs.first?.isDirty == false) + } +}