This reverts commit 4de975e6a4.
This commit is contained in:
parent
4de975e6a4
commit
e7c3961489
17 changed files with 75 additions and 5567 deletions
492
CLI/cmux.swift
492
CLI/cmux.swift
|
|
@ -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>]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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] = [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue