-
Notifications
You must be signed in to change notification settings - Fork 308
Multi-terminal feature #1302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Multi-terminal feature #1302
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<AnyCancellable>() | ||
| 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) | ||
|
Comment on lines
325
to
+334
|
||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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() | ||||
|
||||
| webView.configuration.userContentController.removeAllScriptMessageHandlers() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The backward compatibility property
terminalInstancereturns the active terminal, which could be nil (sinceactiveTerminalis optional), but the property is declared as an implicitly unwrapped optionalTerminalInstance!. This could lead to crashes if code expects a non-nil value but no active terminal exists. While the TerminalManager ensures at least one terminal exists after initialization, there might be edge cases during cleanup or state transitions where this assumption breaks. Consider either making this a regular optionalTerminalInstance?to force callers to handle the nil case explicitly, or add documentation clarifying the invariant that this will never be nil after initialization.