Revert "Add workspace pages in the titlebar (#1030)" (#1040)

This reverts commit 4de975e6a4.
This commit is contained in:
Lawrence Chen 2026-03-07 00:05:58 -08:00 committed by GitHub
parent 4de975e6a4
commit e7c3961489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 75 additions and 5567 deletions

View file

@ -1088,33 +1088,6 @@ struct CMUXCLI {
}
}
case "list-pages":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "page.list", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let pages = payload["pages"] as? [[String: Any]] ?? []
if pages.isEmpty {
print("No pages")
} else {
for page in pages {
let selected = (page["selected"] as? Bool) == true
let handle = textHandle(page, idFormat: idFormat)
let title = (page["title"] as? String) ?? ""
let paneCount = intFromAny(page["pane_count"]) ?? 0
let surfaceCount = intFromAny(page["surface_count"]) ?? 0
let prefix = selected ? "* " : " "
let selectedTag = selected ? " [selected]" : ""
let titlePart = title.isEmpty ? "" : " \(title)"
print("\(prefix)\(handle)\(titlePart) [\(paneCount) pane\(paneCount == 1 ? "" : "s"), \(surfaceCount) surface\(surfaceCount == 1 ? "" : "s")]\(selectedTag)")
}
}
}
case "new-workspace":
let (commandOpt, rem0) = parseOption(commandArgs, name: "--command")
let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd")
@ -1137,35 +1110,6 @@ struct CMUXCLI {
_ = try client.sendV2(method: "surface.send_text", params: sendParams)
}
case "new-page":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (titleOpt, rem1) = parseOption(rem0, name: "--title")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let trailingTitle = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let title = titleOpt ?? (trailingTitle.isEmpty ? nil : trailingTitle)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
if let title, !title.isEmpty { params["title"] = title }
let payload = try client.sendV2(method: "page.create", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "duplicate-page":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (pageOpt, rem1) = parseOption(rem0, name: "--page")
let (titleOpt, rem2) = parseOption(rem1, name: "--title")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let trailingTitle = rem2.dropFirst(rem2.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let title = titleOpt ?? (trailingTitle.isEmpty ? nil : trailingTitle)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let pageId = try normalizePageHandle(pageOpt, client: client, workspaceHandle: wsId, allowCurrent: true)
if let pageId { params["page_id"] = pageId }
if let title, !title.isEmpty { params["title"] = title }
let payload = try client.sendV2(method: "page.duplicate", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "new-split":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (panelArg, rem1) = parseOption(rem0, name: "--panel")
@ -1396,18 +1340,6 @@ struct CMUXCLI {
let payload = try client.sendV2(method: "workspace.close", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "close-page":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
let pageRaw = optionValue(commandArgs, name: "--page") ?? commandArgs.first
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let pageId = try normalizePageHandle(pageRaw, client: client, workspaceHandle: wsId, allowCurrent: true)
if let pageId { params["page_id"] = pageId }
params["force"] = true
let payload = try client.sendV2(method: "page.close", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "select-workspace":
guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else {
throw CLIError(message: "select-workspace requires --workspace")
@ -1418,21 +1350,6 @@ struct CMUXCLI {
let payload = try client.sendV2(method: "workspace.select", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "select-page":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let pageRaw = optionValue(rem0, name: "--page") ?? rem0.first
guard let pageRaw else {
throw CLIError(message: "select-page requires --page <id|ref|index>")
}
var params: [String: Any] = [:]
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let pageId = try normalizePageHandle(pageRaw, client: client, workspaceHandle: wsId)
if let pageId { params["page_id"] = pageId }
let payload = try client.sendV2(method: "page.select", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "rename-workspace", "rename-window":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
@ -1454,58 +1371,6 @@ struct CMUXCLI {
print(response)
}
case "current-page":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "page.current", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "rename-page":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (pageOpt, rem1) = parseOption(rem0, name: "--page")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let titleArgs = rem1.dropFirst(rem1.first == "--" ? 1 : 0)
let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw CLIError(message: "rename-page requires a title")
}
var params: [String: Any] = ["title": title]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let pageId = try normalizePageHandle(pageOpt, client: client, workspaceHandle: wsId, allowCurrent: true)
if let pageId { params["page_id"] = pageId }
let payload = try client.sendV2(method: "page.rename", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "reorder-page":
try runReorderPage(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "next-page":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "page.next", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "previous-page":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "page.previous", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "last-page":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "page.last", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"]))
case "read-screen":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
@ -2186,7 +2051,7 @@ struct CMUXCLI {
let pieces = value.split(separator: ":", omittingEmptySubsequences: false)
guard pieces.count == 2 else { return false }
let kind = String(pieces[0]).lowercased()
guard ["window", "workspace", "page", "pane", "surface"].contains(kind) else { return false }
guard ["window", "workspace", "pane", "surface"].contains(kind) else { return false }
return Int(String(pieces[1])) != nil
}
@ -2247,43 +2112,6 @@ struct CMUXCLI {
throw CLIError(message: "Workspace index not found")
}
private func normalizePageHandle(
_ raw: String?,
client: SocketClient,
workspaceHandle: String? = nil,
allowCurrent: Bool = false
) throws -> String? {
guard let raw else {
if !allowCurrent { return nil }
var params: [String: Any] = [:]
if let workspaceHandle {
params["workspace_id"] = workspaceHandle
}
let current = try client.sendV2(method: "page.current", params: params)
return (current["page_ref"] as? String) ?? (current["page_id"] as? String)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if isUUID(trimmed) || isHandleRef(trimmed) {
return trimmed
}
guard let wantedIndex = Int(trimmed) else {
throw CLIError(message: "Invalid page handle: \(trimmed) (expected UUID, ref like page:1, or index)")
}
var params: [String: Any] = [:]
if let workspaceHandle {
params["workspace_id"] = workspaceHandle
}
let listed = try client.sendV2(method: "page.list", params: params)
let items = listed["pages"] as? [[String: Any]] ?? []
for item in items where intFromAny(item["index"]) == wantedIndex {
return (item["ref"] as? String) ?? (item["id"] as? String)
}
throw CLIError(message: "Page index not found")
}
private func normalizePaneHandle(
_ raw: String?,
client: SocketClient,
@ -2587,45 +2415,6 @@ struct CMUXCLI {
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary)
}
private func runReorderPage(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let pageRaw = optionValue(rem0, name: "--page") ?? rem0.first
guard let pageRaw else {
throw CLIError(message: "reorder-page requires --page <id|ref|index>")
}
let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let workspaceHandle = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
let pageHandle = try normalizePageHandle(pageRaw, client: client, workspaceHandle: workspaceHandle)
let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-page")
let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-page")
let beforeHandle = try normalizePageHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle)
let afterHandle = try normalizePageHandle(afterRaw, client: client, workspaceHandle: workspaceHandle)
var params: [String: Any] = [:]
if let workspaceHandle { params["workspace_id"] = workspaceHandle }
if let pageHandle { params["page_id"] = pageHandle }
if let beforeHandle { params["before_page_id"] = beforeHandle }
if let afterHandle { params["after_page_id"] = afterHandle }
if let indexRaw = optionValue(commandArgs, name: "--index") {
guard let index = Int(indexRaw) else {
throw CLIError(message: "--index must be an integer")
}
params["index"] = index
}
let payload = try client.sendV2(method: "page.reorder", params: params)
let summary = "OK page=\(formatHandle(payload, kind: "page", idFormat: idFormat) ?? "unknown") workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown") index=\(payload["index"] ?? payload["page_index"] ?? "?")"
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary)
}
private func runWorkspaceAction(
commandArgs: [String],
client: SocketClient,
@ -4498,138 +4287,6 @@ struct CMUXCLI {
Example:
cmux list-workspaces
"""
case "list-pages":
return """
Usage: cmux list-pages [--workspace <id|ref|index>]
List pages in a workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
Example:
cmux list-pages
cmux list-pages --workspace workspace:2
"""
case "new-page":
return """
Usage: cmux new-page [--workspace <id|ref|index>] [--title <text>] [--] [title]
Create a new page in a workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--title <text> Optional page title
Example:
cmux new-page
cmux new-page --title "editor"
cmux new-page --workspace workspace:2 "database"
"""
case "duplicate-page":
return """
Usage: cmux duplicate-page [--workspace <id|ref|index>] [--page <id|ref|index>] [--title <text>] [--] [title]
Duplicate a page. Defaults to the current page in the resolved workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--page <id|ref|index> Page to duplicate (default: current page)
--title <text> Optional title override for the duplicated page
Example:
cmux duplicate-page
cmux duplicate-page --page page:2
cmux duplicate-page --title "editor copy"
"""
case "current-page":
return """
Usage: cmux current-page [--workspace <id|ref|index>]
Print the currently selected page in a workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
"""
case "select-page":
return """
Usage: cmux select-page [--workspace <id|ref|index>] (--page <id|ref|index> | <id|ref|index>)
Select a page in a workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--page <id|ref|index> Page to select (required unless passed positionally)
Example:
cmux select-page 1
cmux select-page --page page:3
"""
case "rename-page":
return """
Usage: cmux rename-page [--workspace <id|ref|index>] [--page <id|ref|index>] [--] <title>
Rename a page. Defaults to the current page in the resolved workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--page <id|ref|index> Page to rename (default: current page)
Example:
cmux rename-page "editor"
cmux rename-page --page page:2 "psql"
"""
case "close-page":
return """
Usage: cmux close-page [--workspace <id|ref|index>] [--page <id|ref|index> | <id|ref|index>]
Close a page. Defaults to the current page in the resolved workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--page <id|ref|index> Page to close
Example:
cmux close-page
cmux close-page --page page:2
"""
case "reorder-page":
return """
Usage: cmux reorder-page [--workspace <id|ref|index>] (--page <id|ref|index> | <id|ref|index>) [flags]
Reorder a page within its workspace.
Flags:
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--page <id|ref|index> Page to reorder (required unless passed positionally)
--index <n> Place at this index
--before <id|ref|index> Place before this page
--before-page <id|ref|index> Alias for --before
--after <id|ref|index> Place after this page
--after-page <id|ref|index> Alias for --after
Example:
cmux reorder-page --page page:2 --index 0
cmux reorder-page 1 --after page:3
"""
case "next-page":
return """
Usage: cmux next-page [--workspace <id|ref|index>]
Select the next page in the resolved workspace.
"""
case "previous-page":
return """
Usage: cmux previous-page [--workspace <id|ref|index>]
Select the previous page in the resolved workspace.
"""
case "last-page":
return """
Usage: cmux last-page [--workspace <id|ref|index>]
Select the last page in the resolved workspace.
"""
case "new-split":
return """
Usage: cmux new-split <left|right|up|down> [flags]
@ -4676,7 +4333,7 @@ struct CMUXCLI {
return """
Usage: cmux tree [flags]
Print the hierarchy of windows, workspaces, pages, panes, and surfaces.
Print the hierarchy of windows, workspaces, panes, and surfaces.
Flags:
--all Include all windows (default: current window only)
@ -4688,7 +4345,6 @@ struct CMUXCLI {
- active (true focused window/workspace/pane/surface path)
- here (caller surface where `cmux tree` was invoked)
- workspace [selected]
- page [selected]
- pane [focused]
- surface [selected]
Browser surfaces also include their current URL.
@ -5543,7 +5199,6 @@ struct CMUXCLI {
private struct TreePath {
let windowHandle: String?
let workspaceHandle: String?
let pageHandle: String?
let paneHandle: String?
let surfaceHandle: String?
}
@ -5821,7 +5476,6 @@ struct CMUXCLI {
return TreePath(
windowHandle: treeRelatedHandle(payload, refKey: "window_ref", idKey: "window_id"),
workspaceHandle: treeRelatedHandle(payload, refKey: "workspace_ref", idKey: "workspace_id"),
pageHandle: treeRelatedHandle(payload, refKey: "page_ref", idKey: "page_id"),
paneHandle: treeRelatedHandle(payload, refKey: "pane_ref", idKey: "pane_id"),
surfaceHandle: treeRelatedHandle(payload, refKey: "surface_ref", idKey: "surface_id")
)
@ -5875,20 +5529,21 @@ struct CMUXCLI {
workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle)
let panes = workspace["panes"] as? [[String: Any]] ?? []
workspaceNode["panes"] = panes.map {
treeApplyMarkers(pane: $0, activePath: activePath, callerPath: callerPath)
let paneNodes = panes.map { pane in
var paneNode = pane
paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle)
let surfaces = pane["surfaces"] as? [[String: Any]] ?? []
paneNode["surfaces"] = surfaces.map { surface in
var surfaceNode = surface
surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle)
surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle)
return surfaceNode
}
return paneNode
}
let pages = workspace["pages"] as? [[String: Any]] ?? []
workspaceNode["pages"] = pages.map { page in
var pageNode = page
pageNode["active"] = treeItemMatchesHandle(pageNode, handle: activePath.pageHandle)
let pagePanes = page["panes"] as? [[String: Any]] ?? []
pageNode["panes"] = pagePanes.map {
treeApplyMarkers(pane: $0, activePath: activePath, callerPath: callerPath)
}
return pageNode
}
workspaceNode["panes"] = paneNodes
return workspaceNode
}
@ -5897,24 +5552,6 @@ struct CMUXCLI {
}
}
private func treeApplyMarkers(
pane: [String: Any],
activePath: TreePath,
callerPath: TreePath
) -> [String: Any] {
var paneNode = pane
paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle)
let surfaces = pane["surfaces"] as? [[String: Any]] ?? []
paneNode["surfaces"] = surfaces.map { surface in
var surfaceNode = surface
surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle)
surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle)
return surfaceNode
}
return paneNode
}
private func fetchTreeBrowserURLs(
workspaceHandle: String,
surfaces: [[String: Any]],
@ -6001,24 +5638,19 @@ struct CMUXCLI {
let workspaceIndent = workspaceIsLast ? " " : ""
lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, idFormat: idFormat))")
let pages = workspace["pages"] as? [[String: Any]] ?? []
if !pages.isEmpty {
for (pageIndex, page) in pages.enumerated() {
let pageIsLast = pageIndex == pages.count - 1
let pageBranch = pageIsLast ? "└── " : "├── "
let pageIndent = pageIsLast ? " " : ""
lines.append("\(workspaceIndent)\(pageBranch)\(treePageLabel(page, idFormat: idFormat))")
let panes = page["panes"] as? [[String: Any]] ?? []
appendTreePanes(
panes,
to: &lines,
prefix: workspaceIndent + pageIndent,
idFormat: idFormat
)
let panes = workspace["panes"] as? [[String: Any]] ?? []
for (paneIndex, pane) in panes.enumerated() {
let paneIsLast = paneIndex == panes.count - 1
let paneBranch = paneIsLast ? "└── " : "├── "
let paneIndent = paneIsLast ? " " : ""
lines.append("\(workspaceIndent)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))")
let surfaces = pane["surfaces"] as? [[String: Any]] ?? []
for (surfaceIndex, surface) in surfaces.enumerated() {
let surfaceIsLast = surfaceIndex == surfaces.count - 1
let surfaceBranch = surfaceIsLast ? "└── " : "├── "
lines.append("\(workspaceIndent)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))")
}
} else {
let panes = workspace["panes"] as? [[String: Any]] ?? []
appendTreePanes(panes, to: &lines, prefix: workspaceIndent, idFormat: idFormat)
}
}
}
@ -6026,42 +5658,8 @@ struct CMUXCLI {
return lines.joined(separator: "\n")
}
private func appendTreePanes(
_ panes: [[String: Any]],
to lines: inout [String],
prefix: String,
idFormat: CLIIDFormat
) {
for (paneIndex, pane) in panes.enumerated() {
let paneIsLast = paneIndex == panes.count - 1
let paneBranch = paneIsLast ? "└── " : "├── "
let paneIndent = paneIsLast ? " " : ""
lines.append("\(prefix)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))")
let surfaces = pane["surfaces"] as? [[String: Any]] ?? []
for (surfaceIndex, surface) in surfaces.enumerated() {
let surfaceIsLast = surfaceIndex == surfaces.count - 1
let surfaceBranch = surfaceIsLast ? "└── " : "├── "
lines.append("\(prefix)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))")
}
}
}
private func treeDisplayHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String {
if treeItemHandle(item) != nil {
let handle = textHandle(item, idFormat: idFormat)
if !handle.isEmpty {
return handle
}
}
if let index = intFromAny(item["index"]) {
return "#\(index)"
}
return "?"
}
private func treeWindowLabel(_ window: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["window \(treeDisplayHandle(window, idFormat: idFormat))"]
var parts = ["window \(textHandle(window, idFormat: idFormat))"]
if (window["current"] as? Bool) == true {
parts.append("[current]")
}
@ -6072,7 +5670,7 @@ struct CMUXCLI {
}
private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["workspace \(treeDisplayHandle(workspace, idFormat: idFormat))"]
var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"]
let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !title.isEmpty {
parts.append("\"\(title)\"")
@ -6086,23 +5684,8 @@ struct CMUXCLI {
return parts.joined(separator: " ")
}
private func treePageLabel(_ page: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["page \(treeDisplayHandle(page, idFormat: idFormat))"]
let title = (page["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !title.isEmpty {
parts.append("\"\(title)\"")
}
if (page["selected"] as? Bool) == true {
parts.append("[selected]")
}
if (page["active"] as? Bool) == true {
parts.append("◀ active")
}
return parts.joined(separator: " ")
}
private func treePaneLabel(_ pane: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["pane \(treeDisplayHandle(pane, idFormat: idFormat))"]
var parts = ["pane \(textHandle(pane, idFormat: idFormat))"]
if (pane["focused"] as? Bool) == true {
parts.append("[focused]")
}
@ -6115,7 +5698,7 @@ struct CMUXCLI {
private func treeSurfaceLabel(_ surface: [String: Any], idFormat: CLIIDFormat) -> String {
let rawType = ((surface["type"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let surfaceType = rawType.isEmpty ? "unknown" : rawType
var parts = ["surface \(treeDisplayHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"]
var parts = ["surface \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"]
let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !title.isEmpty {
parts.append("\"\(title)\"")
@ -7323,7 +6906,7 @@ struct CMUXCLI {
cmux [global-options] <command> [options]
Handle Inputs:
For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/page:3/pane:4/surface:5), or indexes.
For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes.
`tab-action` also accepts `tab:<n>` in addition to `surface:<n>`.
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
@ -7345,17 +6928,6 @@ struct CMUXCLI {
workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>]
list-workspaces
new-workspace [--cwd <path>] [--command <text>]
list-pages [--workspace <id|ref|index>]
new-page [--workspace <id|ref|index>] [--title <text>]
duplicate-page [--workspace <id|ref|index>] [--page <id|ref|index>] [--title <text>]
current-page [--workspace <id|ref|index>]
select-page [--workspace <id|ref|index>] (--page <id|ref|index> | <id|ref|index>)
rename-page [--workspace <id|ref|index>] [--page <id|ref|index>] <title>
close-page [--workspace <id|ref|index>] [--page <id|ref|index> | <id|ref|index>]
reorder-page [--workspace <id|ref|index>] (--page <id|ref|index> | <id|ref|index>) (--index <n> | --before <id|ref|index> | --after <id|ref|index>)
next-page [--workspace <id|ref|index>]
previous-page [--workspace <id|ref|index>]
last-page [--workspace <id|ref|index>]
new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
list-panes [--workspace <id|ref>]
list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>]

View file

@ -82,7 +82,6 @@
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
E2000000A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */; };
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
@ -224,7 +223,6 @@
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePagesUITests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
@ -446,7 +444,6 @@
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */,
);
path = cmuxUITests;
sourceTree = "<group>";
@ -683,7 +680,6 @@
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */,
E2000000A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -115,642 +115,6 @@
}
}
},
"command.closeOtherPages.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Close Other Pages"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ほかのページを閉じる"
}
}
}
},
"command.closePage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Close Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを閉じる"
}
}
}
},
"command.duplicatePage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Duplicate Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを複製"
}
}
}
},
"command.movePageLeft.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page Navigation"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページナビゲーション"
}
}
}
},
"command.movePageLeft.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Move Page Left"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを左へ移動"
}
}
}
},
"command.movePageRight.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page Navigation"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページナビゲーション"
}
}
}
},
"command.movePageRight.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Move Page Right"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを右へ移動"
}
}
}
},
"command.newPage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新しいページ"
}
}
}
},
"command.nextPage.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page Navigation"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページナビゲーション"
}
}
}
},
"command.nextPage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Next Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "次のページ"
}
}
}
},
"command.previousPage.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page Navigation"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページナビゲーション"
}
}
}
},
"command.previousPage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "前のページ"
}
}
}
},
"command.renamePage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rename Page…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を変更…"
}
}
}
},
"command.selectPage.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page Navigation"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページナビゲーション"
}
}
}
},
"commandPalette.rename.pageConfirmHint": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Press Enter to apply this page name, or Escape to cancel."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Enterでこのページ名を適用し、Escapeでキャンセルします。"
}
}
}
},
"commandPalette.rename.pageDescription": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose a page name."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を入力します。"
}
}
}
},
"commandPalette.rename.pageInputHint": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a page name. Press Enter to rename, Escape to cancel."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を入力します。Enterで変更し、Escapeでキャンセルします。"
}
}
}
},
"commandPalette.rename.pagePlaceholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page name"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名"
}
}
}
},
"commandPalette.rename.pageTitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rename Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を変更"
}
}
}
},
"dialog.closePage.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This will close the page and all of its panels."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このページとその中のすべてのパネルを閉じます。"
}
}
}
},
"dialog.closePage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Close page?"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを閉じますか?"
}
}
}
},
"dialog.renamePage.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter a name for this page."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このページの名前を入力します。"
}
}
}
},
"dialog.renamePage.placeholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Page name"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名"
}
}
}
},
"dialog.renamePage.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rename Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を変更"
}
}
}
},
"shortcut.closePage.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Close Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを閉じる"
}
}
}
},
"shortcut.newPage.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新しいページ"
}
}
}
},
"shortcut.nextPage.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Next Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "次のページ"
}
}
}
},
"shortcut.previousPage.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "前のページ"
}
}
}
},
"shortcut.renamePage.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rename Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を変更"
}
}
}
},
"shortcut.selectLastPage.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Select Last Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最後のページを選択"
}
}
}
},
"shortcut.selectPage1.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 1" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ1を選択" } }
}
},
"shortcut.selectPage2.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 2" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ2を選択" } }
}
},
"shortcut.selectPage3.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 3" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ3を選択" } }
}
},
"shortcut.selectPage4.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 4" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ4を選択" } }
}
},
"shortcut.selectPage5.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 5" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ5を選択" } }
}
},
"shortcut.selectPage6.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 6" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ6を選択" } }
}
},
"shortcut.selectPage7.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 7" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ7を選択" } }
}
},
"shortcut.selectPage8.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Select Page 8" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ8を選択" } }
}
},
"workspace.page.context.close": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Close Page" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページを閉じる" } }
}
},
"workspace.page.context.closeOthers": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Close Other Pages" } },
"ja": { "stringUnit": { "state": "translated", "value": "ほかのページを閉じる" } }
}
},
"workspace.page.context.duplicate": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Duplicate Page" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページを複製" } }
}
},
"workspace.page.context.moveLeft": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Move Page Left" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページを左へ移動" } }
}
},
"workspace.page.context.moveRight": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Move Page Right" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページを右へ移動" } }
}
},
"workspace.page.context.new": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "New Page" } },
"ja": { "stringUnit": { "state": "translated", "value": "新しいページ" } }
}
},
"workspace.page.context.rename": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Rename Page" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ名を変更" } }
}
},
"workspace.page.defaultTitleFormat": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Page %lld" } },
"ja": { "stringUnit": { "state": "translated", "value": "ページ %lld" } }
}
},
"workspace.page.duplicateTitleFormat": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "%@ Copy" } },
"ja": { "stringUnit": { "state": "translated", "value": "%@ のコピー" } }
}
},
"workspace.page.new.tooltip": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "New Page" } },
"ja": { "stringUnit": { "state": "translated", "value": "新しいページ" } }
}
},
"about.build": {
"extractionState": "manual",
"localizations": {
@ -37489,125 +36853,6 @@
}
}
},
"menu.view.closePage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Close Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを閉じる"
}
}
}
},
"menu.view.duplicatePage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Duplicate Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを複製"
}
}
}
},
"menu.view.newPage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新しいページ"
}
}
}
},
"menu.view.nextPage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Next Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "次のページ"
}
}
}
},
"menu.view.previousPage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "前のページ"
}
}
}
},
"menu.view.selectPage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Select Page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページを選択"
}
}
}
},
"menu.view.renamePage": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Rename Page…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ページ名を変更…"
}
}
}
},
"menu.view.renameWorkspace": {
"extractionState": "manual",
"localizations": {

View file

@ -624,14 +624,6 @@ enum WorkspaceShortcutMapper {
}
return nil
}
static func pageIndex(forOptionDigit digit: Int, pageCount: Int) -> Int? {
workspaceIndex(forCommandDigit: digit, workspaceCount: pageCount)
}
static func optionDigitForPage(at index: Int, pageCount: Int) -> Int? {
commandDigitForWorkspace(at: index, workspaceCount: pageCount)
}
}
struct CmuxCLIPathInstaller {
@ -6851,61 +6843,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return handled
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newPage)) {
_ = tabManager?.selectedWorkspace?.newPage(select: true)
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renamePage)) {
guard let workspace = tabManager?.selectedWorkspace,
let pageId = workspace.activePage?.id else {
return false
}
workspace.promptRenamePage(pageId: pageId)
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closePage)) {
guard let workspace = tabManager?.selectedWorkspace,
let pageId = workspace.activePage?.id else {
return false
}
workspace.closePage(pageId)
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextPage)) {
tabManager?.selectedWorkspace?.selectNextPage()
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .previousPage)) {
tabManager?.selectedWorkspace?.selectPreviousPage()
return true
}
let pageSelectionShortcuts: [(KeyboardShortcutSettings.Action, Int?)] = [
(.selectPage1, 0),
(.selectPage2, 1),
(.selectPage3, 2),
(.selectPage4, 3),
(.selectPage5, 4),
(.selectPage6, 5),
(.selectPage7, 6),
(.selectPage8, 7),
(.selectLastPage, nil),
]
for (action, pageIndex) in pageSelectionShortcuts {
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: action)) {
if let pageIndex {
tabManager?.selectedWorkspace?.selectPage(at: pageIndex)
} else {
tabManager?.selectedWorkspace?.selectLastPage()
}
return true
}
}
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) {
#if DEBUG
@ -7830,14 +7767,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace),
// so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for
// option-only digit/punctuation shortcuts, plus command punctuation shortcuts, since
// non-US layouts can report different characters for the same physical key even when
// the shortcut should still track the ANSI digit/punctuation position.
// command punctuation shortcuts, since some non-US layouts report different characters
// for the same physical key even when menu-equivalent semantics should still apply.
let allowANSIKeyCodeFallback = flags.contains(.control)
|| (!flags.contains(.command)
&& flags.contains(.option)
&& !flags.contains(.control)
&& !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey))
|| (flags.contains(.command)
&& !flags.contains(.control)
&& (

File diff suppressed because it is too large Load diff

View file

@ -25,20 +25,6 @@ enum KeyboardShortcutSettings {
case closeWorkspace
case newSurface
case toggleTerminalCopyMode
case newPage
case renamePage
case closePage
case nextPage
case previousPage
case selectPage1
case selectPage2
case selectPage3
case selectPage4
case selectPage5
case selectPage6
case selectPage7
case selectPage8
case selectLastPage
// Panes / splits
case focusLeft
@ -78,20 +64,6 @@ enum KeyboardShortcutSettings {
case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace")
case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface")
case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode")
case .newPage: return String(localized: "shortcut.newPage.label", defaultValue: "New Page")
case .renamePage: return String(localized: "shortcut.renamePage.label", defaultValue: "Rename Page")
case .closePage: return String(localized: "shortcut.closePage.label", defaultValue: "Close Page")
case .nextPage: return String(localized: "shortcut.nextPage.label", defaultValue: "Next Page")
case .previousPage: return String(localized: "shortcut.previousPage.label", defaultValue: "Previous Page")
case .selectPage1: return String(localized: "shortcut.selectPage1.label", defaultValue: "Select Page 1")
case .selectPage2: return String(localized: "shortcut.selectPage2.label", defaultValue: "Select Page 2")
case .selectPage3: return String(localized: "shortcut.selectPage3.label", defaultValue: "Select Page 3")
case .selectPage4: return String(localized: "shortcut.selectPage4.label", defaultValue: "Select Page 4")
case .selectPage5: return String(localized: "shortcut.selectPage5.label", defaultValue: "Select Page 5")
case .selectPage6: return String(localized: "shortcut.selectPage6.label", defaultValue: "Select Page 6")
case .selectPage7: return String(localized: "shortcut.selectPage7.label", defaultValue: "Select Page 7")
case .selectPage8: return String(localized: "shortcut.selectPage8.label", defaultValue: "Select Page 8")
case .selectLastPage: return String(localized: "shortcut.selectLastPage.label", defaultValue: "Select Last Page")
case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left")
case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right")
case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up")
@ -127,20 +99,6 @@ enum KeyboardShortcutSettings {
case .focusRight: return "shortcut.focusRight"
case .focusUp: return "shortcut.focusUp"
case .focusDown: return "shortcut.focusDown"
case .newPage: return "shortcut.newPage"
case .renamePage: return "shortcut.renamePage"
case .closePage: return "shortcut.closePage"
case .nextPage: return "shortcut.nextPage"
case .previousPage: return "shortcut.previousPage"
case .selectPage1: return "shortcut.selectPage1"
case .selectPage2: return "shortcut.selectPage2"
case .selectPage3: return "shortcut.selectPage3"
case .selectPage4: return "shortcut.selectPage4"
case .selectPage5: return "shortcut.selectPage5"
case .selectPage6: return "shortcut.selectPage6"
case .selectPage7: return "shortcut.selectPage7"
case .selectPage8: return "shortcut.selectPage8"
case .selectLastPage: return "shortcut.selectLastPage"
case .splitRight: return "shortcut.splitRight"
case .splitDown: return "shortcut.splitDown"
case .toggleSplitZoom: return "shortcut.toggleSplitZoom"
@ -186,34 +144,6 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace:
return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false)
case .newPage:
return StoredShortcut(key: "n", command: true, shift: false, option: true, control: false)
case .renamePage:
return StoredShortcut(key: "r", command: true, shift: false, option: true, control: false)
case .closePage:
return StoredShortcut(key: "w", command: true, shift: false, option: true, control: false)
case .nextPage:
return StoredShortcut(key: "]", command: false, shift: false, option: true, control: false)
case .previousPage:
return StoredShortcut(key: "[", command: false, shift: false, option: true, control: false)
case .selectPage1:
return StoredShortcut(key: "1", command: false, shift: false, option: true, control: false)
case .selectPage2:
return StoredShortcut(key: "2", command: false, shift: false, option: true, control: false)
case .selectPage3:
return StoredShortcut(key: "3", command: false, shift: false, option: true, control: false)
case .selectPage4:
return StoredShortcut(key: "4", command: false, shift: false, option: true, control: false)
case .selectPage5:
return StoredShortcut(key: "5", command: false, shift: false, option: true, control: false)
case .selectPage6:
return StoredShortcut(key: "6", command: false, shift: false, option: true, control: false)
case .selectPage7:
return StoredShortcut(key: "7", command: false, shift: false, option: true, control: false)
case .selectPage8:
return StoredShortcut(key: "8", command: false, shift: false, option: true, control: false)
case .selectLastPage:
return StoredShortcut(key: "9", command: false, shift: false, option: true, control: false)
case .focusLeft:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusRight:
@ -302,20 +232,6 @@ enum KeyboardShortcutSettings {
static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) }
static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) }
static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) }
static func newPageShortcut() -> StoredShortcut { shortcut(for: .newPage) }
static func renamePageShortcut() -> StoredShortcut { shortcut(for: .renamePage) }
static func closePageShortcut() -> StoredShortcut { shortcut(for: .closePage) }
static func nextPageShortcut() -> StoredShortcut { shortcut(for: .nextPage) }
static func previousPageShortcut() -> StoredShortcut { shortcut(for: .previousPage) }
static func selectPage1Shortcut() -> StoredShortcut { shortcut(for: .selectPage1) }
static func selectPage2Shortcut() -> StoredShortcut { shortcut(for: .selectPage2) }
static func selectPage3Shortcut() -> StoredShortcut { shortcut(for: .selectPage3) }
static func selectPage4Shortcut() -> StoredShortcut { shortcut(for: .selectPage4) }
static func selectPage5Shortcut() -> StoredShortcut { shortcut(for: .selectPage5) }
static func selectPage6Shortcut() -> StoredShortcut { shortcut(for: .selectPage6) }
static func selectPage7Shortcut() -> StoredShortcut { shortcut(for: .selectPage7) }
static func selectPage8Shortcut() -> StoredShortcut { shortcut(for: .selectPage8) }
static func selectLastPageShortcut() -> StoredShortcut { shortcut(for: .selectLastPage) }
static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) }
static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) }

View file

@ -339,25 +339,6 @@ struct SessionWorkspaceSnapshot: Codable, Sendable {
var logEntries: [SessionLogEntrySnapshot]
var progress: SessionProgressSnapshot?
var gitBranch: SessionGitBranchSnapshot?
var activePageId: UUID?
var pages: [SessionWorkspacePageSnapshot]?
}
struct SessionWorkspacePageStateSnapshot: Codable, Sendable {
var currentDirectory: String
var focusedPanelId: UUID?
var layout: SessionWorkspaceLayoutSnapshot
var panels: [SessionPanelSnapshot]
var statusEntries: [SessionStatusEntrySnapshot]
var logEntries: [SessionLogEntrySnapshot]
var progress: SessionProgressSnapshot?
var gitBranch: SessionGitBranchSnapshot?
}
struct SessionWorkspacePageSnapshot: Codable, Sendable {
var id: UUID
var title: String
var state: SessionWorkspacePageStateSnapshot
}
struct SessionTabManagerSnapshot: Codable, Sendable {

View file

@ -92,10 +92,6 @@ class TerminalController {
"workspace.next",
"workspace.previous",
"workspace.last",
"page.select",
"page.next",
"page.previous",
"page.last",
"surface.focus",
"pane.focus",
"pane.last",
@ -110,7 +106,6 @@ class TerminalController {
private enum V2HandleKind: String, CaseIterable {
case window
case workspace
case page
case pane
case surface
}
@ -118,21 +113,18 @@ class TerminalController {
private var v2NextHandleOrdinal: [V2HandleKind: Int] = [
.window: 1,
.workspace: 1,
.page: 1,
.pane: 1,
.surface: 1,
]
private var v2RefByUUID: [V2HandleKind: [UUID: String]] = [
.window: [:],
.workspace: [:],
.page: [:],
.pane: [:],
.surface: [:],
]
private var v2UUIDByRef: [V2HandleKind: [String: UUID]] = [
.window: [:],
.workspace: [:],
.page: [:],
.pane: [:],
.surface: [:],
]
@ -1745,30 +1737,6 @@ class TerminalController {
case "workspace.last":
return v2Result(id: id, self.v2WorkspaceLast(params: params))
// Pages
case "page.list":
return v2Result(id: id, self.v2PageList(params: params))
case "page.create":
return v2Result(id: id, self.v2PageCreate(params: params))
case "page.duplicate":
return v2Result(id: id, self.v2PageDuplicate(params: params))
case "page.select":
return v2Result(id: id, self.v2PageSelect(params: params))
case "page.current":
return v2Result(id: id, self.v2PageCurrent(params: params))
case "page.close":
return v2Result(id: id, self.v2PageClose(params: params))
case "page.reorder":
return v2Result(id: id, self.v2PageReorder(params: params))
case "page.rename":
return v2Result(id: id, self.v2PageRename(params: params))
case "page.next":
return v2Result(id: id, self.v2PageNext(params: params))
case "page.previous":
return v2Result(id: id, self.v2PagePrevious(params: params))
case "page.last":
return v2Result(id: id, self.v2PageLast(params: params))
// Surfaces / input
case "surface.list":
@ -2104,12 +2072,6 @@ class TerminalController {
return response
}
#if DEBUG
func debugProcessV2Command(_ jsonLine: String) -> String {
processV2Command(jsonLine)
}
#endif
private func v2Capabilities() -> [String: Any] {
var methods: [String] = [
"system.ping",
@ -2134,17 +2096,6 @@ class TerminalController {
"workspace.next",
"workspace.previous",
"workspace.last",
"page.list",
"page.create",
"page.duplicate",
"page.select",
"page.current",
"page.close",
"page.reorder",
"page.rename",
"page.next",
"page.previous",
"page.last",
"surface.list",
"surface.current",
"surface.focus",
@ -2325,16 +2276,11 @@ class TerminalController {
let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
let paneUUID = ws.bonsplitController.focusedPaneId?.id
let surfaceUUID = ws.focusedPanelId
let pageId = ws.activePageId
focused = [
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId),
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId),
"page_index": v2OrNull(ws.pageIndex(pageId: pageId)),
"page_title": ws.pageTitle(pageId: pageId) ?? "",
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"surface_id": v2OrNull(surfaceUUID?.uuidString),
@ -2357,21 +2303,15 @@ class TerminalController {
if let callerObj = params["caller"] as? [String: Any],
let wsId = v2UUIDAny(callerObj["workspace_id"]) {
let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"])
let callerPageId = v2UUIDAny(callerObj["page_id"])
v2MainSync {
let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager
if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) {
let callerWindowId = v2ResolveWindowId(tabManager: callerTabManager)
let pageId = callerPageId.flatMap { ws.pageIndex(pageId: $0) != nil ? $0 : nil } ?? ws.activePageId
var payload: [String: Any] = [
"window_id": v2OrNull(callerWindowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: callerWindowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId),
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId),
"page_index": v2OrNull(ws.pageIndex(pageId: pageId)),
"page_title": ws.pageTitle(pageId: pageId) ?? ""
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
]
if let surfaceId, ws.panels[surfaceId] != nil {
@ -2517,58 +2457,6 @@ class TerminalController {
index: Int,
selected: Bool
) -> [String: Any] {
let pageNodes: [[String: Any]] = workspace.pages.enumerated().compactMap { pageIndex, page in
guard let snapshot = workspace.pageStateSnapshot(pageId: page.id, includeScrollback: false) else {
return nil
}
return v2TreePageNode(
workspace: workspace,
page: page,
index: pageIndex,
snapshot: snapshot
)
}
let selectedPagePanes = pageNodes.first(where: { ($0["selected"] as? Bool) == true })?["panes"] as? [[String: Any]] ?? []
return [
"id": workspace.id.uuidString,
"ref": v2Ref(kind: .workspace, uuid: workspace.id),
"index": index,
"title": workspace.title,
"selected": selected,
"pinned": workspace.isPinned,
"selected_page_id": workspace.activePageId.uuidString,
"selected_page_ref": v2Ref(kind: .page, uuid: workspace.activePageId),
"page_count": pageNodes.count,
"pages": pageNodes,
// Preserve the selected page's panes at workspace.panes for older tree consumers.
"panes": selectedPagePanes
]
}
private func v2TreePageNode(
workspace: Workspace,
page: WorkspacePage,
index: Int,
snapshot: SessionWorkspacePageStateSnapshot
) -> [String: Any] {
let panes = page.id == workspace.activePageId
? v2TreeLivePagePanes(workspace: workspace)
: v2TreeStoredPagePanes(snapshot: snapshot)
return [
"id": page.id.uuidString,
"ref": v2Ref(kind: .page, uuid: page.id),
"index": index,
"title": page.title,
"selected": page.id == workspace.activePageId,
"pane_count": panes.count,
"surface_count": snapshot.panels.count,
"panes": panes
]
}
private func v2TreeLivePagePanes(workspace: Workspace) -> [[String: Any]] {
var paneByPanelId: [UUID: UUID] = [:]
var indexInPaneByPanelId: [UUID: Int] = [:]
var selectedInPaneByPanelId: [UUID: Bool] = [:]
@ -2624,7 +2512,7 @@ class TerminalController {
}
let focusedPaneId = workspace.bonsplitController.focusedPaneId
return paneIds.enumerated().map { paneIndex, paneId in
let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) }
let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId)
@ -2643,57 +2531,16 @@ class TerminalController {
"surfaces": surfacesByPane[paneId.id] ?? []
]
}
}
private func v2TreeStoredPagePanes(snapshot: SessionWorkspacePageStateSnapshot) -> [[String: Any]] {
let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) })
return v2TreePaneSnapshots(in: snapshot.layout).enumerated().map { paneIndex, pane in
let surfaces: [[String: Any]] = pane.panelIds.enumerated().compactMap { surfaceIndex, panelId in
guard let panel = panelSnapshotsById[panelId] else { return nil }
var item: [String: Any] = [
"id": NSNull(),
"ref": NSNull(),
"index": surfaceIndex,
"type": panel.type.rawValue,
"title": panel.customTitle ?? panel.title ?? "",
"focused": panelId == snapshot.focusedPanelId,
"selected": pane.selectedPanelId == panelId,
"selected_in_pane": v2OrNull(pane.selectedPanelId == panelId),
"pane_id": NSNull(),
"pane_ref": NSNull(),
"index_in_pane": surfaceIndex
]
if panel.type == .browser {
item["url"] = panel.browser?.urlString ?? ""
} else {
item["url"] = NSNull()
}
return item
}
let focused = surfaces.contains { ($0["focused"] as? Bool) == true }
return [
"id": NSNull(),
"ref": NSNull(),
"index": paneIndex,
"focused": focused,
"surface_ids": [],
"surface_refs": [],
"selected_surface_id": NSNull(),
"selected_surface_ref": NSNull(),
"surface_count": surfaces.count,
"surfaces": surfaces
]
}
}
private func v2TreePaneSnapshots(in layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneLayoutSnapshot] {
switch layout {
case .pane(let pane):
return [pane]
case .split(let split):
return v2TreePaneSnapshots(in: split.first) + v2TreePaneSnapshots(in: split.second)
}
return [
"id": workspace.id.uuidString,
"ref": v2Ref(kind: .workspace, uuid: workspace.id),
"index": index,
"title": workspace.title,
"selected": selected,
"pinned": workspace.isPinned,
"panes": panes
]
}
// MARK: - V2 Helpers (encoding + result plumbing)
@ -2810,9 +2657,6 @@ class TerminalController {
if let tm = app.tabManagerFor(windowId: item.windowId) {
for ws in tm.tabs {
_ = v2EnsureHandleRef(kind: .workspace, uuid: ws.id)
for page in ws.pages {
_ = v2EnsureHandleRef(kind: .page, uuid: page.id)
}
for paneId in ws.bonsplitController.allPaneIds {
_ = v2EnsureHandleRef(kind: .pane, uuid: paneId.id)
}
@ -2917,11 +2761,6 @@ class TerminalController {
return tm
}
}
if let pageId = v2UUID(params, "page_id") {
if let tm = v2MainSync({ self.v2LocatePage(pageId)?.tabManager }) {
return tm
}
}
return tabManager
}
@ -3495,376 +3334,6 @@ class TerminalController {
return result
}
// MARK: - V2 Page Methods
private func v2LocatePage(_ pageUUID: UUID) -> (windowId: UUID, tabManager: TabManager, workspace: Workspace, pageIndex: Int)? {
guard let app = AppDelegate.shared else { return nil }
let windows = app.listMainWindowSummaries()
for item in windows {
guard let tm = app.tabManagerFor(windowId: item.windowId) else { continue }
for ws in tm.tabs {
if let pageIndex = ws.pageIndex(pageId: pageUUID) {
return (item.windowId, tm, ws, pageIndex)
}
}
}
return nil
}
private func v2PagePayload(
workspace: Workspace,
pageId: UUID,
index: Int,
selected: Bool
) -> [String: Any] {
let summary = workspace.pageStructureSummary(pageId: pageId)
return [
"id": pageId.uuidString,
"ref": v2Ref(kind: .page, uuid: pageId),
"index": index,
"title": workspace.pageTitle(pageId: pageId) ?? "",
"selected": selected,
"pane_count": summary?.paneCount ?? 0,
"surface_count": summary?.surfaceCount ?? 0
]
}
private func v2PageResultPayload(
tabManager: TabManager,
workspace: Workspace,
pageId: UUID
) -> [String: Any] {
let windowId = v2ResolveWindowId(tabManager: tabManager)
let pageIndex = workspace.pageIndex(pageId: pageId)
return [
"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),
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId),
"page_index": v2OrNull(pageIndex),
"page_title": workspace.pageTitle(pageId: pageId) ?? ""
]
}
private func v2PageList(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let pages = workspace.pages.enumerated().map { index, page in
v2PagePayload(
workspace: workspace,
pageId: page.id,
index: index,
selected: page.id == workspace.activePageId
)
}
payload = v2PageResultPayload(
tabManager: tabManager,
workspace: workspace,
pageId: workspace.activePageId
)
payload?["pages"] = pages
}
guard let payload else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
return .ok(payload)
}
private func v2PageCreate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let title = v2String(params, "title")
let select = v2FocusAllowed(requested: v2Bool(params, "select") ?? true)
var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil)
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
if select {
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: workspace)
}
let page = workspace.newPage(select: select)
if let title {
workspace.setPageTitle(pageId: page.id, title: title)
}
result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: page.id))
}
return result
}
private func v2PageDuplicate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let requestedPageId = v2UUID(params, "page_id")
let title = v2String(params, "title")
let select = v2FocusAllowed(requested: v2Bool(params, "select") ?? true)
var result: V2CallResult = .err(code: "not_found", message: "Page not found", data: nil)
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let sourcePageId = requestedPageId.flatMap { candidate in
workspace.pageIndex(pageId: candidate) != nil ? candidate : nil
} ?? workspace.activePageId
guard workspace.pageIndex(pageId: sourcePageId) != nil else {
result = .err(code: "not_found", message: "Page not found", data: [
"page_id": v2OrNull(requestedPageId?.uuidString),
"page_ref": v2Ref(kind: .page, uuid: requestedPageId)
])
return
}
if select {
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: workspace)
}
guard let page = workspace.duplicatePage(
sourcePageId: sourcePageId,
select: select,
title: title
) else {
result = .err(code: "internal_error", message: "Failed to duplicate page", data: [
"page_id": sourcePageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: sourcePageId)
])
return
}
result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: page.id))
}
return result
}
private func v2PageSelect(params: [String: Any]) -> V2CallResult {
guard let pageId = v2UUID(params, "page_id") else {
return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Page not found", data: [
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId)
])
v2MainSync {
if let explicitTabManager = v2ResolveTabManager(params: params),
let workspace = v2ResolveWorkspace(params: params, tabManager: explicitTabManager),
workspace.pageIndex(pageId: pageId) != nil {
v2MaybeFocusWindow(for: explicitTabManager)
v2MaybeSelectWorkspace(explicitTabManager, workspace: workspace)
workspace.selectPage(pageId)
result = .ok(v2PageResultPayload(tabManager: explicitTabManager, workspace: workspace, pageId: pageId))
return
}
guard let located = v2LocatePage(pageId) else { return }
v2MaybeFocusWindow(for: located.tabManager)
v2MaybeSelectWorkspace(located.tabManager, workspace: located.workspace)
located.workspace.selectPage(pageId)
result = .ok(v2PageResultPayload(tabManager: located.tabManager, workspace: located.workspace, pageId: pageId))
}
return result
}
private func v2PageCurrent(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil)
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId))
}
return result
}
private func v2PageClose(params: [String: Any]) -> V2CallResult {
guard let pageId = v2UUID(params, "page_id") else {
return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil)
}
let force = v2Bool(params, "force") ?? true
var result: V2CallResult = .err(code: "not_found", message: "Page not found", data: [
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId)
])
v2MainSync {
let located: (windowId: UUID?, tabManager: TabManager, workspace: Workspace, pageIndex: Int)?
if let explicitTabManager = v2ResolveTabManager(params: params),
let workspace = v2ResolveWorkspace(params: params, tabManager: explicitTabManager),
let pageIndex = workspace.pageIndex(pageId: pageId) {
located = (v2ResolveWindowId(tabManager: explicitTabManager), explicitTabManager, workspace, pageIndex)
} else {
let resolved = v2LocatePage(pageId)
located = resolved.map { ($0.windowId, $0.tabManager, $0.workspace, $0.pageIndex) }
}
guard let located else { return }
guard located.workspace.canClosePage(pageId) else {
result = .err(code: "invalid_state", message: "Cannot close the last page in a workspace", data: [
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId)
])
return
}
located.workspace.closePage(pageId, skipConfirmation: force)
result = .ok([
"window_id": v2OrNull(located.windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: located.windowId),
"workspace_id": located.workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: located.workspace.id),
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId),
"selected_page_id": located.workspace.activePageId.uuidString,
"selected_page_ref": v2Ref(kind: .page, uuid: located.workspace.activePageId)
])
}
return result
}
private func v2PageReorder(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
guard let pageId = v2UUID(params, "page_id") else {
return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil)
}
let index = v2Int(params, "index")
let beforeId = v2UUID(params, "before_page_id")
let afterId = v2UUID(params, "after_page_id")
let targetCount = (index != nil ? 1 : 0) + (beforeId != nil ? 1 : 0) + (afterId != nil ? 1 : 0)
if targetCount != 1 {
return .err(
code: "invalid_params",
message: "Specify exactly one target: index, before_page_id, or after_page_id",
data: nil
)
}
var moved = false
var newIndex: Int?
v2MainSync {
guard workspace.pageIndex(pageId: pageId) != nil else { return }
if let index {
moved = workspace.movePage(pageId: pageId, toIndex: index)
} else if let beforeId, let beforeIndex = workspace.pageIndex(pageId: beforeId) {
moved = workspace.movePage(pageId: pageId, toIndex: beforeIndex)
} else if let afterId, let afterIndex = workspace.pageIndex(pageId: afterId) {
moved = workspace.movePage(pageId: pageId, toIndex: afterIndex)
}
newIndex = workspace.pageIndex(pageId: pageId)
}
guard moved else {
return .err(code: "not_found", message: "Page not found", data: [
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId)
])
}
var payload = v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: pageId)
payload["index"] = v2OrNull(newIndex)
return .ok(payload)
}
private func v2PageRename(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
guard let pageId = v2UUID(params, "page_id") else {
return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil)
}
guard let title = v2String(params, "title") else {
return .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
}
var found = false
v2MainSync {
guard workspace.pageIndex(pageId: pageId) != nil else { return }
workspace.setPageTitle(pageId: pageId, title: title)
found = true
}
guard found else {
return .err(code: "not_found", message: "Page not found", data: [
"page_id": pageId.uuidString,
"page_ref": v2Ref(kind: .page, uuid: pageId)
])
}
return .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: pageId))
}
private func v2PageNext(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil)
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: workspace)
workspace.selectNextPage()
result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId))
}
return result
}
private func v2PagePrevious(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil)
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: workspace)
workspace.selectPreviousPage()
result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId))
}
return result
}
private func v2PageLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil)
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager),
let lastPage = workspace.pages.last else { return }
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: workspace)
workspace.selectPage(lastPage.id)
result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId))
}
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)
@ -4140,9 +3609,6 @@ class TerminalController {
if let wsId = v2UUID(params, "workspace_id") {
return tabManager.tabs.first(where: { $0.id == wsId })
}
if let pageId = v2UUID(params, "page_id") {
return tabManager.tabs.first(where: { $0.pageIndex(pageId: pageId) != nil })
}
if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") {
return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil })
}

View file

@ -106,90 +106,6 @@ private struct SessionPaneRestoreEntry {
extension Workspace {
func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot {
let activePageSnapshot = currentPageSessionStateSnapshot(includeScrollback: includeScrollback)
let pageSnapshots = pages.map { page in
let state: SessionWorkspacePageStateSnapshot
if page.id == activePageId {
state = activePageSnapshot
} else if let storedState = storedPageStates[page.id] {
state = storedState.sessionState
} else {
state = emptyPageSessionStateSnapshot(currentDirectory: currentDirectory)
}
return SessionWorkspacePageSnapshot(id: page.id, title: page.title, state: state)
}
return SessionWorkspaceSnapshot(
processTitle: processTitle,
customTitle: customTitle,
customColor: customColor,
isPinned: isPinned,
currentDirectory: activePageSnapshot.currentDirectory,
focusedPanelId: activePageSnapshot.focusedPanelId,
layout: activePageSnapshot.layout,
panels: activePageSnapshot.panels,
statusEntries: activePageSnapshot.statusEntries,
logEntries: activePageSnapshot.logEntries,
progress: activePageSnapshot.progress,
gitBranch: activePageSnapshot.gitBranch,
activePageId: activePageId,
pages: pageSnapshots
)
}
func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) {
let restoredPages: [SessionWorkspacePageSnapshot] = {
if let pages = snapshot.pages, !pages.isEmpty {
return pages
}
let initialPageId = pages.first?.id ?? activePageId
return [
SessionWorkspacePageSnapshot(
id: initialPageId,
title: pages.first?.title ?? String(
format: String(localized: "workspace.page.defaultTitleFormat", defaultValue: "Page %lld"),
1
),
state: legacyPageStateSnapshot(from: snapshot)
)
]
}()
let pageModels = restoredPages.enumerated().map { offset, page in
WorkspacePage(
id: page.id,
title: normalizedPageTitle(page.title, fallbackIndex: offset + 1)
)
}
let restoredActivePageId = snapshot.activePageId.flatMap { candidate in
pageModels.contains(where: { $0.id == candidate }) ? candidate : nil
} ?? pageModels.first?.id ?? activePageId
storedPageStates.removeAll(keepingCapacity: false)
pages = pageModels
activePageId = restoredActivePageId
nextAutoPageNumber = max(nextAutoPageNumber, pageModels.count + 1)
if let activePageSnapshot = restoredPages.first(where: { $0.id == restoredActivePageId }) {
restoreSessionPageState(activePageSnapshot.state)
} else if let fallbackPageSnapshot = restoredPages.first {
activePageId = fallbackPageSnapshot.id
restoreSessionPageState(fallbackPageSnapshot.state)
} else {
restoreSessionPageState(emptyPageSessionStateSnapshot(currentDirectory: currentDirectory))
}
for page in restoredPages where page.id != activePageId {
storedPageStates[page.id] = StoredPageState(sessionState: page.state, runtimeState: nil)
}
applyProcessTitle(snapshot.processTitle)
setCustomTitle(snapshot.customTitle)
setCustomColor(snapshot.customColor)
isPinned = snapshot.isPinned
}
private func currentPageSessionStateSnapshot(includeScrollback: Bool) -> SessionWorkspacePageStateSnapshot {
let tree = bonsplitController.treeSnapshot()
let layout = sessionLayoutSnapshot(from: tree)
@ -234,7 +150,11 @@ extension Workspace {
SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty)
}
return SessionWorkspacePageStateSnapshot(
return SessionWorkspaceSnapshot(
processTitle: processTitle,
customTitle: customTitle,
customColor: customColor,
isPinned: isPinned,
currentDirectory: currentDirectory,
focusedPanelId: focusedPanelId,
layout: layout,
@ -246,11 +166,8 @@ extension Workspace {
)
}
private func restoreSessionPageState(_ snapshot: SessionWorkspacePageStateSnapshot) {
func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) {
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
metadataBlocks = [:]
pullRequest = nil
panelPullRequests = [:]
let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
if !normalizedCurrentDirectory.isEmpty {
@ -273,15 +190,10 @@ extension Workspace {
pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys))
applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot())
if panels.isEmpty {
let replacement = createReplacementTerminalPanel()
if let replacementTabId = surfaceIdFromPanelId(replacement.id),
let replacementPane = bonsplitController.allPaneIds.first {
bonsplitController.focusPane(replacementPane)
bonsplitController.selectTab(replacementTabId)
applyTabSelection(tabId: replacementTabId, inPane: replacementPane)
}
}
applyProcessTitle(snapshot.processTitle)
setCustomTitle(snapshot.customTitle)
setCustomColor(snapshot.customColor)
isPinned = snapshot.isPinned
statusEntries = Dictionary(
uniqueKeysWithValues: snapshot.statusEntries.map { entry in
@ -321,32 +233,6 @@ extension Workspace {
}
}
private func legacyPageStateSnapshot(from snapshot: SessionWorkspaceSnapshot) -> SessionWorkspacePageStateSnapshot {
SessionWorkspacePageStateSnapshot(
currentDirectory: snapshot.currentDirectory,
focusedPanelId: snapshot.focusedPanelId,
layout: snapshot.layout,
panels: snapshot.panels,
statusEntries: snapshot.statusEntries,
logEntries: snapshot.logEntries,
progress: snapshot.progress,
gitBranch: snapshot.gitBranch
)
}
private func emptyPageSessionStateSnapshot(currentDirectory: String) -> SessionWorkspacePageStateSnapshot {
SessionWorkspacePageStateSnapshot(
currentDirectory: currentDirectory,
focusedPanelId: nil,
layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)),
panels: [],
statusEntries: [],
logEntries: [],
progress: nil,
gitBranch: nil
)
}
private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot {
switch node {
case .pane(let pane):
@ -1022,55 +908,16 @@ struct ClosedBrowserPanelRestoreSnapshot {
let fallbackAnchorPaneId: UUID?
}
struct WorkspacePage: Identifiable, Equatable {
let id: UUID
var title: String
}
/// Workspace represents a sidebar tab.
/// Each workspace contains one BonsplitController that manages split panes and nested surfaces.
@MainActor
final class Workspace: Identifiable, ObservableObject {
private struct StoredPageState {
struct RuntimeState {
var currentDirectory: String
var focusedPanelId: UUID?
var layout: SessionWorkspaceLayoutSnapshot
var detachedSurfaces: [UUID: DetachedSurfaceTransfer]
var statusEntries: [String: SidebarStatusEntry]
var metadataBlocks: [String: SidebarMetadataBlock]
var logEntries: [SidebarLogEntry]
var progress: SidebarProgressState?
var gitBranch: SidebarGitBranchState?
var pullRequest: SidebarPullRequestState?
var panelDirectories: [UUID: String]
var panelTitles: [UUID: String]
var panelCustomTitles: [UUID: String]
var pinnedPanelIds: Set<UUID>
var manualUnreadPanelIds: Set<UUID>
var manualUnreadMarkedAt: [UUID: Date]
var panelGitBranches: [UUID: SidebarGitBranchState]
var panelPullRequests: [UUID: SidebarPullRequestState]
var surfaceListeningPorts: [UUID: [Int]]
var surfaceTTYNames: [UUID: String]
var restoredTerminalScrollbackByPanelId: [UUID: String]
var lastTerminalConfigInheritancePanelId: UUID?
var lastTerminalConfigInheritanceFontPoints: Float?
var terminalInheritanceFontPointsByPanelId: [UUID: Float]
}
var sessionState: SessionWorkspacePageStateSnapshot
var runtimeState: RuntimeState?
}
let id: UUID
@Published var title: String
@Published var customTitle: String?
@Published var isPinned: Bool = false
@Published var customColor: String? // hex string, e.g. "#C0392B"
@Published var currentDirectory: String
@Published private(set) var pages: [WorkspacePage]
@Published private(set) var activePageId: UUID
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
var portOrdinal: Int = 0
@ -1155,8 +1002,6 @@ final class Workspace: Identifiable, ObservableObject {
}
private var processTitle: String
private var storedPageStates: [UUID: StoredPageState] = [:]
private var nextAutoPageNumber: Int = 2
private enum SurfaceKind {
static let terminal = "terminal"
@ -1261,20 +1106,11 @@ final class Workspace: Identifiable, ObservableObject {
portOrdinal: Int = 0,
configTemplate: ghostty_surface_config_s? = nil
) {
let initialPage = WorkspacePage(
id: UUID(),
title: String(
format: String(localized: "workspace.page.defaultTitleFormat", defaultValue: "Page %lld"),
1
)
)
self.id = UUID()
self.portOrdinal = portOrdinal
self.processTitle = title
self.title = title
self.customTitle = nil
self.pages = [initialPage]
self.activePageId = initialPage.id
let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty
@ -1372,455 +1208,6 @@ final class Workspace: Identifiable, ObservableObject {
bonsplitController.configuration = configuration
}
var activePage: WorkspacePage? {
pages.first(where: { $0.id == activePageId })
}
var activePageIndex: Int? {
pages.firstIndex(where: { $0.id == activePageId })
}
func pageIndex(pageId: UUID) -> Int? {
pages.firstIndex(where: { $0.id == pageId })
}
func pageTitle(pageId: UUID) -> String? {
pages.first(where: { $0.id == pageId })?.title
}
func pageStateSnapshot(
pageId: UUID,
includeScrollback: Bool = false
) -> SessionWorkspacePageStateSnapshot? {
guard pages.contains(where: { $0.id == pageId }) else { return nil }
if pageId == activePageId {
return currentPageSessionStateSnapshot(includeScrollback: includeScrollback)
}
return storedPageStates[pageId]?.sessionState
}
func pageStructureSummary(pageId: UUID) -> (paneCount: Int, surfaceCount: Int)? {
guard let snapshot = pageStateSnapshot(pageId: pageId, includeScrollback: false) else {
return nil
}
return (
paneCount: pageCount(in: snapshot.layout),
surfaceCount: snapshot.panels.count
)
}
func canClosePage(_ pageId: UUID) -> Bool {
pages.count > 1 && pages.contains(where: { $0.id == pageId })
}
@discardableResult
func newPage(select: Bool = true) -> WorkspacePage {
let page = WorkspacePage(
id: UUID(),
title: defaultPageTitle(number: nextAutoPageNumber)
)
nextAutoPageNumber += 1
pages.append(page)
if select {
selectPage(page.id)
} else {
storedPageStates[page.id] = StoredPageState(
sessionState: emptyPageSessionStateSnapshot(currentDirectory: currentDirectory),
runtimeState: nil
)
}
return page
}
@discardableResult
func duplicatePage(
sourcePageId: UUID,
select: Bool = true,
title: String? = nil
) -> WorkspacePage? {
guard let sourceIndex = pageIndex(pageId: sourcePageId),
let sourceTitle = pageTitle(pageId: sourcePageId),
let sourceSnapshot = pageStateSnapshot(pageId: sourcePageId, includeScrollback: true) else {
NSSound.beep()
return nil
}
let duplicatedSnapshot = duplicatedPageStateSnapshot(sourceSnapshot)
let page = newPage(select: false)
storedPageStates[page.id] = StoredPageState(sessionState: duplicatedSnapshot, runtimeState: nil)
setPageTitle(
pageId: page.id,
title: title ?? duplicatedPageTitle(from: sourceTitle)
)
_ = movePage(pageId: page.id, toIndex: sourceIndex + 1)
if select {
selectPage(page.id)
}
return page
}
func selectPage(_ pageId: UUID) {
guard pageId != activePageId else { return }
guard pages.contains(where: { $0.id == pageId }) else { return }
hideAllTerminalPortalViews()
hideAllBrowserPortalViews()
storedPageStates[activePageId] = captureActivePageStoredState(detachPanels: true)
restoreStoredPage(pageId)
activePageId = pageId
requestBackgroundTerminalSurfaceStartIfNeeded()
}
func selectNextPage() {
guard let activePageIndex, !pages.isEmpty else { return }
let nextIndex = (activePageIndex + 1) % pages.count
selectPage(pages[nextIndex].id)
}
func selectPreviousPage() {
guard let activePageIndex, !pages.isEmpty else { return }
let previousIndex = (activePageIndex - 1 + pages.count) % pages.count
selectPage(pages[previousIndex].id)
}
func selectPage(at index: Int) {
guard index >= 0 && index < pages.count else { return }
selectPage(pages[index].id)
}
func selectLastPage() {
guard let lastPage = pages.last else { return }
selectPage(lastPage.id)
}
@discardableResult
func movePage(pageId: UUID, toIndex targetIndex: Int) -> Bool {
guard let currentIndex = pages.firstIndex(where: { $0.id == pageId }) else { return false }
guard pages.count > 1 else { return false }
let clampedIndex = max(0, min(targetIndex, pages.count - 1))
guard currentIndex != clampedIndex else { return true }
let page = pages.remove(at: currentIndex)
pages.insert(page, at: clampedIndex)
return true
}
@discardableResult
func movePageLeft(pageId: UUID) -> Bool {
guard let currentIndex = pages.firstIndex(where: { $0.id == pageId }) else { return false }
return movePage(pageId: pageId, toIndex: currentIndex - 1)
}
@discardableResult
func movePageRight(pageId: UUID) -> Bool {
guard let currentIndex = pages.firstIndex(where: { $0.id == pageId }) else { return false }
return movePage(pageId: pageId, toIndex: currentIndex + 1)
}
func setPageTitle(pageId: UUID, title: String?) {
guard let index = pages.firstIndex(where: { $0.id == pageId }) else { return }
let nextTitle = normalizedPageTitle(title, fallbackIndex: index + 1)
guard pages[index].title != nextTitle else { return }
pages[index].title = nextTitle
}
func closePage(_ pageId: UUID, skipConfirmation: Bool = false) {
guard canClosePage(pageId) else {
NSSound.beep()
return
}
let shouldSkipConfirmation =
skipConfirmation || ProcessInfo.processInfo.environment["CMUX_UI_TEST_SKIP_CONFIRM_CLOSE_PAGE"] == "1"
guard shouldSkipConfirmation || !pageNeedsConfirmClose(pageId) || confirmClosePage(pageId: pageId) else { return }
guard let index = pages.firstIndex(where: { $0.id == pageId }) else { return }
let replacementPageId: UUID? = {
if index + 1 < pages.count {
return pages[index + 1].id
}
if index > 0 {
return pages[index - 1].id
}
return nil
}()
if pageId == activePageId {
hideAllTerminalPortalViews()
hideAllBrowserPortalViews()
let closedState = captureActivePageStoredState(detachPanels: true)
teardownStoredPageState(closedState)
pages.remove(at: index)
storedPageStates.removeValue(forKey: pageId)
if let replacementPageId {
restoreStoredPage(replacementPageId)
activePageId = replacementPageId
}
} else {
if let storedState = storedPageStates.removeValue(forKey: pageId) {
teardownStoredPageState(storedState)
}
pages.remove(at: index)
}
}
func closeOtherPages(keeping pageId: UUID? = nil) {
let keepPageId = pageId ?? activePageId
guard pages.count > 1 else { return }
let removableIds = pages.map(\.id).filter { $0 != keepPageId }
for removableId in removableIds.reversed() {
closePage(removableId)
}
}
private func defaultPageTitle(number: Int) -> String {
String(
format: String(localized: "workspace.page.defaultTitleFormat", defaultValue: "Page %lld"),
max(1, number)
)
}
private func duplicatedPageTitle(from sourceTitle: String) -> String {
String(
format: String(localized: "workspace.page.duplicateTitleFormat", defaultValue: "%@ Copy"),
sourceTitle
)
}
private func normalizedPageTitle(_ rawTitle: String?, fallbackIndex: Int) -> String {
let trimmed = rawTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
return defaultPageTitle(number: fallbackIndex)
}
return trimmed
}
private func pageCount(in layout: SessionWorkspaceLayoutSnapshot) -> Int {
switch layout {
case .pane:
return 1
case .split(let split):
return pageCount(in: split.first) + pageCount(in: split.second)
}
}
private func pageNeedsConfirmClose(_ pageId: UUID) -> Bool {
if pageId == activePageId {
return needsConfirmClose()
}
guard let runtimeState = storedPageStates[pageId]?.runtimeState else { return false }
return runtimeState.detachedSurfaces.values.contains { transfer in
guard let terminalPanel = transfer.panel as? TerminalPanel else { return false }
return terminalPanel.needsConfirmClose()
}
}
private func confirmClosePage(pageId: UUID) -> Bool {
let alert = NSAlert()
alert.messageText = String(localized: "dialog.closePage.title", defaultValue: "Close page?")
alert.informativeText = String(localized: "dialog.closePage.message", defaultValue: "This will close the page and all of its panels.")
alert.alertStyle = .warning
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
return alert.runModal() == .alertFirstButtonReturn
}
private func duplicatedPageStateSnapshot(
_ snapshot: SessionWorkspacePageStateSnapshot
) -> SessionWorkspacePageStateSnapshot {
let panelIdMap = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, UUID()) })
let remappedPanels = snapshot.panels.map { panel in
var remappedPanel = panel
remappedPanel.id = panelIdMap[panel.id] ?? panel.id
return remappedPanel
}
return SessionWorkspacePageStateSnapshot(
currentDirectory: snapshot.currentDirectory,
focusedPanelId: snapshot.focusedPanelId.flatMap { panelIdMap[$0] },
layout: duplicatedPageLayoutSnapshot(snapshot.layout, panelIdMap: panelIdMap),
panels: remappedPanels,
statusEntries: snapshot.statusEntries,
logEntries: snapshot.logEntries,
progress: snapshot.progress,
gitBranch: snapshot.gitBranch
)
}
private func duplicatedPageLayoutSnapshot(
_ layout: SessionWorkspaceLayoutSnapshot,
panelIdMap: [UUID: UUID]
) -> SessionWorkspaceLayoutSnapshot {
switch layout {
case .pane(let pane):
return .pane(
SessionPaneLayoutSnapshot(
panelIds: pane.panelIds.map { panelIdMap[$0] ?? $0 },
selectedPanelId: pane.selectedPanelId.flatMap { panelIdMap[$0] }
)
)
case .split(let split):
return .split(
SessionSplitLayoutSnapshot(
orientation: split.orientation,
dividerPosition: split.dividerPosition,
first: duplicatedPageLayoutSnapshot(split.first, panelIdMap: panelIdMap),
second: duplicatedPageLayoutSnapshot(split.second, panelIdMap: panelIdMap)
)
)
}
}
private func captureActivePageStoredState(detachPanels: Bool) -> StoredPageState {
let sessionState = currentPageSessionStateSnapshot(includeScrollback: true)
let runtimeState = StoredPageState.RuntimeState(
currentDirectory: currentDirectory,
focusedPanelId: focusedPanelId,
layout: sessionState.layout,
detachedSurfaces: detachPanels ? detachAllLivePanelsForPageSwitch() : [:],
statusEntries: statusEntries,
metadataBlocks: metadataBlocks,
logEntries: logEntries,
progress: progress,
gitBranch: gitBranch,
pullRequest: pullRequest,
panelDirectories: panelDirectories,
panelTitles: panelTitles,
panelCustomTitles: panelCustomTitles,
pinnedPanelIds: pinnedPanelIds,
manualUnreadPanelIds: manualUnreadPanelIds,
manualUnreadMarkedAt: manualUnreadMarkedAt,
panelGitBranches: panelGitBranches,
panelPullRequests: panelPullRequests,
surfaceListeningPorts: surfaceListeningPorts,
surfaceTTYNames: surfaceTTYNames,
restoredTerminalScrollbackByPanelId: restoredTerminalScrollbackByPanelId,
lastTerminalConfigInheritancePanelId: lastTerminalConfigInheritancePanelId,
lastTerminalConfigInheritanceFontPoints: lastTerminalConfigInheritanceFontPoints,
terminalInheritanceFontPointsByPanelId: terminalInheritanceFontPointsByPanelId
)
return StoredPageState(sessionState: sessionState, runtimeState: runtimeState)
}
private func detachAllLivePanelsForPageSwitch() -> [UUID: DetachedSurfaceTransfer] {
var orderedPanelIds = sidebarOrderedPanelIds()
var seen = Set(orderedPanelIds)
for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted {
orderedPanelIds.append(panelId)
}
var detachedTransfers: [UUID: DetachedSurfaceTransfer] = [:]
for panelId in orderedPanelIds.reversed() {
guard let detached = detachSurface(panelId: panelId) else { continue }
detachedTransfers[panelId] = detached
}
return detachedTransfers
}
private func restoreStoredPage(_ pageId: UUID) {
let storedState = storedPageStates.removeValue(forKey: pageId) ?? StoredPageState(
sessionState: emptyPageSessionStateSnapshot(currentDirectory: currentDirectory),
runtimeState: nil
)
if let runtimeState = storedState.runtimeState {
restoreRuntimePageState(runtimeState)
} else {
restoreSessionPageState(storedState.sessionState)
}
}
private func restoreRuntimePageState(_ runtimeState: StoredPageState.RuntimeState) {
currentDirectory = runtimeState.currentDirectory
statusEntries = runtimeState.statusEntries
metadataBlocks = runtimeState.metadataBlocks
logEntries = runtimeState.logEntries
progress = runtimeState.progress
gitBranch = runtimeState.gitBranch
pullRequest = runtimeState.pullRequest
if runtimeState.detachedSurfaces.isEmpty {
restoreSessionPageState(emptyPageSessionStateSnapshot(currentDirectory: runtimeState.currentDirectory))
return
}
let leafEntries = restoreSessionLayout(runtimeState.layout)
for entry in leafEntries {
let placeholderPanelIds = bonsplitController
.tabs(inPane: entry.paneId)
.compactMap { panelIdFromSurfaceId($0.id) }
let desiredPanelIds = entry.snapshot.panelIds.filter { runtimeState.detachedSurfaces[$0] != nil }
var attachedPanelIds: [UUID] = []
for desiredPanelId in desiredPanelIds {
guard let detached = runtimeState.detachedSurfaces[desiredPanelId] else { continue }
guard let attachedPanelId = attachDetachedSurface(detached, inPane: entry.paneId, focus: false) else { continue }
attachedPanelIds.append(attachedPanelId)
}
for placeholderPanelId in placeholderPanelIds {
_ = closePanel(placeholderPanelId, force: true)
}
for (targetIndex, attachedPanelId) in attachedPanelIds.enumerated() {
_ = reorderSurface(panelId: attachedPanelId, toIndex: targetIndex)
}
let selectedPanelId = entry.snapshot.selectedPanelId.flatMap { desiredPanelIds.contains($0) ? $0 : nil }
?? attachedPanelIds.first
if let selectedPanelId,
let selectedTabId = surfaceIdFromPanelId(selectedPanelId) {
bonsplitController.focusPane(entry.paneId)
bonsplitController.selectTab(selectedTabId)
}
}
panelDirectories = runtimeState.panelDirectories
panelTitles = runtimeState.panelTitles
panelCustomTitles = runtimeState.panelCustomTitles
pinnedPanelIds = runtimeState.pinnedPanelIds
manualUnreadPanelIds = runtimeState.manualUnreadPanelIds
manualUnreadMarkedAt = runtimeState.manualUnreadMarkedAt
panelGitBranches = runtimeState.panelGitBranches
panelPullRequests = runtimeState.panelPullRequests
surfaceListeningPorts = runtimeState.surfaceListeningPorts
surfaceTTYNames = runtimeState.surfaceTTYNames
restoredTerminalScrollbackByPanelId = runtimeState.restoredTerminalScrollbackByPanelId
lastTerminalConfigInheritancePanelId = runtimeState.lastTerminalConfigInheritancePanelId
lastTerminalConfigInheritanceFontPoints = runtimeState.lastTerminalConfigInheritanceFontPoints
terminalInheritanceFontPointsByPanelId = runtimeState.terminalInheritanceFontPointsByPanelId
pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys))
applySessionDividerPositions(snapshotNode: runtimeState.layout, liveNode: bonsplitController.treeSnapshot())
for paneId in bonsplitController.allPaneIds {
normalizePinnedTabs(in: paneId)
for tab in bonsplitController.tabs(inPane: paneId) {
if let panelId = panelIdFromSurfaceId(tab.id) {
syncUnreadBadgeStateForPanel(panelId)
}
}
}
recomputeListeningPorts()
if let focusedPanelId = runtimeState.focusedPanelId, panels[focusedPanelId] != nil {
focusPanel(focusedPanelId)
} else {
scheduleFocusReconcile()
}
}
private func teardownStoredPageState(_ storedState: StoredPageState) {
guard let runtimeState = storedState.runtimeState else { return }
for transfer in runtimeState.detachedSurfaces.values {
transfer.panel.close()
}
}
// MARK: - Surface ID to Panel ID Mapping
/// Mapping from bonsplit TabID (surface ID) to panel UUID
@ -3468,8 +2855,6 @@ final class Workspace: Identifiable, ObservableObject {
} else if let browserPanel = detached.panel as? BrowserPanel {
browserPanel.updateWorkspaceId(id)
installBrowserPanelSubscription(browserPanel)
} else if let markdownPanel = detached.panel as? MarkdownPanel {
installMarkdownPanelSubscription(markdownPanel)
}
if let directory = detached.directory {
@ -4171,29 +3556,6 @@ final class Workspace: Identifiable, ObservableObject {
createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL)
}
func promptRenamePage(pageId: UUID) {
guard let pageIndex = pages.firstIndex(where: { $0.id == pageId }) else { return }
let alert = NSAlert()
alert.messageText = String(localized: "dialog.renamePage.title", defaultValue: "Rename Page")
alert.informativeText = String(localized: "dialog.renamePage.message", defaultValue: "Enter a name for this page.")
let input = NSTextField(string: pages[pageIndex].title)
input.placeholderString = String(localized: "dialog.renamePage.placeholder", defaultValue: "Page name")
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
setPageTitle(pageId: pageId, title: input.stringValue)
}
private func promptRenamePanel(tabId: TabID) {
guard let panelId = panelIdFromSurfaceId(tabId),
let panel = panels[panelId] else { return }

View file

@ -37,11 +37,6 @@ struct cmuxApp: App {
@AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newPage.defaultsKey) private var newPageShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.renamePage.defaultsKey) private var renamePageShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.closePage.defaultsKey) private var closePageShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.nextPage.defaultsKey) private var nextPageShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.previousPage.defaultsKey) private var previousPageShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
@ -592,103 +587,6 @@ struct cmuxApp: App {
Divider()
splitCommandButton(title: String(localized: "menu.view.newPage", defaultValue: "New Page"), shortcut: newPageMenuShortcut) {
_ = activeTabManager.selectedWorkspace?.newPage(select: true)
}
Button(String(localized: "menu.view.duplicatePage", defaultValue: "Duplicate Page")) {
guard let workspace = activeTabManager.selectedWorkspace,
let pageId = workspace.activePage?.id else {
NSSound.beep()
return
}
_ = workspace.duplicatePage(sourcePageId: pageId, select: true)
}
splitCommandButton(title: String(localized: "menu.view.renamePage", defaultValue: "Rename Page…"), shortcut: renamePageMenuShortcut) {
guard let workspace = activeTabManager.selectedWorkspace,
let pageId = workspace.activePage?.id else {
NSSound.beep()
return
}
workspace.promptRenamePage(pageId: pageId)
}
splitCommandButton(title: String(localized: "menu.view.closePage", defaultValue: "Close Page"), shortcut: closePageMenuShortcut) {
guard let workspace = activeTabManager.selectedWorkspace,
let pageId = workspace.activePage?.id else {
NSSound.beep()
return
}
workspace.closePage(pageId)
}
splitCommandButton(title: String(localized: "menu.view.nextPage", defaultValue: "Next Page"), shortcut: nextPageMenuShortcut) {
activeTabManager.selectedWorkspace?.selectNextPage()
}
splitCommandButton(title: String(localized: "menu.view.previousPage", defaultValue: "Previous Page"), shortcut: previousPageMenuShortcut) {
activeTabManager.selectedWorkspace?.selectPreviousPage()
}
Button(String(localized: "workspace.page.context.closeOthers", defaultValue: "Close Other Pages")) {
guard let workspace = activeTabManager.selectedWorkspace,
let pageId = workspace.activePage?.id else {
NSSound.beep()
return
}
workspace.closeOtherPages(keeping: pageId)
}
.disabled((activeTabManager.selectedWorkspace?.pages.count ?? 0) <= 1)
Button(String(localized: "workspace.page.context.moveLeft", defaultValue: "Move Page Left")) {
guard let workspace = activeTabManager.selectedWorkspace,
let pageId = workspace.activePage?.id else {
NSSound.beep()
return
}
_ = workspace.movePageLeft(pageId: pageId)
}
.disabled((activeTabManager.selectedWorkspace?.activePageIndex ?? 0) <= 0)
Button(String(localized: "workspace.page.context.moveRight", defaultValue: "Move Page Right")) {
guard let workspace = activeTabManager.selectedWorkspace,
let pageId = workspace.activePage?.id else {
NSSound.beep()
return
}
_ = workspace.movePageRight(pageId: pageId)
}
.disabled({
guard let workspace = activeTabManager.selectedWorkspace,
let activeIndex = workspace.activePageIndex else {
return true
}
return activeIndex >= workspace.pages.count - 1
}())
Menu(String(localized: "menu.view.selectPage", defaultValue: "Select Page")) {
if let workspace = activeTabManager.selectedWorkspace {
let activePageId = workspace.activePage?.id
ForEach(Array(workspace.pages.enumerated()), id: \.element.id) { index, page in
if let shortcut = pageSelectionMenuShortcut(index: index, pageCount: workspace.pages.count) {
splitCommandButton(title: page.title, shortcut: shortcut) {
workspace.selectPage(page.id)
}
.disabled(page.id == activePageId)
} else {
Button(page.title) {
workspace.selectPage(page.id)
}
.disabled(page.id == activePageId)
}
}
}
}
.disabled((activeTabManager.selectedWorkspace?.pages.isEmpty ?? true))
Divider()
splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: splitRightMenuShortcut) {
performSplitFromMenu(direction: .right)
}
@ -876,41 +774,6 @@ struct cmuxApp: App {
)
}
private var newPageMenuShortcut: StoredShortcut {
decodeShortcut(
from: newPageShortcutData,
fallback: KeyboardShortcutSettings.Action.newPage.defaultShortcut
)
}
private var renamePageMenuShortcut: StoredShortcut {
decodeShortcut(
from: renamePageShortcutData,
fallback: KeyboardShortcutSettings.Action.renamePage.defaultShortcut
)
}
private var closePageMenuShortcut: StoredShortcut {
decodeShortcut(
from: closePageShortcutData,
fallback: KeyboardShortcutSettings.Action.closePage.defaultShortcut
)
}
private var nextPageMenuShortcut: StoredShortcut {
decodeShortcut(
from: nextPageShortcutData,
fallback: KeyboardShortcutSettings.Action.nextPage.defaultShortcut
)
}
private var previousPageMenuShortcut: StoredShortcut {
decodeShortcut(
from: previousPageShortcutData,
fallback: KeyboardShortcutSettings.Action.previousPage.defaultShortcut
)
}
private var notificationMenuSnapshot: NotificationMenuSnapshot {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
@ -966,34 +829,6 @@ struct cmuxApp: App {
}
}
private func pageSelectionMenuShortcut(index: Int, pageCount: Int) -> StoredShortcut? {
switch index {
case 0:
return KeyboardShortcutSettings.shortcut(for: .selectPage1)
case 1:
return KeyboardShortcutSettings.shortcut(for: .selectPage2)
case 2:
return KeyboardShortcutSettings.shortcut(for: .selectPage3)
case 3:
return KeyboardShortcutSettings.shortcut(for: .selectPage4)
case 4:
return KeyboardShortcutSettings.shortcut(for: .selectPage5)
case 5:
return KeyboardShortcutSettings.shortcut(for: .selectPage6)
case 6:
return KeyboardShortcutSettings.shortcut(for: .selectPage7)
case 7:
return KeyboardShortcutSettings.shortcut(for: .selectPage8)
default:
break
}
if index == pageCount - 1 {
return KeyboardShortcutSettings.shortcut(for: .selectLastPage)
}
return nil
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {

View file

@ -899,495 +899,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
}
}
func testOptionDigitPageShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id else {
XCTFail("Expected test window and workspace")
return
}
_ = workspace.newPage(select: true)
let selectedBeforeShortcut = workspace.activePage?.id
XCTAssertNotEqual(selectedBeforeShortcut, firstPageId)
withTemporaryShortcut(action: .selectPage1) {
// Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key.
// Option+1 page selection should still match via keyCode fallback.
guard let event = makeKeyDownEvent(
key: "&",
modifiers: [.option],
keyCode: 18, // kVK_ANSI_1
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Option+1 event on ANSI 1 key")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
XCTAssertEqual(workspace.activePage?.id, firstPageId)
XCTAssertNotEqual(workspace.activePage?.id, selectedBeforeShortcut)
}
func testOption9SelectsLastPageInEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId),
let firstWorkspace = firstManager.selectedWorkspace,
let secondWorkspace = secondManager.selectedWorkspace,
let firstWorkspaceFirstPageId = firstWorkspace.activePage?.id,
let secondWorkspaceFirstPageId = secondWorkspace.activePage?.id else {
XCTFail("Expected both window contexts to exist")
return
}
_ = firstWorkspace.newPage(select: true)
firstWorkspace.selectPage(firstWorkspaceFirstPageId)
_ = secondWorkspace.newPage(select: true)
let secondWorkspaceLastPage = secondWorkspace.newPage(select: true)
secondWorkspace.selectPage(secondWorkspaceFirstPageId)
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "9",
modifiers: [.option],
keyCode: 25, // kVK_ANSI_9
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Option+9 event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(
firstWorkspace.activePage?.id,
firstWorkspaceFirstPageId,
"Option+9 must not select a page in the stale active window"
)
XCTAssertEqual(
secondWorkspace.activePage?.id,
secondWorkspaceLastPage.id,
"Option+9 should select the last page in the event window"
)
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCustomSelectLastPageShortcutOverrideIsHonored() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id else {
XCTFail("Expected test window and workspace")
return
}
_ = workspace.newPage(select: true)
let lastPage = workspace.newPage(select: true)
workspace.selectPage(firstPageId)
withTemporaryShortcut(
action: .selectLastPage,
shortcut: StoredShortcut(key: "0", command: false, shift: false, option: true, control: false)
) {
guard let event = makeKeyDownEvent(
key: "0",
modifiers: [.option],
keyCode: 29, // kVK_ANSI_0
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct custom Option+0 event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
XCTAssertEqual(workspace.activePage?.id, lastPage.id)
XCTAssertNotEqual(workspace.activePage?.id, firstPageId)
}
func testOptionRightBracketPageShortcutFallsBackByKeyCodeOnNonUSLayouts() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id else {
XCTFail("Expected test window and workspace")
return
}
let secondPage = workspace.newPage(select: false)
workspace.selectPage(firstPageId)
withTemporaryShortcut(action: .nextPage) {
// Some non-US layouts can report unrelated symbols for the ANSI ] key.
// Option+] should still work via keyCode fallback.
guard let event = makeKeyDownEvent(
key: "*",
modifiers: [.option],
keyCode: 30, // kVK_ANSI_RightBracket
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Option+] event on ANSI ] key")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
XCTAssertEqual(workspace.activePage?.id, secondPage.id)
XCTAssertNotEqual(workspace.activePage?.id, firstPageId)
}
func testCmdOptionNCreatesPageInEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId),
let firstWorkspace = firstManager.selectedWorkspace,
let secondWorkspace = secondManager.selectedWorkspace else {
XCTFail("Expected both window contexts to exist")
return
}
let firstPageCount = firstWorkspace.pages.count
let secondPageCount = secondWorkspace.pages.count
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "n",
modifiers: [.command, .option],
keyCode: 45, // kVK_ANSI_N
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+Option+N event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstWorkspace.pages.count, firstPageCount, "Cmd+Option+N must not create a page in stale active window")
XCTAssertEqual(secondWorkspace.pages.count, secondPageCount + 1, "Cmd+Option+N should create a page in the event window")
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testDuplicatePagePreservesStructureAndRemapsPanelIdentity() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let sourcePageId = workspace.activePage?.id else {
XCTFail("Expected test window and workspace")
return
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
guard let sourceStructure = workspace.pageStructureSummary(pageId: sourcePageId),
let sourceSnapshot = workspace.pageStateSnapshot(pageId: sourcePageId, includeScrollback: false) else {
XCTFail("Expected source page snapshot")
return
}
guard let duplicatePage = workspace.duplicatePage(sourcePageId: sourcePageId, select: false) else {
XCTFail("Expected duplicate page")
return
}
guard let duplicateStructure = workspace.pageStructureSummary(pageId: duplicatePage.id),
let duplicateSnapshot = workspace.pageStateSnapshot(pageId: duplicatePage.id, includeScrollback: false) else {
XCTFail("Expected duplicate page snapshot")
return
}
XCTAssertEqual(duplicateStructure.paneCount, sourceStructure.paneCount)
XCTAssertEqual(duplicateStructure.surfaceCount, sourceStructure.surfaceCount)
XCTAssertEqual(duplicatePage.title, "Page 1 Copy")
XCTAssertEqual(sourceSnapshot.panels.count, duplicateSnapshot.panels.count)
if let sourcePanelId = sourceSnapshot.panels.first?.id,
let duplicatePanelId = duplicateSnapshot.panels.first?.id {
XCTAssertNotEqual(sourcePanelId, duplicatePanelId, "Duplicated pages should remap panel identities")
} else {
XCTFail("Expected duplicated page snapshots to include at least one panel")
}
}
func testClosingActivePageSelectsNearestRightNeighbor() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id else {
XCTFail("Expected test window and workspace")
return
}
let middlePage = workspace.newPage(select: false)
let rightPage = workspace.newPage(select: false)
workspace.selectPage(middlePage.id)
workspace.closePage(middlePage.id, skipConfirmation: true)
XCTAssertEqual(workspace.activePage?.id, rightPage.id)
XCTAssertEqual(workspace.pages.map(\.id), [firstPageId, rightPage.id])
}
func testV2PageListIncludesStoredAndActivePageStructureCounts() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer {
TerminalController.shared.setActiveTabManager(nil)
closeWindow(withId: windowId)
}
guard let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id,
let firstPaneId = workspace.bonsplitController.allPaneIds.first else {
XCTFail("Expected test window and workspace")
return
}
workspace.setPageTitle(pageId: firstPageId, title: "Agents")
XCTAssertNotNil(workspace.newTerminalSurface(inPane: firstPaneId, focus: false))
let secondPage = workspace.newPage(select: true)
workspace.setPageTitle(pageId: secondPage.id, title: "Editor")
TerminalController.shared.setActiveTabManager(manager)
let result = v2Result(
method: "page.list",
params: ["workspace_id": workspace.id.uuidString]
)
let pages = result["pages"] as? [[String: Any]]
XCTAssertEqual(result["page_id"] as? String, secondPage.id.uuidString)
XCTAssertEqual(result["page_title"] as? String, "Editor")
XCTAssertEqual(pages?.count, 2)
let pagesByTitle = Dictionary(
uniqueKeysWithValues: (pages ?? []).compactMap { page -> (String, [String: Any])? in
guard let title = page["title"] as? String else { return nil }
return (title, page)
}
)
XCTAssertEqual(pagesByTitle["Agents"]?["pane_count"] as? Int, 1)
XCTAssertEqual(pagesByTitle["Agents"]?["surface_count"] as? Int, 2)
XCTAssertEqual(pagesByTitle["Agents"]?["selected"] as? Bool, false)
XCTAssertEqual(pagesByTitle["Editor"]?["pane_count"] as? Int, 1)
XCTAssertEqual(pagesByTitle["Editor"]?["surface_count"] as? Int, 1)
XCTAssertEqual(pagesByTitle["Editor"]?["selected"] as? Bool, true)
}
func testV2PageSelectAndCurrentReturnUpdatedSelection() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer {
TerminalController.shared.setActiveTabManager(nil)
closeWindow(withId: windowId)
}
guard let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id else {
XCTFail("Expected test window and workspace")
return
}
workspace.setPageTitle(pageId: firstPageId, title: "Agents")
let secondPage = workspace.newPage(select: false)
workspace.setPageTitle(pageId: secondPage.id, title: "Editor")
workspace.selectPage(firstPageId)
TerminalController.shared.setActiveTabManager(manager)
let selectResult = v2Result(
method: "page.select",
params: [
"workspace_id": workspace.id.uuidString,
"page_id": secondPage.id.uuidString
]
)
XCTAssertEqual(workspace.activePageId, secondPage.id)
XCTAssertEqual(selectResult["page_id"] as? String, secondPage.id.uuidString)
XCTAssertEqual(selectResult["page_title"] as? String, "Editor")
XCTAssertEqual(selectResult["workspace_id"] as? String, workspace.id.uuidString)
let currentResult = v2Result(
method: "page.current",
params: ["workspace_id": workspace.id.uuidString]
)
XCTAssertEqual(currentResult["page_id"] as? String, secondPage.id.uuidString)
XCTAssertEqual(currentResult["page_title"] as? String, "Editor")
}
func testV2SystemTreeIncludesPagesAndSelectedPagePaneMirror() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer {
TerminalController.shared.setActiveTabManager(nil)
closeWindow(withId: windowId)
}
guard let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace,
let firstPageId = workspace.activePage?.id,
let firstPaneId = workspace.bonsplitController.allPaneIds.first else {
XCTFail("Expected test window and workspace")
return
}
workspace.setPageTitle(pageId: firstPageId, title: "Agents")
XCTAssertNotNil(workspace.newTerminalSurface(inPane: firstPaneId, focus: false))
let secondPage = workspace.newPage(select: true)
workspace.setPageTitle(pageId: secondPage.id, title: "Editor")
TerminalController.shared.setActiveTabManager(manager)
let result = v2Result(
method: "system.tree",
params: ["workspace_id": workspace.id.uuidString]
)
guard let windows = result["windows"] as? [[String: Any]],
let window = windows.first,
let workspaces = window["workspaces"] as? [[String: Any]],
let workspaceNode = workspaces.first,
let pages = workspaceNode["pages"] as? [[String: Any]] else {
XCTFail("Expected system.tree page hierarchy")
return
}
XCTAssertEqual(windows.count, 1)
XCTAssertEqual(workspaceNode["selected_page_id"] as? String, secondPage.id.uuidString)
XCTAssertEqual(pages.count, 2)
let pagesByTitle = Dictionary(
uniqueKeysWithValues: pages.compactMap { page -> (String, [String: Any])? in
guard let title = page["title"] as? String else { return nil }
return (title, page)
}
)
let agentsPage = pagesByTitle["Agents"]
let editorPage = pagesByTitle["Editor"]
XCTAssertEqual(agentsPage?["selected"] as? Bool, false)
XCTAssertEqual(editorPage?["selected"] as? Bool, true)
XCTAssertEqual((agentsPage?["panes"] as? [[String: Any]])?.count, 1)
XCTAssertEqual((editorPage?["panes"] as? [[String: Any]])?.count, 1)
let agentsSurfaceCount = ((agentsPage?["panes"] as? [[String: Any]])?.first?["surfaces"] as? [[String: Any]])?.count
let editorSurfaceCount = ((editorPage?["panes"] as? [[String: Any]])?.first?["surfaces"] as? [[String: Any]])?.count
let mirroredSurfaceCount = ((workspaceNode["panes"] as? [[String: Any]])?.first?["surfaces"] as? [[String: Any]])?.count
XCTAssertEqual(agentsSurfaceCount, 2)
XCTAssertEqual(editorSurfaceCount, 1)
XCTAssertEqual(mirroredSurfaceCount, editorSurfaceCount)
}
func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -2742,71 +2253,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
#endif
}
private func v2Result(
method: String,
params: [String: Any] = [:],
file: StaticString = #filePath,
line: UInt = #line
) -> [String: Any] {
let request: [String: Any] = [
"id": "test",
"method": method,
"params": params
]
guard JSONSerialization.isValidJSONObject(request) else {
XCTFail("Expected valid JSON request", file: file, line: line)
return [:]
}
let requestData: Data
do {
requestData = try JSONSerialization.data(withJSONObject: request, options: [])
} catch {
XCTFail("Failed to encode request JSON: \(error)", file: file, line: line)
return [:]
}
guard let requestString = String(data: requestData, encoding: .utf8) else {
XCTFail("Failed to encode UTF-8 request", file: file, line: line)
return [:]
}
let responseString: String
#if DEBUG
responseString = TerminalController.shared.debugProcessV2Command(requestString)
#else
XCTFail("debugProcessV2Command is only available in DEBUG", file: file, line: line)
return [:]
#endif
guard let responseData = responseString.data(using: .utf8) else {
XCTFail("Failed to decode UTF-8 response", file: file, line: line)
return [:]
}
let responseObject: Any
do {
responseObject = try JSONSerialization.jsonObject(with: responseData, options: [])
} catch {
XCTFail("Failed to decode response JSON: \(error)", file: file, line: line)
return [:]
}
guard let response = responseObject as? [String: Any] else {
XCTFail("Expected JSON object response", file: file, line: line)
return [:]
}
let isOK = (response["ok"] as? Bool) == true
XCTAssertTrue(isOK, "Expected successful v2 response: \(responseString)", file: file, line: line)
guard let result = response["result"] as? [String: Any] else {
XCTFail("Expected result payload in response: \(responseString)", file: file, line: line)
return [:]
}
return result
}
private func window(withId windowId: UUID) -> NSWindow? {
let identifier = "cmux.main.\(windowId.uuidString)"
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })

View file

@ -5812,243 +5812,6 @@ final class SidebarDragAutoScrollPlannerTests: XCTestCase {
}
}
final class TitlebarPageDropPlannerTests: XCTestCase {
func testNoIndicatorForNoOpEdges() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
XCTAssertNil(
TitlebarPageDropPlanner.indicator(
draggedPageId: first,
targetPageId: first,
pageIds: pageIds
)
)
XCTAssertNil(
TitlebarPageDropPlanner.indicator(
draggedPageId: third,
targetPageId: nil,
pageIds: pageIds
)
)
}
func testNoIndicatorWhenOnlyOnePageExists() {
let only = UUID()
XCTAssertNil(
TitlebarPageDropPlanner.indicator(
draggedPageId: only,
targetPageId: nil,
pageIds: [only]
)
)
XCTAssertNil(
TitlebarPageDropPlanner.indicator(
draggedPageId: only,
targetPageId: only,
pageIds: [only]
)
)
}
func testIndicatorAppearsForRealMoveToEnd() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
let indicator = TitlebarPageDropPlanner.indicator(
draggedPageId: second,
targetPageId: nil,
pageIds: pageIds
)
XCTAssertEqual(indicator?.pageId, nil)
XCTAssertEqual(indicator?.edge, .trailing)
}
func testTargetIndexForMoveToEndFromMiddle() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
let index = TitlebarPageDropPlanner.targetIndex(
draggedPageId: second,
targetPageId: nil,
indicator: TitlebarPageDropIndicator(pageId: nil, edge: .trailing),
pageIds: pageIds
)
XCTAssertEqual(index, 2)
}
func testPointerLeadingEdgeSuppressesNoOpWhenDraggingFirstOverSecond() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
XCTAssertNil(
TitlebarPageDropPlanner.indicator(
draggedPageId: first,
targetPageId: second,
pageIds: pageIds,
pointerX: 2,
targetWidth: 40
)
)
}
func testPointerTrailingEdgeAllowsMoveWhenDraggingFirstOverSecond() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
let indicator = TitlebarPageDropPlanner.indicator(
draggedPageId: first,
targetPageId: second,
pageIds: pageIds,
pointerX: 38,
targetWidth: 40
)
XCTAssertEqual(indicator?.pageId, third)
XCTAssertEqual(indicator?.edge, .leading)
XCTAssertEqual(
TitlebarPageDropPlanner.targetIndex(
draggedPageId: first,
targetPageId: second,
indicator: indicator,
pageIds: pageIds
),
1
)
}
func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
let fromTrailingEdgeOfSecond = TitlebarPageDropPlanner.indicator(
draggedPageId: first,
targetPageId: second,
pageIds: pageIds,
pointerX: 38,
targetWidth: 40
)
let fromLeadingEdgeOfThird = TitlebarPageDropPlanner.indicator(
draggedPageId: first,
targetPageId: third,
pageIds: pageIds,
pointerX: 2,
targetWidth: 40
)
XCTAssertEqual(fromTrailingEdgeOfSecond?.pageId, third)
XCTAssertEqual(fromTrailingEdgeOfSecond?.edge, .leading)
XCTAssertEqual(fromLeadingEdgeOfThird?.pageId, third)
XCTAssertEqual(fromLeadingEdgeOfThird?.edge, .leading)
}
func testPointerTrailingEdgeSuppressesNoOpWhenDraggingLastOverSecond() {
let first = UUID()
let second = UUID()
let third = UUID()
let pageIds = [first, second, third]
XCTAssertNil(
TitlebarPageDropPlanner.indicator(
draggedPageId: third,
targetPageId: second,
pageIds: pageIds,
pointerX: 38,
targetWidth: 40
)
)
}
}
final class TitlebarPageDragAutoScrollPlannerTests: XCTestCase {
func testAutoScrollPlanTriggersNearLeadingAndTrailingEdgesOnly() {
let leadingPlan = TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: 4,
distanceToTrailing: 96,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
XCTAssertEqual(leadingPlan?.direction, .left)
XCTAssertNotNil(leadingPlan)
let trailingPlan = TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: 96,
distanceToTrailing: 4,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
XCTAssertEqual(trailingPlan?.direction, .right)
XCTAssertNotNil(trailingPlan)
XCTAssertNil(
TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: 60,
distanceToTrailing: 60,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
)
}
func testAutoScrollPlanSpeedsUpCloserToEdge() {
let nearLeading = TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: 1,
distanceToTrailing: 99,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
let midLeading = TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: 22,
distanceToTrailing: 78,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
XCTAssertNotNil(nearLeading)
XCTAssertNotNil(midLeading)
XCTAssertGreaterThan(nearLeading?.pointsPerTick ?? 0, midLeading?.pointsPerTick ?? 0)
}
func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() {
let pastLeading = TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: -500,
distanceToTrailing: 600,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
XCTAssertEqual(pastLeading?.direction, .left)
XCTAssertEqual(pastLeading?.pointsPerTick, 12)
let pastTrailing = TitlebarPageDragAutoScrollPlanner.plan(
distanceToLeading: 600,
distanceToTrailing: -500,
edgeInset: 44,
minStep: 2,
maxStep: 12
)
XCTAssertEqual(pastTrailing?.direction, .right)
XCTAssertEqual(pastTrailing?.pointsPerTick, 12)
}
}
final class FinderServicePathResolverTests: XCTestCase {
func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() {
let input: [URL] = [

View file

@ -65,42 +65,6 @@ final class SessionPersistenceTests: XCTestCase {
XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor)
}
func testSaveAndLoadRoundTripPreservesWorkspacePagesAndActivePageSelection() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
let firstPageId = UUID()
let secondPageId = UUID()
snapshot.windows[0].tabManager.workspaces[0].activePageId = secondPageId
snapshot.windows[0].tabManager.workspaces[0].pages = [
SessionWorkspacePageSnapshot(
id: firstPageId,
title: "Agents",
state: makePageStateSnapshot(currentDirectory: "/tmp/project/agents")
),
SessionWorkspacePageSnapshot(
id: secondPageId,
title: "Editor",
state: makePageStateSnapshot(currentDirectory: "/tmp/project/editor")
),
]
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
let workspace = loaded?.windows.first?.tabManager.workspaces.first
XCTAssertEqual(workspace?.activePageId, secondPageId)
XCTAssertEqual(workspace?.pages?.map(\.title), ["Agents", "Editor"])
XCTAssertEqual(workspace?.pages?.map(\.state.currentDirectory), [
"/tmp/project/agents",
"/tmp/project/editor",
])
}
func testLoadRejectsSchemaVersionMismatch() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
@ -768,19 +732,6 @@ final class SessionPersistenceTests: XCTestCase {
windows: [window]
)
}
private func makePageStateSnapshot(currentDirectory: String) -> SessionWorkspacePageStateSnapshot {
SessionWorkspacePageStateSnapshot(
currentDirectory: currentDirectory,
focusedPanelId: nil,
layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)),
panels: [],
statusEntries: [],
logEntries: [],
progress: nil,
gitBranch: nil
)
}
}
final class SocketListenerAcceptPolicyTests: XCTestCase {

View file

@ -47,85 +47,3 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase {
)
}
}
final class WorkspaceHandoffPolicyTests: XCTestCase {
func testStaleRetiringWorkspaceReturnsSupersededWorkspace() {
let currentRetiring = UUID()
let nextRetiring = UUID()
let selected = UUID()
XCTAssertEqual(
WorkspaceHandoffPolicy.staleRetiringWorkspaceId(
currentRetiring: currentRetiring,
nextRetiring: nextRetiring,
selected: selected
),
currentRetiring
)
}
func testStaleRetiringWorkspaceDoesNotHideNewlySelectedWorkspace() {
let currentRetiring = UUID()
let nextRetiring = UUID()
XCTAssertNil(
WorkspaceHandoffPolicy.staleRetiringWorkspaceId(
currentRetiring: currentRetiring,
nextRetiring: nextRetiring,
selected: currentRetiring
)
)
}
func testStaleRetiringWorkspaceDoesNotHideNextRetiringWorkspace() {
let nextRetiring = UUID()
XCTAssertNil(
WorkspaceHandoffPolicy.staleRetiringWorkspaceId(
currentRetiring: nextRetiring,
nextRetiring: nextRetiring,
selected: UUID()
)
)
}
}
@MainActor
final class WorkspacePageLifecycleTests: XCTestCase {
func testSwitchingPagesPreservesLivePanelIdentityAcrossDetachAndReattach() throws {
let workspace = Workspace()
let firstPageId = workspace.activePageId
let firstPaneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first)
XCTAssertNotNil(workspace.newTerminalSurface(inPane: firstPaneId, focus: false))
let firstPagePanelIds = Set(workspace.panels.keys)
XCTAssertEqual(firstPagePanelIds.count, 2)
let secondPage = workspace.newPage(select: true)
XCTAssertEqual(workspace.activePageId, secondPage.id)
let secondPagePanelIds = Set(workspace.panels.keys)
XCTAssertEqual(
secondPagePanelIds.count,
1,
"A fresh page should mount its own placeholder terminal"
)
XCTAssertNotEqual(firstPagePanelIds, secondPagePanelIds)
workspace.selectPage(firstPageId)
XCTAssertEqual(workspace.activePageId, firstPageId)
XCTAssertEqual(
Set(workspace.panels.keys),
firstPagePanelIds,
"Returning to the first page should reattach the parked live panels"
)
workspace.selectPage(secondPage.id)
XCTAssertEqual(workspace.activePageId, secondPage.id)
XCTAssertEqual(
Set(workspace.panels.keys),
secondPagePanelIds,
"Returning to the second page should reuse its parked live panel instead of rebuilding a new one"
)
}
}

View file

@ -1,232 +0,0 @@
import XCTest
private func workspacePagesPollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class WorkspacePagesUITests: XCTestCase {
private let launchTag = "ui-tests-workspace-pages"
private var interruptionMonitor: NSObjectProtocol?
override func setUp() {
super.setUp()
continueAfterFailure = false
interruptionMonitor = addUIInterruptionMonitor(withDescription: "Notification Center") { dialog in
Self.dismissInterruptingDialog(dialog)
}
}
override func tearDown() {
if let interruptionMonitor {
removeUIInterruptionMonitor(interruptionMonitor)
}
interruptionMonitor = nil
super.tearDown()
}
func testTitlebarPageStripCreateSelectCloseAndHintFlow() {
let app = configuredApp()
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for workspace pages UI test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForPageButtonCount(1, app: app, timeout: 8.0))
guard let firstPageToken = activePageToken(in: app) else {
XCTFail("Expected initial active titlebar page button")
return
}
XCTAssertTrue(waitForElementVisible(app.staticTexts["titlebarPageHint.1"], timeout: 6.0))
app.typeKey("n", modifierFlags: [.command, .option])
XCTAssertTrue(waitForPageButtonCount(2, app: app, timeout: 8.0))
guard let secondPageToken = activePageToken(in: app) else {
XCTFail("Expected created page to become active")
return
}
XCTAssertNotEqual(secondPageToken, firstPageToken)
XCTAssertTrue(waitForElementVisible(app.staticTexts["titlebarPageHint.2"], timeout: 6.0))
let firstPageButton = app.buttons["titlebarPageButton.\(firstPageToken)"]
XCTAssertTrue(waitForElementExists(firstPageButton, timeout: 6.0))
firstPageButton.click()
XCTAssertTrue(waitForActivePageToken(firstPageToken, app: app, timeout: 6.0))
let closeButton = app.buttons["titlebarPageCloseButton.\(firstPageToken)"]
XCTAssertTrue(waitForElementExists(closeButton, timeout: 6.0))
XCTAssertTrue(clickElementHandlingInterruptions(
closeButton,
app: app,
successCondition: { self.waitForPageButtonCount(1, app: app, timeout: 1.0) }
))
XCTAssertTrue(waitForPageButtonCount(1, app: app, timeout: 8.0))
XCTAssertTrue(waitForActivePageToken(secondPageToken, app: app, timeout: 6.0))
}
private func configuredApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_TAG"] = launchTag
app.launchEnvironment["CMUX_UI_TEST_SKIP_CONFIRM_CLOSE_PAGE"] = "1"
app.launchArguments += ["-shortcutHintAlwaysShow", "YES"]
app.launchArguments += ["-shortcutHintTitlebarXOffset", "4"]
app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"]
return app
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private func waitForPageButtonCount(_ count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
workspacePagesPollUntil(timeout: timeout) {
pageButtons(in: app).count == count
}
}
private func waitForActivePageToken(_ token: String, app: XCUIApplication, timeout: TimeInterval) -> Bool {
workspacePagesPollUntil(timeout: timeout) {
activePageToken(in: app) == token
}
}
private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
workspacePagesPollUntil(timeout: timeout) {
guard element.exists else { return false }
let frame = element.frame
return frame.width > 1 && frame.height > 1
}
}
private func waitForElementExists(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
workspacePagesPollUntil(timeout: timeout) {
element.exists
}
}
private func clickElementHandlingInterruptions(
_ element: XCUIElement,
app: XCUIApplication,
attempts: Int = 2,
successCondition: () -> Bool
) -> Bool {
for attempt in 0..<attempts {
dismissNotificationCenterIfPresent()
if app.state != .runningForeground {
app.activate()
_ = app.wait(for: .runningForeground, timeout: 4.0)
}
guard element.exists else { return false }
element.click()
if successCondition() {
return true
}
let dismissedInterruption = dismissNotificationCenterIfPresent()
if successCondition() {
return true
}
guard dismissedInterruption, attempt + 1 < attempts else { continue }
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
return successCondition()
}
@discardableResult
private func dismissNotificationCenterIfPresent() -> Bool {
let notificationCenter = XCUIApplication(bundleIdentifier: "com.apple.UserNotificationCenter")
let dialog = notificationCenter.dialogs.firstMatch
if dialog.exists || dialog.waitForExistence(timeout: 0.2) {
return Self.dismissInterruptingDialog(dialog)
}
let sheet = notificationCenter.sheets.firstMatch
if sheet.exists || sheet.waitForExistence(timeout: 0.2) {
return Self.dismissInterruptingDialog(sheet)
}
return false
}
private static func dismissInterruptingDialog(_ dialog: XCUIElement) -> Bool {
let preferredButtonIDs = [
"Close",
"Dismiss",
"Clear",
"Later",
"Not Now",
"OK",
"Cancel",
"action-button-3",
"action-button-2",
"action-button-1",
"action-button-0",
]
for buttonID in preferredButtonIDs {
let button = dialog.buttons[buttonID]
if button.exists {
button.click()
return true
}
}
let buttons = dialog.descendants(matching: .button).allElementsBoundByIndex
if let fallback = buttons.reversed().first(where: { $0.exists && $0.isHittable }) {
fallback.click()
return true
}
if let fallback = buttons.first(where: { $0.exists }) {
fallback.click()
return true
}
return false
}
private func activePageToken(in app: XCUIApplication) -> String? {
let query = activePageButtons(in: app)
guard query.count == 1 else { return nil }
return pageToken(from: query.element(boundBy: 0).identifier)
}
private func pageButtons(in app: XCUIApplication) -> XCUIElementQuery {
let predicate = NSPredicate(format: "identifier BEGINSWITH %@", "titlebarPageButton.")
return app.descendants(matching: .button).matching(predicate)
}
private func activePageButtons(in app: XCUIApplication) -> XCUIElementQuery {
let predicate = NSPredicate(format: "identifier BEGINSWITH %@", "titlebarPageButton.active.")
return app.descendants(matching: .button).matching(predicate)
}
private func pageToken(from identifier: String) -> String? {
if identifier.hasPrefix("titlebarPageButton.active.") {
return String(identifier.dropFirst("titlebarPageButton.active.".count))
}
if identifier.hasPrefix("titlebarPageButton.") {
return String(identifier.dropFirst("titlebarPageButton.".count))
}
return nil
}
}

View file

@ -1,453 +0,0 @@
# Workspace Pages Spec
Last updated: March 7, 2026
Related issue: https://github.com/manaflow-ai/cmux/issues/569
## Problem
Today a workspace owns exactly one Bonsplit layout. That forces users to either:
1. Keep editor or database panes full-width in a separate workspace.
2. Keep everything in one workspace and accept squeezed layouts.
The requested hierarchy is:
1. `workspace`
2. `page`
3. `pane`
4. `surface`
A workspace stays "one project or repo". Pages let that project hold multiple full-layout views in the same workspace.
## Naming
Recommended public name: `page`
Canonical terms:
1. `workspace`: the vertical sidebar item for a project/task.
2. `page`: a titlebar-level layout inside a workspace.
3. `pane`: a split region inside a page.
4. `surface`: a tab inside a pane.
Why `page`:
1. `tab` is already overloaded in cmux for workspaces, Bonsplit tabs, and browser tabs.
2. `layout` sounds static, but this object is navigated, renamed, closed, and reordered.
3. `scene` is distinctive but reads too novel for a terminal app.
4. `page` is short, works in menus and shortcuts, and fits the titlebar text-strip UI.
Rejected names for now:
1. `workspace tab`
2. `top-level tab`
3. `scene`
4. `layout`
## Product Shape
Each workspace owns an ordered list of pages. Each page owns one full Bonsplit tree plus its page-local focus state.
The active page is shown in a horizontal titlebar strip. Switching pages swaps the active Bonsplit layout without changing the selected workspace.
The sidebar continues to represent workspaces only. V1 does not add a second sidebar layer.
## Implementation Status
Implemented on this branch:
1. `Workspace` now owns an ordered `pages` list plus an active page selection.
2. Page order, titles, active selection, and page-local layouts persist through session restore.
3. The fake titlebar now shows a horizontal page strip instead of the folder icon.
4. The page `+` is hover-only, pinned on the far right, and does not steal drag space while hidden.
5. Page close-button visibility follows the active/hover rules in the titlebar strip.
6. Page context menus support create, duplicate, rename, close, close others, move left, and move right.
7. Page switching detaches inactive Ghostty and WKWebView-backed panels from the live hierarchy instead of killing PTYs or browser state.
8. Holding Option reveals direct-select page shortcut badges in the titlebar strip, using the existing shortcut-hint pattern.
9. Customizable page shortcuts exist in `KeyboardShortcutSettings`, and the default bindings are wired through app-level shortcut handling.
10. `Cmd+Shift+P` exposes page create, duplicate, rename, close, close others, next/previous, move left/right, and direct page selection commands.
11. The app menu exposes page create, duplicate, rename, close, close others, move left/right, next/previous, and direct page selection actions.
12. The page strip supports drag-and-drop reordering with horizontal auto-scroll.
13. Socket v2 page APIs exist for list/current/create/duplicate/select/rename/close/reorder/next/previous/last.
14. CLI page commands exist for `list-pages`, `new-page`, `duplicate-page`, `current-page`, `select-page`, `rename-page`, `close-page`, `reorder-page`, `next-page`, `previous-page`, and `last-page`.
15. `system.identify` now includes focused page identity via `page_id`, `page_ref`, `page_index`, and `page_title`.
16. `system.tree` and `cmux tree` now render `workspace -> page -> pane -> surface`, while keeping the selected page mirrored into the legacy workspace-level `panes` field for older consumers.
17. Unit coverage now exists for page drag-drop planner behavior, page-strip autoscroll planning, page persistence round-trips, shortcut routing, duplicate-page structure preservation, active-page close-neighbor selection, runtime page detach/reattach identity across switches, and the v2 JSON page/tree path for `page.list`, `page.select`, `page.current`, and `system.tree`.
18. Dedicated UI automation now exists for the titlebar page strip create/select/close and shortcut-hint flow.
19. A `tests_v2` regression now exists for external CLI and socket page parity across create, select, reorder, current, last, and close flows.
Not implemented yet:
1. The deeper model refactor where each page owns its own `bonsplitController` and live panel map directly.
2. CI execution and stabilization for the new page UI automation and external page API regressions still needs to be wired and kept green on this branch.
## Titlebar UX
Replace the current folder icon and single titlebar label area with a text-only page strip.
V1 strip rules:
1. Page items render as text only.
2. The active page is visually distinct and keeps its close button visible.
3. Inactive pages reveal their close button on hover.
4. When a workspace has only one page, the active page still reserves the close slot, but the close button is disabled so a workspace never reaches zero pages.
5. A page `+` control sits at the far right of the fake titlebar lane, outside the scrollable page list.
6. Right click on a page opens its context menu.
7. Empty titlebar space remains draggable.
8. Holding Option should reveal the direct-select shortcut labels for visible pages, using the existing shortcut-hint pattern instead of adding permanent chrome.
9. The page `+` control is only visible while hovering the fake titlebar.
The current titlebar folder icon goes away in V1. `Open Folder` remains available through existing menu, command palette, and shortcut paths.
## Page UI Detail
The page strip should feel like part of the macOS titlebar, not like a second toolbar.
Visual direction:
1. Text-first, not boxed tabs.
2. No persistent pill backgrounds, segmented control borders, or folder/file chrome.
3. Typography should be close to the current titlebar label treatment, with the active page using stronger weight and opacity.
4. Hover can add a very light background wash, but the default state should read as text in the titlebar.
Titlebar layout:
1. Traffic lights stay where they are now.
2. The page strip replaces the current folder-icon-plus-title area.
3. Existing titlebar controls on the trailing side stay separate from the page strip.
4. The strip should consume available width before squeezing the trailing controls.
5. Any leftover titlebar gap outside page hit targets remains window-drag space.
6. The page list itself is a scrollable lane.
7. The page `+` control is pinned to the far right of the fake titlebar lane and is not part of the scrolling content.
Page item anatomy:
1. Page title text.
2. Reserved close-button slot on the trailing edge of the item.
3. Hover/active hit area large enough to be easy to target in the titlebar.
Page item state rules:
1. Active page:
- stronger text weight
- higher contrast
- close button always visible
2. Inactive page:
- lighter text treatment
- close button hidden until hover
3. Hovered page:
- subtle background wash is allowed
- close button becomes visible
4. Pressed page:
- same layout, just a stronger hover/pressed wash
5. Single remaining page:
- keeps the close slot visible for layout stability
- close button is disabled
Close-button behavior:
1. Use an `x` or close glyph sized for titlebar density, not a large filled control.
2. The close button must not shift page text when it appears.
3. Clicking the close button closes only that page.
4. Closing the active page selects the nearest surviving neighbor, preferring the page to the right.
Sizing and truncation:
1. Single-line titles only.
2. Tail truncation when a title is too long.
3. Each page item keeps a stable minimum clickable width even for short names.
4. The active page gets slightly higher layout priority before truncation.
Overflow behavior:
1. The strip stays single-row and never wraps.
2. When pages exceed available width, the strip becomes horizontally scrollable.
3. Selecting, creating, or moving to a page should auto-scroll it into view.
4. The pinned page `+` control stays visible on the far right while the page list scrolls underneath its own lane.
5. Leading and trailing fade hints are acceptable if needed, but V1 should avoid adding heavy chrome.
Interaction details:
1. Left click selects the page.
2. Right click opens the page context menu for the clicked page.
3. Right click should not require activating the page first.
4. Double click rename can wait until later; V1 can use menu, command palette, and shortcut-driven rename only.
5. The context menu and close button must not break titlebar drag behavior outside their hit regions.
6. The fake titlebar should still drag the window anywhere that is not an actual page hit target or the visible page `+` hit target.
Creation affordance:
1. The page `+` control is pinned to the far right of the fake titlebar lane.
2. It should visually match the text-first style instead of looking like a toolbar button.
3. It is hidden by default.
4. It fades in only while hovering the fake titlebar region.
5. When hidden, that area should behave like normal titlebar drag space rather than a dead zone.
6. Only the visible glyph and its small padded hit target become clickable.
7. It should stay easy to hit without competing with the existing `New Workspace` titlebar control.
Tooltips and hints:
1. Hovering a page should show the full page title when truncated.
2. Hovering the `+` affordance should show `New Page` plus its effective shortcut.
3. Holding Option should show page-index shortcut hints in the strip, following the same “hold modifier to reveal hints” idea already used elsewhere in cmux.
## Page Behavior
Each page preserves its own:
1. Split topology.
2. Surface order inside each pane.
3. Focused pane.
4. Selected surface per pane.
5. Scrollback and restore state already tracked by the current workspace/session model.
Workspace-level state remains shared:
1. Sidebar row identity and ordering.
2. Workspace name and color.
3. Notification aggregation and unread state.
4. Workspace-level commands such as rename, move, and close workspace.
For single-value sidebar metadata in V1, use the active page as the source of truth. We can revisit cross-page aggregation later if this feels misleading.
## Efficiency And Lifecycle
Pages should not behave like multiple fully mounted workspaces stacked on top of each other.
Lifecycle policy:
1. Only the active page in the selected workspace keeps its Ghostty terminal views and WKWebViews mounted in the live window hierarchy.
2. When a page becomes inactive, its terminal portal views and browser portal views should be hidden or detached through the same kind of unmount path cmux already uses for workspace switches.
3. Switching pages must not kill PTYs, throw away scrollback, or reload browser state just because the page is inactive.
4. Re-activating a page should reattach its existing panels instead of reconstructing the whole layout from scratch.
5. Hidden pages should not keep participating in hit testing, layout, or display-driven redraw work.
6. Rapid workspace switching must also hide portal-hosted views for superseded retiring workspaces immediately, so deferred handoff cleanup cannot leave stale terminal or browser portals alive after churn.
Performance rule:
1. There should never be more than one visible page worth of portal-hosted Ghostty surfaces or WKWebViews for a workspace at once.
2. The selected page should remount fast enough that page switches feel like view changes, not restore flows.
3. If later measurement shows browser-heavy workspaces still consume too much memory, add a follow-on cold-parking policy for long-idle pages instead of forcing that complexity into the first implementation.
## Commands And Shortcuts
Required page actions:
1. `New Page`
2. `Rename Page`
3. `Close Page`
4. `Close Other Pages`
5. `Next Page`
6. `Previous Page`
7. `Select Page 1` through `Select Page 8`
8. `Select Last Page`
9. `Duplicate Page`
10. `Move Page Left`
11. `Move Page Right`
Default shortcuts:
1. `Command+Option+N`: new page.
2. `Command+Option+R`: rename page.
3. `Command+Option+W`: close page.
4. `Option+1` through `Option+8`: select page by index.
5. `Option+9`: select the last page.
6. `Option+]`: next page.
7. `Option+[`: previous page.
All page shortcuts must be first-class `KeyboardShortcutSettings` actions so they appear in Settings and can be customized.
The same actions should also appear in the command palette and the app menu.
Implementation note:
Direct page selection should route by physical digit intent, not by text produced after Option modifies the character, so `Option+digit` keeps working across keyboard layouts.
## Cmd+Shift+P Commands
`Cmd+Shift+P` should expose page actions as first-class commands, not as hidden side effects.
Required command-palette entries:
1. `New Page`
2. `Duplicate Page`
3. `Rename Page…`
4. `Close Page`
5. `Close Other Pages`
6. `Next Page`
7. `Previous Page`
8. `Move Page Left`
9. `Move Page Right`
10. `Select Page <title>`
Command-palette behavior:
1. `Rename Page…` should use the same inline rename flow style already used for rename-oriented palette actions.
2. Page commands should resolve against the active window, active workspace, and selected page unless the command explicitly targets another page.
3. Palette results should show current shortcut hints where they exist.
4. Dynamic `Select Page <title>` results should make it easy to jump directly to any page even when there are more than nine.
## Context Menu
Right-clicking a page should expose:
1. `New Page`
2. `Rename Page…`
3. `Move Left`
4. `Move Right`
5. `Close Page`
6. `Close Other Pages`
Current branch status:
1. Implemented.
## Drag And Drop Reordering
The page strip should support drag-and-drop reordering, not just menu-based movement.
Required behavior:
1. Dragging starts from the page item, not from its close button.
2. The reorder indicator should be a single insertion gap, similar to the sidebar workspace reordering model.
3. If the strip is horizontally scrolled, dragging near the left or right edge should auto-scroll it.
4. Dragging a page must never drag the window.
5. Reordering stays within the current workspace in V1.
6. Context-menu move actions remain as keyboard and accessibility fallback.
Current branch status:
1. Implemented.
## Page Naming
V1 default names:
1. `Page 1`
2. `Page 2`
3. `Page 3`
User rename is the primary naming path. Automatic labels based on the active process or focused surface can be added later if the default names feel too generic.
## Model Direction
The current `Workspace` object in `Sources/Workspace.swift` still mixes project-level identity with page-level layout state.
Long-term direction:
1. Keep `Workspace` as the sidebar/project container.
2. Add a `WorkspacePage` model under `Workspace`.
3. Move `bonsplitController` into `WorkspacePage`.
4. Move page-local `panels` into `WorkspacePage`.
5. Move page-local focus and selected-surface state into `WorkspacePage`.
6. Move page-local session snapshot data into `WorkspacePage`.
7. Keep workspace-level sidebar and metadata state on `Workspace`.
This is the cleanest long-term shape for `workspace -> page -> pane -> surface`.
Current branch status:
1. Only the first two steps are implemented.
2. The branch intentionally keeps the existing single `bonsplitController` on `Workspace` and swaps page state in and out around it.
## Persistence
Session restore should persist:
1. page order
2. selected page per workspace
3. each page's Bonsplit snapshot
4. page custom titles
Workspace restore should reopen the last selected page, then restore page-local focus within that page.
Current branch status:
1. Implemented.
## Socket And CLI APIs
Pages need first-class API support because cmux is scriptable and page state will sit between workspace and pane.
Implemented v2 API surface:
1. `page.list`
2. `page.current`
3. `page.create`
4. `page.duplicate`
5. `page.select`
6. `page.rename`
7. `page.close`
8. `page.reorder`
9. `page.next`
10. `page.previous`
11. `page.last`
Identity and targeting:
1. `system.identify` includes `focused.page_id`, `focused.page_ref`, `focused.page_index`, and `focused.page_title`.
2. Short refs support `page:<n>`.
3. Commands that target panes or surfaces without an explicit page should resolve against the currently selected page in the targeted workspace.
Implemented CLI surface:
1. `list-pages [--workspace <id|ref>]`
2. `current-page [--workspace <id|ref>]`
3. `new-page [--workspace <id|ref>] [--title <text>]`
4. `duplicate-page [--workspace <id|ref>] [--page <id|ref>] [--title <text>]`
5. `select-page --page <id|ref|index> [--workspace <id|ref>]`
6. `rename-page [--workspace <id|ref>] [--page <id|ref>] <title>`
7. `close-page [--page <id|ref>] [--workspace <id|ref>]`
8. `reorder-page --page <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--workspace <id|ref>]`
9. `next-page [--workspace <id|ref>]`
10. `previous-page [--workspace <id|ref>]`
11. `last-page [--workspace <id|ref>]`
## Non-Goals For V1
1. Page-level badges, git metadata, or notification chips in the titlebar strip.
2. Cross-workspace page moves.
3. Nested page groups.
4. Aggressively destroying inactive PTYs or browser sessions on every page switch.
## Acceptance Criteria
The first implementation should feel complete if all of this is true:
1. A workspace can hold multiple pages with independent pane/tab layouts.
2. The titlebar strip replaces the folder icon area and is usable with mouse only.
3. `Option+1..9` works by default and is customizable in Settings.
4. Right click works on page items without breaking window dragging or terminal focus.
5. Active-page close button visibility matches the rules above.
6. Inactive pages unmount from the live UI so only the active page's terminal and browser views stay mounted.
7. Drag-and-drop page reordering works, including edge auto-scroll for overflowed strips.
8. `Cmd+Shift+P` exposes page commands and inline rename behavior.
9. Socket and CLI page APIs exist, including `system.identify` page context.
10. App relaunch restores page order, selection, and layout.
11. Existing workspace and pane navigation continue to behave as before.
Current branch status:
1. The V1 acceptance list is implemented.
2. The remaining work is follow-on coverage and the deeper per-page controller refactor described above.
## Test Expectations
Once implementation starts, add coverage for:
1. titlebar hit testing, page item interaction, and empty-space drag behavior
2. page switching preserving per-page Bonsplit state
3. `Option+1..9` routing, including `9 -> last`
4. custom shortcut overrides for page actions
5. `Cmd+Shift+P` page commands and rename flow
6. page context menu actions
7. inactive-page terminal and browser unmount behavior
8. page drag reordering, including overflow auto-scroll
9. session restore of page order and selected page
10. socket and CLI page commands, including `system.identify` page fields
Current branch status:
1. Unit coverage now exists for page persistence round-trips and page shortcut routing, including `Option+9 -> last page`, `Option+]`, `Cmd+Option+N`, and symbol-first layout fallback for page shortcuts.
2. Unit coverage also exists for duplicate-page structure preservation and active-page close-neighbor selection.
3. UI and end-to-end coverage for titlebar hit testing, drag behavior, and page lifecycle still needs to be added.

View file

@ -1,210 +0,0 @@
#!/usr/bin/env python3
"""Regression: page CLI and socket v2 stay in sync."""
import glob
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Tuple
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str], json_output: bool) -> str:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
cmd = [cli, "--socket", SOCKET_PATH]
if json_output:
cmd.append("--json")
cmd.extend(args)
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout
def _run_cli_json(cli: str, args: List[str]) -> Dict:
output = _run_cli(cli, args, json_output=True)
try:
return json.loads(output or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})")
def _page_titles_and_selected(payload: Dict) -> Tuple[List[str], List[str]]:
pages = payload.get("pages") or []
titles = [str(page.get("title") or "") for page in pages]
selected = [str(page.get("title") or "") for page in pages if bool(page.get("selected"))]
return titles, selected
def _workspace_node(tree: Dict, workspace_id: str) -> Dict:
windows = tree.get("windows") or []
for window in windows:
for workspace in window.get("workspaces") or []:
if str(workspace.get("id") or "") == workspace_id:
return workspace
raise cmuxError(f"Workspace {workspace_id} not present in system.tree: {tree}")
def main() -> int:
cli = _find_cli_binary()
help_text = _run_cli(cli, ["list-pages", "--help"], json_output=False)
_must("page:<n>" in help_text, "list-pages --help should mention page:<n> refs")
_must("current-page" in help_text, "list-pages --help should mention related page commands")
with cmux(SOCKET_PATH) as c:
created = c._call("workspace.create", {}) or {}
workspace_id = str(created.get("workspace_id") or "")
_must(bool(workspace_id), f"workspace.create returned no workspace_id: {created}")
try:
c._call("workspace.select", {"workspace_id": workspace_id})
initial = c._call("page.current", {"workspace_id": workspace_id}) or {}
first_page_id = str(initial.get("page_id") or "")
first_page_ref = str(initial.get("page_ref") or "")
_must(bool(first_page_id) and bool(first_page_ref), f"page.current returned no initial page handle: {initial}")
renamed = _run_cli_json(
cli,
["rename-page", "--workspace", workspace_id, "--page", first_page_ref, "agents"],
)
_must(str(renamed.get("page_id") or "") == first_page_id, f"rename-page targeted wrong page: {renamed}")
_must(str(renamed.get("page_title") or "") == "agents", f"rename-page did not set title: {renamed}")
created_page = _run_cli_json(
cli,
["new-page", "--workspace", workspace_id, "--title", "editor"],
)
second_page_id = str(created_page.get("page_id") or "")
second_page_ref = str(created_page.get("page_ref") or "")
_must(
bool(second_page_id) and second_page_id != first_page_id,
f"new-page did not create a distinct page: {created_page}",
)
_must(str(created_page.get("page_title") or "") == "editor", f"new-page did not set title: {created_page}")
listed = c._call("page.list", {"workspace_id": workspace_id}) or {}
titles, selected_titles = _page_titles_and_selected(listed)
_must(titles == ["agents", "editor"], f"page.list returned unexpected titles after create: {listed}")
_must(selected_titles == ["editor"], f"page.list should report editor selected after create: {listed}")
_must(str(listed.get("page_id") or "") == second_page_id, f"page.list should mirror active page: {listed}")
selected = _run_cli_json(
cli,
["select-page", "--workspace", workspace_id, "--page", first_page_ref],
)
_must(str(selected.get("page_id") or "") == first_page_id, f"select-page targeted wrong page: {selected}")
current_after_select = c._call("page.current", {"workspace_id": workspace_id}) or {}
_must(
str(current_after_select.get("page_id") or "") == first_page_id,
f"page.current disagrees with select-page: {current_after_select}",
)
duplicated = _run_cli_json(
cli,
["duplicate-page", "--workspace", workspace_id, "--page", first_page_ref, "--title", "database"],
)
duplicate_page_id = str(duplicated.get("page_id") or "")
duplicate_page_ref = str(duplicated.get("page_ref") or "")
_must(
bool(duplicate_page_id) and duplicate_page_id not in {first_page_id, second_page_id},
f"duplicate-page did not create a distinct page: {duplicated}",
)
_must(str(duplicated.get("page_title") or "") == "database", f"duplicate-page did not set title: {duplicated}")
reordered = c._call(
"page.reorder",
{"workspace_id": workspace_id, "page_id": duplicate_page_id, "index": 0},
) or {}
_must(int(reordered.get("page_index", -1)) == 0, f"page.reorder did not move page to index 0: {reordered}")
tree = c._call("system.tree", {"workspace_id": workspace_id}) or {}
workspace = _workspace_node(tree, workspace_id)
tree_titles = [str(page.get("title") or "") for page in (workspace.get("pages") or [])]
_must(
tree_titles == ["database", "agents", "editor"],
f"system.tree page order did not match reorder result: {workspace}",
)
_must(
str(workspace.get("selected_page_id") or "") == duplicate_page_id,
f"system.tree selected page did not mirror active duplicated page: {workspace}",
)
last_page = c._call("page.last", {"workspace_id": workspace_id}) or {}
_must(str(last_page.get("page_id") or "") == second_page_id, f"page.last should select editor: {last_page}")
current_cli = _run_cli_json(cli, ["current-page", "--workspace", workspace_id])
_must(
str(current_cli.get("page_id") or "") == second_page_id,
f"current-page CLI should agree with page.last: {current_cli}",
)
closed = _run_cli_json(
cli,
["close-page", "--workspace", workspace_id, "--page", duplicate_page_ref],
)
_must(str(closed.get("page_id") or "") == duplicate_page_id, f"close-page closed wrong page: {closed}")
_must(
str(closed.get("selected_page_id") or "") == first_page_id,
f"close-page should select the nearest surviving neighbor after closing the leftmost active page: {closed}",
)
final_list = _run_cli_json(cli, ["list-pages", "--workspace", workspace_id])
final_titles, final_selected = _page_titles_and_selected(final_list)
_must(final_titles == ["agents", "editor"], f"list-pages should reflect closed duplicate page: {final_list}")
_must(final_selected == ["agents"], f"list-pages should report agents selected after close: {final_list}")
_must(str(final_list.get("page_id") or "") == first_page_id, f"list-pages active page mismatch after close: {final_list}")
_must(
second_page_ref.startswith("page:"),
f"new-page should return a page ref handle: {created_page}",
)
finally:
try:
c.close_workspace(workspace_id)
except Exception:
pass
print("PASS: page CLI and socket APIs stay consistent across create/select/reorder/close flows")
return 0
if __name__ == "__main__":
raise SystemExit(main())