From 6f610d8c526b8937293a795144dc1421e7bf1989 Mon Sep 17 00:00:00 2001 From: Thales Matheus <1417625@sga.pucminas.br> Date: Mon, 19 Jan 2026 00:31:13 -0600 Subject: [PATCH 1/2] Improve terminal management and cleanup logic Refactors terminal data routing to use a tracked remote terminal for consistency, adds a cleanup method to TerminalInstance for resource management, and enhances TerminalManager with unique terminal naming and remote terminal tracking. Also updates TerminalTabBar for better accessibility and code organization, and removes the unused 'Go to Parent Folder' action from ExplorerFileTreeSection. Add multi-terminal support with TerminalManager Introduces TerminalManager to manage multiple terminal instances, refactors codebase to use terminalManager instead of a single terminalInstance, and adds a TerminalTabBar UI for switching between terminals. Updates related views, containers, and extensions to support multi-terminal workflows, including keyboard toolbar and local execution. Maintains backward compatibility for terminalInstance references and ensures UI updates on terminal changes. Improve terminal management and UX for busy terminals Adds confirmation dialog when killing a terminal with a running process, improves cleanup of terminal resources, and optimizes rendering to only show the active terminal. Also refactors terminal options handling and enhances data routing for remote terminals. Improve terminal management and add logging Refactored terminal options loading to use a helper for safer initialization. Added main thread assertions in TerminalManager for thread safety in debug builds. Enhanced terminal naming with localization support and improved duplicate handling. Introduced os.log-based logging for dropped remote terminal data and general terminal management events. Improve terminal naming and UI interactions TerminalManager now generates unique terminal names by reusing gaps from closed terminals, ensuring the lowest available number is used. Terminal actions in ToolbarView now target the active terminal, and MultiTerminalView adds a smooth animation for the tab bar when multiple terminals are present. Add session identifier and service provider support Executor now accepts a customizable sessionIdentifier, allowing unique identification for each terminal instance. TerminalManager tracks and propagates a TerminalServiceProvider to all TerminalInstance objects, improving remote connection handling and service management. Improve terminal management logging and error handling Adds detailed logging to terminal creation, closing, and switching in TerminalManager for better traceability. Enhances error handling in LocalExecutionExtension by providing user notifications for missing or busy executors. Refactors TerminalExtension to avoid repeated lookups of the active terminal when executing scripts. Improve terminal initialization and accessibility handling Adds a displayName property to Executor.State for better user messages, ensures terminal fitAddon is called only when ready, and posts a notification when a terminal is initialized. Refines accessibility labels for terminal tabs and improves active terminal management logic. Also prevents redundant open editor configuration in MainApp. Refactor terminal management and rendering logic Updated TerminalManager to set the terminal service provider only on the active terminal and improved remote terminal tracking. Refactored MultiTerminalView to render all terminals in a ZStack, showing only the active one, to support better view transitions and state management. Improve terminal management and accessibility handling Refactored terminal cleanup and service provider logic for better resource management and reliability. Enhanced terminal naming to avoid duplicates and added logging for failed active terminal assignments. Updated accessibility label construction in TerminalTabBar for clarity. Fixed command evaluation in LocalExecutionExtension to use the correct executor. --- Code.xcodeproj/project.pbxproj | 12 + CodeApp/Containers/MainScene.swift | 6 +- CodeApp/Containers/RemoteContainer.swift | 5 +- CodeApp/Managers/Executor.swift | 20 +- CodeApp/Managers/MainApp.swift | 69 +++- CodeApp/Managers/TerminalInstance.swift | 40 ++- CodeApp/Managers/TerminalManager.swift | 295 ++++++++++++++++++ CodeApp/Views/ActivityBar.swift | 2 +- CodeApp/Views/TerminalKeyboardToolbar.swift | 48 +-- CodeApp/Views/TerminalTabBar.swift | 133 ++++++++ .../LocalExecutionExtension.swift | 24 +- .../TerminalService/TerminalExtension.swift | 134 +++++--- 12 files changed, 694 insertions(+), 94 deletions(-) create mode 100644 CodeApp/Managers/TerminalManager.swift create mode 100644 CodeApp/Views/TerminalTabBar.swift 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..575378796 100644 --- a/CodeApp/Managers/MainApp.swift +++ b/CodeApp/Managers/MainApp.swift @@ -7,11 +7,14 @@ import Combine import CoreSpotlight +import os.log import SwiftGit2 import SwiftUI import UniformTypeIdentifiers import ios_system +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Code", category: "MainApp") + struct CheckoutDestination: Identifiable { var id = UUID() var reference: ReferenceType @@ -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,13 @@ 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 +321,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 +982,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..2f8fc289b --- /dev/null +++ b/CodeApp/Managers/TerminalManager.swift @@ -0,0 +1,295 @@ +// +// 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..998c8b6d9 --- /dev/null +++ b/CodeApp/Views/TerminalTabBar.swift @@ -0,0 +1,133 @@ +// +// 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) } } From 1d1df5dca5bfd05b833f695eff8bdfac8feb2182 Mon Sep 17 00:00:00 2001 From: Thales Matheus <1417625@sga.pucminas.br> Date: Mon, 19 Jan 2026 03:16:32 -0600 Subject: [PATCH 2/2] Reformat long lines for readability in terminal code Refactored several long lines in MainApp.swift, TerminalManager.swift, and TerminalTabBar.swift to improve readability and maintain consistent code style. No functional changes were made. --- CodeApp/Managers/MainApp.swift | 6 +++-- CodeApp/Managers/TerminalManager.swift | 37 ++++++++++++++++++-------- CodeApp/Views/TerminalTabBar.swift | 15 ++++++++--- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/CodeApp/Managers/MainApp.swift b/CodeApp/Managers/MainApp.swift index 575378796..867cdb201 100644 --- a/CodeApp/Managers/MainApp.swift +++ b/CodeApp/Managers/MainApp.swift @@ -7,11 +7,11 @@ import Combine import CoreSpotlight -import os.log import SwiftGit2 import SwiftUI import UniformTypeIdentifiers import ios_system +import os.log private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Code", category: "MainApp") @@ -268,7 +268,9 @@ class MainApp: ObservableObject { if let terminal = self.terminalManager.remoteTerminal { terminal.write(data: data) } else { - logger.warning("Remote terminal data dropped: no remote terminal available (\(data.count) bytes)") + logger.warning( + "Remote terminal data dropped: no remote terminal available (\(data.count) bytes)" + ) } } loadRepository(url: rootDir) diff --git a/CodeApp/Managers/TerminalManager.swift b/CodeApp/Managers/TerminalManager.swift index 2f8fc289b..c2553dcbe 100644 --- a/CodeApp/Managers/TerminalManager.swift +++ b/CodeApp/Managers/TerminalManager.swift @@ -8,7 +8,8 @@ import SwiftUI import os.log -private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Code", category: "TerminalManager") +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. @@ -35,8 +36,9 @@ class TerminalManager: ObservableObject { /// 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) { + let data = rawValue.data(using: .utf8), + let decoded = try? JSONDecoder().decode(TerminalOptions.self, from: data) + { return decoded } return TerminalOptions() @@ -128,7 +130,8 @@ class TerminalManager: ObservableObject { while existingNames.contains(candidateName) && suffix < maxAttempts { suffix += 1 candidateName = String( - format: NSLocalizedString("%@ (%d)", comment: "Terminal name with duplicate suffix"), + format: NSLocalizedString( + "%@ (%d)", comment: "Terminal name with duplicate suffix"), baseName, suffix ) } @@ -151,10 +154,13 @@ class TerminalManager: ObservableObject { 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))") + 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") + precondition( + !terminals.isEmpty, "TerminalManager must always have at least one terminal") return activeTerminal ?? terminals.first! } @@ -162,7 +168,9 @@ class TerminalManager: ObservableObject { 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)") + logger.info( + "created terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)" + ) return terminal } @@ -170,7 +178,9 @@ class TerminalManager: ObservableObject { 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)") + logger.debug( + "close blocked: last terminal (count: \(self.terminals.count, privacy: .public)) id: \(id, privacy: .public)" + ) return } @@ -193,7 +203,9 @@ class TerminalManager: ObservableObject { terminals.remove(at: index) syncRemoteTerminalId() - logger.info("closed terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)") + logger.info( + "closed terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)" + ) } /// Check if a terminal has a running process @@ -209,7 +221,9 @@ class TerminalManager: ObservableObject { return } setActiveTerminalId(terminal.id) - logger.info("switched terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)") + logger.info( + "switched terminal name: \(terminal.name, privacy: .public) id: \(terminal.id, privacy: .public)" + ) } func renameTerminal(id: UUID, name: String) { @@ -286,7 +300,8 @@ class TerminalManager: ObservableObject { } if let currentId = remoteTerminalId, - terminals.contains(where: { $0.id == currentId && $0.terminalServiceProvider != nil }) { + terminals.contains(where: { $0.id == currentId && $0.terminalServiceProvider != nil }) + { return } diff --git a/CodeApp/Views/TerminalTabBar.swift b/CodeApp/Views/TerminalTabBar.swift index 998c8b6d9..118e462cb 100644 --- a/CodeApp/Views/TerminalTabBar.swift +++ b/CodeApp/Views/TerminalTabBar.swift @@ -83,7 +83,9 @@ struct TerminalTabRow: View { // Icon Image(systemName: "terminal") .font(.system(size: TerminalTabBarConstants.iconSize)) - .foregroundColor(isActive ? Color(id: "list.activeSelectionForeground") : Color(id: "foreground")) + .foregroundColor( + isActive ? Color(id: "list.activeSelectionForeground") : Color(id: "foreground") + ) .frame(width: 20, height: 20) Spacer() } @@ -106,7 +108,11 @@ struct TerminalTabRow: View { .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")) + .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) { @@ -127,7 +133,10 @@ struct TerminalTabRow: View { onClose() } } message: { - Text(NSLocalizedString("This terminal has a running process. Are you sure you want to kill it?", comment: "")) + Text( + NSLocalizedString( + "This terminal has a running process. Are you sure you want to kill it?", + comment: "")) } } }