diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 304d12112c31..87f8e2ca4e7a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,11 +1,11 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -20362 CLI/cmux.swift +20361 CLI/cmux.swift 17115 Sources/TerminalController.swift -15902 Sources/ContentView.swift +15919 Sources/ContentView.swift 14164 Sources/AppDelegate.swift -13686 Sources/Workspace.swift +13694 Sources/Workspace.swift 13403 Sources/GhosttyTerminalView.swift 10600 Sources/Panels/BrowserPanel.swift 8412 Sources/cmuxApp.swift diff --git a/CLI/CMUXCLI+MoveTabToNewWorkspace.swift b/CLI/CMUXCLI+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..29d1a97b0a52 --- /dev/null +++ b/CLI/CMUXCLI+MoveTabToNewWorkspace.swift @@ -0,0 +1,62 @@ +import Foundation + +extension CMUXCLI { + func applyTabActionFocusOption(_ focusOpt: String?, to params: inout [String: Any]) throws { + guard let focusOpt else { return } + guard let focus = parseBoolString(focusOpt) else { + throw CLIError(message: "--focus must be true|false") + } + params["focus"] = focus + } + + func appendCreatedWorkspaceSummaryParts( + from payload: [String: Any], + idFormat: CLIIDFormat, + to summaryParts: inout [String] + ) { + guard let id = payload["created_workspace_id"] as? String else { return } + var createdWorkspacePayload: [String: Any] = ["workspace_id": id] + if let ref = payload["created_workspace_ref"] as? String { + createdWorkspacePayload["workspace_ref"] = ref + } + if let createdWorkspace = formatHandle(createdWorkspacePayload, kind: "workspace", idFormat: idFormat) { + summaryParts.append("created_workspace=\(createdWorkspace)") + } + } + + func runMoveTabToNewWorkspace( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + if commandArgs.contains(where: { $0 == "--action" || $0.hasPrefix("--action=") }) { + throw CLIError(message: "move-tab-to-new-workspace does not accept --action") + } + try runTabAction( + commandArgs: ["--action", "move-to-new-workspace"] + commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + } + + static let moveTabToNewWorkspaceCommandHelp = """ + Usage: cmux move-tab-to-new-workspace [--tab ] [--surface ] [--workspace ] [--title ] [--focus ] + + Move a tab into a newly created workspace in the same window. + + Flags: + --tab Target tab (accepts tab: or surface:; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab) + --surface Alias for --tab + --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + --title Optional title for the new workspace + --focus Focus the new workspace when supported (default: false) + + Example: + cmux move-tab-to-new-workspace --tab tab:2 + cmux move-tab-to-new-workspace --surface surface:3 --title "build logs" + """ +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 00d6d07fc4bc..6f66d2da2728 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2551,10 +2551,10 @@ struct CMUXCLI { case "workspace-action": try runWorkspaceAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) - case "tab-action": try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) - + case "move-tab-to-new-workspace", "detach-tab": + try runMoveTabToNewWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) case "rename-tab": try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) @@ -3767,7 +3767,7 @@ struct CMUXCLI { return nil } - private func parseBoolString(_ raw: String) -> Bool? { + func parseBoolString(_ raw: String) -> Bool? { switch raw.lowercased() { case "1", "true", "yes", "on": return true @@ -3966,7 +3966,7 @@ struct CMUXCLI { return "tab:\(ordinal)" } - private func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? { + func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? { let id = payload["\(kind)_id"] as? String let ref = payload["\(kind)_ref"] as? String switch idFormat { @@ -4396,7 +4396,7 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } - private func runTabAction( + func runTabAction( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4409,8 +4409,9 @@ struct CMUXCLI { let (actionOpt, rem3) = parseOption(rem2, name: "--action") let (titleOpt, rem4) = parseOption(rem3, name: "--title") let (urlOpt, rem5) = parseOption(rem4, name: "--url") + let (focusOpt, rem6) = parseOption(rem5, name: "--focus") - var positional = rem5 + var positional = rem6 let actionRaw: String if let actionOpt { actionRaw = actionOpt @@ -4464,24 +4465,16 @@ struct CMUXCLI { if let urlOpt, !urlOpt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { params["url"] = urlOpt.trimmingCharacters(in: .whitespacesAndNewlines) } - + try applyTabActionFocusOption(focusOpt, to: ¶ms) let payload = try client.sendV2(method: "tab.action", params: params) var summaryParts = ["OK", "action=\(action)"] - if let tabHandle = formatTabHandle(payload, idFormat: idFormat) { - summaryParts.append("tab=\(tabHandle)") - } - if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { - summaryParts.append("workspace=\(workspaceHandle)") - } - if let closed = payload["closed"] { - summaryParts.append("closed=\(closed)") - } - if let created = formatCreatedTabHandle(payload, idFormat: idFormat) { - summaryParts.append("created=\(created)") - } + if let tabHandle = formatTabHandle(payload, idFormat: idFormat) { summaryParts.append("tab=\(tabHandle)") } + if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { summaryParts.append("workspace=\(workspaceHandle)") } + if let closed = payload["closed"] { summaryParts.append("closed=\(closed)") } + if let created = formatCreatedTabHandle(payload, idFormat: idFormat) { summaryParts.append("created=\(created)") } + appendCreatedWorkspaceSummaryParts(from: payload, idFormat: idFormat, to: &summaryParts) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } - private func runRenameTab( commandArgs: [String], client: SocketClient, @@ -8696,6 +8689,7 @@ struct CMUXCLI { rename | clear-name close-left | close-right | close-others new-terminal-right | new-browser-right + move-to-new-workspace reload | duplicate pin | unpin mark-unread @@ -8707,12 +8701,16 @@ struct CMUXCLI { --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) --title Title for rename (or pass trailing title text) --url Optional URL for new-browser-right + --focus Focus the destination when supported (default: false for move-to-new-workspace) Example: cmux tab-action --tab tab:3 --action pin cmux tab-action --action close-right + cmux tab-action --tab tab:2 --action move-to-new-workspace cmux tab-action --tab tab:2 --action rename --title "build logs" """ + case "move-tab-to-new-workspace", "detach-tab": + return Self.moveTabToNewWorkspaceCommandHelp case "rename-tab": return """ Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] @@ -20212,6 +20210,7 @@ export default CMUXSessionRestore; move-workspace-to-window --workspace <id|ref> --window <id|ref> reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>] workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] [--color <name|#hex>] [--description <text>] + move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--focus <true|false>] list-workspaces new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>] @@ -20227,7 +20226,7 @@ export default CMUXSessionRestore; close-surface [--surface <id|ref>] [--workspace <id|ref>] move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>] reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) - tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] + tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] [--focus <true|false>] rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 076fb6e7e711..d3beee395e73 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ A50019B0 /* SettingsSearchAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B1 /* SettingsSearchAliases.swift */; }; A50019B2 /* SettingsSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B3 /* SettingsSearchIndexTests.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; + D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */; }; 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */; }; @@ -65,9 +66,12 @@ E30750000000000000000002 /* WorkspaceTabColorResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000001 /* WorkspaceTabColorResolution.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; + D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; + D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; + D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; @@ -99,6 +103,7 @@ A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; + D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; @@ -109,6 +114,7 @@ A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; + D7AB00000000000000000009 /* CmuxWebView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000A /* CmuxWebView+MoveTabToNewWorkspace.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; @@ -153,10 +159,12 @@ C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */; }; B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; + D7AB0000000000000000000D /* CMUXCLI+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */; }; B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */; }; B9000030A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */; }; + D7AB00000000000000000011 /* WorkspaceAdjacentPaneMoveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */; }; B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; @@ -212,6 +220,7 @@ FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; }; + D7AB0000000000000000000F /* AppDelegateMoveTabToNewWorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */; }; FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; @@ -328,6 +337,7 @@ A50019B1 /* SettingsSearchAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchAliases.swift; sourceTree = "<group>"; }; A50019B3 /* SettingsSearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchIndexTests.swift; sourceTree = "<group>"; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; + D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; D0C0D0C0D0C0D0C0D0C0D002 /* DockPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockPanelView.swift; sourceTree = "<group>"; }; C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewIdentifierCopyCommands.swift; sourceTree = "<group>"; }; 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WorkspaceRuntimeSettings.swift; sourceTree = "<group>"; }; @@ -348,18 +358,21 @@ E30750000000000000000001 /* WorkspaceTabColorResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorResolution.swift; sourceTree = "<group>"; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; }; + D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = "<group>"; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = "<group>"; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; }; + D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; C7A501000000000000000001 /* CmuxTopSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshot.swift; sourceTree = "<group>"; }; C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = "<group>"; }; C7A503000000000000000001 /* TaskManagerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerSnapshot.swift; sourceTree = "<group>"; }; C7A504000000000000000001 /* TaskManagerTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerTypes.swift; sourceTree = "<group>"; }; C7A505000000000000000001 /* TerminalControllerTopSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerTopSupport.swift; sourceTree = "<group>"; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; + D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; @@ -387,6 +400,7 @@ A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = "<group>"; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = "<group>"; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; }; + D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; }; @@ -398,6 +412,7 @@ A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; }; + D7AB0000000000000000000A /* CmuxWebView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/CmuxWebView+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; }; @@ -434,10 +449,12 @@ C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIdentifierClipboardUITests.swift; sourceTree = "<group>"; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = "<group>"; }; + D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Themes.swift"; sourceTree = "<group>"; }; B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ThemeSupport.swift"; sourceTree = "<group>"; }; B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+DocsSettings.swift"; sourceTree = "<group>"; }; + D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceAdjacentPaneMoveTests.swift; sourceTree = "<group>"; }; B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TopRendering.swift"; sourceTree = "<group>"; }; B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = "<group>"; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = "<group>"; }; @@ -451,6 +468,7 @@ B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = "<group>"; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = "<group>"; }; + D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateMoveTabToNewWorkspaceTests.swift; sourceTree = "<group>"; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; }; C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = "<group>"; }; E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigNamedColorTests.swift; sourceTree = "<group>"; }; @@ -647,6 +665,7 @@ A50019A1 /* SettingsNavigation.swift */, A50019B1 /* SettingsSearchAliases.swift */, A5001012 /* ContentView.swift */, + D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */, @@ -679,6 +698,7 @@ E30760000000000000000001 /* TmuxWorkspacePaneOverlayView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, + D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */, D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */, D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */, A5001531 /* TerminalWindowPortal.swift */, @@ -686,6 +706,7 @@ A5001533 /* BrowserWindowPortal.swift */, D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */, A5001019 /* TerminalController.swift */, + D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */, C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, @@ -699,6 +720,7 @@ A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */, 2F0C05000000000000000001 /* MainWindowFocusController.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -708,6 +730,7 @@ A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, A5001412 /* BrowserPanel.swift */, + D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */, A500RG00 /* ReactGrab.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, @@ -716,6 +739,7 @@ A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, + D7AB0000000000000000000A /* CmuxWebView+MoveTabToNewWorkspace.swift */, A5001415 /* PanelContentView.swift */, A5001211 /* UpdateController.swift */, A5001212 /* UpdateDelegate.swift */, @@ -772,6 +796,7 @@ isa = PBXGroup; children = ( B9000001A1B2C3D4E5F60719 /* cmux.swift */, + D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */, B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */, B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */, B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */, @@ -804,9 +829,11 @@ D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */, D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */, D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */, 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, + D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */, 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, @@ -1065,6 +1092,7 @@ A50019A0 /* SettingsNavigation.swift in Sources */, A50019B0 /* SettingsSearchAliases.swift in Sources */, A5001002 /* ContentView.swift in Sources */, + D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */, C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */, 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */, 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */, @@ -1097,6 +1125,7 @@ E30760000000000000000002 /* TmuxWorkspacePaneOverlayView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, + D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */, D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, @@ -1104,6 +1133,7 @@ A5001534 /* BrowserWindowPortal.swift in Sources */, D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, + D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, @@ -1116,6 +1146,7 @@ A5001601 /* SentryHelper.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, + D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */, 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, @@ -1125,6 +1156,7 @@ A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, + D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, @@ -1133,6 +1165,7 @@ A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, + D7AB00000000000000000009 /* CmuxWebView+MoveTabToNewWorkspace.swift in Sources */, A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, A5001202 /* UpdateDelegate.swift in Sources */, @@ -1185,6 +1218,7 @@ buildActionMask = 2147483647; files = ( B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */, + D7AB0000000000000000000D /* CMUXCLI+MoveTabToNewWorkspace.swift in Sources */, B9000030A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */, B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */, B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */, @@ -1256,9 +1290,11 @@ D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */, D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */, 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, + D7AB00000000000000000011 /* WorkspaceAdjacentPaneMoveTests.swift in Sources */, D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */, 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, + D7AB0000000000000000000F /* AppDelegateMoveTabToNewWorkspaceTests.swift in Sources */, 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1a6e48c65264..b05e738166eb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -10093,6 +10093,23 @@ } } }, + "browser.contextMenu.moveTabToNewWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Tab to New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを新しいワークスペースに移動" + } + } + } + }, "browser.contextMenu.openLinkInNewTab": { "extractionState": "manual", "localizations": { @@ -23625,6 +23642,23 @@ } } }, + "command.moveTabToNewWorkspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Tab to New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを新しいワークスペースに移動" + } + } + } + }, "command.renameTab.title": { "extractionState": "manual", "localizations": { @@ -101732,6 +101766,29 @@ } } }, + "terminalContextMenu.moveTabToNewWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move Tab to New Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブを新しいワークスペースに移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "탭을 새 워크스페이스로 이동" + } + } + } + }, "terminalContextMenu.paste": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+MoveTabToNewWorkspace.swift b/Sources/AppDelegate+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..8d6b5063ab43 --- /dev/null +++ b/Sources/AppDelegate+MoveTabToNewWorkspace.swift @@ -0,0 +1,128 @@ +import Foundation + +struct SurfaceNewWorkspaceMoveResult { + let sourceWindowId: UUID + let sourceWorkspaceId: UUID + let destinationWindowId: UUID? + let destinationWorkspaceId: UUID + let surfaceId: UUID + let paneId: UUID? +} + +@MainActor +extension AppDelegate { + func canMoveSurfaceToNewWorkspace(panelId: UUID) -> Bool { + guard let source = locateSurface(surfaceId: panelId), + let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), + sourceWorkspace.panels[panelId] != nil else { + return false + } + return sourceWorkspace.panels.count > 1 + } + + func canMoveBonsplitTabToNewWorkspace(tabId: UUID) -> Bool { + guard let located = locateBonsplitSurface(tabId: tabId) else { return false } + return canMoveSurfaceToNewWorkspace(panelId: located.panelId) + } + + @discardableResult + func moveBonsplitTabToNewWorkspace( + tabId: UUID, + destinationManager: TabManager? = nil, + title: String? = nil, + focus: Bool = true, + focusWindow: Bool = true, + placementOverride: NewWorkspacePlacement? = nil + ) -> SurfaceNewWorkspaceMoveResult? { + guard let located = locateBonsplitSurface(tabId: tabId) else { return nil } + return moveSurfaceToNewWorkspace( + panelId: located.panelId, + destinationManager: destinationManager, + title: title, + focus: focus, + focusWindow: focusWindow, + placementOverride: placementOverride + ) + } + + @discardableResult + func moveSurfaceToNewWorkspace( + panelId: UUID, + destinationManager: TabManager? = nil, + title: String? = nil, + focus: Bool = true, + focusWindow: Bool = true, + placementOverride: NewWorkspacePlacement? = nil + ) -> SurfaceNewWorkspaceMoveResult? { + guard let source = locateSurface(surfaceId: panelId), + let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), + let sourcePanel = sourceWorkspace.panels[panelId], + sourceWorkspace.panels.count > 1 else { + return nil + } + + let targetManager = destinationManager ?? source.tabManager + let destinationTitle = titleForDetachedWorkspace( + explicitTitle: title, + workspace: sourceWorkspace, + panelId: panelId, + panel: sourcePanel + ) + let destinationWorkspace = targetManager.addWorkspace( + title: destinationTitle, + select: focus, + placementOverride: placementOverride, + autoWelcomeIfNeeded: false + ) + let bootstrapPanelIds = Set(destinationWorkspace.panels.keys) + + let moved = moveSurface( + panelId: panelId, + toWorkspace: destinationWorkspace.id, + focus: focus, + focusWindow: focusWindow + ) + guard moved else { + if targetManager.tabs.count > 1, + targetManager.tabs.contains(where: { $0.id == destinationWorkspace.id }) { + targetManager.closeWorkspace(destinationWorkspace) + } + return nil + } + + for bootstrapPanelId in bootstrapPanelIds where bootstrapPanelId != panelId { + if destinationWorkspace.panels[bootstrapPanelId] != nil { + _ = destinationWorkspace.closePanel(bootstrapPanelId, force: true) + } + } + + return SurfaceNewWorkspaceMoveResult( + sourceWindowId: source.windowId, + sourceWorkspaceId: source.workspaceId, + destinationWindowId: windowId(for: targetManager), + destinationWorkspaceId: destinationWorkspace.id, + surfaceId: panelId, + paneId: destinationWorkspace.paneId(forPanelId: panelId)?.id + ) + } + + private func titleForDetachedWorkspace( + explicitTitle: String?, + workspace: Workspace, + panelId: UUID, + panel: any Panel + ) -> String { + let trimmedTitle = explicitTitle?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTitle, !trimmedTitle.isEmpty { + return trimmedTitle + } + + let fallbackTitle = workspace.panelTitle(panelId: panelId) ?? panel.displayTitle + let trimmedFallbackTitle = fallbackTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedFallbackTitle.isEmpty { + return trimmedFallbackTitle + } + + return String(localized: "commandPalette.subtitle.tabFallback", defaultValue: "Tab") + } +} diff --git a/Sources/ContentView+MoveTabToNewWorkspace.swift b/Sources/ContentView+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..0de2195906b0 --- /dev/null +++ b/Sources/ContentView+MoveTabToNewWorkspace.swift @@ -0,0 +1,152 @@ +import AppKit +import SwiftUI + +extension ContentView { + func appendMoveTabToNewWorkspaceCommandContribution( + to contributions: inout [CommandPaletteCommandContribution], + panelSubtitle: @escaping (CommandPaletteContextSnapshot) -> String + ) { + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveTabToNewWorkspace", + title: { _ in String(localized: "command.moveTabToNewWorkspace.title", defaultValue: "Move Tab to New Workspace") }, + subtitle: panelSubtitle, + keywords: ["move", "tab", "workspace", "detach", "sidebar", "surface"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) }, + enablement: { $0.bool(CommandPaletteContextKeys.panelCanMoveToNewWorkspace) } + ) + ) + } + + func moveFocusedPanelToNewWorkspace() -> Bool { + guard let panelContext = focusedPanelContext else { return false } + return AppDelegate.shared?.moveSurfaceToNewWorkspace( + panelId: panelContext.panelId, + focus: true, + focusWindow: false + ) != nil + } +} + +struct SidebarBonsplitTabNewWorkspaceDropOverlay: NSViewRepresentable { + let tabManager: TabManager + @Binding var selectedTabIds: Set<UUID> + @Binding var lastSidebarSelectionIndex: Int? + @Binding var dropIndicator: SidebarDropIndicator? + + func makeNSView(context: Context) -> SidebarBonsplitTabNewWorkspaceDropView { + return SidebarBonsplitTabNewWorkspaceDropView() + } + + func updateNSView(_ nsView: SidebarBonsplitTabNewWorkspaceDropView, context: Context) { + nsView.isValidTransfer = { + guard let transfer = BonsplitTabDragPayload.currentTransfer() else { return false } + return AppDelegate.shared?.canMoveBonsplitTabToNewWorkspace(tabId: transfer.tab.id) ?? false + } + nsView.setDropActive = { isActive in + dropIndicator = isActive ? SidebarDropIndicator(tabId: nil, edge: .bottom) : nil + } + nsView.performMove = { + guard let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared, + let result = app.moveBonsplitTabToNewWorkspace( + tabId: transfer.tab.id, + destinationManager: tabManager, + focus: true, + focusWindow: true, + placementOverride: .end + ) else { + return false + } + + selectedTabIds = [result.destinationWorkspaceId] + syncSidebarSelection(preferredSelectedTabId: result.destinationWorkspaceId) + return true + } + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + +final class SidebarBonsplitTabNewWorkspaceDropView: NSView { + private static let pasteboardType = NSPasteboard.PasteboardType(BonsplitTabDragPayload.typeIdentifier) + + var isValidTransfer: () -> Bool = { false } + var setDropActive: (Bool) -> Void = { _ in } + var performMove: () -> Bool = { false } + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([Self.pasteboardType]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let capture = shouldCaptureHitTest() + guard capture else { return nil } + return super.hitTest(point) + } + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDrag(sender) + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDrag(sender) + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + setDropActive(false) + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + acceptsDrag(sender) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + defer { setDropActive(false) } + guard acceptsDrag(sender) else { return false } + return performMove() + } + + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + setDropActive(false) + } + + private func updateDrag(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard acceptsDrag(sender) else { + setDropActive(false) + return [] + } + setDropActive(true) + return .move + } + + private func acceptsDrag(_ sender: any NSDraggingInfo) -> Bool { + guard sender.draggingPasteboard.types?.contains(Self.pasteboardType) == true else { return false } + return isValidTransfer() + } + + private func shouldCaptureHitTest() -> Bool { + guard BonsplitTabDragPayload.currentTransfer() != nil else { return false } + guard let eventType = NSApp.currentEvent?.type else { return true } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged, .cursorUpdate, .mouseMoved: + return true + default: + return false + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b2a444ef16b3..ee579ddd3ef8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1975,7 +1975,6 @@ struct ContentView: View { static let workspaceHasUnread = "workspace.hasUnread" static let workspaceHasRead = "workspace.hasRead" static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" - static let hasFocusedPanel = "panel.hasFocus" static let panelName = "panel.name" static let panelIsBrowser = "panel.isBrowser" @@ -1984,11 +1983,10 @@ struct ContentView: View { static let panelHasCustomName = "panel.hasCustomName" static let panelShouldPin = "panel.shouldPin" static let panelHasUnread = "panel.hasUnread" - + static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" static let updateHasAvailable = "update.hasAvailable" static let cliInstalledInPATH = "cli.installedInPATH" static let browserDisabled = "browser.disabled" - static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { "terminal.openTarget.\(target.rawValue).available" } @@ -6828,17 +6826,14 @@ struct ContentView: View { let panelId = panelContext.panelId let panelIsTerminal = panelContext.panel.panelType == .terminal snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) - snapshot.setString( - CommandPaletteContextKeys.panelName, - panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) - ) + snapshot.setString(CommandPaletteContextKeys.panelName, panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle)) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasPane, workspace.paneId(forPanelId: panelId) != nil) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) - let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) - || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) + snapshot.setBool(CommandPaletteContextKeys.panelCanMoveToNewWorkspace, workspace.panels.count > 1) + let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) if panelIsTerminal { @@ -7322,6 +7317,7 @@ struct ContentView: View { } ) ) + appendMoveTabToNewWorkspaceCommandContribution(to: &contributions, panelSubtitle: panelSubtitle) contributions.append( CommandPaletteCommandContribution( commandId: "palette.toggleTabPin", @@ -8004,6 +8000,9 @@ struct ContentView: View { } panelContext.workspace.setPanelCustomTitle(panelId: panelContext.panelId, title: nil) } + registry.register(commandId: "palette.moveTabToNewWorkspace") { + guard moveFocusedPanelToNewWorkspace() else { NSSound.beep(); return } + } registry.register(commandId: "palette.toggleTabPin") { guard let panelContext = focusedPanelContext else { NSSound.beep() @@ -9931,6 +9930,23 @@ struct VerticalTabsSidebar: View { .frame(height: sidebarTitlebarInteractionHeight) .background(TitlebarDoubleClickMonitorView()) } + .overlay(alignment: .top) { + if draggedTabId != nil, let firstWorkspaceId = renderContext.workspaceIds.first { + Color.clear + .contentShape(Rectangle()) + .frame(height: workspaceScrollTopVisibilityInset + 8) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: firstWorkspaceId, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: nil, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + } + } .overlay(alignment: .topLeading) { if isMinimalMode { HiddenTitlebarSidebarControlsView( @@ -12319,6 +12335,7 @@ private struct SidebarEmptyArea: View { dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) + .overlay { SidebarBonsplitTabNewWorkspaceDropOverlay(tabManager: tabManager, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, dropIndicator: $dropIndicator).frame(maxWidth: .infinity, maxHeight: .infinity) } .overlay(alignment: .top) { if shouldShowTopDropIndicator { Rectangle() @@ -12331,7 +12348,7 @@ private struct SidebarEmptyArea: View { } private var shouldShowTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } + guard let indicator = dropIndicator else { return false } if indicator.tabId == nil { return true } @@ -14807,7 +14824,7 @@ private enum SidebarTabDragPayload { } } -private enum BonsplitTabDragPayload { +enum BonsplitTabDragPayload { static let typeIdentifier = "com.splittabbar.tabtransfer" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] diff --git a/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift b/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..b92544016a36 --- /dev/null +++ b/Sources/GhosttyNSView+MoveTabToNewWorkspace.swift @@ -0,0 +1,36 @@ +import AppKit + +extension GhosttyNSView { + func appendMoveCurrentSurfaceToNewWorkspaceMenuItem(to menu: NSMenu) { + guard canMoveCurrentSurfaceToNewWorkspace() else { return } + + menu.addItem(.separator()) + let item = menu.addItem( + withTitle: String(localized: "terminalContextMenu.moveTabToNewWorkspace", defaultValue: "Move Tab to New Workspace"), + action: #selector(moveCurrentSurfaceToNewWorkspace(_:)), + keyEquivalent: "" + ) + item.target = self + item.image = NSImage( + systemSymbolName: "rectangle.portrait.and.arrow.right", + accessibilityDescription: nil + ) + } + + private func canMoveCurrentSurfaceToNewWorkspace() -> Bool { + guard let surfaceId = terminalSurface?.id else { return false } + return AppDelegate.shared?.canMoveSurfaceToNewWorkspace(panelId: surfaceId) ?? false + } + + @objc func moveCurrentSurfaceToNewWorkspace(_ sender: Any?) { + guard let surfaceId = terminalSurface?.id, + AppDelegate.shared?.moveSurfaceToNewWorkspace( + panelId: surfaceId, + focus: true, + focusWindow: false + ) != nil else { + NSSound.beep() + return + } + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b48c141e55a7..0f6ff971e96b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8781,7 +8781,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { systemSymbolName: "rectangle.righthalf.inset.filled", accessibilityDescription: nil ) - menu.addItem(.separator()) + appendMoveCurrentSurfaceToNewWorkspaceMenuItem(to: menu); menu.addItem(.separator()) let resetTerminalItem = menu.addItem( withTitle: String(localized: "terminalContextMenu.resetTerminal", defaultValue: "Reset Terminal"), action: #selector(resetTerminal(_:)), diff --git a/Sources/Panels/BrowserPanel+MoveTabToNewWorkspace.swift b/Sources/Panels/BrowserPanel+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..5bc7205ab7cc --- /dev/null +++ b/Sources/Panels/BrowserPanel+MoveTabToNewWorkspace.swift @@ -0,0 +1,18 @@ +import Foundation + +extension BrowserPanel { + func configureMoveTabToNewWorkspaceContextMenu(for webView: CmuxWebView) { + webView.contextMenuCanMoveTabToNewWorkspace = { [weak self] in + guard let self else { return false } + return AppDelegate.shared?.canMoveSurfaceToNewWorkspace(panelId: self.id) ?? false + } + webView.contextMenuMoveTabToNewWorkspace = { [weak self] in + guard let self else { return false } + return AppDelegate.shared?.moveSurfaceToNewWorkspace( + panelId: self.id, + focus: true, + focusWindow: false + ) != nil + } + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index ee9ada235a3c..a7330bfd7da4 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2637,7 +2637,7 @@ final class BrowserPanel: Panel, ObservableObject { webView.onContextMenuOpenLinkInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } - configureNavigationDelegateCallbacks() + configureMoveTabToNewWorkspaceContextMenu(for: webView); configureNavigationDelegateCallbacks() webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) diff --git a/Sources/Panels/CmuxWebView+MoveTabToNewWorkspace.swift b/Sources/Panels/CmuxWebView+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..a28a4fc38569 --- /dev/null +++ b/Sources/Panels/CmuxWebView+MoveTabToNewWorkspace.swift @@ -0,0 +1,37 @@ +import AppKit + +extension CmuxWebView { + func appendMoveTabToNewWorkspaceContextMenuItem(to menu: NSMenu) { + let title = String(localized: "browser.contextMenu.moveTabToNewWorkspace", defaultValue: "Move Tab to New Workspace") + guard contextMenuMoveTabToNewWorkspace != nil, + contextMenuCanMoveTabToNewWorkspace?() ?? true, + !hasMoveTabToNewWorkspaceContextMenuItem(in: menu, title: title) else { + return + } + + if !menu.items.isEmpty { + menu.addItem(.separator()) + } + let item = NSMenuItem( + title: title, + action: #selector(contextMenuMoveTabToNewWorkspace(_:)), + keyEquivalent: "" + ) + item.target = self + menu.addItem(item) + } + + private func hasMoveTabToNewWorkspaceContextMenuItem(in menu: NSMenu, title: String) -> Bool { + menu.items.contains { item in + item.action == #selector(contextMenuMoveTabToNewWorkspace(_:)) || item.title == title + } + } + + @objc func contextMenuMoveTabToNewWorkspace(_ sender: Any?) { + _ = sender + guard contextMenuMoveTabToNewWorkspace?() == true else { + NSSound.beep() + return + } + } +} diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index e184996cb5e7..0b0151012aa2 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -349,13 +349,13 @@ final class CmuxWebView: WKWebView { private static var contextMenuFallbackKey: UInt8 = 0 private static let pasteAsPlainTextKeyCode: UInt16 = 9 // V key (hardware position, layout-independent) - var onContextMenuDownloadStateChanged: ((Bool) -> Void)? /// Called when "Open Link in New Tab" context menu is selected. /// Bypasses createWebViewWith so the link opens as a tab, not a popup. var onContextMenuOpenLinkInNewTab: ((URL) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? + var contextMenuCanMoveTabToNewWorkspace: (() -> Bool)?; var contextMenuMoveTabToNewWorkspace: (() -> Bool)? /// Guard against background panes stealing first responder (e.g. page autofocus). /// BrowserPanelView updates this as pane focus state changes. var allowsFirstResponderAcquisition: Bool = true @@ -2129,8 +2129,8 @@ final class CmuxWebView: WKWebView { item.target = self menu.insertItem(item, at: min(openLinkInsertionIndex, menu.items.count)) } + appendMoveTabToNewWorkspaceContextMenuItem(to: menu) } - @objc private func contextMenuOpenLinkInDefaultBrowser(_ sender: Any?) { _ = sender let point = lastContextMenuPoint diff --git a/Sources/TerminalController+MoveTabToNewWorkspace.swift b/Sources/TerminalController+MoveTabToNewWorkspace.swift new file mode 100644 index 000000000000..c5ca154c2c01 --- /dev/null +++ b/Sources/TerminalController+MoveTabToNewWorkspace.swift @@ -0,0 +1,60 @@ +import Foundation + +extension TerminalController { + func v2MoveTabToNewWorkspaceActionResult( + action: String, + params: [String: Any], + tabManager: TabManager, + workspace: Workspace, + surfaceId: UUID + ) -> V2CallResult { + guard workspace.panels.count > 1 else { + return .err( + code: "invalid_state", + message: "Tab cannot be moved to a new workspace because it is the only tab in its workspace", + data: nil + ) + } + guard let app = AppDelegate.shared else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) + guard let result = app.moveSurfaceToNewWorkspace( + panelId: surfaceId, + destinationManager: tabManager, + title: v2String(params, "title"), + focus: focus, + focusWindow: false + ) else { + return .err(code: "internal_error", message: "Failed to move tab to new workspace", data: nil) + } + + return .ok(v2MoveTabToNewWorkspacePayload(action: action, result: result)) + } + + private func v2MoveTabToNewWorkspacePayload( + action: String, + result: SurfaceNewWorkspaceMoveResult + ) -> [String: Any] { + [ + "action": action, + "source_window_id": result.sourceWindowId.uuidString, + "source_window_ref": v2Ref(kind: .window, uuid: result.sourceWindowId), + "source_workspace_id": result.sourceWorkspaceId.uuidString, + "source_workspace_ref": v2Ref(kind: .workspace, uuid: result.sourceWorkspaceId), + "window_id": v2OrNull(result.destinationWindowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: result.destinationWindowId), + "workspace_id": result.destinationWorkspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: result.destinationWorkspaceId), + "created_workspace_id": result.destinationWorkspaceId.uuidString, + "created_workspace_ref": v2Ref(kind: .workspace, uuid: result.destinationWorkspaceId), + "surface_id": result.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: result.surfaceId), + "tab_id": result.surfaceId.uuidString, + "tab_ref": v2TabRef(uuid: result.surfaceId), + "pane_id": v2OrNull(result.paneId?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: result.paneId), + ] + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 59ab268700b3..8d4ec942f097 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -153,7 +153,7 @@ class TerminalController { "feed.jump" ] - private enum V2HandleKind: String, CaseIterable { + enum V2HandleKind: String, CaseIterable { case window case workspace case pane @@ -276,7 +276,7 @@ class TerminalController { Self.allowsInAppFocusMutationsForActiveSocketCommand() } - private func v2FocusAllowed(requested: Bool = true) -> Bool { + func v2FocusAllowed(requested: Bool = true) -> Bool { requested && socketCommandAllowsInAppFocusMutations() } @@ -3698,7 +3698,7 @@ class TerminalController { return result } - private nonisolated func v2OrNull(_ value: Any?) -> Any { + nonisolated func v2OrNull(_ value: Any?) -> Any { // Avoid relying on `?? NSNull()` inference (Swift toolchains can disagree). if let value { return value } return NSNull() @@ -3786,7 +3786,7 @@ class TerminalController { ]) } - private enum V2CallResult { + enum V2CallResult { case ok(Any) case err(code: String, message: String, data: Any?) } @@ -3844,7 +3844,7 @@ class TerminalController { return nil } - private func v2Ref(kind: V2HandleKind, uuid: UUID?) -> Any { + func v2Ref(kind: V2HandleKind, uuid: UUID?) -> Any { guard let uuid else { return NSNull() } return v2EnsureHandleRef(kind: kind, uuid: uuid) } @@ -3870,7 +3870,7 @@ class TerminalController { ) } - private func v2TabRef(uuid: UUID?) -> Any { + func v2TabRef(uuid: UUID?) -> Any { guard let uuid else { return NSNull() } let surfaceRef = v2EnsureHandleRef(kind: .surface, uuid: uuid) return surfaceRef.replacingOccurrences(of: "surface:", with: "tab:") @@ -3941,7 +3941,7 @@ class TerminalController { // MARK: - V2 Param Parsing - private func v2String(_ params: [String: Any], _ key: String) -> String? { + func v2String(_ params: [String: Any], _ key: String) -> String? { guard let raw = params[key] as? String else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed @@ -4009,7 +4009,7 @@ class TerminalController { } return v2ResolveHandleRef(trimmed) } - private func v2Bool(_ params: [String: Any], _ key: String) -> Bool? { + func v2Bool(_ params: [String: Any], _ key: String) -> Bool? { if let b = params[key] as? Bool { return b } if let n = params[key] as? NSNumber { return n.boolValue } if let s = params[key] as? String { @@ -5246,7 +5246,6 @@ class TerminalController { guard let action = v2ActionKey(params) else { return .err(code: "invalid_params", message: "Missing action", data: nil) } - let supportedActions = [ "pin", "unpin", "rename", "clear_name", "set_description", "clear_description", @@ -5445,10 +5444,9 @@ class TerminalController { "rename", "clear_name", "close_left", "close_right", "close_others", "new_terminal_right", "new_browser_right", - "reload", "duplicate", + "reload", "duplicate", "move_to_new_workspace", "detach_to_workspace", "detach_to_new_workspace", "pin", "unpin", "mark_read", "mark_unread" ] - var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ "action": action, "supported_actions": supportedActions @@ -5568,6 +5566,8 @@ class TerminalController { workspace.markPanelUnread(surfaceId) finish() + case "move_to_new_workspace", "detach_to_workspace", "detach_to_new_workspace": + result = v2MoveTabToNewWorkspaceActionResult(action: action, params: params, tabManager: tabManager, workspace: workspace, surfaceId: surfaceId) case "reload", "reload_tab": guard let browserPanel = workspace.browserPanel(for: surfaceId) else { result = .err(code: "invalid_state", message: "Reload is only available for browser tabs", data: nil) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 78883effb0f6..5518c60b6acf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7866,6 +7866,9 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.onExternalTabDrop = { [weak self] request in self?.handleExternalTabDrop(request) ?? false } + bonsplitController.tabContextMoveDestinationsProvider = { [weak self] tabId, _ in + self?.bonsplitTabMoveDestinations(for: tabId) ?? [] + } bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in self?.markExplicitClose(surfaceId: tabId) } @@ -10833,6 +10836,16 @@ final class Workspace: Identifiable, ObservableObject { return true } + @discardableResult + private func moveSurfaceToAdjacentPane(panelId: UUID, direction: NavigationDirection) -> Bool { + guard panels[panelId] != nil, + let sourcePaneId = paneId(forPanelId: panelId), + let targetPaneId = bonsplitController.adjacentPane(to: sourcePaneId, direction: direction) else { + return false + } + return moveSurface(panelId: panelId, toPane: targetPaneId, focus: true) + } + @discardableResult func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } @@ -12258,91 +12271,72 @@ final class Workspace: Identifiable, ObservableObject { setPanelCustomTitle(panelId: panelId, title: input.stringValue) } - private enum PanelMoveDestination { - case newWorkspaceInCurrentWindow - case selectedWorkspaceInNewWindow - case existingWorkspace(UUID) - } + private static let bonsplitMoveNewWorkspaceDestinationId = "new-workspace" + private static let bonsplitMoveExistingWorkspacePrefix = "workspace:" - private func promptMovePanel(tabId: TabID) { + private func bonsplitTabMoveDestinations(for tabId: TabID) -> [TabContextMoveDestination] { guard let panelId = panelIdFromSurfaceId(tabId), - let app = AppDelegate.shared else { return } + let app = AppDelegate.shared else { return [] } let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } let workspaceTargets = app.workspaceMoveTargets( excludingWorkspaceId: id, referenceWindowId: currentWindowId ) - - var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "alert.moveTab.newWorkspaceInCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "alert.moveTab.selectedWorkspaceInNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), - ] - options.append(contentsOf: workspaceTargets.map { target in - (target.label, .existingWorkspace(target.workspaceId)) + var destinations: [TabContextMoveDestination] = [] + if app.canMoveSurfaceToNewWorkspace(panelId: panelId) { + destinations.append(TabContextMoveDestination( + id: Self.bonsplitMoveNewWorkspaceDestinationId, + title: String(localized: "command.newWorkspace.title", defaultValue: "New Workspace") + )) + } + destinations.append(contentsOf: workspaceTargets.map { target in + TabContextMoveDestination( + id: Self.bonsplitMoveExistingWorkspacePrefix + target.workspaceId.uuidString, + title: target.label + ) }) + return destinations + } - let alert = NSAlert() - alert.messageText = String(localized: "alert.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "alert.moveTab.message", defaultValue: "Choose a destination for this tab.") - let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) - for option in options { - popup.addItem(withTitle: option.title) - } - popup.selectItem(at: 0) - alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "alert.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "alert.cancel", defaultValue: "Cancel")) - - guard alert.runModal() == .alertFirstButtonReturn else { return } - let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) - let destination = options[selectedIndex].destination + @discardableResult + private func moveBonsplitTab(_ tabId: TabID, toMoveDestination destinationId: String) -> Bool { + guard let panelId = panelIdFromSurfaceId(tabId), + let app = AppDelegate.shared else { return false } let moved: Bool - switch destination { - case .newWorkspaceInCurrentWindow: - guard let manager = app.tabManagerFor(tabId: id) else { return } - let workspace = manager.addWorkspace(select: true) - moved = app.moveSurface( + if destinationId == Self.bonsplitMoveNewWorkspaceDestinationId { + moved = app.moveSurfaceToNewWorkspace( panelId: panelId, - toWorkspace: workspace.id, focus: true, focusWindow: false - ) - - case .selectedWorkspaceInNewWindow: - let newWindowId = app.createMainWindow() - guard let destinationManager = app.tabManagerFor(windowId: newWindowId), - let destinationWorkspaceId = destinationManager.selectedTabId else { - return - } - moved = app.moveSurface( - panelId: panelId, - toWorkspace: destinationWorkspaceId, - focus: true, - focusWindow: true - ) - if !moved { - _ = app.closeMainWindow(windowId: newWindowId) - } - - case .existingWorkspace(let workspaceId): + ) != nil + } else if destinationId.hasPrefix(Self.bonsplitMoveExistingWorkspacePrefix) { + let rawWorkspaceId = destinationId.dropFirst(Self.bonsplitMoveExistingWorkspacePrefix.count) + guard let workspaceId = UUID(uuidString: String(rawWorkspaceId)) else { return false } moved = app.moveSurface( panelId: panelId, toWorkspace: workspaceId, focus: true, focusWindow: true ) + } else { + moved = false } if !moved { - let failure = NSAlert() - failure.alertStyle = .warning - failure.messageText = String(localized: "alert.moveTab.failed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "alert.moveTab.failed.message", defaultValue: "cmux could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) - _ = failure.runModal() + showMoveTabFailureAlert() } + return moved + } + + private func showMoveTabFailureAlert() { + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = String(localized: "alert.moveTab.failed.title", defaultValue: "Move Failed") + failure.informativeText = String(localized: "alert.moveTab.failed.message", defaultValue: "cmux could not move this tab to the selected destination.") + failure.addButton(withTitle: String(localized: "alert.ok", defaultValue: "OK")) + _ = failure.runModal() } private func handleSessionDrop( @@ -13645,7 +13639,17 @@ extension Workspace: BonsplitDelegate { case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) case .move: - promptMovePanel(tabId: tab.id) + if let destination = bonsplitTabMoveDestinations(for: tab.id).first { + _ = moveBonsplitTab(tab.id, toMoveDestination: destination.id) + } + case .moveToNewWorkspace: + _ = AppDelegate.shared?.moveBonsplitTabToNewWorkspace(tabId: tab.id.uuid, focus: true, focusWindow: false) + case .moveToLeftPane: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + _ = moveSurfaceToAdjacentPane(panelId: panelId, direction: .left) + case .moveToRightPane: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + _ = moveSurfaceToAdjacentPane(panelId: panelId, direction: .right) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -13674,6 +13678,10 @@ extension Workspace: BonsplitDelegate { } } + func splitTabBar(_ controller: BonsplitController, didRequestTabMoveToDestination destinationId: String, for tab: Bonsplit.Tab, inPane pane: PaneID) { + _ = moveBonsplitTab(tab.id, toMoveDestination: destinationId) + } + func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { tmuxLayoutSnapshot = snapshot scheduleTerminalGeometryReconcile() diff --git a/cmuxTests/AppDelegateMoveTabToNewWorkspaceTests.swift b/cmuxTests/AppDelegateMoveTabToNewWorkspaceTests.swift new file mode 100644 index 000000000000..96bb9bced6ca --- /dev/null +++ b/cmuxTests/AppDelegateMoveTabToNewWorkspaceTests.swift @@ -0,0 +1,93 @@ +import AppKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateMoveTabToNewWorkspaceTests: XCTestCase { + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + func testMoveSurfaceToNewWorkspaceCreatesSinglePanelWorkspaceFromPanelTitle() throws { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.close() } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let sourcePaneId = try XCTUnwrap(sourceWorkspace.bonsplitController.allPaneIds.first) + let remainingPanelId = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) + let movedPanel = try XCTUnwrap(sourceWorkspace.newTerminalSurface(inPane: sourcePaneId, focus: false)) + sourceWorkspace.setPanelCustomTitle(panelId: movedPanel.id, title: "Build logs") + + let originalWorkspaceCount = manager.tabs.count + let result = try XCTUnwrap(app.moveSurfaceToNewWorkspace( + panelId: movedPanel.id, + focus: false, + focusWindow: false + )) + + let destinationWorkspace = try XCTUnwrap(manager.tabs.first { $0.id == result.destinationWorkspaceId }) + XCTAssertEqual(result.sourceWindowId, windowId) + XCTAssertEqual(result.sourceWorkspaceId, sourceWorkspace.id) + XCTAssertEqual(result.destinationWindowId, windowId) + XCTAssertEqual(manager.tabs.count, originalWorkspaceCount + 1) + XCTAssertEqual(destinationWorkspace.title, "Build logs") + XCTAssertEqual(destinationWorkspace.panels.count, 1) + XCTAssertNotNil(destinationWorkspace.panels[movedPanel.id]) + XCTAssertNil(sourceWorkspace.panels[movedPanel.id]) + XCTAssertNotNil(sourceWorkspace.panels[remainingPanelId]) + XCTAssertEqual(result.paneId, destinationWorkspace.paneId(forPanelId: movedPanel.id)?.id) + } + + func testMoveSurfaceToNewWorkspaceRejectsOnlyPanel() throws { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.close() } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + + let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let onlyPanelId = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) + + XCTAssertFalse(app.canMoveSurfaceToNewWorkspace(panelId: onlyPanelId)) + XCTAssertNil(app.moveSurfaceToNewWorkspace(panelId: onlyPanelId, focus: false, focusWindow: false)) + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(sourceWorkspace.panels[onlyPanelId]) + } +} diff --git a/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift b/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift new file mode 100644 index 000000000000..08f7cbdfef4a --- /dev/null +++ b/cmuxTests/WorkspaceAdjacentPaneMoveTests.swift @@ -0,0 +1,50 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class WorkspaceAdjacentPaneMoveTests: XCTestCase { + func testTabContextMoveToRightPaneMovesSurfaceToAdjacentPane() throws { + let workspace = Workspace() + let leftPanelId = try XCTUnwrap(workspace.focusedPanelId) + let leftPaneId = try XCTUnwrap(workspace.paneId(forPanelId: leftPanelId)) + let rightPanel = try XCTUnwrap(workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal, focus: false)) + let rightPaneId = try XCTUnwrap(workspace.paneId(forPanelId: rightPanel.id)) + let leftTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(leftPanelId)) + let leftTab = try XCTUnwrap(workspace.bonsplitController.tab(leftTabId)) + + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .moveToRightPane, + for: leftTab, + inPane: leftPaneId + ) + + XCTAssertEqual(workspace.paneId(forPanelId: leftPanelId), rightPaneId) + XCTAssertTrue(workspace.bonsplitController.tabs(inPane: rightPaneId).contains { $0.id == leftTabId }) + } + + func testTabContextMoveToLeftPaneMovesSurfaceToAdjacentPane() throws { + let workspace = Workspace() + let leftPanelId = try XCTUnwrap(workspace.focusedPanelId) + let leftPaneId = try XCTUnwrap(workspace.paneId(forPanelId: leftPanelId)) + let rightPanel = try XCTUnwrap(workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal, focus: false)) + let rightPaneId = try XCTUnwrap(workspace.paneId(forPanelId: rightPanel.id)) + let rightTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(rightPanel.id)) + let rightTab = try XCTUnwrap(workspace.bonsplitController.tab(rightTabId)) + + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .moveToLeftPane, + for: rightTab, + inPane: rightPaneId + ) + + XCTAssertEqual(workspace.paneId(forPanelId: rightPanel.id), leftPaneId) + XCTAssertTrue(workspace.bonsplitController.tabs(inPane: leftPaneId).contains { $0.id == rightTabId }) + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index a1fb2111f5e6..cb4910e5d960 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit a1fb2111f5e6f1babdd40357ad6c0e7f61834fc7 +Subproject commit cb4910e5d9602f97aa717e0790e6b9a4a3530650