import SwiftUI import Combine @MainActor protocol TerminalSessionFactoryType { func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession } struct LiveTerminalSessionFactory: TerminalSessionFactoryType { func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession { TerminalSession(fontSize: fontSize, theme: theme, shellPath: shellPath) } } @MainActor final class WorkspaceController: ObservableObject { let id: WorkspaceID let createdAt: Date @Published private(set) var name: String @Published private(set) var hotkey: HotkeyBinding? @Published private(set) var tabs: [TerminalSession] = [] @Published private(set) var activeTabIndex: Int = 0 private let sessionFactory: TerminalSessionFactoryType private let settingsProvider: TerminalSessionConfigurationProviding private var titleObservers: [UUID: AnyCancellable] = [:] init( summary: WorkspaceSummary, sessionFactory: TerminalSessionFactoryType, settingsProvider: TerminalSessionConfigurationProviding, bootstrapDefaultTab: Bool = true ) { self.id = summary.id self.name = summary.name self.createdAt = summary.createdAt self.hotkey = summary.hotkey self.sessionFactory = sessionFactory self.settingsProvider = settingsProvider if bootstrapDefaultTab { newTab() } } convenience init(summary: WorkspaceSummary) { self.init( summary: summary, sessionFactory: LiveTerminalSessionFactory(), settingsProvider: AppSettingsController.shared ) } var summary: WorkspaceSummary { WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey) } var state: WorkspaceState { WorkspaceState( id: id, name: name, tabs: tabs.map { WorkspaceTabState(id: $0.id, title: $0.title) }, activeTabID: activeTab?.id ) } var activeTab: TerminalSession? { guard tabs.indices.contains(activeTabIndex) else { return nil } return tabs[activeTabIndex] } var activeTitle: String { activeTab?.title ?? "shell" } func rename(to updatedName: String) { let trimmed = updatedName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != name else { return } name = trimmed } func updateHotkey(_ updatedHotkey: HotkeyBinding?) { guard hotkey != updatedHotkey else { return } hotkey = updatedHotkey } func newTab() { let config = settingsProvider.terminalSessionConfiguration let session = sessionFactory.makeSession( fontSize: config.fontSize, theme: config.theme, shellPath: config.shellPath ) titleObservers[session.id] = session.$title .receive(on: RunLoop.main) .sink { [weak self] _ in self?.objectWillChange.send() } tabs.append(session) activeTabIndex = tabs.count - 1 } func closeTab(at index: Int) { guard tabs.indices.contains(index) else { return } let session = tabs.remove(at: index) titleObservers.removeValue(forKey: session.id) session.terminate() if tabs.isEmpty { newTab() } else if activeTabIndex >= tabs.count { activeTabIndex = tabs.count - 1 } } func closeActiveTab() { closeTab(at: activeTabIndex) } func switchToTab(at index: Int) { guard tabs.indices.contains(index) else { return } activeTabIndex = index } func switchToTab(id: UUID) { guard let index = tabs.firstIndex(where: { $0.id == id }) else { return } activeTabIndex = index } func nextTab() { guard tabs.count > 1 else { return } activeTabIndex = (activeTabIndex + 1) % tabs.count } func previousTab() { guard tabs.count > 1 else { return } activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count } func detachTab(at index: Int) -> TerminalSession? { guard tabs.indices.contains(index) else { return nil } let session = tabs.remove(at: index) titleObservers.removeValue(forKey: session.id) if tabs.isEmpty { newTab() } else if activeTabIndex >= tabs.count { activeTabIndex = tabs.count - 1 } return session } func detachActiveTab() -> TerminalSession? { detachTab(at: activeTabIndex) } func updateAllFontSizes(_ size: CGFloat) { for tab in tabs { tab.updateFontSize(size) } } func updateAllThemes(_ theme: TerminalTheme) { for tab in tabs { tab.applyTheme(theme) } } }