Add tab/workspace action APIs and consistent naming
This commit is contained in:
parent
dc2b3e506b
commit
10e44396df
3 changed files with 633 additions and 19 deletions
196
CLI/cmux.swift
196
CLI/cmux.swift
|
|
@ -583,6 +583,12 @@ struct CMUXCLI {
|
|||
case "reorder-workspace":
|
||||
try runReorderWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
|
||||
|
||||
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 "list-workspaces":
|
||||
let payload = try client.sendV2(method: "workspace.list")
|
||||
if jsonOutput {
|
||||
|
|
@ -1492,6 +1498,144 @@ struct CMUXCLI {
|
|||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary)
|
||||
}
|
||||
|
||||
private func runWorkspaceAction(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat,
|
||||
windowOverride: String?
|
||||
) throws {
|
||||
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let (actionOpt, rem1) = parseOption(rem0, name: "--action")
|
||||
let (titleOpt, rem2) = parseOption(rem1, name: "--title")
|
||||
|
||||
var positional = rem2
|
||||
let actionRaw: String
|
||||
if let actionOpt {
|
||||
actionRaw = actionOpt
|
||||
} else if let first = positional.first {
|
||||
actionRaw = first
|
||||
positional.removeFirst()
|
||||
} else {
|
||||
throw CLIError(message: "workspace-action requires --action <name>")
|
||||
}
|
||||
|
||||
if let unknown = positional.first(where: { $0.hasPrefix("--") }) {
|
||||
throw CLIError(message: "workspace-action: unknown flag '\(unknown)'")
|
||||
}
|
||||
|
||||
let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_")
|
||||
let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
|
||||
|
||||
let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if action == "rename", (title?.isEmpty ?? true) {
|
||||
throw CLIError(message: "workspace-action rename requires --title <text> (or a trailing title)")
|
||||
}
|
||||
|
||||
var params: [String: Any] = ["action": action]
|
||||
if let workspaceId {
|
||||
params["workspace_id"] = workspaceId
|
||||
}
|
||||
if let title, !title.isEmpty {
|
||||
params["title"] = title
|
||||
}
|
||||
|
||||
let payload = try client.sendV2(method: "workspace.action", params: params)
|
||||
var summaryParts = ["OK", "action=\(action)"]
|
||||
if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) {
|
||||
summaryParts.append("workspace=\(workspaceHandle)")
|
||||
}
|
||||
if let windowHandle = formatHandle(payload, kind: "window", idFormat: idFormat) {
|
||||
summaryParts.append("window=\(windowHandle)")
|
||||
}
|
||||
if let closed = payload["closed"] {
|
||||
summaryParts.append("closed=\(closed)")
|
||||
}
|
||||
if let index = payload["index"] {
|
||||
summaryParts.append("index=\(index)")
|
||||
}
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
|
||||
}
|
||||
|
||||
private func runTabAction(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
jsonOutput: Bool,
|
||||
idFormat: CLIIDFormat,
|
||||
windowOverride: String?
|
||||
) throws {
|
||||
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
|
||||
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
|
||||
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
|
||||
let (actionOpt, rem3) = parseOption(rem2, name: "--action")
|
||||
let (titleOpt, rem4) = parseOption(rem3, name: "--title")
|
||||
let (urlOpt, rem5) = parseOption(rem4, name: "--url")
|
||||
|
||||
var positional = rem5
|
||||
let actionRaw: String
|
||||
if let actionOpt {
|
||||
actionRaw = actionOpt
|
||||
} else if let first = positional.first {
|
||||
actionRaw = first
|
||||
positional.removeFirst()
|
||||
} else {
|
||||
throw CLIError(message: "tab-action requires --action <name>")
|
||||
}
|
||||
|
||||
if let unknown = positional.first(where: { $0.hasPrefix("--") }) {
|
||||
throw CLIError(message: "tab-action: unknown flag '\(unknown)'")
|
||||
}
|
||||
|
||||
let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_")
|
||||
let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
|
||||
let tabArg = tabOpt ?? surfaceOpt ?? (workspaceOpt == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
|
||||
|
||||
let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
|
||||
let surfaceId = try normalizeSurfaceHandle(tabArg, client: client, workspaceHandle: workspaceId, allowFocused: true)
|
||||
|
||||
let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if action == "rename", (title?.isEmpty ?? true) {
|
||||
throw CLIError(message: "tab-action rename requires --title <text> (or a trailing title)")
|
||||
}
|
||||
|
||||
var params: [String: Any] = ["action": action]
|
||||
if let workspaceId {
|
||||
params["workspace_id"] = workspaceId
|
||||
}
|
||||
if let surfaceId {
|
||||
params["surface_id"] = surfaceId
|
||||
}
|
||||
if let title, !title.isEmpty {
|
||||
params["title"] = title
|
||||
}
|
||||
if let urlOpt, !urlOpt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
params["url"] = urlOpt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
let payload = try client.sendV2(method: "tab.action", params: params)
|
||||
var summaryParts = ["OK", "action=\(action)"]
|
||||
if let tabHandle = formatHandle(payload, kind: "surface", 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 = payload["created_surface_ref"] as? String {
|
||||
summaryParts.append("created=\(created)")
|
||||
} else if let createdId = payload["created_surface_id"] as? String {
|
||||
summaryParts.append("created=\(createdId)")
|
||||
}
|
||||
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
|
||||
}
|
||||
|
||||
private func runBrowserCommand(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
|
|
@ -2761,6 +2905,56 @@ struct CMUXCLI {
|
|||
cmux reorder-workspace --workspace workspace:2 --index 0
|
||||
cmux reorder-workspace --workspace workspace:3 --after workspace:1
|
||||
"""
|
||||
case "workspace-action":
|
||||
return """
|
||||
Usage: cmux workspace-action --action <name> [flags]
|
||||
|
||||
Perform workspace context-menu actions from CLI/socket.
|
||||
|
||||
Actions:
|
||||
pin | unpin
|
||||
rename | clear-name
|
||||
move-up | move-down | move-top
|
||||
close-others | close-above | close-below
|
||||
mark-read | mark-unread
|
||||
|
||||
Flags:
|
||||
--action <name> Action name (required if not positional)
|
||||
--workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)
|
||||
--title <text> Title for rename
|
||||
|
||||
Example:
|
||||
cmux workspace-action --workspace workspace:2 --action pin
|
||||
cmux workspace-action --action rename --title "infra"
|
||||
cmux workspace-action close-others
|
||||
"""
|
||||
case "tab-action":
|
||||
return """
|
||||
Usage: cmux tab-action --action <name> [flags]
|
||||
|
||||
Perform horizontal tab context-menu actions from CLI/socket.
|
||||
|
||||
Actions:
|
||||
rename | clear-name
|
||||
close-left | close-right | close-others
|
||||
new-terminal-right | new-browser-right
|
||||
reload | duplicate
|
||||
pin | unpin
|
||||
mark-unread
|
||||
|
||||
Flags:
|
||||
--action <name> Action name (required if not positional)
|
||||
--tab <id|ref|index> Target tab (alias: --surface)
|
||||
--surface <id|ref|index> Alias for --tab
|
||||
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
|
||||
--title <text> Title for rename
|
||||
--url <url> Optional URL for new-browser-right
|
||||
|
||||
Example:
|
||||
cmux tab-action --tab surface:3 --action pin
|
||||
cmux tab-action --action close-right
|
||||
cmux tab-action --tab surface:2 --action rename --title "build logs"
|
||||
"""
|
||||
case "new-workspace":
|
||||
return """
|
||||
Usage: cmux new-workspace
|
||||
|
|
@ -4021,6 +4215,7 @@ struct CMUXCLI {
|
|||
close-window --window <id>
|
||||
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>]
|
||||
list-workspaces
|
||||
new-workspace [--command <text>]
|
||||
new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
|
||||
|
|
@ -4032,6 +4227,7 @@ struct CMUXCLI {
|
|||
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>] [--workspace <id|ref|index>] [--title <text>] [--url <url>]
|
||||
drag-surface-to-split --surface <id|ref> <left|right|up|down>
|
||||
refresh-surfaces
|
||||
surface-health [--workspace <id|ref>]
|
||||
|
|
|
|||
|
|
@ -2421,8 +2421,11 @@ private struct TabItemView: View {
|
|||
let targetIds = contextTargetIds()
|
||||
let shouldPin = !tab.isPinned
|
||||
let pinLabel = targetIds.count > 1
|
||||
? (shouldPin ? "Pin Tabs" : "Unpin Tabs")
|
||||
: (shouldPin ? "Pin Tab" : "Unpin Tab")
|
||||
? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces")
|
||||
: (shouldPin ? "Pin Workspace" : "Unpin Workspace")
|
||||
let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace"
|
||||
let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read"
|
||||
let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread"
|
||||
Button(pinLabel) {
|
||||
for id in targetIds {
|
||||
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
||||
|
|
@ -2432,12 +2435,12 @@ private struct TabItemView: View {
|
|||
syncSelectionAfterMutation()
|
||||
}
|
||||
|
||||
Button("Rename Tab…") {
|
||||
Button("Rename Workspace…") {
|
||||
promptRename()
|
||||
}
|
||||
|
||||
if tab.hasCustomTitle {
|
||||
Button("Remove Custom Name") {
|
||||
Button("Remove Custom Workspace Name") {
|
||||
tabManager.clearCustomTitle(tabId: tab.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -2454,14 +2457,20 @@ private struct TabItemView: View {
|
|||
}
|
||||
.disabled(index >= tabManager.tabs.count - 1)
|
||||
|
||||
Button("Move to Top") {
|
||||
tabManager.moveTabsToTop(Set(targetIds))
|
||||
syncSelectionAfterMutation()
|
||||
}
|
||||
.disabled(targetIds.isEmpty)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Close Workspaces") {
|
||||
Button(closeLabel) {
|
||||
closeTabs(targetIds, allowPinned: true)
|
||||
}
|
||||
.disabled(targetIds.isEmpty)
|
||||
|
||||
Button("Close Others") {
|
||||
Button("Close Other Workspaces") {
|
||||
closeOtherTabs(targetIds)
|
||||
}
|
||||
.disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count)
|
||||
|
|
@ -2478,20 +2487,12 @@ private struct TabItemView: View {
|
|||
|
||||
Divider()
|
||||
|
||||
Button("Move to Top") {
|
||||
tabManager.moveTabsToTop(Set(targetIds))
|
||||
syncSelectionAfterMutation()
|
||||
}
|
||||
.disabled(targetIds.isEmpty)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Mark as Read") {
|
||||
Button(markReadLabel) {
|
||||
markTabsRead(targetIds)
|
||||
}
|
||||
.disabled(!hasUnreadNotifications(in: targetIds))
|
||||
|
||||
Button("Mark as Unread") {
|
||||
Button(markUnreadLabel) {
|
||||
markTabsUnread(targetIds)
|
||||
}
|
||||
.disabled(!hasReadNotifications(in: targetIds))
|
||||
|
|
@ -2729,10 +2730,10 @@ private struct TabItemView: View {
|
|||
|
||||
private func promptRename() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Rename Tab"
|
||||
alert.informativeText = "Enter a custom name for this tab."
|
||||
alert.messageText = "Rename Workspace"
|
||||
alert.informativeText = "Enter a custom name for this workspace."
|
||||
let input = NSTextField(string: tab.customTitle ?? tab.title)
|
||||
input.placeholderString = "Tab name"
|
||||
input.placeholderString = "Workspace name"
|
||||
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
||||
alert.accessoryView = input
|
||||
alert.addButton(withTitle: "Rename")
|
||||
|
|
|
|||
|
|
@ -693,6 +693,8 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2WorkspaceReorder(params: params))
|
||||
case "workspace.rename":
|
||||
return v2Result(id: id, self.v2WorkspaceRename(params: params))
|
||||
case "workspace.action":
|
||||
return v2Result(id: id, self.v2WorkspaceAction(params: params))
|
||||
case "workspace.next":
|
||||
return v2Result(id: id, self.v2WorkspaceNext(params: params))
|
||||
case "workspace.previous":
|
||||
|
|
@ -718,6 +720,10 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2SurfaceMove(params: params))
|
||||
case "surface.reorder":
|
||||
return v2Result(id: id, self.v2SurfaceReorder(params: params))
|
||||
case "surface.action":
|
||||
return v2Result(id: id, self.v2TabAction(params: params))
|
||||
case "tab.action":
|
||||
return v2Result(id: id, self.v2TabAction(params: params))
|
||||
case "surface.drag_to_split":
|
||||
return v2Result(id: id, self.v2SurfaceDragToSplit(params: params))
|
||||
case "surface.refresh":
|
||||
|
|
@ -1007,6 +1013,7 @@ class TerminalController {
|
|||
"workspace.move_to_window",
|
||||
"workspace.reorder",
|
||||
"workspace.rename",
|
||||
"workspace.action",
|
||||
"workspace.next",
|
||||
"workspace.previous",
|
||||
"workspace.last",
|
||||
|
|
@ -1019,6 +1026,8 @@ class TerminalController {
|
|||
"surface.drag_to_split",
|
||||
"surface.move",
|
||||
"surface.reorder",
|
||||
"surface.action",
|
||||
"tab.action",
|
||||
"surface.refresh",
|
||||
"surface.health",
|
||||
"surface.send_text",
|
||||
|
|
@ -1359,6 +1368,11 @@ class TerminalController {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? {
|
||||
guard let action = v2String(params, key) else { return nil }
|
||||
return action.lowercased().replacingOccurrences(of: "-", with: "_")
|
||||
}
|
||||
|
||||
private func v2RawString(_ params: [String: Any], _ key: String) -> String? {
|
||||
params[key] as? String
|
||||
}
|
||||
|
|
@ -1851,6 +1865,409 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
guard let action = v2ActionKey(params) else {
|
||||
return .err(code: "invalid_params", message: "Missing action", data: nil)
|
||||
}
|
||||
|
||||
let supportedActions = [
|
||||
"pin", "unpin", "rename", "clear_name",
|
||||
"move_up", "move_down", "move_top",
|
||||
"close_others", "close_above", "close_below",
|
||||
"mark_read", "mark_unread"
|
||||
]
|
||||
|
||||
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [
|
||||
"action": action,
|
||||
"supported_actions": supportedActions
|
||||
])
|
||||
|
||||
v2MainSync {
|
||||
let requestedWorkspaceId = v2UUID(params, "workspace_id") ?? tabManager.selectedTabId
|
||||
guard let workspaceId = requestedWorkspaceId,
|
||||
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
|
||||
@MainActor
|
||||
func closeWorkspaces(_ workspaces: [Workspace]) -> Int {
|
||||
var closed = 0
|
||||
for candidate in workspaces where candidate.id != workspace.id {
|
||||
let existedBefore = tabManager.tabs.contains(where: { $0.id == candidate.id })
|
||||
guard existedBefore else { continue }
|
||||
tabManager.closeWorkspace(candidate)
|
||||
if !tabManager.tabs.contains(where: { $0.id == candidate.id }) {
|
||||
closed += 1
|
||||
}
|
||||
}
|
||||
return closed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func finish(_ extras: [String: Any] = [:]) {
|
||||
var payload: [String: Any] = [
|
||||
"action": action,
|
||||
"workspace_id": workspace.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId)
|
||||
]
|
||||
for (key, value) in extras {
|
||||
payload[key] = value
|
||||
}
|
||||
result = .ok(payload)
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "pin":
|
||||
tabManager.setPinned(workspace, pinned: true)
|
||||
finish(["pinned": true])
|
||||
|
||||
case "unpin":
|
||||
tabManager.setPinned(workspace, pinned: false)
|
||||
finish(["pinned": false])
|
||||
|
||||
case "rename":
|
||||
guard let titleRaw = v2String(params, "title"),
|
||||
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
|
||||
return
|
||||
}
|
||||
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tabManager.setCustomTitle(tabId: workspace.id, title: title)
|
||||
finish(["title": title])
|
||||
|
||||
case "clear_name":
|
||||
tabManager.clearCustomTitle(tabId: workspace.id)
|
||||
finish(["title": workspace.title])
|
||||
|
||||
case "move_up":
|
||||
guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0))
|
||||
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
|
||||
|
||||
case "move_down":
|
||||
guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1))
|
||||
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
|
||||
|
||||
case "move_top":
|
||||
tabManager.moveTabToTop(workspace.id)
|
||||
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
|
||||
|
||||
case "close_others":
|
||||
let candidates = tabManager.tabs.filter { $0.id != workspace.id && !$0.isPinned }
|
||||
let closed = closeWorkspaces(candidates)
|
||||
finish(["closed": closed])
|
||||
|
||||
case "close_above":
|
||||
guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
let candidates = Array(tabManager.tabs.prefix(index)).filter { !$0.isPinned }
|
||||
let closed = closeWorkspaces(candidates)
|
||||
finish(["closed": closed])
|
||||
|
||||
case "close_below":
|
||||
guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
let candidates: [Workspace]
|
||||
if index + 1 < tabManager.tabs.count {
|
||||
candidates = Array(tabManager.tabs.suffix(from: index + 1)).filter { !$0.isPinned }
|
||||
} else {
|
||||
candidates = []
|
||||
}
|
||||
let closed = closeWorkspaces(candidates)
|
||||
finish(["closed": closed])
|
||||
|
||||
case "mark_read":
|
||||
AppDelegate.shared?.notificationStore?.markRead(forTabId: workspace.id)
|
||||
finish()
|
||||
|
||||
case "mark_unread":
|
||||
AppDelegate.shared?.notificationStore?.markUnread(forTabId: workspace.id)
|
||||
finish()
|
||||
|
||||
default:
|
||||
result = .err(code: "invalid_params", message: "Unknown workspace action", data: [
|
||||
"action": action,
|
||||
"supported_actions": supportedActions
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2TabAction(params: [String: Any]) -> V2CallResult {
|
||||
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
}
|
||||
guard let action = v2ActionKey(params) else {
|
||||
return .err(code: "invalid_params", message: "Missing action", data: nil)
|
||||
}
|
||||
|
||||
let supportedActions = [
|
||||
"rename", "clear_name",
|
||||
"close_left", "close_right", "close_others",
|
||||
"new_terminal_right", "new_browser_right",
|
||||
"reload", "duplicate",
|
||||
"pin", "unpin", "mark_unread"
|
||||
]
|
||||
|
||||
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [
|
||||
"action": action,
|
||||
"supported_actions": supportedActions
|
||||
])
|
||||
|
||||
v2MainSync {
|
||||
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let surfaceId = v2UUID(params, "surface_id") ?? workspace.focusedPanelId
|
||||
guard let surfaceId else {
|
||||
result = .err(code: "not_found", message: "No focused tab", data: nil)
|
||||
return
|
||||
}
|
||||
guard workspace.panels[surfaceId] != nil else {
|
||||
result = .err(code: "not_found", message: "Tab not found", data: [
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||
|
||||
@MainActor
|
||||
func finish(_ extras: [String: Any] = [:]) {
|
||||
var payload: [String: Any] = [
|
||||
"action": action,
|
||||
"window_id": v2OrNull(windowId?.uuidString),
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"workspace_id": workspace.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
|
||||
]
|
||||
if let paneId = workspace.paneId(forPanelId: surfaceId)?.id {
|
||||
payload["pane_id"] = paneId.uuidString
|
||||
payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneId)
|
||||
} else {
|
||||
payload["pane_id"] = NSNull()
|
||||
payload["pane_ref"] = NSNull()
|
||||
}
|
||||
for (key, value) in extras {
|
||||
payload[key] = value
|
||||
}
|
||||
result = .ok(payload)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func insertionIndexToRight(anchorTabId: TabID, inPane paneId: PaneID) -> Int {
|
||||
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
||||
guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count }
|
||||
let pinnedCount = tabs.reduce(into: 0) { count, tab in
|
||||
if let panelId = workspace.panelIdFromSurfaceId(tab.id),
|
||||
workspace.isPanelPinned(panelId) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
let rawTarget = min(anchorIndex + 1, tabs.count)
|
||||
return max(rawTarget, pinnedCount)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func closeTabs(_ tabIds: [TabID]) -> (closed: Int, skippedPinned: Int) {
|
||||
var closed = 0
|
||||
var skippedPinned = 0
|
||||
for tabId in tabIds {
|
||||
guard let panelId = workspace.panelIdFromSurfaceId(tabId) else { continue }
|
||||
if workspace.isPanelPinned(panelId) {
|
||||
skippedPinned += 1
|
||||
continue
|
||||
}
|
||||
if workspace.panels.count <= 1 {
|
||||
break
|
||||
}
|
||||
if workspace.closePanel(panelId, force: true) {
|
||||
closed += 1
|
||||
}
|
||||
}
|
||||
return (closed, skippedPinned)
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "rename":
|
||||
guard let titleRaw = v2String(params, "title"),
|
||||
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
|
||||
return
|
||||
}
|
||||
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
workspace.setPanelCustomTitle(panelId: surfaceId, title: title)
|
||||
finish(["title": title])
|
||||
|
||||
case "clear_name":
|
||||
workspace.setPanelCustomTitle(panelId: surfaceId, title: nil)
|
||||
finish()
|
||||
|
||||
case "pin":
|
||||
workspace.setPanelPinned(panelId: surfaceId, pinned: true)
|
||||
finish(["pinned": true])
|
||||
|
||||
case "unpin":
|
||||
workspace.setPanelPinned(panelId: surfaceId, pinned: false)
|
||||
finish(["pinned": false])
|
||||
|
||||
case "mark_unread", "mark_as_unread":
|
||||
workspace.markPanelUnread(surfaceId)
|
||||
finish()
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
browserPanel.reload()
|
||||
finish()
|
||||
|
||||
case "duplicate", "duplicate_tab":
|
||||
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = workspace.paneId(forPanelId: surfaceId),
|
||||
let browserPanel = workspace.browserPanel(for: surfaceId) else {
|
||||
result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = workspace.newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: browserPanel.currentURL,
|
||||
focus: true
|
||||
) else {
|
||||
result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil)
|
||||
return
|
||||
}
|
||||
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
finish([
|
||||
"created_surface_id": newPanel.id.uuidString,
|
||||
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id)
|
||||
])
|
||||
|
||||
case "new_terminal_right", "new_terminal_to_right", "new_terminal_tab_to_right":
|
||||
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
||||
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else {
|
||||
result = .err(code: "internal_error", message: "Failed to create tab", data: nil)
|
||||
return
|
||||
}
|
||||
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
finish([
|
||||
"created_surface_id": newPanel.id.uuidString,
|
||||
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id)
|
||||
])
|
||||
|
||||
case "new_browser_right", "new_browser_to_right", "new_browser_tab_to_right":
|
||||
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
||||
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let urlRaw = v2String(params, "url")
|
||||
let url = urlRaw.flatMap { URL(string: $0) }
|
||||
if urlRaw != nil && url == nil {
|
||||
result = .err(code: "invalid_params", message: "Invalid URL", data: ["url": v2OrNull(urlRaw)])
|
||||
return
|
||||
}
|
||||
|
||||
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else {
|
||||
result = .err(code: "internal_error", message: "Failed to create tab", data: nil)
|
||||
return
|
||||
}
|
||||
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
finish([
|
||||
"created_surface_id": newPanel.id.uuidString,
|
||||
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id)
|
||||
])
|
||||
|
||||
case "close_left", "close_to_left":
|
||||
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
||||
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
||||
return
|
||||
}
|
||||
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
||||
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else {
|
||||
result = .err(code: "not_found", message: "Tab not found in pane", data: nil)
|
||||
return
|
||||
}
|
||||
let targetIds = Array(tabs.prefix(index).map(\.id))
|
||||
let closeResult = closeTabs(targetIds)
|
||||
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
|
||||
|
||||
case "close_right", "close_to_right":
|
||||
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
||||
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
||||
return
|
||||
}
|
||||
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
||||
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else {
|
||||
result = .err(code: "not_found", message: "Tab not found in pane", data: nil)
|
||||
return
|
||||
}
|
||||
let targetIds = (index + 1 < tabs.count) ? Array(tabs.suffix(from: index + 1).map(\.id)) : []
|
||||
let closeResult = closeTabs(targetIds)
|
||||
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
|
||||
|
||||
case "close_others", "close_other_tabs":
|
||||
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = workspace.paneId(forPanelId: surfaceId) else {
|
||||
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
|
||||
return
|
||||
}
|
||||
let targetIds = workspace.bonsplitController.tabs(inPane: paneId)
|
||||
.map(\.id)
|
||||
.filter { $0 != anchorTabId }
|
||||
let closeResult = closeTabs(targetIds)
|
||||
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
|
||||
|
||||
default:
|
||||
result = .err(code: "invalid_params", message: "Unknown tab action", data: [
|
||||
"action": action,
|
||||
"supported_actions": supportedActions
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - V2 Surface Methods
|
||||
|
||||
private func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue