diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index ca1f8ae55..ba96d6436 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -631,6 +631,8 @@ 947BBCF82C12F5AA00FFD0C5 /* TreeSitterYAMLRunestone in Frameworks */ = {isa = PBXBuildFile; productRef = 947BBCF72C12F5AA00FFD0C5 /* TreeSitterYAMLRunestone */; }; 947BF349262453040015DAEB /* SearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947BF348262453040015DAEB /* SearchManager.swift */; }; 94801F93266FB5E400B29D80 /* TerminalInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94801F92266FB5E400B29D80 /* TerminalInstance.swift */; }; + 94801F97266FB5E500B29D80 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94801F96266FB5E500B29D80 /* TerminalManager.swift */; }; + 94801F98266FB5E500B29D80 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94801F96266FB5E500B29D80 /* TerminalManager.swift */; }; 94801F95266FBC3500B29D80 /* ViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94801F94266FBC3500B29D80 /* ViewRepresentable.swift */; }; 948488F92BE61684004D4A70 /* TerminalKeyboardToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948488F82BE61684004D4A70 /* TerminalKeyboardToolbar.swift */; }; 948488FA2BE61684004D4A70 /* TerminalKeyboardToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948488F82BE61684004D4A70 /* TerminalKeyboardToolbar.swift */; }; @@ -723,6 +725,8 @@ 94A77829257BC951008FE7B2 /* DocumentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77828257BC951008FE7B2 /* DocumentPickerView.swift */; }; 94A7782D257BCEE2008FE7B2 /* getRootDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7782C257BCEE2008FE7B2 /* getRootDirectory.swift */; }; 94A77830257BD680008FE7B2 /* EditorTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7782F257BD680008FE7B2 /* EditorTabs.swift */; }; + 94A77831257BD681008FE7B2 /* TerminalTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */; }; + 94A77832257BD682008FE7B2 /* TerminalTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */; }; 94A77832257BDC50008FE7B2 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77831257BDC50008FE7B2 /* SafariView.swift */; }; 94A77834257BE2D8008FE7B2 /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77833257BE2D8008FE7B2 /* EditorView.swift */; }; 94A7FFB3268D085300369147 /* BottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7FFB2268D085300369147 /* BottomBar.swift */; }; @@ -1833,6 +1837,7 @@ 947A9D8827D3C562007680C3 /* UIApplication+getSafeArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+getSafeArea.swift"; sourceTree = ""; }; 947BF348262453040015DAEB /* SearchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchManager.swift; sourceTree = ""; }; 94801F92266FB5E400B29D80 /* TerminalInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalInstance.swift; sourceTree = ""; }; + 94801F96266FB5E500B29D80 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; 94801F94266FBC3500B29D80 /* ViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewRepresentable.swift; sourceTree = ""; }; 948488F82BE61684004D4A70 /* TerminalKeyboardToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalKeyboardToolbar.swift; sourceTree = ""; }; 9484BDD02B46FAEE003BCB8A /* injection.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = injection.js; sourceTree = ""; }; @@ -1902,6 +1907,7 @@ 94A77828257BC951008FE7B2 /* DocumentPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPickerView.swift; sourceTree = ""; }; 94A7782C257BCEE2008FE7B2 /* getRootDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = getRootDirectory.swift; sourceTree = ""; }; 94A7782F257BD680008FE7B2 /* EditorTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabs.swift; sourceTree = ""; }; + 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabBar.swift; sourceTree = ""; }; 94A77831257BDC50008FE7B2 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 94A77833257BE2D8008FE7B2 /* EditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = ""; }; 94A7FFB2268D085300369147 /* BottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomBar.swift; sourceTree = ""; }; @@ -2958,6 +2964,7 @@ 94A045FD2804853200182275 /* DescriptionText.swift */, 948E478126502B6900A6110D /* EditorTab.swift */, 94A7782F257BD680008FE7B2 /* EditorTabs.swift */, + 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */, 94F6B50F280EFD07000DBAE2 /* FileDisplayName.swift */, 94A77791257AB805008FE7B2 /* FileIcon.swift */, 94A046002804865000182275 /* SideBarButton.swift */, @@ -3140,6 +3147,7 @@ 94A777BD257B74EE008FE7B2 /* NotificationManager.swift */, 942ACB9C281312900067114D /* SpellChecker.swift */, 94801F92266FB5E400B29D80 /* TerminalInstance.swift */, + 94801F96266FB5E500B29D80 /* TerminalManager.swift */, 9F046C312922203E00BDE4E9 /* ToolbarManager.swift */, 9F046C3429222D8E00BDE4E9 /* ExtensionManager.swift */, 9FA1225B2A8B209500E7B417 /* CodeAppContributionPointManager.swift */, @@ -3696,6 +3704,7 @@ 942ACB9E281312900067114D /* SpellChecker.swift in Sources */, 94E6CC7C2806FBAF00939E4F /* SFTPFileSystemProvider.swift in Sources */, 94196973280316C7008AAEB2 /* EditorTabs.swift in Sources */, + 94A77832257BD682008FE7B2 /* TerminalTabBar.swift in Sources */, 94196974280316C7008AAEB2 /* LocalGitServiceProvider.swift in Sources */, 9474D2982B6B4B1300CCC530 /* EditorImplemenationView.swift in Sources */, 94196975280316C7008AAEB2 /* SourceControlIdentityConfiguration.swift in Sources */, @@ -3739,6 +3748,7 @@ 947313132BDFB80D004A9960 /* ExtensionCommunicationHelper.swift in Sources */, 94196986280316C7008AAEB2 /* node.swift in Sources */, 94196988280316C7008AAEB2 /* TerminalInstance.swift in Sources */, + 94801F98266FB5E500B29D80 /* TerminalManager.swift in Sources */, 94196989280316C7008AAEB2 /* convertCArguments.swift in Sources */, 94FF337528435158003DE5DD /* SettingsFontPicker.swift in Sources */, 9F062FEA2B58D416006210AA /* TableView.swift in Sources */, @@ -3888,6 +3898,7 @@ 942ACB9D281312900067114D /* SpellChecker.swift in Sources */, 94E6CC7B2806FBAF00939E4F /* SFTPFileSystemProvider.swift in Sources */, 94A77830257BD680008FE7B2 /* EditorTabs.swift in Sources */, + 94A77831257BD681008FE7B2 /* TerminalTabBar.swift in Sources */, 944FC3FF25C543CD00C7C43C /* LocalGitServiceProvider.swift in Sources */, 9474D2972B6B4B1300CCC530 /* EditorImplemenationView.swift in Sources */, 9494A3A52587479F004A103E /* SourceControlIdentityConfiguration.swift in Sources */, @@ -3931,6 +3942,7 @@ 947313122BDFB80D004A9960 /* ExtensionCommunicationHelper.swift in Sources */, 949B3CC725DEAAA700BC83B5 /* node.swift in Sources */, 94801F93266FB5E400B29D80 /* TerminalInstance.swift in Sources */, + 94801F97266FB5E500B29D80 /* TerminalManager.swift in Sources */, 94F2FEA926A2EB0E007EBC6D /* convertCArguments.swift in Sources */, 94FF337428435158003DE5DD /* SettingsFontPicker.swift in Sources */, 9F062FE92B58D416006210AA /* TableView.swift in Sources */, diff --git a/CodeApp/Containers/MainScene.swift b/CodeApp/Containers/MainScene.swift index 9fe7b35ce..6a2f3df8a 100644 --- a/CodeApp/Containers/MainScene.swift +++ b/CodeApp/Containers/MainScene.swift @@ -119,7 +119,7 @@ struct MainScene: View { } App.monacoInstance.theme = EditorTheme( dark: ThemeManager.darkTheme, light: ThemeManager.lightTheme) - App.terminalInstance.applyTheme(rawTheme: theme.dictionary) + App.terminalManager.applyThemeToAll(rawTheme: theme.dictionary) } ) } @@ -154,7 +154,7 @@ private struct MainView: View { panelHeight = 200 } isPanelVisible.toggle() - App.terminalInstance.webView.becomeFirstResponder() + App.terminalManager.activeTerminal?.webView.becomeFirstResponder() } var body: some View { @@ -232,7 +232,7 @@ private struct MainView: View { App.setUpEditorInstance() } .onChange(of: terminalOptions) { newValue in - App.terminalInstance.options = newValue.value + App.terminalManager.applyOptionsToAll(newValue.value) } .hiddenScrollableContentBackground() .onAppear { diff --git a/CodeApp/Containers/RemoteContainer.swift b/CodeApp/Containers/RemoteContainer.swift index 176cb0f24..e521dde82 100644 --- a/CodeApp/Containers/RemoteContainer.swift +++ b/CodeApp/Containers/RemoteContainer.swift @@ -179,8 +179,9 @@ struct RemoteContainer: View { App.loadRepository(url: hostUrl) App.notificationManager.showInformationMessage( "remote.connected") - App.terminalInstance.terminalServiceProvider = - App.workSpaceStorage.terminalServiceProvider + // Set terminal service provider for the active terminal + App.terminalManager.setTerminalServiceProviderForAll( + App.workSpaceStorage.terminalServiceProvider) } continuation.resume(returning: ()) } diff --git a/CodeApp/Managers/Executor.swift b/CodeApp/Managers/Executor.swift index b056e8d37..7b71221b1 100644 --- a/CodeApp/Managers/Executor.swift +++ b/CodeApp/Managers/Executor.swift @@ -16,7 +16,7 @@ class Executor { case interactive } - private let persistentIdentifier = "com.thebaselab.terminal" + private let persistentIdentifier: String private var pid: pid_t? = nil private var stdin_file: UnsafeMutablePointer? @@ -40,10 +40,13 @@ class Executor { } init( - root: URL, onStdout: @escaping ((_ data: Data) -> Void), + root: URL, + sessionIdentifier: String = "com.thebaselab.terminal", + onStdout: @escaping ((_ data: Data) -> Void), onStderr: @escaping ((_ data: Data) -> Void), onRequestInput: @escaping ((_ prompt: String) -> Void) ) { + persistentIdentifier = sessionIdentifier currentWorkingDirectory = root prompt = "\(root.lastPathComponent) $ " receivedStdout = onStdout @@ -283,3 +286,16 @@ class Executor { } } } + +extension Executor.State { + var displayName: String { + switch self { + case .idle: + return NSLocalizedString("Idle", comment: "Executor state label") + case .running: + return NSLocalizedString("Running", comment: "Executor state label") + case .interactive: + return NSLocalizedString("Interactive", comment: "Executor state label") + } + } +} diff --git a/CodeApp/Managers/MainApp.swift b/CodeApp/Managers/MainApp.swift index 080d540a7..867cdb201 100644 --- a/CodeApp/Managers/MainApp.swift +++ b/CodeApp/Managers/MainApp.swift @@ -11,6 +11,9 @@ import SwiftGit2 import SwiftUI import UniformTypeIdentifiers import ios_system +import os.log + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Code", category: "MainApp") struct CheckoutDestination: Identifiable { var id = UUID() @@ -191,8 +194,13 @@ class MainApp: ObservableObject { var editorShortcuts: [MonacoEditorAction] = [] var monacoStateToRestore: String? = nil - var terminalInstance: TerminalInstance! = nil + let terminalManager: TerminalManager var monacoInstance: EditorImplementation! = nil + + // Backward compatibility: returns the active terminal + var terminalInstance: TerminalInstance! { + terminalManager.activeTerminal + } var editorTypesMonitor: FolderMonitor? = nil let deviceSupportsBiometricAuth: Bool = biometricAuthSupported() let sceneIdentifier = UUID() @@ -202,6 +210,8 @@ class MainApp: ObservableObject { private var searchCancellable: AnyCancellable? = nil private var textSearchCancellable: AnyCancellable? = nil private var workSpaceCancellable: AnyCancellable? = nil + private var cancellables = Set() + private var isConfiguringOpenEditors = false @AppStorage("alwaysOpenInNewTab") var alwaysOpenInNewTab: Bool = false @AppStorage("explorer.confirmBeforeDelete") var confirmBeforeDelete = false @@ -221,18 +231,23 @@ class MainApp: ObservableObject { self.workSpaceStorage = WorkSpaceStorage(url: rootDir) - terminalInstance = TerminalInstance(root: rootDir, options: terminalOptions.value) + // Use helper to read options before self is fully initialized + let options = TerminalManager.readTerminalOptionsFromDefaults() + self.terminalManager = TerminalManager(rootURL: rootDir, options: options) setUpEditorInstance() - terminalInstance.openEditor = { [weak self] url in - if url.isDirectory { - DispatchQueue.main.async { - self?.loadFolder(url: url) - } - } else { - self?.openFile(url: url) + // Set up openEditor callback for initial terminal + configureOpenEditorForTerminals() + + // Forward terminalManager changes to MainApp so UI updates + terminalManager.objectWillChange.sink { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.objectWillChange.send() + // Set up openEditor for any new terminals + self.scheduleConfigureOpenEditorForTerminals() } - } + }.store(in: &cancellables) // TODO: Support deleted files detection for remote files workSpaceStorage.onDirectoryChange { [weak self] url in @@ -248,7 +263,15 @@ class MainApp: ObservableObject { } } workSpaceStorage.onTerminalData { [weak self] data in - self?.terminalInstance.write(data: data) + guard let self = self else { return } + // Use the tracked remote terminal for consistent data routing + if let terminal = self.terminalManager.remoteTerminal { + terminal.write(data: data) + } else { + logger.warning( + "Remote terminal data dropped: no remote terminal available (\(data.count) bytes)" + ) + } } loadRepository(url: rootDir) @@ -300,6 +323,30 @@ class MainApp: ObservableObject { } } + private func configureOpenEditorForTerminals() { + for terminal in terminalManager.terminals where terminal.openEditor == nil { + terminal.openEditor = { [weak self] url in + if url.isDirectory { + DispatchQueue.main.async { + self?.loadFolder(url: url) + } + } else { + self?.openFile(url: url) + } + } + } + } + + private func scheduleConfigureOpenEditorForTerminals() { + guard !isConfiguringOpenEditors else { return } + isConfiguringOpenEditors = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.configureOpenEditorForTerminals() + self.isConfiguringOpenEditors = false + } + } + private func updateActiveEditor() async { guard let activeTextEditor else { Task { @@ -937,7 +984,7 @@ class MainApp: ObservableObject { if resetEditors { DispatchQueue.main.async { self.closeAllEditors() - self.terminalInstance.resetAndSetNewRootDirectory(url: url) + self.terminalManager.resetAndSetNewRootDirectory(url: url) } } extensionManager.onWorkSpaceStorageChanged(newUrl: url) diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 6c5819bd1..62db1ae13 100644 --- a/CodeApp/Managers/TerminalInstance.swift +++ b/CodeApp/Managers/TerminalInstance.swift @@ -18,7 +18,11 @@ struct TerminalOptions: Codable { // subsequent options must be made optional } -class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { +class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate, Identifiable { + + let id: UUID + var name: String + private(set) var isReady = false var options: TerminalOptions { didSet { @@ -34,11 +38,10 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { if terminalServiceProvider != nil { self.startInteractive() } - terminalServiceProvider?.onDisconnect(callback: { + if terminalServiceProvider == nil && oldValue != nil { self.stopInteractive() - self.terminalServiceProvider = nil self.clearLine() - }) + } } } public var webView: WebViewBase = WebViewBase() @@ -383,6 +386,8 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { self.applyTheme(rawTheme: darkTheme.dictionary) } configureCustomOptions() + isReady = true + NotificationCenter.default.post(name: .terminalDidInitialize, object: self) case "window.size.change": let cols = result["Cols"] as! Int let rows = result["Rows"] as! Int @@ -440,11 +445,14 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { } } - init(root: URL, options: TerminalOptions) { + init(root: URL, options: TerminalOptions, name: String = "Terminal", id: UUID = UUID()) { + self.id = id + self.name = name self.options = options super.init() self.executor = Executor( root: root, + sessionIdentifier: "com.thebaselab.terminal.\(id.uuidString)", onStdout: { [weak self] data in self?.writeToLocalTerminal(data: data) }, @@ -535,6 +543,27 @@ extension TerminalInstance: WKUIDelegate { } } +// Cleanup method + +extension TerminalInstance { + /// Cleans up resources before the terminal is deallocated. + /// Call this method before removing the terminal from TerminalManager. + func cleanup() { + if executor?.state != .idle { + executor?.kill() + } + terminalServiceProvider?.kill() + terminalServiceProvider = nil + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + webView.removeFromSuperview() + webView.configuration.userContentController.removeAllScriptMessageHandlers() + executor = nil + openEditor = nil + } +} + // Keyboard toolbar methods extension TerminalInstance { @@ -567,4 +596,5 @@ extension TerminalInstance { extension Notification.Name { static let terminalControlReset = Notification.Name("terminalControlReset") static let terminalAltReset = Notification.Name("terminalAltReset") + static let terminalDidInitialize = Notification.Name("terminalDidInitialize") } diff --git a/CodeApp/Managers/TerminalManager.swift b/CodeApp/Managers/TerminalManager.swift new file mode 100644 index 000000000..c2553dcbe --- /dev/null +++ b/CodeApp/Managers/TerminalManager.swift @@ -0,0 +1,310 @@ +// +// TerminalManager.swift +// Code +// +// Created by Thales Matheus Mendonça Santos - January 2026 +// + +import SwiftUI +import os.log + +private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "Code", category: "TerminalManager") + +/// Manages multiple terminal instances. +/// - Important: Access this class only from the main thread. Debug builds will assert this. +class TerminalManager: ObservableObject { + @Published var terminals: [TerminalInstance] = [] + @Published var activeTerminalId: UUID? + + /// Tracks the terminal that initiated a remote connection for proper data routing + @Published private(set) var remoteTerminalId: UUID? + + private var rootURL: URL + private var options: TerminalOptions + private var terminalServiceProvider: TerminalServiceProvider? + private var terminalCounter: Int = 1 + + /// Asserts that we're on the main thread in debug builds + private func assertMainThread(_ function: String = #function) { + assert(Thread.isMainThread, "TerminalManager.\(function) must be called on the main thread") + } + + static let maxTerminals = 10 + + /// Reads terminal options from UserDefaults. + /// Useful during initialization when @AppStorage properties aren't yet accessible. + static func readTerminalOptionsFromDefaults() -> TerminalOptions { + if let rawValue = UserDefaults.standard.string(forKey: "terminalOptions"), + let data = rawValue.data(using: .utf8), + let decoded = try? JSONDecoder().decode(TerminalOptions.self, from: data) + { + return decoded + } + return TerminalOptions() + } + + var activeTerminal: TerminalInstance? { + get { + guard let id = activeTerminalId else { return terminals.first } + return terminals.first { $0.id == id } ?? terminals.first + } + set { + guard + let id = newValue?.id, + terminals.contains(where: { $0.id == id }) + else { + let attemptedId = newValue?.id.uuidString ?? "nil" + let availableIds = terminals.map { $0.id.uuidString }.joined(separator: ", ") + logger.warning( + "active terminal set failed: attempted id: \(attemptedId, privacy: .public), available ids: [\(availableIds, privacy: .public)]" + ) + setActiveTerminalId(nil) + return + } + setActiveTerminalId(id) + } + } + + /// Returns the terminal designated for remote data. + var remoteTerminal: TerminalInstance? { + if let id = remoteTerminalId { + return terminals.first { $0.id == id } + } + return terminals.first { $0.terminalServiceProvider != nil } + } + + init(rootURL: URL, options: TerminalOptions) { + self.rootURL = rootURL + self.options = options + self.terminalServiceProvider = nil + + // Create the initial terminal + let initialName = String( + format: NSLocalizedString("Terminal %d", comment: "Terminal name with number"), + 1 + ) + let initialTerminal = createTerminalInstance(name: initialName) + terminals.append(initialTerminal) + setActiveTerminalId(initialTerminal.id) + } + + private func createTerminalInstance(name: String) -> TerminalInstance { + let terminal = TerminalInstance(root: rootURL, options: options, name: name) + if let provider = terminalServiceProvider, terminal.id == remoteTerminalId { + terminal.terminalServiceProvider = provider + } + return terminal + } + + /// Generates a unique terminal name by finding the lowest available number. + /// This reuses gaps from closed terminals (e.g., if "Terminal 2" was closed, the next terminal uses "Terminal 2"). + private func generateUniqueTerminalName() -> String { + let existingNames = Set(terminals.map { $0.name }) + + // Find the lowest available terminal number + var number = 1 + while number <= TerminalManager.maxTerminals + 1 { + let candidateName = String( + format: NSLocalizedString("Terminal %d", comment: "Terminal name with number"), + number + ) + if !existingNames.contains(candidateName) { + return candidateName + } + number += 1 + } + + // Fallback: use counter with suffix (should rarely happen) + terminalCounter += 1 + let baseName = String( + format: NSLocalizedString("Terminal %d", comment: "Terminal name with number"), + terminalCounter + ) + let maxAttempts = TerminalManager.maxTerminals + 1 + var suffix = 1 + var candidateName = String( + format: NSLocalizedString("%@ (%d)", comment: "Terminal name with duplicate suffix"), + baseName, suffix + ) + while existingNames.contains(candidateName) && suffix < maxAttempts { + suffix += 1 + candidateName = String( + format: NSLocalizedString( + "%@ (%d)", comment: "Terminal name with duplicate suffix"), + baseName, suffix + ) + } + if existingNames.contains(candidateName) { + terminalCounter += 1 + candidateName = String( + format: NSLocalizedString( + "%@ (%d-%d)", + comment: "Terminal name with duplicate suffix and unique token" + ), + baseName, + suffix, + terminalCounter + ) + } + return candidateName + } + + @discardableResult + func createTerminal(name: String? = nil) -> TerminalInstance { + assertMainThread() + guard terminals.count < TerminalManager.maxTerminals else { + logger.debug( + "create blocked: max reached (count: \(self.terminals.count, privacy: .public), max: \(TerminalManager.maxTerminals, privacy: .public))" + ) + // Return the active terminal if at max capacity + // Invariant: terminals array is never empty after init (enforced by closeTerminal guard) + precondition( + !terminals.isEmpty, "TerminalManager must always have at least one terminal") + return activeTerminal ?? terminals.first! + } + + let terminalName = name ?? generateUniqueTerminalName() + let terminal = createTerminalInstance(name: terminalName) + terminals.append(terminal) + setActiveTerminalId(terminal.id) + logger.info( + "created terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)" + ) + return terminal + } + + func closeTerminal(id: UUID) { + assertMainThread() + // Don't allow closing the last terminal + guard terminals.count > 1 else { + logger.debug( + "close blocked: last terminal (count: \(self.terminals.count, privacy: .public)) id: \(id, privacy: .public)" + ) + return + } + + guard let index = terminals.firstIndex(where: { $0.id == id }) else { + logger.debug("close failed: terminal not found id: \(id, privacy: .public)") + return + } + + // If closing active terminal, switch to another one + if activeTerminalId == id { + if index > 0 { + setActiveTerminalId(terminals[index - 1].id) + } else { + setActiveTerminalId(terminals[1].id) + } + } + // Clean up the terminal's resources using the cleanup method + let terminal = terminals[index] + terminal.cleanup() + + terminals.remove(at: index) + syncRemoteTerminalId() + logger.info( + "closed terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)" + ) + } + + /// Check if a terminal has a running process + func isTerminalBusy(id: UUID) -> Bool { + guard let terminal = terminals.first(where: { $0.id == id }) else { return false } + return terminal.executor?.state == .running || terminal.executor?.state == .interactive + } + + func setActiveTerminal(id: UUID) { + assertMainThread() + guard let terminal = terminals.first(where: { $0.id == id }) else { + logger.debug("switch failed: terminal not found id: \(id, privacy: .public)") + return + } + setActiveTerminalId(terminal.id) + logger.info( + "switched terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)" + ) + } + + func renameTerminal(id: UUID, name: String) { + assertMainThread() + guard let terminal = terminals.first(where: { $0.id == id }) else { return } + objectWillChange.send() + terminal.name = name + } + + func applyThemeToAll(rawTheme: [String: Any]) { + assertMainThread() + for terminal in terminals { + terminal.applyTheme(rawTheme: rawTheme) + } + } + + func applyOptionsToAll(_ options: TerminalOptions) { + assertMainThread() + self.options = options + for terminal in terminals { + terminal.options = options + } + } + + func resetAndSetNewRootDirectory(url: URL) { + assertMainThread() + rootURL = url + for terminal in terminals { + terminal.resetAndSetNewRootDirectory(url: url) + } + } + + /// Sets the terminal service provider on the active terminal only. + func setTerminalServiceProviderForAll(_ provider: TerminalServiceProvider?) { + assertMainThread() + terminalServiceProvider = provider + let targetId = activeTerminalId ?? terminals.first?.id + for terminal in terminals { + terminal.terminalServiceProvider = + terminal.id == targetId ? provider : nil + } + // Track the active terminal as the remote terminal when connecting + if let provider = provider { + provider.onDisconnect { [weak self] in + DispatchQueue.main.async { + self?.setTerminalServiceProviderForAll(nil) + } + } + remoteTerminalId = targetId + } else { + remoteTerminalId = nil + } + syncRemoteTerminalId() + } + + var canCreateNewTerminal: Bool { + terminals.count < TerminalManager.maxTerminals + } + + private func setActiveTerminalId(_ id: UUID?) { + activeTerminalId = id + syncRemoteTerminalId() + } + + private func syncRemoteTerminalId() { + guard terminalServiceProvider != nil else { + remoteTerminalId = nil + return + } + + if let active = activeTerminal, active.terminalServiceProvider != nil { + remoteTerminalId = active.id + return + } + + if let currentId = remoteTerminalId, + terminals.contains(where: { $0.id == currentId && $0.terminalServiceProvider != nil }) + { + return + } + + remoteTerminalId = terminals.first { $0.terminalServiceProvider != nil }?.id + } +} diff --git a/CodeApp/Views/ActivityBar.swift b/CodeApp/Views/ActivityBar.swift index cb3e760be..465254a6a 100644 --- a/CodeApp/Views/ActivityBar.swift +++ b/CodeApp/Views/ActivityBar.swift @@ -80,7 +80,7 @@ struct ActivityBar: View { func removeFocus() { Task { await App.monacoInstance.blur() } - App.terminalInstance.executeScript( + App.terminalManager.activeTerminal?.executeScript( "document.getElementById('overlay').focus()") } diff --git a/CodeApp/Views/TerminalKeyboardToolbar.swift b/CodeApp/Views/TerminalKeyboardToolbar.swift index 193e8fbb4..6237dc29e 100644 --- a/CodeApp/Views/TerminalKeyboardToolbar.swift +++ b/CodeApp/Views/TerminalKeyboardToolbar.swift @@ -21,28 +21,38 @@ struct TerminalKeyboardToolBar: View { @State var altLastTapTime: Date? @State var altGeneration = 0 + // Optional terminal ID - if nil, uses active terminal + var terminalId: UUID? + + private var terminal: TerminalInstance? { + if let id = terminalId { + return App.terminalManager.terminals.first { $0.id == id } + } + return App.terminalManager.activeTerminal + } + private let doubleTapInterval: TimeInterval = 0.3 private func resetModifierStates() { controlActive = false controlLocked = false - App.terminalInstance.setControlActive(false, generation: controlGeneration) - App.terminalInstance.setControlLocked(false) + terminal?.setControlActive(false, generation: controlGeneration) + terminal?.setControlLocked(false) altActive = false altLocked = false - App.terminalInstance.setAltActive(false, generation: altGeneration) - App.terminalInstance.setAltLocked(false) + terminal?.setAltActive(false, generation: altGeneration) + terminal?.setAltLocked(false) } private func resetUnlockedModifiers() { // Reset modifiers only if they are not locked if !controlLocked { controlActive = false - App.terminalInstance.setControlActive(false, generation: controlGeneration) + terminal?.setControlActive(false, generation: controlGeneration) } if !altLocked { altActive = false - App.terminalInstance.setAltActive(false, generation: altGeneration) + terminal?.setAltActive(false, generation: altGeneration) } } @@ -57,17 +67,17 @@ struct TerminalKeyboardToolBar: View { controlLocked = false controlActive = false controlGeneration += 1 - App.terminalInstance.setControlLocked(false) - App.terminalInstance.setControlActive(false, generation: controlGeneration) + terminal?.setControlLocked(false) + terminal?.setControlActive(false, generation: controlGeneration) } else if isDoubleTap && controlActive { // Double tap while active: lock controlLocked = true - App.terminalInstance.setControlLocked(true) + terminal?.setControlLocked(true) } else { // Single tap: toggle active state controlActive.toggle() controlGeneration += 1 - App.terminalInstance.setControlActive(controlActive, generation: controlGeneration) + terminal?.setControlActive(controlActive, generation: controlGeneration) } } @@ -82,27 +92,27 @@ struct TerminalKeyboardToolBar: View { altLocked = false altActive = false altGeneration += 1 - App.terminalInstance.setAltLocked(false) - App.terminalInstance.setAltActive(false, generation: altGeneration) + terminal?.setAltLocked(false) + terminal?.setAltActive(false, generation: altGeneration) } else if isDoubleTap && altActive { // Double tap while active: lock altLocked = true - App.terminalInstance.setAltLocked(true) + terminal?.setAltLocked(true) } else { // Single tap: toggle active state altActive.toggle() altGeneration += 1 - App.terminalInstance.setAltActive(altActive, generation: altGeneration) + terminal?.setAltActive(altActive, generation: altGeneration) } } private func typeAndResetModifiers(text: String) { - App.terminalInstance.type(text: text) + terminal?.type(text: text) resetUnlockedModifiers() } private func moveCursorAndResetModifiers(codeSequence: String) { - App.terminalInstance.moveCursor(codeSequence: codeSequence) + terminal?.moveCursor(codeSequence: codeSequence) resetUnlockedModifiers() } @@ -220,7 +230,7 @@ struct TerminalKeyboardToolBar: View { Button( action: { resetModifierStates() - App.terminalInstance.blur() + terminal?.blur() }, label: { Image(systemName: "keyboard.chevron.compact.down") @@ -246,7 +256,7 @@ struct TerminalKeyboardToolBar: View { .onReceive( NotificationCenter.default.publisher( for: .terminalControlReset, - object: App.terminalInstance + object: terminal ), perform: { notification in if let generation = notification.userInfo?["generation"] as? Int, @@ -259,7 +269,7 @@ struct TerminalKeyboardToolBar: View { .onReceive( NotificationCenter.default.publisher( for: .terminalAltReset, - object: App.terminalInstance + object: terminal ), perform: { notification in if let generation = notification.userInfo?["generation"] as? Int, diff --git a/CodeApp/Views/TerminalTabBar.swift b/CodeApp/Views/TerminalTabBar.swift new file mode 100644 index 000000000..118e462cb --- /dev/null +++ b/CodeApp/Views/TerminalTabBar.swift @@ -0,0 +1,142 @@ +// +// TerminalTabBar.swift +// Code +// +// Created by Thales Matheus Mendonça Santos - January 2026 +// + +import SwiftUI + +private enum TerminalTabBarConstants { + static let tabBarWidth: CGFloat = 50 + static let rowHeight: CGFloat = 36 + static let iconSize: CGFloat = 14 + static let activeIndicatorWidth: CGFloat = 2 +} + +struct TerminalTabBar: View { + @EnvironmentObject var App: MainApp + + var body: some View { + VStack(spacing: 0) { + // Terminal list + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(App.terminalManager.terminals) { terminal in + TerminalTabRow( + terminal: terminal, + isActive: terminal.id == App.terminalManager.activeTerminalId, + canClose: App.terminalManager.terminals.count > 1, + onSelect: { + App.terminalManager.setActiveTerminal(id: terminal.id) + }, + onClose: { + App.terminalManager.closeTerminal(id: terminal.id) + } + ) + } + } + } + } + .frame(width: TerminalTabBarConstants.tabBarWidth) + .background(Color(id: "sideBar.background")) + } +} + +struct TerminalTabRow: View { + @EnvironmentObject var App: MainApp + + let terminal: TerminalInstance + let isActive: Bool + let canClose: Bool + let onSelect: () -> Void + let onClose: () -> Void + + @State private var showingKillConfirmation = false + + private var isTerminalBusy: Bool { + App.terminalManager.isTerminalBusy(id: terminal.id) + } + + private var accessibilityLabel: String { + let activeLabel = NSLocalizedString( + "Active", + comment: "Accessibility label for active terminal" + ) + let runningLabel = NSLocalizedString( + "Running", + comment: "Accessibility label for running terminal" + ) + var parts = [terminal.name] + if isActive { + parts.append(activeLabel) + } + if isTerminalBusy { + parts.append(runningLabel) + } + return parts.joined(separator: ", ") + } + + var body: some View { + HStack(spacing: 0) { + Spacer() + // Icon + Image(systemName: "terminal") + .font(.system(size: TerminalTabBarConstants.iconSize)) + .foregroundColor( + isActive ? Color(id: "list.activeSelectionForeground") : Color(id: "foreground") + ) + .frame(width: 20, height: 20) + Spacer() + } + .frame(height: TerminalTabBarConstants.rowHeight) + .overlay( + // Left border indicator for active tab (VS Code style) + HStack { + if isActive { + Rectangle() + .fill(Color.accentColor) + .frame(width: TerminalTabBarConstants.activeIndicatorWidth) + } + Spacer() + } + ) + .contentShape(Rectangle()) + .onTapGesture { + onSelect() + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(isActive ? [.isSelected, .isButton] : [.isButton]) + .accessibilityHint( + NSLocalizedString( + "Double tap to switch to this terminal", + comment: "Accessibility hint for terminal tab") + ) + .contextMenu { + if canClose { + Button(NSLocalizedString("Kill Terminal", comment: ""), role: .destructive) { + if isTerminalBusy { + showingKillConfirmation = true + } else { + onClose() + } + } + } + } + .alert( + NSLocalizedString("Kill Terminal?", comment: ""), + isPresented: $showingKillConfirmation + ) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + Button(NSLocalizedString("Kill", comment: ""), role: .destructive) { + onClose() + } + } message: { + Text( + NSLocalizedString( + "This terminal has a running process. Are you sure you want to kill it?", + comment: "")) + } + } +} diff --git a/Extensions/LocalExecution/LocalExecutionExtension.swift b/Extensions/LocalExecution/LocalExecutionExtension.swift index a2acb2d74..9ee0f184c 100644 --- a/Extensions/LocalExecution/LocalExecutionExtension.swift +++ b/Extensions/LocalExecution/LocalExecutionExtension.swift @@ -44,7 +44,23 @@ class LocalExecutionExtension: CodeAppExtension { private func runCodeLocally(app: MainApp) async { - guard app.terminalInstance.executor?.state == .idle else { return } + guard let activeTerminal = app.terminalManager.activeTerminal else { + app.notificationManager.showErrorMessage("Cannot run: no active terminal.") + return + } + + guard let executor = activeTerminal.executor else { + app.notificationManager.showErrorMessage( + "Cannot run: terminal '\(activeTerminal.name)' has no executor.") + return + } + + guard executor.state == .idle else { + app.notificationManager.showWarningMessage( + "Cannot run: terminal '\(activeTerminal.name)' executor is \(executor.state.displayName) (expected idle)." + ) + return + } guard let activeTextEditor = app.activeTextEditor else { return @@ -71,14 +87,14 @@ class LocalExecutionExtension: CodeAppExtension { } if app.terminalOptions.value.shouldShowCompilerPath { - app.terminalInstance.executeScript( + activeTerminal.executeScript( "localEcho.println(`\(parsedCommands.joined(separator: " && "))`);readLine('');") } else { let commandName = parsedCommands.first?.components(separatedBy: " ").first ?? activeTextEditor.languageIdentifier - app.terminalInstance.executeScript("localEcho.println(`\(commandName)`);readLine('');") + activeTerminal.executeScript("localEcho.println(`\(commandName)`);readLine('');") } - app.terminalInstance.executor?.evaluateCommands(parsedCommands) + executor.evaluateCommands(parsedCommands) } } diff --git a/Extensions/TerminalService/TerminalExtension.swift b/Extensions/TerminalService/TerminalExtension.swift index ea14039d7..4cafb6d5a 100644 --- a/Extensions/TerminalService/TerminalExtension.swift +++ b/Extensions/TerminalService/TerminalExtension.swift @@ -11,7 +11,7 @@ class TerminalExtension: CodeAppExtension { override func onInitialize(app: MainApp, contribution: CodeAppExtension.Contribution) { let panel = Panel( labelId: "TERMINAL", - mainView: AnyView(TerminalView()), + mainView: AnyView(MultiTerminalView()), toolBarView: AnyView(ToolbarView()) ) contribution.panel.registerPanel(panel: panel) @@ -23,9 +23,17 @@ private struct ToolbarView: View { var body: some View { HStack(spacing: 12) { + Button(action: { + App.terminalManager.createTerminal() + }) { + Image(systemName: "plus") + } + .disabled(!App.terminalManager.canCreateNewTerminal) + .help("New Terminal") + Button( action: { - App.terminalInstance.sendInterrupt() + App.terminalManager.activeTerminal?.sendInterrupt() }, label: { Text("^C") @@ -34,7 +42,7 @@ private struct ToolbarView: View { Button( action: { - App.terminalInstance.reset() + App.terminalManager.activeTerminal?.reset() }, label: { Image(systemName: "trash") @@ -45,13 +53,13 @@ private struct ToolbarView: View { } private struct _TerminalView: UIViewRepresentable { - var implementation: TerminalInstance + let terminal: TerminalInstance @EnvironmentObject var App: MainApp private func injectBarButtons(webView: WebViewBase) { let toolbar = UIHostingController( - rootView: TerminalKeyboardToolBar().environmentObject(App)) + rootView: TerminalKeyboardToolBar(terminalId: terminal.id).environmentObject(App)) toolbar.view.frame = CGRect( x: 0, y: 0, width: (webView.bounds.width), height: 40) @@ -63,63 +71,97 @@ private struct _TerminalView: UIViewRepresentable { } func makeUIView(context: Context) -> UIView { - if implementation.options.toolbarEnabled { - injectBarButtons(webView: implementation.webView) + if terminal.options.toolbarEnabled { + injectBarButtons(webView: terminal.webView) } - return implementation.webView + return terminal.webView } func updateUIView(_ uiView: UIView, context: Context) { - if implementation.options.toolbarEnabled { - injectBarButtons(webView: implementation.webView) + if terminal.options.toolbarEnabled { + injectBarButtons(webView: terminal.webView) } else { - removeBarButtons(webView: implementation.webView) + removeBarButtons(webView: terminal.webView) } } - } -private struct TerminalView: View { +private struct MultiTerminalView: View { @EnvironmentObject var App: MainApp @AppStorage("consoleFontSize") var consoleFontSize: Int = 14 + private func fitTerminalIfReady(_ terminal: TerminalInstance) { + guard terminal.isReady else { return } + terminal.executeScript("fitAddon.fit()") + } + var body: some View { - ZStack { - _TerminalView(implementation: App.terminalInstance) - .onTapGesture { - let notification = Notification( - name: Notification.Name("terminal.focus"), - userInfo: ["sceneIdentifier": App.sceneIdentifier] - ) - NotificationCenter.default.post(notification) + HStack(spacing: 0) { + ZStack { + ForEach(App.terminalManager.terminals) { terminal in + let isActive = terminal.id == App.terminalManager.activeTerminalId + _TerminalView(terminal: terminal) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .accessibilityHidden(!isActive) } - .onReceive( - NotificationCenter.default.publisher( - for: Notification.Name("editor.focus"), - object: nil), - perform: { notification in - App.terminalInstance.blur() - } - ) - .onReceive( - NotificationCenter.default.publisher( - for: Notification.Name("terminal.focus"), - object: nil), - perform: { notification in - guard - let sceneIdentifier = notification.userInfo?["sceneIdentifier"] - as? UUID, - sceneIdentifier != App.sceneIdentifier - else { return } - App.terminalInstance.blur() - } + } + .contentShape(Rectangle()) + .onTapGesture { + let notification = Notification( + name: Notification.Name("terminal.focus"), + userInfo: ["sceneIdentifier": App.sceneIdentifier] ) - .onAppear(perform: { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - App.terminalInstance.executeScript("fitAddon.fit()") - } - }) + NotificationCenter.default.post(notification) + } + .onReceive( + NotificationCenter.default.publisher( + for: Notification.Name("editor.focus"), + object: nil), + perform: { _ in + App.terminalManager.activeTerminal?.blur() + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: Notification.Name("terminal.focus"), + object: nil), + perform: { notification in + guard + let sceneIdentifier = notification.userInfo?["sceneIdentifier"] as? UUID, + sceneIdentifier != App.sceneIdentifier + else { return } + App.terminalManager.activeTerminal?.blur() + } + ) + .onAppear(perform: { + guard let terminal = App.terminalManager.activeTerminal else { return } + fitTerminalIfReady(terminal) + }) + .onChange(of: App.terminalManager.activeTerminalId) { _ in + guard let terminal = App.terminalManager.activeTerminal else { + return + } + fitTerminalIfReady(terminal) + } + .onReceive( + NotificationCenter.default.publisher(for: .terminalDidInitialize), + perform: { notification in + guard + let terminal = notification.object as? TerminalInstance, + terminal.id == App.terminalManager.activeTerminalId + else { return } + fitTerminalIfReady(terminal) + } + ) + + // Tab bar on the right (only show if more than one terminal) + if App.terminalManager.terminals.count > 1 { + TerminalTabBar() + .transition(.move(edge: .trailing).combined(with: .opacity)) + } } + .animation(.easeInOut(duration: 0.2), value: App.terminalManager.terminals.count > 1) .foregroundColor(.clear) } }