diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index f4cd4ece6..ad15e3bf3 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -8,7 +8,7 @@ concurrency: jobs: deploy: name: Deploying to App Store - runs-on: macOS-15 + runs-on: macOS-26 steps: - name: Checkout repository @@ -38,4 +38,4 @@ jobs: JUDGE0_KEY: "${{ secrets.JUDGE0_KEY }}" JUDGE0_ENDPOINT: "${{ secrets.JUDGE0_ENDPOINT }}" DISTRIBUTE_EXTERNAL: "false" - XCODE_PATH: "/Applications/Xcode_26.0.app" + XCODE_PATH: "/Applications/Xcode_26.2.app" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30fe17da1..67043a68a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,12 @@ on: - "*" - "!gitbook" +permissions: + contents: read + jobs: Linting: - runs-on: macOS-15 + runs-on: macOS-26 steps: - uses: actions/checkout@v3 - name: Install swift-format @@ -17,4 +20,4 @@ jobs: - name: Unit Tests run: fastlane tests env: - XCODE_PATH: "/Applications/Xcode_26.0.app" + XCODE_PATH: "/Applications/Xcode_26.2.app" diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 6b7bec9c4..21e96acfc 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -753,6 +753,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 */; }; 9484BDD12B46FAEE003BCB8A /* injection.js in Resources */ = {isa = PBXBuildFile; fileRef = 9484BDD02B46FAEE003BCB8A /* injection.js */; }; 948A5FE92A87B09500303C12 /* NSError+init.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948A5FE82A87B09500303C12 /* NSError+init.swift */; }; 948A5FEA2A87B09500303C12 /* NSError+init.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948A5FE82A87B09500303C12 /* NSError+init.swift */; }; @@ -803,6 +805,8 @@ 94A7781E257BC473008FE7B2 /* ArchiveDir.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7781D257BC473008FE7B2 /* ArchiveDir.swift */; }; 94A77820257BC5AA008FE7B2 /* MainScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7781F257BC5AA008FE7B2 /* MainScene.swift */; }; 94A7782D257BCEE2008FE7B2 /* getRootDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7782C257BCEE2008FE7B2 /* getRootDirectory.swift */; }; + 94A77831257BD681008FE7B2 /* TerminalTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */; }; + 94A77832257BD682008FE7B2 /* TerminalTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */; }; 94A7FFB3268D085300369147 /* BottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7FFB2268D085300369147 /* BottomBar.swift */; }; 94AA00022E7F886A00BBCC02 /* node.wasm in Resources */ = {isa = PBXBuildFile; fileRef = 94AA00012E7F886A00BBCC01 /* node.wasm */; }; 94AA00032E7F886A00BBCC03 /* node.wasm in Resources */ = {isa = PBXBuildFile; fileRef = 94AA00012E7F886A00BBCC01 /* node.wasm */; }; @@ -1982,6 +1986,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 = ""; }; 9484BDD02B46FAEE003BCB8A /* injection.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = injection.js; sourceTree = ""; }; 948A5FE82A87B09500303C12 /* NSError+init.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+init.swift"; sourceTree = ""; }; 948D11FE2583F20C008F877A /* extraCommandsDictionary.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = extraCommandsDictionary.plist; sourceTree = ""; }; @@ -2022,6 +2027,7 @@ 94A7781D257BC473008FE7B2 /* ArchiveDir.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveDir.swift; sourceTree = ""; }; 94A7781F257BC5AA008FE7B2 /* MainScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScene.swift; sourceTree = ""; }; 94A7782C257BCEE2008FE7B2 /* getRootDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = getRootDirectory.swift; sourceTree = ""; }; + 94A77830257BD681008FE7B2 /* TerminalTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTabBar.swift; sourceTree = ""; }; 94A7FFB2268D085300369147 /* BottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomBar.swift; sourceTree = ""; }; 94AA00012E7F886A00BBCC01 /* node.wasm */ = {isa = PBXFileReference; lastKnownFileType = file; path = node.wasm; sourceTree = ""; }; 94B3D5D525F776A900C4F2B1 /* cacert.pem */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = cacert.pem; sourceTree = ""; }; @@ -3126,72 +3132,73 @@ 9F3C2DEF291CAD2200BFF14C /* Views */ = { isa = PBXGroup; children = ( - 1C4C8DCD2EA89040008E5E33 /* ActivityBar.swift */, - 1C4C8DCE2EA89040008E5E33 /* ActivityBarItemView.swift */, - 1C4C8DCF2EA89040008E5E33 /* ChatView.swift */, - 1C4C8DD02EA89040008E5E33 /* CompactSidebar.swift */, - 1C4C8DD12EA89040008E5E33 /* DebugMenu.swift */, - 1C4C8DD22EA89040008E5E33 /* DescriptionText.swift */, - F97209442F4638C200DA803A /* DebuggerSidebarView.swift */, - 1C4C8DD32EA89040008E5E33 /* DirectoryPickerView.swift */, - 1C4C8DD42EA89040008E5E33 /* DocumentPickerView.swift */, - 1C4C8DD52EA89040008E5E33 /* EditorContextMenu.swift */, - 1C4C8DD62EA89040008E5E33 /* EditorKeyboardToolBar.swift */, - 1C4C8DD72EA89040008E5E33 /* EditorTab.swift */, - 1C4C8DD82EA89040008E5E33 /* EditorTabs.swift */, - 1C4C8DD92EA89040008E5E33 /* EditorView.swift */, - 1C4C8DDA2EA89040008E5E33 /* ExplorerCell.swift */, - 1C4C8DDB2EA89040008E5E33 /* ExplorerEditorListSection.swift */, - 1C4C8DDC2EA89040008E5E33 /* ExplorerFileTreeSection.swift */, - 1C4C8DDD2EA89040008E5E33 /* ExtensionSettingsView.swift */, - 1C4C8DDE2EA89040008E5E33 /* FileDisplayName.swift */, - 1C4C8DDF2EA89040008E5E33 /* FileIcon.swift */, - 1C4C8DE02EA89040008E5E33 /* FileTreeView.swift */, - 1C4C8DE12EA89040008E5E33 /* GitHubSearchView.swift */, - 1C4C8DE22EA89040008E5E33 /* HierarchyList.swift */, - 1C4C8DE32EA89040008E5E33 /* HighlightedText.swift */, - 1C4C8DE42EA89040008E5E33 /* InfiniteProgressView.swift */, - 1C4C8DE52EA89040008E5E33 /* MarkdownView.swift */, - 1C4C8DE62EA89040008E5E33 /* MenuButtonView.swift */, - 1C4C8DE72EA89040008E5E33 /* ModelSettingsView.swift */, - 1C4C8DE82EA89040008E5E33 /* NewFileView.swift */, - 1C4C8DE92EA89040008E5E33 /* NotificationCentreView.swift */, - 1C4C8DEA2EA89040008E5E33 /* PanelSelector.swift */, - 1C4C8DEB2EA89040008E5E33 /* PanelView.swift */, - 1C4C8DEC2EA89040008E5E33 /* RegularSidebar.swift */, - 1C4C8DED2EA89040008E5E33 /* RemoteConnectedSection.swift */, - 1C4C8DEE2EA89040008E5E33 /* RemoteCreateSection.swift */, - 1C4C8DEF2EA89040008E5E33 /* RemoteHostCell.swift */, - 1C4C8DF02EA89040008E5E33 /* RemoteImage.swift */, - 1C4C8DF12EA89040008E5E33 /* RemoteListSection.swift */, - 1C4C8DF22EA89040008E5E33 /* RemoteTypeLabel.swift */, - 1C4C8DF32EA89040008E5E33 /* RightPanelView.swift */, - 1C4C8DF42EA89040008E5E33 /* SafariView.swift */, - 1C4C8DF52EA89040008E5E33 /* SearchBar.swift */, - 1C4C8DF62EA89040008E5E33 /* SearchResultsSection.swift */, - 1C4C8DF72EA89040008E5E33 /* SearchSection.swift */, - 1C4C8DF82EA89040008E5E33 /* SearchUnsupportedSection.swift */, - 1C4C8DF92EA89040008E5E33 /* SettingsFontPicker.swift */, - 1C4C8DFA2EA89040008E5E33 /* SettingsKeyboardShortcuts.swift */, - 1C4C8DFB2EA89040008E5E33 /* SettingsThemeConfiguration.swift */, - 1C4C8DFC2EA89040008E5E33 /* SettingsView.swift */, - 1C4C8DFD2EA89040008E5E33 /* SideBarButton.swift */, - 1C4C8DFE2EA89040008E5E33 /* SourceControlAuthenticationConfiguration.swift */, - 1C4C8DFF2EA89040008E5E33 /* SourceControlCloneSection.swift */, - 1C4C8E002EA89040008E5E33 /* SourceControlEmptySection.swift */, - 1C4C8E012EA89040008E5E33 /* SourceControlEntry.swift */, - 1C4C8E022EA89040008E5E33 /* SourceControlIdentityConfiguration.swift */, - 1C4C8E032EA89040008E5E33 /* SourceControlSection.swift */, - 1C4C8E042EA89040008E5E33 /* SourceControlTemplateSection.swift */, - 1C4C8E052EA89040008E5E33 /* SourceControlTextFieldAlert.swift */, - 1C4C8E062EA89040008E5E33 /* SourceControlUnsupportedSection.swift */, - 1C4C8E072EA89040008E5E33 /* TableView.swift */, - 1C4C8E082EA89040008E5E33 /* TerminalKeyboardToolbar.swift */, - 1C4C8E092EA89040008E5E33 /* TextEditorWithPlaceholder.swift */, - 1C4C8E0A2EA89040008E5E33 /* TopBar.swift */, - 1C4C8E0B2EA89040008E5E33 /* ViewRepresentable.swift */, - AAB10005302B000100AABB01 /* VibeCodingView.swift */, - 1C4C8E0C2EA89040008E5E33 /* VISXPackageManager.swift */, +1C4C8DCD2EA89040008E5E33 /* ActivityBar.swift */, +1C4C8DCE2EA89040008E5E33 /* ActivityBarItemView.swift */, +1C4C8DCF2EA89040008E5E33 /* ChatView.swift */, +1C4C8DD02EA89040008E5E33 /* CompactSidebar.swift */, +1C4C8DD12EA89040008E5E33 /* DebugMenu.swift */, +1C4C8DD22EA89040008E5E33 /* DescriptionText.swift */, +F97209442F4638C200DA803A /* DebuggerSidebarView.swift */, +1C4C8DD32EA89040008E5E33 /* DirectoryPickerView.swift */, +1C4C8DD42EA89040008E5E33 /* DocumentPickerView.swift */, +1C4C8DD52EA89040008E5E33 /* EditorContextMenu.swift */, +1C4C8DD62EA89040008E5E33 /* EditorKeyboardToolBar.swift */, +1C4C8DD72EA89040008E5E33 /* EditorTab.swift */, +1C4C8DD82EA89040008E5E33 /* EditorTabs.swift */, +1C4C8DD92EA89040008E5E33 /* EditorView.swift */, +1C4C8DDA2EA89040008E5E33 /* ExplorerCell.swift */, +1C4C8DDB2EA89040008E5E33 /* ExplorerEditorListSection.swift */, +1C4C8DDC2EA89040008E5E33 /* ExplorerFileTreeSection.swift */, +1C4C8DDD2EA89040008E5E33 /* ExtensionSettingsView.swift */, +1C4C8DDE2EA89040008E5E33 /* FileDisplayName.swift */, +1C4C8DDF2EA89040008E5E33 /* FileIcon.swift */, +1C4C8DE02EA89040008E5E33 /* FileTreeView.swift */, +1C4C8DE12EA89040008E5E33 /* GitHubSearchView.swift */, +1C4C8DE22EA89040008E5E33 /* HierarchyList.swift */, +1C4C8DE32EA89040008E5E33 /* HighlightedText.swift */, +1C4C8DE42EA89040008E5E33 /* InfiniteProgressView.swift */, +1C4C8DE52EA89040008E5E33 /* MarkdownView.swift */, +1C4C8DE62EA89040008E5E33 /* MenuButtonView.swift */, +1C4C8DE72EA89040008E5E33 /* ModelSettingsView.swift */, +1C4C8DE82EA89040008E5E33 /* NewFileView.swift */, +1C4C8DE92EA89040008E5E33 /* NotificationCentreView.swift */, +1C4C8DEA2EA89040008E5E33 /* PanelSelector.swift */, +1C4C8DEB2EA89040008E5E33 /* PanelView.swift */, +1C4C8DEC2EA89040008E5E33 /* RegularSidebar.swift */, +1C4C8DED2EA89040008E5E33 /* RemoteConnectedSection.swift */, +1C4C8DEE2EA89040008E5E33 /* RemoteCreateSection.swift */, +1C4C8DEF2EA89040008E5E33 /* RemoteHostCell.swift */, +1C4C8DF02EA89040008E5E33 /* RemoteImage.swift */, +1C4C8DF12EA89040008E5E33 /* RemoteListSection.swift */, +1C4C8DF22EA89040008E5E33 /* RemoteTypeLabel.swift */, +1C4C8DF32EA89040008E5E33 /* RightPanelView.swift */, +1C4C8DF42EA89040008E5E33 /* SafariView.swift */, +1C4C8DF52EA89040008E5E33 /* SearchBar.swift */, +1C4C8DF62EA89040008E5E33 /* SearchResultsSection.swift */, +1C4C8DF72EA89040008E5E33 /* SearchSection.swift */, +1C4C8DF82EA89040008E5E33 /* SearchUnsupportedSection.swift */, +1C4C8DF92EA89040008E5E33 /* SettingsFontPicker.swift */, +1C4C8DFA2EA89040008E5E33 /* SettingsKeyboardShortcuts.swift */, +1C4C8DFB2EA89040008E5E33 /* SettingsThemeConfiguration.swift */, +1C4C8DFC2EA89040008E5E33 /* SettingsView.swift */, +1C4C8DFD2EA89040008E5E33 /* SideBarButton.swift */, +1C4C8DFE2EA89040008E5E33 /* SourceControlAuthenticationConfiguration.swift */, +1C4C8DFF2EA89040008E5E33 /* SourceControlCloneSection.swift */, +1C4C8E002EA89040008E5E33 /* SourceControlEmptySection.swift */, +1C4C8E012EA89040008E5E33 /* SourceControlEntry.swift */, +1C4C8E022EA89040008E5E33 /* SourceControlIdentityConfiguration.swift */, +1C4C8E032EA89040008E5E33 /* SourceControlSection.swift */, +1C4C8E042EA89040008E5E33 /* SourceControlTemplateSection.swift */, +1C4C8E052EA89040008E5E33 /* SourceControlTextFieldAlert.swift */, +1C4C8E062EA89040008E5E33 /* SourceControlUnsupportedSection.swift */, +1C4C8E072EA89040008E5E33 /* TableView.swift */, +1C4C8E082EA89040008E5E33 /* TerminalKeyboardToolbar.swift */, +94A77830257BD681008FE7B2 /* TerminalTabBar.swift */, +1C4C8E092EA89040008E5E33 /* TextEditorWithPlaceholder.swift */, +1C4C8E0A2EA89040008E5E33 /* TopBar.swift */, +1C4C8E0B2EA89040008E5E33 /* ViewRepresentable.swift */, +AAB10005302B000100AABB01 /* VibeCodingView.swift */, +1C4C8E0C2EA89040008E5E33 /* VISXPackageManager.swift */, ); path = Views; sourceTree = ""; @@ -3326,6 +3333,7 @@ 94A777BD257B74EE008FE7B2 /* NotificationManager.swift */, 942ACB9C281312900067114D /* SpellChecker.swift */, 94801F92266FB5E400B29D80 /* TerminalInstance.swift */, + 94801F96266FB5E500B29D80 /* TerminalManager.swift */, 9F046C312922203E00BDE4E9 /* ToolbarManager.swift */, 9F046C3429222D8E00BDE4E9 /* ExtensionManager.swift */, 9FA1225B2A8B209500E7B417 /* CodeAppContributionPointManager.swift */, @@ -3927,6 +3935,7 @@ 1C4C8E932EA89089008E5E33 /* ChatExtension.swift in Sources */, AAB10001302B000100AABB01 /* VibeCodingView.swift in Sources */, 94E6CC7C2806FBAF00939E4F /* SFTPFileSystemProvider.swift in Sources */, + 94A77832257BD682008FE7B2 /* TerminalTabBar.swift in Sources */, 94196974280316C7008AAEB2 /* LocalGitServiceProvider.swift in Sources */, 9474D2982B6B4B1300CCC530 /* EditorImplemenationView.swift in Sources */, 94196976280316C7008AAEB2 /* SearchContainer.swift in Sources */, @@ -3963,6 +3972,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 */, 9FD5BCED292495DF00F20C4B /* DefaultUIState.swift in Sources */, 9419698B280316C7008AAEB2 /* EditorInstance.swift in Sources */, @@ -4149,6 +4159,7 @@ 94F52707259F9B4800337306 /* KeychainWrapper.swift in Sources */, 942ACB9D281312900067114D /* SpellChecker.swift in Sources */, 94E6CC7B2806FBAF00939E4F /* SFTPFileSystemProvider.swift in Sources */, + 94A77831257BD681008FE7B2 /* TerminalTabBar.swift in Sources */, 944FC3FF25C543CD00C7C43C /* LocalGitServiceProvider.swift in Sources */, 9474D2972B6B4B1300CCC530 /* EditorImplemenationView.swift in Sources */, 94A777D2257B8A32008FE7B2 /* SearchContainer.swift in Sources */, @@ -4185,6 +4196,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 */, 9FD5BCEC292495DF00F20C4B /* DefaultUIState.swift in Sources */, 94A77796257ABB69008FE7B2 /* EditorInstance.swift in Sources */, @@ -4291,7 +4303,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; OTHER_SWIFT_FLAGS = "-Xcc -Wno-incomplete-umbrella"; PRODUCT_BUNDLE_IDENTIFIER = "vickiegpt.VS-Code"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4340,7 +4352,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; OTHER_SWIFT_FLAGS = "-Xcc -Wno-incomplete-umbrella"; PRODUCT_BUNDLE_IDENTIFIER = "vickiegpt.VS-Code"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4591,7 +4603,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; OTHER_SWIFT_FLAGS = "-Xcc -Wno-incomplete-umbrella"; PRODUCT_BUNDLE_IDENTIFIER = "vickiegpt.VS-Code"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4639,7 +4651,7 @@ "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; OTHER_SWIFT_FLAGS = "-Xcc -Wno-incomplete-umbrella"; PRODUCT_BUNDLE_IDENTIFIER = "vickiegpt.VS-Code"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/CodeApp/Containers/MainScene.swift b/CodeApp/Containers/MainScene.swift index 876cdd28c..23f2c8f39 100644 --- a/CodeApp/Containers/MainScene.swift +++ b/CodeApp/Containers/MainScene.swift @@ -120,7 +120,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) } ) } @@ -155,7 +155,7 @@ private struct MainView: View { panelHeight = 200 } isPanelVisible.toggle() - App.terminalInstance.webView.becomeFirstResponder() + App.terminalManager.activeTerminal?.webView.becomeFirstResponder() } var body: some View { @@ -239,7 +239,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..7dfa764a2 100644 --- a/CodeApp/Containers/RemoteContainer.swift +++ b/CodeApp/Containers/RemoteContainer.swift @@ -176,11 +176,7 @@ struct RemoteContainer: View { continuation.resume(throwing: error) } else { DispatchQueue.main.async { - App.loadRepository(url: hostUrl) - App.notificationManager.showInformationMessage( - "remote.connected") - App.terminalInstance.terminalServiceProvider = - App.workSpaceStorage.terminalServiceProvider + App.loadFolder(url: hostUrl) } continuation.resume(returning: ()) } diff --git a/CodeApp/Localization/de.lproj/Localizable.strings b/CodeApp/Localization/de.lproj/Localizable.strings index 8e56b85c0..67b45c275 100644 --- a/CodeApp/Localization/de.lproj/Localizable.strings +++ b/CodeApp/Localization/de.lproj/Localizable.strings @@ -8,7 +8,7 @@ "Welcome Message" = "# CodifyOne -##### **v1.11.0 (Oktober 2025)** +##### **v1.12.0 (Februar 2026)** #### Start [Neue Datei](https://thebaselab.com/code/newfile) [Datei öffnen](https://thebaselab.com/code/openfile) @@ -22,7 +22,14 @@ "; "Changelog.message" = -" +"### 1.12.0 (February 2026) +- Unterstützung für mehrere Terminals +- Unterstützung für Terminal‑Modifikatortasten +- Besonderer Dank an @ThalesMMS für den Beitrag + +### 1.11.0 (Januar 2026) +- Liquid Glass-Benutzeroberfläche übernommen + ### 1.10.4 (September 2025) - Fehlerbehebungen im Zusammenhang mit dem Diff-Editor @@ -224,13 +231,15 @@ --- -### v1.2.3 (April 2021) +### 1.12.0 (Februar 2026) +- Unterstützung für mehrere Terminals +- Unterstützung für Terminal‑Modifikatortasten +- Besonderer Dank an @ThalesMMS für den Beitrag + - Editor - 6 Additional Color Themes - Read-only mode - - Built-in types for Node.js - Support for @types definitions -- Explorer - Multi-file searching - Python Runtime - Built-in Matplotlib, Pandas @@ -663,4 +672,13 @@ "remote.port_forward.configure_description" = "Um eine Portweiterleitung zu konfigurieren, geben Sie die lokale und die Remote-Adresse ein. Wenn Sie eine Adresse eingeben, wird der Port 22 verwendet."; "remote.port_forward.address_example" = "z.B. 6000 oder 127.0.0.1:6000"; "remote.settings.ssh_remote" = "SSH Remote"; -"remote.settings.resolve_home_path" = "Pfad auflösen"; \ No newline at end of file +"remote.settings.resolve_home_path" = "Pfad auflösen"; + +"terminal.tab.accessibility.active" = "Aktiv"; +"terminal.tab.accessibility.running" = "Läuft"; +"terminal.tab.accessibility.hint" = "Tippen, um zu diesem Terminal zu wechseln"; +"terminal.tab.kill" = "Terminal beenden"; +"terminal.tab.kill_confirmation.title" = "Terminal beenden?"; +"terminal.tab.kill_confirmation.cancel" = "Abbrechen"; +"terminal.tab.kill_confirmation.kill" = "Beenden"; +"terminal.tab.kill_confirmation.message" = "Dieses Terminal hat einen laufenden Prozess. Möchten Sie ihn wirklich beenden?"; diff --git a/CodeApp/Localization/en.lproj/Localizable.strings b/CodeApp/Localization/en.lproj/Localizable.strings index 38c1e69b9..c213d4609 100644 --- a/CodeApp/Localization/en.lproj/Localizable.strings +++ b/CodeApp/Localization/en.lproj/Localizable.strings @@ -8,7 +8,7 @@ "Welcome Message" = "# CodifyOne -##### **v1.11.0 (October 2025)** +##### **v1.12.0 (February 2026)** #### Start [New file](https://thebaselab.com/code/newfile) [Open file](https://thebaselab.com/code/openfile) @@ -23,6 +23,14 @@ "Changelog.message" = " +### 1.12.0 (February 2026) +- Support multi-terminal +- Support terminal key modifiers +- Special thanks to @ThalesMMS for contribution + +### 1.11.0 (January 2026) +- Adopt Liquid Glass UI + ### 1.10.4 (September 2025) - Bug fixes regarding diff editor @@ -558,3 +566,11 @@ are licensed under [BSD-3-Clause License](https://en.wikipedia.org/wiki/BSD_lice "remote.port_forward.address_example" = "e.g. 6000 or 127.0.0.1:6000"; "remote.settings.ssh_remote" = "SSH Remote"; "remote.settings.resolve_home_path" = "Resolve Home Path"; +"terminal.tab.accessibility.active" = "Active"; +"terminal.tab.accessibility.running" = "Running"; +"terminal.tab.accessibility.hint" = "Tap to switch to this terminal"; +"terminal.tab.kill" = "Kill Terminal"; +"terminal.tab.kill_confirmation.title" = "Kill Terminal?"; +"terminal.tab.kill_confirmation.cancel" = "Cancel"; +"terminal.tab.kill_confirmation.kill" = "Kill"; +"terminal.tab.kill_confirmation.message" = "This terminal has a running process. Are you sure you want to kill it?"; \ No newline at end of file diff --git a/CodeApp/Localization/ja.lproj/Localizable.strings b/CodeApp/Localization/ja.lproj/Localizable.strings index 8184d366d..d9562c4f8 100644 --- a/CodeApp/Localization/ja.lproj/Localizable.strings +++ b/CodeApp/Localization/ja.lproj/Localizable.strings @@ -8,7 +8,7 @@ "Welcome Message" = "# CodifyOne -##### **v1.11.0 (2025年10月)** +##### **v1.12.0 (2026 年 2 月)** #### 開始 [新しいファイル](https://thebaselab.com/code/newfile) [ファイルを開く](https://thebaselab.com/code/openfile) @@ -22,7 +22,14 @@ "; "Changelog.message" = -" +"### 1.12.0 (February 2026) +- マルチターミナルに対応 +- ターミナルの修飾キーに対応 +- @ThalesMMS の貢献に特別感謝 + +### 1.11.0 (2026 年 1 月) +- Liquid Glass UI の採用 + ### 1.10.4 (2025 年 9 月) - diff エディターに関するバグ修正 @@ -225,13 +232,15 @@ --- -### v1.2.3 (2021年 4月) +### 1.12.0 (2026年2月) +- マルチターミナルに対応 +- ターミナルの修飾キーに対応 +- @ThalesMMS の貢献に特別感謝 + - エディター - 6 つの追加カラー テーマ - 読み取り専用モード - - Node.js の組み込み型 - @types 定義のサポート -- エクスプローラー - 複数ファイル検索 - Python ランタイム - 組み込みの Matplotlib、Pandas @@ -663,4 +672,13 @@ "remote.port_forward.configure_description" = "ポートフォワーディングを使用すると、リモート ホストのポートをローカル ホストに転送できます。"; "remote.port_forward.address_example" = "例えば 6000 または 127.0.0.1:6000"; "remote.settings.ssh_remote" = "SSH リモート"; -"remote.settings.resolve_home_path" = "ホーム パスを解決する"; \ No newline at end of file +"remote.settings.resolve_home_path" = "ホーム パスを解決する"; + +"terminal.tab.accessibility.active" = "アクティブ"; +"terminal.tab.accessibility.running" = "実行中"; +"terminal.tab.accessibility.hint" = "タップしてこのターミナルに切り替える"; +"terminal.tab.kill" = "ターミナルを閉じる"; +"terminal.tab.kill_confirmation.title" = "ターミナルを閉じますか?"; +"terminal.tab.kill_confirmation.cancel" = "キャンセル"; +"terminal.tab.kill_confirmation.kill" = "閉じる"; +"terminal.tab.kill_confirmation.message" = "このターミナルは実行中のプロセスを持っています。本当に閉じますか?"; diff --git a/CodeApp/Localization/ko.lproj/Localizable.strings b/CodeApp/Localization/ko.lproj/Localizable.strings index 141537335..549f7dfc0 100644 --- a/CodeApp/Localization/ko.lproj/Localizable.strings +++ b/CodeApp/Localization/ko.lproj/Localizable.strings @@ -8,7 +8,7 @@ "Welcome Message" = "# CodifyOne -##### **v1.11.0 (2025년 10월)** +##### **v1.12.0 (2026년 2월)** #### 시작 [새 파일...](https://thebaselab.com/code/newfile) [파일 열기...](https://thebaselab.com/code/openfile) @@ -22,7 +22,14 @@ "; "Changelog.message" = -" +"### 1.12.0 (February 2026) +- 멀티 터미널 지원 +- 터미널 키 모디파이어 지원 +- 기여해 주신 @ThalesMMS님께 특별한 감사 + +### 1.11.0 (2026년 1월) +- Liquid Glass UI 채택 + ### 1.10.4 (2025년 9월) - diff 에디터 관련 버그 수정 @@ -662,4 +669,13 @@ "remote.port_forward.configure_description" = "원격 시스템의 포트에 액세스하도록 포트 전달을 구성합니다."; "remote.port_forward.address_example" = "예를 들어 6000 또는 127.0.0.1:6000"; "remote.settings.ssh_remote" = "SSH 원격 서버"; -"remote.settings.resolve_home_path" = "홈 경로 해결"; \ No newline at end of file +"remote.settings.resolve_home_path" = "홈 경로 해결"; + +"terminal.tab.accessibility.active" = "활성"; +"terminal.tab.accessibility.running" = "실행 중"; +"terminal.tab.accessibility.hint" = "이 터미널로 전환하려면 탭하세요"; +"terminal.tab.kill" = "터미널 닫기"; +"terminal.tab.kill_confirmation.title" = "터미널을 닫으시겠습니까?"; +"terminal.tab.kill_confirmation.cancel" = "취소"; +"terminal.tab.kill_confirmation.kill" = "닫기"; +"terminal.tab.kill_confirmation.message" = "이 터미널은 실행 중의 프로세스가 있습니다. 정말 닫으시겠습니까?"; diff --git a/CodeApp/Localization/ru.lproj/Localizable.strings b/CodeApp/Localization/ru.lproj/Localizable.strings index 5e8495a37..bd2298493 100644 --- a/CodeApp/Localization/ru.lproj/Localizable.strings +++ b/CodeApp/Localization/ru.lproj/Localizable.strings @@ -8,7 +8,7 @@ "Welcome Message" = "# CodifyOne -##### **v1.11.0 (Октябрь 2025)** +##### **v1.12.0 (Февраль 2026)** #### Начало [Новый файл](https://thebaselab.com/code/newfile) [Открыть файл](https://thebaselab.com/code/openfile) @@ -22,7 +22,14 @@ "; "Changelog.message" = -" +"### 1.12.0 (February 2026) +- Поддержка нескольких терминалов +- Поддержка клавиш-модификаторов терминала +- Особая благодарность @ThalesMMS за вклад + +### 1.11.0 (Январь 2026) +- Принята Liquid Glass UI + ### 1.10.4 (Сентябрь 2025) - Исправлены ошибки, связанные с редактором различий @@ -701,3 +708,11 @@ "remote.port_forward.address_example" = "пр. 6000 или 127.0.0.1:6000"; "remote.settings.ssh_remote" = "SSH-подключение"; "remote.settings.resolve_home_path" = "Открывать домашний каталог"; +"terminal.tab.accessibility.active" = "Активна"; +"terminal.tab.accessibility.running" = "Выполняется"; +"terminal.tab.accessibility.hint" = "Нажмите, чтобы переключиться на этот терминал"; +"terminal.tab.kill" = "Закрыть терминал"; +"terminal.tab.kill_confirmation.title" = "Закрыть терминал?"; +"terminal.tab.kill_confirmation.cancel" = "Отмена"; +"terminal.tab.kill_confirmation.kill" = "Закрыть"; +"terminal.tab.kill_confirmation.message" = "Этот терминал имеет запущенный процесс. Вы уверены, что хотите его закрыть?"; \ No newline at end of file diff --git a/CodeApp/Localization/zh-Hans.lproj/Localizable.strings b/CodeApp/Localization/zh-Hans.lproj/Localizable.strings index 3df3c08e1..efca9c8ae 100644 --- a/CodeApp/Localization/zh-Hans.lproj/Localizable.strings +++ b/CodeApp/Localization/zh-Hans.lproj/Localizable.strings @@ -8,7 +8,7 @@ "Welcome Message" = "# CodifyOne -##### **v1.11.0 (2025 年 10 月)** +##### **v1.12.0 (2026 年 2 月)** #### 开始 [新文件](https://thebaselab.com/code/newfile) [打开文件](https://thebaselab.com/code/openfile) @@ -22,7 +22,14 @@ "; "Changelog.message" = -" +"### 1.12.0 (February 2026) +- 支持多终端 +- 支持终端修饰键 +- 特别感谢 @ThalesMMS 的贡献 + +### 1.11.0 (2026 年 1 月) +- 采用 Liquid Glass 用户界面 + ### 1.10.4 (2025 年 9 月) - 修复了 diff 编辑器相关的错误 @@ -224,13 +231,15 @@ --- -### v1.2.3 (April 2021) +### 1.12.0 (2026年2月) +- 支持多终端 +- 支持终端修饰键 +- 特别感谢 @ThalesMMS 的贡献 + - 编辑器 - 6 个颜色主题 - 只读模式 - - Node.js 的内置类型 - 支持 @types 定义 -- 资源管理器 - 多文件搜索 - Python - 内置 Matplotlib,Pandas @@ -654,3 +663,11 @@ "remote.port_forward.address_example" = "例如 6000 或 127.0.0.1:6000"; "remote.settings.ssh_remote" = "SSH 远程"; "remote.settings.resolve_home_path" = "解析主目录路径"; +"terminal.tab.accessibility.active" = "活跃"; +"terminal.tab.accessibility.running" = "运行中"; +"terminal.tab.accessibility.hint" = "点按以切换到此终端"; +"terminal.tab.kill" = "关闭终端"; +"terminal.tab.kill_confirmation.title" = "关闭终端?"; +"terminal.tab.kill_confirmation.cancel" = "取消"; +"terminal.tab.kill_confirmation.kill" = "关闭"; +"terminal.tab.kill_confirmation.message" = "此终端有正在运行的进程。您确定要关闭它吗?"; \ No newline at end of file diff --git a/CodeApp/Managers/Executor.swift b/CodeApp/Managers/Executor.swift index 956dea59f..58f1045c4 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 @@ -495,3 +498,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/FileSystem/WorkSpaceStorage.swift b/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift index 8443ff8a2..24da45866 100644 --- a/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift +++ b/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift @@ -24,6 +24,7 @@ class WorkSpaceStorage: ObservableObject { private var directoryMonitor = DirectoryMonitor() private var onDirectoryChangeAction: ((String) -> Void)? = nil private var onTerminalDataAction: ((Data) -> Void)? = nil + private var onRemoteDisconnectAction: (() -> Void)? = nil private var directoryStorage: [String: [(FileItemRepresentable)]] = [:] private var fss: [String: FileSystemProvider] = [:] private var isConnecting = false @@ -228,20 +229,24 @@ class WorkSpaceStorage: ObservableObject { fss[currentScheme!] = nil - let documentDir = getRootDirectory() - self.currentDirectory = FileItemRepresentable( - name: documentDir.lastPathComponent, url: documentDir.absoluteString, isDirectory: true) - self.requestDirectoryUpdateAt(id: documentDir.absoluteString) + onRemoteDisconnectAction?() } + /// Assign the callback that fires when a remote server has terminal data incoming func onTerminalData(_ action: @escaping (Data) -> Void) { onTerminalDataAction = action } + /// Assign the callback that fires when files changes are detected in the directory func onDirectoryChange(_ action: @escaping ((String) -> Void)) { onDirectoryChangeAction = action } + /// Assign the callback that fires when a remote server is being disconnected + func onRemoteDisconnect(_ action: @escaping (() -> Void)) { + onRemoteDisconnectAction = action + } + /// Reload the whole directory and invalidate all existing cache func updateDirectory(name: String, url: String) { if url != currentDirectory.url { diff --git a/CodeApp/Managers/MainApp.swift b/CodeApp/Managers/MainApp.swift index ac4324750..142abcabd 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() @@ -193,8 +196,9 @@ class MainApp: ObservableObject { var editorShortcuts: [MonacoEditorAction] = [] var monacoStateToRestore: String? = nil - var terminalInstance: TerminalInstance! = nil + let terminalManager: TerminalManager var monacoInstance: EditorImplementation! = nil + var editorTypesMonitor: FolderMonitor? = nil let deviceSupportsBiometricAuth: Bool = biometricAuthSupported() let sceneIdentifier = UUID() @@ -204,6 +208,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 @@ -223,18 +229,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 @@ -250,7 +261,22 @@ 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)" + ) + } + } + workSpaceStorage.onRemoteDisconnect { [weak self] in + guard let self = self else { return } + Task { @MainActor in + let documentDir = getRootDirectory() + self.loadFolder(url: documentDir) + } } loadRepository(url: rootDir) @@ -302,6 +328,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 { @@ -459,7 +509,7 @@ class MainApp: ObservableObject { self.stateManager.showsNewFileSheet.toggle() }, onSelectFolderAsWorkspaceStorage: { url in - self.loadFolder(url: url, resetEditors: true) + self.loadFolder(url: url) }, onSelectFolder: { self.stateManager.showsDirectoryPicker.toggle() @@ -758,7 +808,7 @@ class MainApp: ObservableObject { guard let url = URL(string: workSpaceStorage.currentDirectory.url) else { return } - loadFolder(url: url, resetEditors: false) + loadFolder(url: url, resetEditorsAndTerminals: false) } private func groupStatusEntries(entries: [StatusEntry]) -> ( @@ -899,49 +949,62 @@ class MainApp: ObservableObject { updateGitRepositoryStatus() } - func loadFolder(url: URL, resetEditors: Bool = true) { - let url = url.standardizedFileURL - if workSpaceStorage.remoteConnected && url.isFileURL { - workSpaceStorage.disconnect() - } - - ios_setDirectoryURL(url) - - self.workSpaceStorage.updateDirectory( - name: url.lastPathComponent, url: url.absoluteString) - - loadRepository(url: url) - - if url.isFileURL, + private func saveLocalURLToRecentFolders(url: URL) { + guard url.isFileURL, let newBookmark = try? url.bookmarkData() - { - if var bookmarks = UserDefaults.standard.value(forKey: "recentFolder") as? [Data] { - bookmarks = bookmarks.filter { - var isStale = false - guard - let newURL = try? URL( - resolvingBookmarkData: $0, bookmarkDataIsStale: &isStale) - else { - return false - } - // We do not have a stable identity of a url due to sandboxing, compare lastPathComponent instead - return (newURL.lastPathComponent != url.lastPathComponent && !isStale) - } - bookmarks = [newBookmark] + bookmarks - if bookmarks.count > 5 { - bookmarks.removeLast() + else { + return + } + if var bookmarks = UserDefaults.standard.value(forKey: "recentFolder") as? [Data] { + bookmarks = bookmarks.filter { + var isStale = false + guard + let newURL = try? URL( + resolvingBookmarkData: $0, bookmarkDataIsStale: &isStale) + else { + return false } - UserDefaults.standard.setValue(bookmarks, forKey: "recentFolder") - } else { - UserDefaults.standard.setValue([newBookmark], forKey: "recentFolder") + // We do not have a stable identity of a url due to sandboxing, compare lastPathComponent instead + return (newURL.lastPathComponent != url.lastPathComponent && !isStale) } + bookmarks = [newBookmark] + bookmarks + if bookmarks.count > 5 { + bookmarks.removeLast() + } + UserDefaults.standard.setValue(bookmarks, forKey: "recentFolder") + } else { + UserDefaults.standard.setValue([newBookmark], forKey: "recentFolder") } - if resetEditors { - DispatchQueue.main.async { - self.closeAllEditors() - self.terminalInstance.resetAndSetNewRootDirectory(url: url) + } + + /// Set the specified url as the root directory of the working space. The url can either be a local path or a remote path like sftp://xxxxx + @MainActor + func loadFolder(url: URL, resetEditorsAndTerminals: Bool = true) { + if resetEditorsAndTerminals { + self.closeAllEditors() + self.terminalManager.resetAndSetNewRootDirectory(url: url) + } + + let url = url.standardizedFileURL + if url.isFileURL { + // Local urls + ios_setDirectoryURL(url) + if workSpaceStorage.remoteConnected && url.isFileURL { + workSpaceStorage.disconnect() } + self.workSpaceStorage.updateDirectory( + name: url.lastPathComponent, url: url.absoluteString) + saveLocalURLToRecentFolders(url: url) + } else { + // Remote urls + notificationManager.showInformationMessage( + "remote.connected") + // Set terminal service provider for the active terminal + terminalManager.setTerminalServiceProviderForActiveTerminal( + workSpaceStorage.terminalServiceProvider) } + + loadRepository(url: url) extensionManager.onWorkSpaceStorageChanged(newUrl: url) } diff --git a/CodeApp/Managers/TerminalInstance.swift b/CodeApp/Managers/TerminalInstance.swift index 3fe32e381..ca1ec8b11 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() @@ -52,6 +55,10 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { executeScript("document.getElementById('overlay').focus()") } + func focus() { + executeScript("term.focus()") + } + func sendInterrupt() { executeScript("sendInterrupt()") } @@ -233,14 +240,47 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { if let input = result["Input"] as? String { ts.write(data: "\(input)".data(using: .utf8)!) } + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) default: return } return } - if self.executor?.state == .interactive && event == "Data" { - self.executor?.sendInput(input: result["Input"] as! String) + if self.executor?.state == .interactive { + switch event { + case "Data": + self.executor?.sendInput(input: result["Input"] as! String) + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) + default: + break + } return } @@ -350,6 +390,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 @@ -368,6 +410,20 @@ class TerminalInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate { executor?.kill() } } + case "ControlReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalControlReset, + object: self, + userInfo: ["generation": generation] + ) + case "AltReset": + let generation = result["Generation"] as? Int ?? 0 + NotificationCenter.default.post( + name: .terminalAltReset, + object: self, + userInfo: ["generation": generation] + ) default: print("\(result) Event not handled") } @@ -393,11 +449,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) }, @@ -488,15 +547,88 @@ 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 + } +} + +struct ModifierStates: Codable { + var controlActive: Bool + var controlLocked: Bool + var controlGeneration: Int + var altActive: Bool + var altLocked: Bool + var altGeneration: Int +} + // Keyboard toolbar methods extension TerminalInstance { func type(text: String) { guard let base64 = text.base64Encoded() else { return } - executeScript("term.input(base64ToString(`\(base64)`))") + executeScript("inputWithModifiers(base64ToString(`\(base64)`))") } func moveCursor(codeSequence: String) { - executeScript("term.input(String.fromCharCode(0x1b)+'\(codeSequence)')") + executeScript("inputWithModifiers(String.fromCharCode(0x1b)+'\(codeSequence)')") + } + + func setControlActive(_ active: Bool, generation: Int) { + executeScript("setControlActive(\(active), \(generation))") + } + + func setControlLocked(_ locked: Bool) { + executeScript("setControlLocked(\(locked))") + } + + func setAltActive(_ active: Bool, generation: Int) { + executeScript("setAltActive(\(active), \(generation))") + } + + func setAltLocked(_ locked: Bool) { + executeScript("setAltLocked(\(locked))") } + + /// Asynchronously obtains all modifier states from JavaScript in a single call + func getModifierStates() async -> ModifierStates { + guard + let dict = try? await webView.evaluateJavaScript("getModifierStates()") + as? [String: Any], + let jsonData = try? JSONSerialization.data(withJSONObject: dict), + let states = try? JSONDecoder().decode(ModifierStates.self, from: jsonData) + else { + return ModifierStates( + controlActive: false, + controlLocked: false, + controlGeneration: 0, + altActive: false, + altLocked: false, + altGeneration: 0 + ) + } + + return states + } +} + +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..f9c6dbffc --- /dev/null +++ b/CodeApp/Managers/TerminalManager.swift @@ -0,0 +1,317 @@ +// +// 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 } + } else { + return 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) + // We do not support creating remote terminal instances for now + 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)" + ) + // Introduce delay to allow web view finishes loading before calling focus + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + terminal.focus() + } + 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 + } + } + + /// Close all terminals except the first one, and reset that terminal's state + func resetAndSetNewRootDirectory(url: URL) { + assertMainThread() + rootURL = url + for terminal in terminals.suffix(terminals.count - 1) { + closeTerminal(id: terminal.id) + } + terminals.first?.resetAndSetNewRootDirectory(url: url) + } + + /// Sets the terminal service provider on the active terminal only. + func setTerminalServiceProviderForActiveTerminal(_ provider: TerminalServiceProvider?) { + assertMainThread() + terminalServiceProvider = provider + activeTerminal?.terminalServiceProvider = provider + + // Track the active terminal as the remote terminal when connecting + if let provider = provider { + provider.onDisconnect { [weak self] in + DispatchQueue.main.async { + self?.setTerminalServiceProviderForActiveTerminal(nil) + } + } + remoteTerminalId = activeTerminal?.id + } else { + remoteTerminalId = nil + } + syncRemoteTerminalId() + } + + var canCreateNewTerminal: Bool { + terminals.count < TerminalManager.maxTerminals && remoteTerminalId == nil + } + + var canReset: Bool { + terminalServiceProvider == nil + } + + private func setActiveTerminalId(_ id: UUID?) { + activeTerminalId = id + syncRemoteTerminalId() + activeTerminal?.focus() + } + + 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 7a7556f78..ade0978fb 100644 --- a/CodeApp/Views/ActivityBar.swift +++ b/CodeApp/Views/ActivityBar.swift @@ -109,7 +109,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 8cd6327ba..ef4e21d40 100644 --- a/CodeApp/Views/TerminalKeyboardToolbar.swift +++ b/CodeApp/Views/TerminalKeyboardToolbar.swift @@ -12,28 +12,178 @@ struct TerminalKeyboardToolBar: View { @EnvironmentObject var App: MainApp @Environment(\.horizontalSizeClass) var horizontalSizeClass @State var pasteBoardHasContent = false + @State var controlActive = false + @State var controlLocked = false + @State var controlLastTapTime: Date? + @State var controlGeneration = 0 + @State var altActive = false + @State var altLocked = false + @State var altLastTapTime: Date? + @State var altGeneration = 0 + + private var terminal: TerminalInstance? { + return App.terminalManager.activeTerminal + } + + private let doubleTapInterval: TimeInterval = 0.3 + + private func resetModifierStates() { + controlActive = false + controlLocked = false + terminal?.setControlActive(false, generation: controlGeneration) + terminal?.setControlLocked(false) + altActive = false + altLocked = false + terminal?.setAltActive(false, generation: altGeneration) + terminal?.setAltLocked(false) + } + + private func resetUnlockedModifiers() { + // Reset modifiers only if they are not locked + if !controlLocked { + controlActive = false + terminal?.setControlActive(false, generation: controlGeneration) + } + if !altLocked { + altActive = false + terminal?.setAltActive(false, generation: altGeneration) + } + } + + private func handleControlTap() { + let now = Date() + let isDoubleTap = + controlLastTapTime.map { now.timeIntervalSince($0) < doubleTapInterval } ?? false + controlLastTapTime = now + + if controlLocked { + // Single tap while locked: unlock and deactivate + controlLocked = false + controlActive = false + controlGeneration += 1 + terminal?.setControlLocked(false) + terminal?.setControlActive(false, generation: controlGeneration) + } else if isDoubleTap && controlActive { + // Double tap while active: lock + controlLocked = true + terminal?.setControlLocked(true) + } else { + // Single tap: toggle active state + controlActive.toggle() + controlGeneration += 1 + terminal?.setControlActive(controlActive, generation: controlGeneration) + } + } + + private func handleAltTap() { + let now = Date() + let isDoubleTap = + altLastTapTime.map { now.timeIntervalSince($0) < doubleTapInterval } ?? false + altLastTapTime = now + + if altLocked { + // Single tap while locked: unlock and deactivate + altLocked = false + altActive = false + altGeneration += 1 + terminal?.setAltLocked(false) + terminal?.setAltActive(false, generation: altGeneration) + } else if isDoubleTap && altActive { + // Double tap while active: lock + altLocked = true + terminal?.setAltLocked(true) + } else { + // Single tap: toggle active state + altActive.toggle() + altGeneration += 1 + terminal?.setAltActive(altActive, generation: altGeneration) + } + } + + private func typeAndResetModifiers(text: String) { + terminal?.type(text: text) + resetUnlockedModifiers() + } + + private func moveCursorAndResetModifiers(codeSequence: String) { + terminal?.moveCursor(codeSequence: codeSequence) + resetUnlockedModifiers() + } + + private func syncModifierStatesFromTerminal() { + guard let terminal = terminal else { return } + Task { + let states = await terminal.getModifierStates() + + await MainActor.run { + controlActive = states.controlActive + controlLocked = states.controlLocked + altActive = states.altActive + altLocked = states.altLocked + controlGeneration = states.controlGeneration + altGeneration = states.altGeneration + } + } + } var body: some View { HStack(spacing: horizontalSizeClass == .compact ? 8 : 14) { Group { - if UIPasteboard.general.hasStrings || pasteBoardHasContent { - Button( - action: { - if let string = UIPasteboard.general.string { - App.terminalInstance.type(text: string) - } - }, - label: { - Image(systemName: "doc.on.clipboard") - }) - } Button( action: { - App.terminalInstance.type(text: "\t") + typeAndResetModifiers(text: "\u{1b}") }, label: { - Text("↹") + Text("Esc") + } + ) + .accessibilityLabel("Escape") + Button( + action: { + typeAndResetModifiers(text: "\t") + }, + label: { + Text("Tab") }) + Button( + action: { + handleControlTap() + }, + label: { + Text("Ctrl") + .padding(.horizontal, 2) + .background( + controlActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: controlLocked ? 2 : 0) + ) + } + ) + .accessibilityLabel("Control") + .accessibilityValue( + controlLocked ? "Locked" : (controlActive ? "Active" : "Inactive")) + Button( + action: { + handleAltTap() + }, + label: { + Text("Alt") + .padding(.horizontal, 2) + .background( + altActive ? Color.accentColor.opacity(0.3) : Color.clear + ) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: altLocked ? 2 : 0) + ) + } + ) + .accessibilityLabel("Alt") + .accessibilityValue(altLocked ? "Locked" : (altActive ? "Active" : "Inactive")) } Spacer() @@ -41,35 +191,56 @@ struct TerminalKeyboardToolBar: View { Group { Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[A") + typeAndResetModifiers(text: "\u{1b}[3~") + }, + label: { + Image(systemName: "delete.right") + } + ) + .accessibilityLabel("Delete") + if UIPasteboard.general.hasStrings || pasteBoardHasContent { + Button( + action: { + if let string = UIPasteboard.general.string { + typeAndResetModifiers(text: string) + } + }, + label: { + Image(systemName: "doc.on.clipboard") + }) + } + Button( + action: { + moveCursorAndResetModifiers(codeSequence: "[A") }, label: { Image(systemName: "arrow.up") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[B") + moveCursorAndResetModifiers(codeSequence: "[B") }, label: { Image(systemName: "arrow.down") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[D") + moveCursorAndResetModifiers(codeSequence: "[D") }, label: { Image(systemName: "arrow.left") }) Button( action: { - App.terminalInstance.moveCursor(codeSequence: "[C") + moveCursorAndResetModifiers(codeSequence: "[C") }, label: { Image(systemName: "arrow.right") }) Button( action: { - App.terminalInstance.blur() + resetModifierStates() + terminal?.blur() }, label: { Image(systemName: "keyboard.chevron.compact.down") @@ -84,12 +255,45 @@ struct TerminalKeyboardToolBar: View { .ignoresSafeArea() .onReceive( NotificationCenter.default.publisher(for: UIPasteboard.changedNotification), - perform: { val in + perform: { _ in if UIPasteboard.general.hasStrings { pasteBoardHasContent = true } else { pasteBoardHasContent = false } - }) + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: .terminalControlReset, + object: terminal + ), + perform: { notification in + if let generation = notification.userInfo?["generation"] as? Int, + generation == controlGeneration + { + controlActive = false + } + } + ) + .onReceive( + NotificationCenter.default.publisher( + for: .terminalAltReset, + object: terminal + ), + perform: { notification in + if let generation = notification.userInfo?["generation"] as? Int, + generation == altGeneration + { + altActive = false + } + } + ) + .onChange(of: App.terminalManager.activeTerminalId) { _ in + syncModifierStatesFromTerminal() + } + .onAppear { + syncModifierStatesFromTerminal() + } } } diff --git a/CodeApp/Views/TerminalTabBar.swift b/CodeApp/Views/TerminalTabBar.swift new file mode 100644 index 000000000..7480a0068 --- /dev/null +++ b/CodeApp/Views/TerminalTabBar.swift @@ -0,0 +1,140 @@ +// +// 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( + "terminal.tab.accessibility.active", + comment: "Accessibility label for active terminal" + ) + let runningLabel = NSLocalizedString( + "terminal.tab.accessibility.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(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( + "terminal.tab.accessibility.hint", + comment: "Accessibility hint for terminal tab") + ) + .contextMenu { + if canClose { + Button(role: .destructive) { + if isTerminalBusy { + showingKillConfirmation = true + } else { + onClose() + } + } label: { + Label("terminal.tab.kill", systemImage: "xmark") + } + } + } + .alert( + "terminal.tab.kill_confirmation.title", + isPresented: $showingKillConfirmation + ) { + Button("terminal.tab.kill_confirmation.cancel", role: .cancel) {} + Button("terminal.tab.kill_confirmation.kill", role: .destructive) { + onClose() + } + } message: { + Text( + "terminal.tab.kill_confirmation.message") + } + } +} diff --git a/Dependencies/terminal.bundle/index.html b/Dependencies/terminal.bundle/index.html index 494ca9024..f9482692e 100644 --- a/Dependencies/terminal.bundle/index.html +++ b/Dependencies/terminal.bundle/index.html @@ -154,6 +154,172 @@ localEcho.addAutocompleteHandler(autocompleteCommonCommands); localEcho.addAutocompleteHandler(autocompleteCommonFiles); + var controlActive = false; + var controlLocked = false; + var controlGeneration = 0; + var altActive = false; + var altLocked = false; + var altGeneration = 0; + + function setControlActive(active, generation) { + controlActive = active; + controlGeneration = generation; + } + + function setControlLocked(locked) { + controlLocked = locked; + } + + function setAltActive(active, generation) { + altActive = active; + altGeneration = generation; + } + + function setAltLocked(locked) { + altLocked = locked; + } + + function getModifierStates() { + return { + controlActive: controlActive, + controlLocked: controlLocked, + controlGeneration: controlGeneration, + altActive: altActive, + altLocked: altLocked, + altGeneration: altGeneration + }; + } + + function shouldApplyModifierToCsi(final, params) { + if (final >= "A" && final <= "D") { + return true; + } + if (final === "F" || final === "H") { + return true; + } + if (final === "~") { + var primaryParam = params.split(";")[0]; + var keycode = parseInt(primaryParam, 10); + if (isNaN(keycode)) { + return false; + } + // Exclude bracketed paste mode sequences (ESC[200~ and ESC[201~). + // Modifying these would corrupt the paste protocol. + return keycode !== 200 && keycode !== 201; + } + return false; + } + + function applyModifierToEscapeSequence(data, wasControlActive, wasAltActive) { + var modifier = 1 + (wasAltActive ? 2 : 0) + (wasControlActive ? 4 : 0); + + // CSI sequences (ESC [ ...). + var csiMatch = data.match(/^\x1b\[([0-9;]*)([@-~])$/); + if (csiMatch) { + var params = csiMatch[1]; + var final = csiMatch[2]; + + if (!shouldApplyModifierToCsi(final, params)) { + return data; + } + + var parts = params.length ? params.split(";") : []; + if (parts.length === 0) { + parts = ["1", String(modifier)]; + } else if (parts.length === 1) { + parts.push(String(modifier)); + } else { + var lastIndex = parts.length - 1; + var existing = parseInt(parts[lastIndex], 10); + if (!isNaN(existing)) { + var modBits = Math.max(existing, 1) - 1; + if (wasAltActive) { + modBits |= 2; + } + if (wasControlActive) { + modBits |= 4; + } + parts[lastIndex] = String(modBits + 1); + } else { + parts.push(String(modifier)); + } + } + + return "\x1b[" + parts.join(";") + final; + } + + // SS3 sequences (ESC O ...). + var ss3Match = data.match(/^\x1bO([A-Za-z])$/); + if (ss3Match) { + var final = ss3Match[1]; + if ( + (final >= "A" && final <= "D") || + final === "F" || + final === "H" || + (final >= "P" && final <= "S") + ) { + return "\x1b[1;" + String(modifier) + final; + } + } + + return data; + } + + function applyModifierStates(data) { + // Capture which modifiers are active before potentially resetting + var wasControlActive = controlActive; + var wasAltActive = altActive; + + if (!wasControlActive && !wasAltActive) { + return data; + } + + // Reset modifiers only if not locked, and notify Swift + if (wasControlActive && !controlLocked) { + controlActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "ControlReset", + Generation: controlGeneration, + }); + } + if (wasAltActive && !altLocked) { + altActive = false; + window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ + Event: "AltReset", + Generation: altGeneration, + }); + } + + var result = data; + + if (data.length === 1) { + // Apply Control: convert to control character (A-Z, @, [, \, ], ^, _) + if (wasControlActive) { + const code = result.toUpperCase().charCodeAt(0); + if (code >= 0x40 && code <= 0x5f) { + result = String.fromCharCode(code & 0x1f); + } + } + + // Apply Alt: prepend ESC (meta key behavior) + if (wasAltActive) { + result = "\x1b" + result; + } + } else if (data.charCodeAt(0) === 0x1b) { + result = applyModifierToEscapeSequence( + data, + wasControlActive, + wasAltActive + ); + } + + return result; + } + + function inputWithModifiers(data) { + term.input(applyModifierStates(data)); + } + function startInteractive() { localEcho.detach(); } @@ -163,6 +329,7 @@ } term.onData((data) => { + data = applyModifierStates(data); window.webkit.messageHandlers.toggleMessageHandler2.postMessage({ Event: "Data", Input: data, diff --git a/Extensions/LocalExecution/LocalExecutionExtension.swift b/Extensions/LocalExecution/LocalExecutionExtension.swift index a2acb2d74..d21ad2fa6 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 = await activeTerminal.executor else { + app.notificationManager.showErrorMessage( + "Cannot run: terminal '\(await activeTerminal.name)' has no executor.") + return + } + + guard executor.state == .idle else { + app.notificationManager.showWarningMessage( + "Cannot run: terminal '\(await 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..761422d91 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,29 +23,31 @@ private struct ToolbarView: View { var body: some View { HStack(spacing: 12) { - Button( - action: { - App.terminalInstance.sendInterrupt() - }, - label: { - Text("^C") + if App.terminalManager.canCreateNewTerminal { + Button(action: { + App.terminalManager.createTerminal() + }) { + Image(systemName: "plus") } - ).keyboardShortcut("c", modifiers: [.control]) - - Button( - action: { - App.terminalInstance.reset() - }, - label: { - Image(systemName: "trash") - } - ).keyboardShortcut("k", modifiers: [.command]) + .help("New Terminal") + } + + if App.terminalManager.canReset { + Button( + action: { + App.terminalManager.activeTerminal?.reset() + }, + label: { + Image(systemName: "trash") + } + ).keyboardShortcut("k", modifiers: [.command]) + } } } } private struct _TerminalView: UIViewRepresentable { - var implementation: TerminalInstance + let terminal: TerminalInstance @EnvironmentObject var App: MainApp @@ -63,63 +65,100 @@ 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 } + // Allow WKWebView to get the correct frame size before calling fit + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + 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) } }