From 4de975e6a4cc84fd099aa1673348617cafd0e50a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:23:11 -0800 Subject: [PATCH] Add workspace pages in the titlebar (#1030) * Add workspace pages in the titlebar * Add workspace pages UI test target entry * Relax workspace pages UI test titlebar checks * Use page close button in workspace pages UI test * Stabilize workspace pages UI test interruptions * Skip page close confirms in UI tests * Clean up superseded workspace handoffs * Tighten page hint UI assertions --------- Co-authored-by: cmux --- CLI/cmux.swift | 492 +++++++- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 755 ++++++++++++ Sources/AppDelegate.swift | 72 +- Sources/ContentView.swift | 1020 ++++++++++++++++- Sources/KeyboardShortcutSettings.swift | 84 ++ Sources/SessionPersistence.swift | 19 + Sources/TerminalController.swift | 556 ++++++++- Sources/Workspace.swift | 658 ++++++++++- Sources/cmuxApp.swift | 165 +++ .../AppDelegateShortcutRoutingTests.swift | 554 +++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 237 ++++ cmuxTests/SessionPersistenceTests.swift | 49 + .../WorkspaceContentViewVisibilityTests.swift | 82 ++ cmuxUITests/WorkspacePagesUITests.swift | 232 ++++ docs/workspace-pages-spec.md | 453 ++++++++ tests_v2/test_page_cli_socket_parity.py | 210 ++++ 17 files changed, 5567 insertions(+), 75 deletions(-) create mode 100644 cmuxUITests/WorkspacePagesUITests.swift create mode 100644 docs/workspace-pages-spec.md create mode 100644 tests_v2/test_page_cli_socket_parity.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 44989796..6dff2216 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1088,6 +1088,33 @@ 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") @@ -1110,6 +1137,35 @@ 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") @@ -1340,6 +1396,18 @@ 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") @@ -1350,6 +1418,21 @@ 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 ") + } + 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) @@ -1371,6 +1454,58 @@ 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") @@ -2051,7 +2186,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", "pane", "surface"].contains(kind) else { return false } + guard ["window", "workspace", "page", "pane", "surface"].contains(kind) else { return false } return Int(String(pieces[1])) != nil } @@ -2112,6 +2247,43 @@ 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, @@ -2415,6 +2587,45 @@ 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 ") + } + + 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, @@ -4287,6 +4498,138 @@ struct CMUXCLI { Example: cmux list-workspaces """ + case "list-pages": + return """ + Usage: cmux list-pages [--workspace ] + + List pages in a workspace. + + Flags: + --workspace 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 ] [--title ] [--] [title] + + Create a new page in a workspace. + + Flags: + --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + --title 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 ] [--page ] [--title ] [--] [title] + + Duplicate a page. Defaults to the current page in the resolved workspace. + + Flags: + --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + --page Page to duplicate (default: current page) + --title 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 ] + + Print the currently selected page in a workspace. + + Flags: + --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + """ + case "select-page": + return """ + Usage: cmux select-page [--workspace ] (--page | ) + + Select a page in a workspace. + + Flags: + --workspace Workspace context (default: current/$CMUX_WORKSPACE_ID) + --page 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 ] [--page ] [--] + + 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] @@ -4333,7 +4676,7 @@ struct CMUXCLI { return """ Usage: cmux tree [flags] - Print the hierarchy of windows, workspaces, panes, and surfaces. + Print the hierarchy of windows, workspaces, pages, panes, and surfaces. Flags: --all Include all windows (default: current window only) @@ -4345,6 +4688,7 @@ 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. @@ -5199,6 +5543,7 @@ struct CMUXCLI { private struct TreePath { let windowHandle: String? let workspaceHandle: String? + let pageHandle: String? let paneHandle: String? let surfaceHandle: String? } @@ -5476,6 +5821,7 @@ 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") ) @@ -5529,21 +5875,20 @@ struct CMUXCLI { workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) let panes = workspace["panes"] as? [[String: Any]] ?? [] - 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 + workspaceNode["panes"] = panes.map { + treeApplyMarkers(pane: $0, activePath: activePath, callerPath: callerPath) } - workspaceNode["panes"] = paneNodes + 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 + } return workspaceNode } @@ -5552,6 +5897,24 @@ 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]], @@ -5638,19 +6001,24 @@ struct CMUXCLI { let workspaceIndent = workspaceIsLast ? " " : "│ " lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, 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))") + 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 + ) } + } else { + let panes = workspace["panes"] as? [[String: Any]] ?? [] + appendTreePanes(panes, to: &lines, prefix: workspaceIndent, idFormat: idFormat) } } } @@ -5658,8 +6026,42 @@ 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 \(textHandle(window, idFormat: idFormat))"] + var parts = ["window \(treeDisplayHandle(window, idFormat: idFormat))"] if (window["current"] as? Bool) == true { parts.append("[current]") } @@ -5670,7 +6072,7 @@ struct CMUXCLI { } private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String { - var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"] + var parts = ["workspace \(treeDisplayHandle(workspace, idFormat: idFormat))"] let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { parts.append("\"\(title)\"") @@ -5684,8 +6086,23 @@ 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 \(textHandle(pane, idFormat: idFormat))"] + var parts = ["pane \(treeDisplayHandle(pane, idFormat: idFormat))"] if (pane["focused"] as? Bool) == true { parts.append("[focused]") } @@ -5698,7 +6115,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 \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] + var parts = ["surface \(treeDisplayHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { parts.append("\"\(title)\"") @@ -6906,7 +7323,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/pane:3/surface:4), or indexes. + For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/page:3/pane:4/surface:5), 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. @@ -6928,6 +7345,17 @@ 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>] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index fade9fc0..7b943ae5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 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 */; }; @@ -223,6 +224,7 @@ 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>"; }; @@ -444,6 +446,7 @@ D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, + E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */, ); path = cmuxUITests; sourceTree = "<group>"; @@ -680,6 +683,7 @@ D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, + E2000000A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dd1a936a..666521c2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -115,6 +115,642 @@ } } }, + "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": { @@ -36853,6 +37489,125 @@ } } }, + "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": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fa539d3d..5e0b6250 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -624,6 +624,14 @@ 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 { @@ -6843,6 +6851,61 @@ 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 @@ -7767,9 +7830,14 @@ 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 - // command punctuation shortcuts, since some non-US layouts report different characters - // for the same physical key even when menu-equivalent semantics should still apply. + // 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. let allowANSIKeyCodeFallback = flags.contains(.control) + || (!flags.contains(.command) + && flags.contains(.option) + && !flags.contains(.control) + && !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey)) || (flags.contains(.command) && !flags.contains(.control) && ( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a592e491..2e086282 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1248,6 +1248,20 @@ enum WorkspaceMountPolicy { } } +enum WorkspaceHandoffPolicy { + static func staleRetiringWorkspaceId( + currentRetiring: UUID?, + nextRetiring: UUID?, + selected: UUID? + ) -> UUID? { + guard let currentRetiring else { return nil } + if currentRetiring == nextRetiring || currentRetiring == selected { + return nil + } + return currentRetiring + } +} + /// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil, @@ -1297,6 +1311,13 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task<Void, Never>? @State private var titlebarThemeGeneration: UInt64 = 0 + @State private var isTitlebarHovered = false + @State private var hoveredTitlebarPageId: UUID? + @State private var draggedTitlebarPageId: UUID? + @State private var titlebarPageDropIndicator: TitlebarPageDropIndicator? + @StateObject private var titlebarPageDragAutoScrollController = TitlebarPageDragAutoScrollController() + @StateObject private var titlebarPageShortcutHintMonitor = ShortcutHintModifierMonitor(requiredModifierFlags: [.option]) + @State private var titlebarPageDragMonitorTask: Task<Void, Never>? @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @@ -1333,6 +1354,9 @@ struct ContentView: View { @State private var commandPaletteResultsRevision: UInt64 = 0 @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @State private var isFeedbackComposerPresented = false + @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX + @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) @@ -1365,6 +1389,7 @@ struct ContentView: View { enum Kind: Equatable { case workspace(workspaceId: UUID) case tab(workspaceId: UUID, panelId: UUID) + case page(workspaceId: UUID, pageId: UUID) } let kind: Kind @@ -1376,6 +1401,8 @@ struct ContentView: View { return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") case .tab: return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") + case .page: + return String(localized: "commandPalette.rename.pageTitle", defaultValue: "Rename Page") } } @@ -1385,6 +1412,8 @@ struct ContentView: View { return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") case .tab: return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") + case .page: + return String(localized: "commandPalette.rename.pageDescription", defaultValue: "Choose a page name.") } } @@ -1394,6 +1423,8 @@ struct ContentView: View { return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") case .tab: return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") + case .page: + return String(localized: "commandPalette.rename.pagePlaceholder", defaultValue: "Page name") } } } @@ -1984,6 +2015,41 @@ struct ContentView: View { ? Color.black.opacity(0.78) : Color.white.opacity(0.82) } + + private var showsTitlebarPageShortcutHints: Bool { + titlebarPageShortcutHintMonitor.isModifierPressed || alwaysShowShortcutHints + } + + private func titlebarPageShortcutLabel(index: Int, pageCount: Int) -> String? { + commandPalettePageShortcutHint(index: index, pageCount: pageCount) + } + + private func titlebarPageHintSlotWidth(label: String?, showsCloseButton: Bool) -> CGFloat { + let pillWidth: CGFloat = { + guard let label, !label.isEmpty else { return 0 } + return max(28, CGFloat(label.count) * 7.5 + 14) + }() + let closeWidth: CGFloat = showsCloseButton ? 16 : 0 + let gap: CGFloat = (pillWidth > 0 && closeWidth > 0) ? 4 : 0 + return pillWidth + closeWidth + gap + abs(CGFloat(titlebarShortcutHintXOffset)) + } + + private func titlebarPageShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundColor(fakeTitlebarTextColor.opacity(0.92)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ShortcutHintPillBackground(emphasis: 0.9)) + .offset( + x: ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset), + y: ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset) + ) + } + private var fullscreenControls: some View { TitlebarControlsView( notificationStore: TerminalNotificationStore.shared, @@ -2013,17 +2079,12 @@ struct ContentView: View { fullscreenControls } - // Draggable folder icon + focused command name - if let directory = focusedDirectory { - DraggableFolderIcon(directory: directory) + if let workspace = tabManager.selectedWorkspace { + titlebarPageStrip(workspace: workspace) + } else { + Spacer(minLength: 0) } - Text(titlebarText) - .font(.system(size: 13, weight: .bold)) - .foregroundColor(fakeTitlebarTextColor) - .lineLimit(1) - .allowsHitTesting(false) - Spacer() } @@ -2032,6 +2093,12 @@ struct ContentView: View { .padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? 12 : titlebarLeadingInset + CGFloat(debugTitlebarLeadingExtra))) .padding(.trailing, 8) } + .onHover { hovering in + isTitlebarHovered = hovering + if !hovering { + hoveredTitlebarPageId = nil + } + } .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) @@ -2067,6 +2134,278 @@ struct ContentView: View { } } + private func startTitlebarPageDragMonitor() { + titlebarPageDragMonitorTask?.cancel() + titlebarPageDragMonitorTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 50_000_000) + if NSEvent.pressedMouseButtons != 0 { + continue + } + draggedTitlebarPageId = nil + titlebarPageDropIndicator = nil + titlebarPageDragAutoScrollController.stop() + break + } + } + } + + private func stopTitlebarPageDragMonitor() { + titlebarPageDragMonitorTask?.cancel() + titlebarPageDragMonitorTask = nil + } + + private func titlebarPageStrip(workspace: Workspace) -> some View { + ScrollViewReader { proxy in + HStack(spacing: 6) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(Array(workspace.pages.enumerated()), id: \.element.id) { index, page in + titlebarPageItem(workspace: workspace, page: page, index: index) + .id(page.id) + } + + Color.clear + .frame(width: 12, height: 22) + .contentShape(Rectangle()) + .onDrop( + of: TitlebarPageDragPayload.dropContentTypes, + delegate: TitlebarPageDropDelegate( + targetPageId: nil, + workspace: workspace, + draggedPageId: $draggedTitlebarPageId, + dragAutoScrollController: titlebarPageDragAutoScrollController, + dropIndicator: $titlebarPageDropIndicator + ) + ) + .overlay(alignment: .leading) { + if draggedTitlebarPageId != nil, + titlebarPageDropIndicator?.pageId == nil { + TitlebarPageDropIndicatorLine() + } + } + } + .padding(.trailing, 4) + } + .scrollClipDisabled() + .background( + TitlebarPageScrollViewResolver { scrollView in + titlebarPageDragAutoScrollController.attach(scrollView: scrollView) + } + .frame(width: 0, height: 0) + ) + + if isTitlebarHovered { + Button(action: { + _ = workspace.newPage(select: true) + }) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(fakeTitlebarTextColor.opacity(0.88)) + .frame(width: 18, height: 18) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(fakeTitlebarTextColor.opacity(0.08)) + ) + } + .buttonStyle(.plain) + .help(String(localized: "workspace.page.new.tooltip", defaultValue: "New Page")) + .accessibilityIdentifier("titlebarPageNewButton") + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("titlebarPageStrip") + .onAppear { + proxy.scrollTo(workspace.activePageId, anchor: .center) + } + .onChange(of: workspace.activePageId) { _, pageId in + withAnimation(.easeInOut(duration: 0.16)) { + proxy.scrollTo(pageId, anchor: .center) + } + } + .onChange(of: workspace.pages.map(\.id)) { _, _ in + withAnimation(.easeInOut(duration: 0.16)) { + proxy.scrollTo(workspace.activePageId, anchor: .center) + } + } + .onChange(of: draggedTitlebarPageId) { _, draggedPageId in + if draggedPageId == nil { + stopTitlebarPageDragMonitor() + titlebarPageDragAutoScrollController.stop() + titlebarPageDropIndicator = nil + } + } + .onDisappear { + stopTitlebarPageDragMonitor() + draggedTitlebarPageId = nil + titlebarPageDropIndicator = nil + titlebarPageDragAutoScrollController.stop() + } + } + } + + private func titlebarPageItem(workspace: Workspace, page: WorkspacePage, index: Int) -> some View { + let isActive = workspace.activePageId == page.id + let isHovered = hoveredTitlebarPageId == page.id + let canClose = workspace.canClosePage(page.id) + let showCloseButton = isActive || isHovered + let shortcutLabel = titlebarPageShortcutLabel(index: index, pageCount: workspace.pages.count) + let showsShortcutHint = showsTitlebarPageShortcutHints && shortcutLabel != nil + let trailingSlotWidth = titlebarPageHintSlotWidth(label: shortcutLabel, showsCloseButton: showCloseButton) + let isDragged = draggedTitlebarPageId == page.id + let dragIndicator = titlebarPageDropIndicator + let showLeadingDropIndicator = dragIndicator?.pageId == page.id && dragIndicator?.edge == .leading + let showTrailingDropIndicator = dragIndicator?.pageId == page.id && dragIndicator?.edge == .trailing + + return ZStack(alignment: .trailing) { + Button(action: { + workspace.selectPage(page.id) + }) { + HStack(spacing: 6) { + Text(page.title) + .font(.system(size: 13, weight: isActive ? .semibold : .medium)) + .foregroundColor(fakeTitlebarTextColor.opacity(isActive ? 0.95 : 0.72)) + .lineLimit(1) + .truncationMode(.tail) + + Color.clear + .frame(width: max(12, trailingSlotWidth), height: 12) + } + .padding(.leading, 9) + .padding(.trailing, 8) + .padding(.vertical, 4) + .frame(minWidth: 72, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(fakeTitlebarTextColor.opacity(isActive ? 0.11 : (isHovered ? 0.06 : 0.001))) + ) + } + .buttonStyle(.plain) + .help(page.title) + .accessibilityIdentifier(titlebarPageButtonAccessibilityIdentifier(pageId: page.id, isActive: isActive)) + + HStack(spacing: 4) { + if showsShortcutHint, let shortcutLabel { + titlebarPageShortcutHint(text: shortcutLabel) + .accessibilityIdentifier(titlebarPageHintAccessibilityIdentifier(index: index)) + .transition(.opacity) + } + + Button(action: { + workspace.closePage(page.id) + }) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(fakeTitlebarTextColor.opacity(canClose ? 0.82 : 0.32)) + .frame(width: 12, height: 12) + } + .buttonStyle(.plain) + .disabled(!canClose) + .opacity(showCloseButton ? 1 : 0) + .allowsHitTesting(showCloseButton) + .accessibilityIdentifier(titlebarPageCloseButtonAccessibilityIdentifier(pageId: page.id)) + } + .padding(.trailing, 8) + .animation(.easeInOut(duration: 0.12), value: showCloseButton) + .animation(.easeInOut(duration: 0.12), value: showsShortcutHint) + } + .contentShape(Rectangle()) + .opacity(isDragged ? 0.55 : 1) + .onHover { hovering in + if hovering { + hoveredTitlebarPageId = page.id + } else if hoveredTitlebarPageId == page.id { + hoveredTitlebarPageId = nil + } + } + .onDrag { + draggedTitlebarPageId = page.id + titlebarPageDropIndicator = TitlebarPageDropPlanner.indicator( + draggedPageId: page.id, + targetPageId: page.id, + pageIds: workspace.pages.map(\.id), + pointerX: nil, + targetWidth: nil + ) + startTitlebarPageDragMonitor() + return TitlebarPageDragPayload.provider(for: page.id) + } + .onDrop( + of: TitlebarPageDragPayload.dropContentTypes, + delegate: TitlebarPageDropDelegate( + targetPageId: page.id, + workspace: workspace, + draggedPageId: $draggedTitlebarPageId, + dragAutoScrollController: titlebarPageDragAutoScrollController, + dropIndicator: $titlebarPageDropIndicator + ) + ) + .overlay(alignment: .leading) { + if draggedTitlebarPageId != nil, showLeadingDropIndicator { + TitlebarPageDropIndicatorLine() + } + } + .overlay(alignment: .trailing) { + if draggedTitlebarPageId != nil, showTrailingDropIndicator { + TitlebarPageDropIndicatorLine() + } + } + .contextMenu { + Button(String(localized: "workspace.page.context.new", defaultValue: "New Page")) { + _ = workspace.newPage(select: true) + } + + Button(String(localized: "workspace.page.context.duplicate", defaultValue: "Duplicate Page")) { + _ = workspace.duplicatePage(sourcePageId: page.id, select: true) + } + + Button(String(localized: "workspace.page.context.rename", defaultValue: "Rename Page")) { + workspace.promptRenamePage(pageId: page.id) + } + + if canClose { + Button(String(localized: "workspace.page.context.close", defaultValue: "Close Page")) { + workspace.closePage(page.id) + } + } + + if workspace.pages.count > 1 { + Button(String(localized: "workspace.page.context.closeOthers", defaultValue: "Close Other Pages")) { + workspace.closeOtherPages(keeping: page.id) + } + } + + if index > 0 { + Button(String(localized: "workspace.page.context.moveLeft", defaultValue: "Move Page Left")) { + _ = workspace.movePageLeft(pageId: page.id) + } + } + + if index + 1 < workspace.pages.count { + Button(String(localized: "workspace.page.context.moveRight", defaultValue: "Move Page Right")) { + _ = workspace.movePageRight(pageId: page.id) + } + } + } + } + + private func titlebarPageButtonAccessibilityIdentifier(pageId: UUID, isActive: Bool) -> String { + let normalizedPageId = pageId.uuidString.lowercased() + if isActive { + return "titlebarPageButton.active.\(normalizedPageId)" + } + return "titlebarPageButton.\(normalizedPageId)" + } + + private func titlebarPageCloseButtonAccessibilityIdentifier(pageId: UUID) -> String { + "titlebarPageCloseButton.\(pageId.uuidString.lowercased())" + } + + private func titlebarPageHintAccessibilityIdentifier(index: Int) -> String { + "titlebarPageHint.\(index + 1)" + } + private func scheduleTitlebarTextRefresh() { titlebarTextUpdateCoalescer.signal { updateTitlebarText() @@ -2188,6 +2527,7 @@ struct ContentView: View { reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() + titlebarPageShortcutHintMonitor.start() let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) if abs(sidebarWidth - restoredWidth) > 0.5 { sidebarWidth = restoredWidth @@ -2579,9 +2919,11 @@ struct ContentView: View { view = AnyView(view.onDisappear { removeSidebarResizerPointerMonitor() + titlebarPageShortcutHintMonitor.stop() }) view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in + titlebarPageShortcutHintMonitor.setHostWindow(window) window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) window.titlebarAppearsTransparent = true // Do not make the entire background draggable; it interferes with drag gestures @@ -2818,9 +3160,15 @@ struct ContentView: View { private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { let oldSelectedId = previousSelectedWorkspaceId + let staleRetiringWorkspaceId = WorkspaceHandoffPolicy.staleRetiringWorkspaceId( + currentRetiring: retiringWorkspaceId, + nextRetiring: oldSelectedId, + selected: newSelectedId + ) previousSelectedWorkspaceId = newSelectedId guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else { + hidePortalViewsForWorkspace(staleRetiringWorkspaceId, reason: "no_handoff") tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() @@ -2830,6 +3178,7 @@ struct ContentView: View { workspaceHandoffGeneration &+= 1 let generation = workspaceHandoffGeneration + hidePortalViewsForWorkspace(staleRetiringWorkspaceId, reason: "superseded") retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() @@ -2876,10 +3225,7 @@ struct ContentView: View { // the workspace — but dismantleNSView intentionally doesn't hide portal views // during transient rebuilds. Hiding here prevents stale terminal/browser // portals from covering the newly selected workspace. - if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { - workspace.hideAllTerminalPortalViews() - workspace.hideAllBrowserPortalViews() - } + hidePortalViewsForWorkspace(retiring, reason: reason) retiringWorkspaceId = nil tabManager.completePendingWorkspaceUnfocus(reason: reason) @@ -2895,6 +3241,24 @@ struct ContentView: View { #endif } + private func hidePortalViewsForWorkspace(_ workspaceId: UUID?, reason: String) { + guard let workspaceId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + workspace.hideAllTerminalPortalViews() + workspace.hideAllBrowserPortalViews() +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.hide id=\(snapshot.id) dt=\(debugMsText(dtMs)) " + + "workspace=\(debugShortWorkspaceId(workspaceId)) reason=\(reason)" + ) + } else { + dlog("ws.handoff.hide id=none workspace=\(debugShortWorkspaceId(workspaceId)) reason=\(reason)") + } +#endif + } + private var commandPaletteOverlay: some View { GeometryReader { proxy in let maxAllowedWidth = max(340, proxy.size.width - 260) @@ -3221,6 +3585,8 @@ struct ContentView: View { return String(localized: "commandPalette.rename.workspaceInputHint", defaultValue: "Enter a workspace name. Press Enter to rename, Escape to cancel.") case .tab: return String(localized: "commandPalette.rename.tabInputHint", defaultValue: "Enter a tab name. Press Enter to rename, Escape to cancel.") + case .page: + return String(localized: "commandPalette.rename.pageInputHint", defaultValue: "Enter a page name. Press Enter to rename, Escape to cancel.") } } @@ -3230,6 +3596,8 @@ struct ContentView: View { return String(localized: "commandPalette.rename.workspaceConfirmHint", defaultValue: "Press Enter to apply this workspace name, or Escape to cancel.") case .tab: return String(localized: "commandPalette.rename.tabConfirmHint", defaultValue: "Press Enter to apply this tab name, or Escape to cancel.") + case .page: + return String(localized: "commandPalette.rename.pageConfirmHint", defaultValue: "Press Enter to apply this page name, or Escape to cancel.") } } @@ -3867,9 +4235,65 @@ struct ContentView: View { nextRank += 1 } + if let workspace = tabManager.selectedWorkspace { + for pageCommand in commandPalettePageSelectionCommands(workspace: workspace, startingRank: nextRank) { + commands.append(pageCommand) + nextRank += 1 + } + } + return commands } + private func commandPalettePageSelectionCommands( + workspace: Workspace, + startingRank: Int + ) -> [CommandPaletteCommand] { + workspace.pages.enumerated().map { index, page in + CommandPaletteCommand( + id: "palette.selectPage.\(page.id.uuidString.lowercased())", + rank: startingRank + index, + title: page.title, + subtitle: String(localized: "command.selectPage.subtitle", defaultValue: "Page Navigation"), + shortcutHint: commandPalettePageShortcutHint(index: index, pageCount: workspace.pages.count), + keywords: ["select", "page", "workspace", page.title], + dismissOnRun: true, + action: { + workspace.selectPage(page.id) + } + ) + } + } + + private func commandPalettePageShortcutHint(index: Int, pageCount: Int) -> String? { + guard let digit = WorkspaceShortcutMapper.optionDigitForPage(at: index, pageCount: pageCount) else { + return nil + } + + let action: KeyboardShortcutSettings.Action + switch digit { + case 1: + action = .selectPage1 + case 2: + action = .selectPage2 + case 3: + action = .selectPage3 + case 4: + action = .selectPage4 + case 5: + action = .selectPage5 + case 6: + action = .selectPage6 + case 7: + action = .selectPage7 + case 8: + action = .selectPage8 + default: + action = .selectLastPage + } + return KeyboardShortcutSettings.shortcut(for: action).displayString + } + private func commandPaletteShortcutHint( for contribution: CommandPaletteCommandContribution, context: CommandPaletteContextSnapshot @@ -3912,6 +4336,16 @@ struct ContentView: View { return .renameTab case "palette.renameWorkspace": return .renameWorkspace + case "palette.newPage": + return .newPage + case "palette.renamePage": + return .renamePage + case "palette.closePage": + return .closePage + case "palette.nextPage": + return .nextPage + case "palette.previousPage": + return .previousPage case "palette.nextWorkspace": return .nextSidebarTab case "palette.previousWorkspace": @@ -4292,6 +4726,89 @@ struct ContentView: View { ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newPage", + title: constant(String(localized: "command.newPage.title", defaultValue: "New Page")), + subtitle: workspaceSubtitle, + keywords: ["new", "page", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.duplicatePage", + title: constant(String(localized: "command.duplicatePage.title", defaultValue: "Duplicate Page")), + subtitle: workspaceSubtitle, + keywords: ["duplicate", "clone", "page", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renamePage", + title: constant(String(localized: "command.renamePage.title", defaultValue: "Rename Page…")), + subtitle: workspaceSubtitle, + keywords: ["rename", "page", "workspace"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closePage", + title: constant(String(localized: "command.closePage.title", defaultValue: "Close Page")), + subtitle: workspaceSubtitle, + keywords: ["close", "page", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeOtherPages", + title: constant(String(localized: "command.closeOtherPages.title", defaultValue: "Close Other Pages")), + subtitle: workspaceSubtitle, + keywords: ["close", "other", "pages", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextPage", + title: constant(String(localized: "command.nextPage.title", defaultValue: "Next Page")), + subtitle: constant(String(localized: "command.nextPage.subtitle", defaultValue: "Page Navigation")), + keywords: ["next", "page", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousPage", + title: constant(String(localized: "command.previousPage.title", defaultValue: "Previous Page")), + subtitle: constant(String(localized: "command.previousPage.subtitle", defaultValue: "Page Navigation")), + keywords: ["previous", "page", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.movePageLeft", + title: constant(String(localized: "command.movePageLeft.title", defaultValue: "Move Page Left")), + subtitle: constant(String(localized: "command.movePageLeft.subtitle", defaultValue: "Page Navigation")), + keywords: ["move", "page", "left", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.movePageRight", + title: constant(String(localized: "command.movePageRight.title", defaultValue: "Move Page Right")), + subtitle: constant(String(localized: "command.movePageRight.subtitle", defaultValue: "Page Navigation")), + keywords: ["move", "page", "right", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( CommandPaletteCommandContribution( commandId: "palette.renameTab", @@ -4767,6 +5284,53 @@ struct ContentView: View { registry.register(commandId: "palette.previousWorkspace") { tabManager.selectPreviousTab() } + registry.register(commandId: "palette.newPage") { + _ = tabManager.selectedWorkspace?.newPage(select: true) + } + registry.register(commandId: "palette.duplicatePage") { + guard let workspace = tabManager.selectedWorkspace, + let pageId = workspace.activePage?.id else { + NSSound.beep() + return + } + _ = workspace.duplicatePage(sourcePageId: pageId, select: true) + } + registry.register(commandId: "palette.renamePage") { + beginRenamePageFlow() + } + registry.register(commandId: "palette.closePage") { + guard let workspace = tabManager.selectedWorkspace, + let pageId = workspace.activePage?.id else { + NSSound.beep() + return + } + workspace.closePage(pageId) + } + registry.register(commandId: "palette.closeOtherPages") { + tabManager.selectedWorkspace?.closeOtherPages() + } + registry.register(commandId: "palette.nextPage") { + tabManager.selectedWorkspace?.selectNextPage() + } + registry.register(commandId: "palette.previousPage") { + tabManager.selectedWorkspace?.selectPreviousPage() + } + registry.register(commandId: "palette.movePageLeft") { + guard let workspace = tabManager.selectedWorkspace, + let pageId = workspace.activePage?.id else { + NSSound.beep() + return + } + _ = workspace.movePageLeft(pageId: pageId) + } + registry.register(commandId: "palette.movePageRight") { + guard let workspace = tabManager.selectedWorkspace, + let pageId = workspace.activePage?.id else { + NSSound.beep() + return + } + _ = workspace.movePageRight(pageId: pageId) + } registry.register(commandId: "palette.renameTab") { beginRenameTabFlow() @@ -5780,6 +6344,19 @@ struct ContentView: View { startRenameFlow(target) } + private func beginRenamePageFlow() { + guard let workspace = tabManager.selectedWorkspace, + let page = workspace.activePage else { + NSSound.beep() + return + } + let target = CommandPaletteRenameTarget( + kind: .page(workspaceId: workspace.id, pageId: page.id), + currentName: page.title + ) + startRenameFlow(target) + } + private func startRenameFlow(_ target: CommandPaletteRenameTarget) { commandPaletteRenameDraft = target.currentName commandPaletteMode = .renameInput(target) @@ -5806,6 +6383,12 @@ struct ContentView: View { return } workspace.setPanelCustomTitle(panelId: panelId, title: normalizedName) + case .page(let workspaceId, let pageId): + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + NSSound.beep() + return + } + workspace.setPageTitle(pageId: pageId, title: normalizedName) } dismissCommandPalette() @@ -6918,7 +7501,7 @@ struct VerticalTabsSidebar: View { @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set<UUID> @Binding var lastSidebarSelectionIndex: Int? - @StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor() + @StateObject private var modifierKeyMonitor = ShortcutHintModifierMonitor(requiredModifierFlags: [.command]) @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @@ -7058,13 +7641,17 @@ enum ShortcutHintModifierPolicy { static func shouldShowHints( for modifierFlags: NSEvent.ModifierFlags, + requiredModifierFlags: NSEvent.ModifierFlags = [.command], defaults: UserDefaults = .standard ) -> Bool { let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) - guard normalized == [.command] else { + guard normalized == requiredModifierFlags else { return false } - return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) + if requiredModifierFlags == [.command] { + return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) + } + return true } static func isCurrentWindow( @@ -7086,9 +7673,14 @@ enum ShortcutHintModifierPolicy { hostWindowIsKey: Bool, eventWindowNumber: Int?, keyWindowNumber: Int?, + requiredModifierFlags: NSEvent.ModifierFlags = [.command], defaults: UserDefaults = .standard ) -> Bool { - shouldShowHints(for: modifierFlags, defaults: defaults) && + shouldShowHints( + for: modifierFlags, + requiredModifierFlags: requiredModifierFlags, + defaults: defaults + ) && isCurrentWindow( hostWindowNumber: hostWindowNumber, hostWindowIsKey: hostWindowIsKey, @@ -7688,9 +8280,10 @@ private struct SidebarExternalDropDelegate: DropDelegate { } @MainActor -private final class SidebarShortcutHintModifierMonitor: ObservableObject { +private final class ShortcutHintModifierMonitor: ObservableObject { @Published private(set) var isModifierPressed = false + private let requiredModifierFlags: NSEvent.ModifierFlags private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? private var hostWindowDidResignKeyObserver: NSObjectProtocol? @@ -7699,6 +8292,10 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + init(requiredModifierFlags: NSEvent.ModifierFlags = [.command]) { + self.requiredModifierFlags = requiredModifierFlags + } + func setHostWindow(_ window: NSWindow?) { guard hostWindow !== window else { return } removeHostWindowObservers() @@ -7797,7 +8394,8 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, - keyWindowNumber: NSApp.keyWindow?.windowNumber + keyWindowNumber: NSApp.keyWindow?.windowNumber, + requiredModifierFlags: requiredModifierFlags ) else { cancelPendingHintShow(resetVisible: true) return @@ -7818,7 +8416,8 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, - keyWindowNumber: NSApp.keyWindow?.windowNumber + keyWindowNumber: NSApp.keyWindow?.windowNumber, + requiredModifierFlags: self.requiredModifierFlags ) else { return } self.isModifierPressed = true } @@ -10368,6 +10967,387 @@ enum SidebarDropEdge { case bottom } +enum TitlebarPageDropEdge { + case leading + case trailing +} + +struct TitlebarPageDropIndicator { + let pageId: UUID? + let edge: TitlebarPageDropEdge +} + +enum TitlebarPageDropPlanner { + static func indicator( + draggedPageId: UUID?, + targetPageId: UUID?, + pageIds: [UUID], + pointerX: CGFloat? = nil, + targetWidth: CGFloat? = nil + ) -> TitlebarPageDropIndicator? { + guard pageIds.count > 1, let draggedPageId else { return nil } + guard let fromIndex = pageIds.firstIndex(of: draggedPageId) else { return nil } + + let insertionPosition: Int + if let targetPageId { + guard let targetPageIndex = pageIds.firstIndex(of: targetPageId) else { return nil } + let edge: TitlebarPageDropEdge + if let pointerX, let targetWidth { + edge = edgeForPointer(locationX: pointerX, targetWidth: targetWidth) + } else { + edge = preferredEdge(fromIndex: fromIndex, targetPageId: targetPageId, pageIds: pageIds) + } + insertionPosition = (edge == .trailing) ? targetPageIndex + 1 : targetPageIndex + } else { + insertionPosition = pageIds.count + } + + let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: pageIds.count) + guard targetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(insertionPosition, pageIds: pageIds) + } + + static func targetIndex( + draggedPageId: UUID, + targetPageId: UUID?, + indicator: TitlebarPageDropIndicator?, + pageIds: [UUID] + ) -> Int? { + guard let fromIndex = pageIds.firstIndex(of: draggedPageId) else { return nil } + + let insertionPosition: Int + if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, pageIds: pageIds) { + insertionPosition = indicatorInsertion + } else if let targetPageId { + guard let targetPageIndex = pageIds.firstIndex(of: targetPageId) else { return nil } + let edge = (indicator?.pageId == targetPageId) + ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetPageId: targetPageId, pageIds: pageIds)) + : preferredEdge(fromIndex: fromIndex, targetPageId: targetPageId, pageIds: pageIds) + insertionPosition = (edge == .trailing) ? targetPageIndex + 1 : targetPageIndex + } else { + insertionPosition = pageIds.count + } + + return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: pageIds.count) + } + + private static func indicatorForInsertionPosition( + _ insertionPosition: Int, + pageIds: [UUID] + ) -> TitlebarPageDropIndicator { + let clampedInsertion = max(0, min(insertionPosition, pageIds.count)) + if clampedInsertion >= pageIds.count { + return TitlebarPageDropIndicator(pageId: nil, edge: .trailing) + } + return TitlebarPageDropIndicator(pageId: pageIds[clampedInsertion], edge: .leading) + } + + private static func insertionPositionForIndicator( + _ indicator: TitlebarPageDropIndicator, + pageIds: [UUID] + ) -> Int? { + if let pageId = indicator.pageId { + guard let targetPageIndex = pageIds.firstIndex(of: pageId) else { return nil } + return indicator.edge == .trailing ? targetPageIndex + 1 : targetPageIndex + } + return pageIds.count + } + + private static func preferredEdge(fromIndex: Int, targetPageId: UUID, pageIds: [UUID]) -> TitlebarPageDropEdge { + guard let targetIndex = pageIds.firstIndex(of: targetPageId) else { return .leading } + return fromIndex < targetIndex ? .trailing : .leading + } + + private static func edgeForPointer(locationX: CGFloat, targetWidth: CGFloat) -> TitlebarPageDropEdge { + guard targetWidth > 0 else { return .leading } + let clampedX = min(max(locationX, 0), targetWidth) + return clampedX < (targetWidth / 2) ? .leading : .trailing + } + + private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { + let clampedInsertion = max(0, min(insertionPosition, totalCount)) + let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion + return max(0, min(adjusted, max(0, totalCount - 1))) + } +} + +private struct TitlebarPageDropIndicatorLine: View { + var body: some View { + Rectangle() + .fill(cmuxAccentColor()) + .frame(width: 2, height: 18) + } +} + +private struct TitlebarPageScrollViewResolver: NSViewRepresentable { + let onResolve: (NSScrollView?) -> Void + + func makeNSView(context: Context) -> TitlebarPageScrollViewResolverView { + let view = TitlebarPageScrollViewResolverView() + view.onResolve = onResolve + return view + } + + func updateNSView(_ nsView: TitlebarPageScrollViewResolverView, context: Context) { + nsView.onResolve = onResolve + nsView.resolveScrollView() + } +} + +private final class TitlebarPageScrollViewResolverView: NSView { + var onResolve: ((NSScrollView?) -> Void)? + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + resolveScrollView() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolveScrollView() + } + + func resolveScrollView() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onResolve?(self.enclosingScrollView) + } + } +} + +enum TitlebarPageAutoScrollDirection: Equatable { + case left + case right +} + +struct TitlebarPageAutoScrollPlan: Equatable { + let direction: TitlebarPageAutoScrollDirection + let pointsPerTick: CGFloat +} + +enum TitlebarPageDragAutoScrollPlanner { + static let edgeInset: CGFloat = 48 + static let minStep: CGFloat = 2 + static let maxStep: CGFloat = 12 + + static func plan( + distanceToLeading: CGFloat, + distanceToTrailing: CGFloat, + edgeInset: CGFloat = TitlebarPageDragAutoScrollPlanner.edgeInset, + minStep: CGFloat = TitlebarPageDragAutoScrollPlanner.minStep, + maxStep: CGFloat = TitlebarPageDragAutoScrollPlanner.maxStep + ) -> TitlebarPageAutoScrollPlan? { + guard edgeInset > 0, maxStep >= minStep else { return nil } + if distanceToLeading <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToLeading) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return TitlebarPageAutoScrollPlan(direction: .left, pointsPerTick: step) + } + if distanceToTrailing <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToTrailing) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return TitlebarPageAutoScrollPlan(direction: .right, pointsPerTick: step) + } + return nil + } +} + +@MainActor +private final class TitlebarPageDragAutoScrollController: ObservableObject { + private weak var scrollView: NSScrollView? + private var timer: Timer? + private var activePlan: TitlebarPageAutoScrollPlan? + + func attach(scrollView: NSScrollView?) { + self.scrollView = scrollView + } + + func updateFromDragLocation() { + guard let scrollView else { + stop() + return + } + guard let plan = plan(for: scrollView) else { + stop() + return + } + activePlan = plan + startTimerIfNeeded() + } + + func stop() { + timer?.invalidate() + timer = nil + activePlan = nil + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + } + + private func tick() { + guard NSEvent.pressedMouseButtons != 0 else { + stop() + return + } + guard let scrollView else { + stop() + return + } + + if applyNativeAutoscroll(to: scrollView) { + activePlan = plan(for: scrollView) + if activePlan == nil { + stop() + } + return + } + + activePlan = plan(for: scrollView) + guard let plan = activePlan else { + stop() + return + } + _ = apply(plan: plan, to: scrollView) + } + + private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { + guard let event = NSApp.currentEvent else { return false } + switch event.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + break + default: + return false + } + + let clipView = scrollView.contentView + let didScroll = clipView.autoscroll(with: event) + if didScroll { + scrollView.reflectScrolledClipView(clipView) + } + return didScroll + } + + private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> TitlebarPageAutoScrollPlan? { + let viewportWidth = clipView.bounds.width + guard viewportWidth > 0 else { return nil } + return TitlebarPageDragAutoScrollPlanner.plan( + distanceToLeading: mousePoint.x, + distanceToTrailing: viewportWidth - mousePoint.x + ) + } + + private func mousePoint(in clipView: NSClipView) -> CGPoint { + let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero + return clipView.convert(mouseInWindow, from: nil) + } + + private func plan(for scrollView: NSScrollView) -> TitlebarPageAutoScrollPlan? { + let clipView = scrollView.contentView + return planForMousePoint(mousePoint(in: clipView), in: clipView) + } + + private func apply(plan: TitlebarPageAutoScrollPlan, to scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + let clipView = scrollView.contentView + let maxOriginX = max(0, documentView.bounds.width - clipView.bounds.width) + guard maxOriginX > 0 else { return false } + + let delta: CGFloat = (plan.direction == .right) ? plan.pointsPerTick : -plan.pointsPerTick + let currentX = clipView.bounds.origin.x + let targetX = min(max(currentX + delta, 0), maxOriginX) + guard abs(targetX - currentX) > 0.01 else { return false } + + clipView.scroll(to: CGPoint(x: targetX, y: clipView.bounds.origin.y)) + scrollView.reflectScrolledClipView(clipView) + return true + } +} + +private enum TitlebarPageDragPayload { + static let typeIdentifier = "com.cmux.titlebar-page-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let prefix = "cmux.titlebar-page." + + static func provider(for pageId: UUID) -> NSItemProvider { + let provider = NSItemProvider() + let payload = "\(prefix)\(pageId.uuidString)" + provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in + completion(payload.data(using: .utf8), nil) + return nil + } + return provider + } +} + +private struct TitlebarPageDropDelegate: DropDelegate { + let targetPageId: UUID? + let workspace: Workspace + @Binding var draggedPageId: UUID? + let dragAutoScrollController: TitlebarPageDragAutoScrollController + @Binding var dropIndicator: TitlebarPageDropIndicator? + + private let targetWidthEstimate: CGFloat = 80 + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [TitlebarPageDragPayload.typeIdentifier]) && draggedPageId != nil + } + + func dropEntered(info: DropInfo) { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + } + + func dropExited(info: DropInfo) { + if dropIndicator?.pageId == targetPageId { + dropIndicator = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedPageId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + guard let draggedPageId else { return false } + let pageIds = workspace.pages.map(\.id) + guard let targetIndex = TitlebarPageDropPlanner.targetIndex( + draggedPageId: draggedPageId, + targetPageId: targetPageId, + indicator: dropIndicator, + pageIds: pageIds + ) else { + return false + } + return workspace.movePage(pageId: draggedPageId, toIndex: targetIndex) + } + + private func updateDropIndicator(for info: DropInfo) { + dropIndicator = TitlebarPageDropPlanner.indicator( + draggedPageId: draggedPageId, + targetPageId: targetPageId, + pageIds: workspace.pages.map(\.id), + pointerX: targetPageId == nil ? nil : info.location.x, + targetWidth: targetPageId == nil ? nil : targetWidthEstimate + ) + } +} + struct SidebarDropIndicator { let tabId: UUID? let edge: SidebarDropEdge diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f06c255b..a07bc2c8 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -25,6 +25,20 @@ 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 @@ -64,6 +78,20 @@ 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") @@ -99,6 +127,20 @@ 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" @@ -144,6 +186,34 @@ 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: @@ -232,6 +302,20 @@ 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) } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 53eb995e..c0bb77af 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -339,6 +339,25 @@ 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 { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7da9f856..901aefae 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -92,6 +92,10 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "page.select", + "page.next", + "page.previous", + "page.last", "surface.focus", "pane.focus", "pane.last", @@ -106,6 +110,7 @@ class TerminalController { private enum V2HandleKind: String, CaseIterable { case window case workspace + case page case pane case surface } @@ -113,18 +118,21 @@ 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: [:], ] @@ -1737,6 +1745,30 @@ 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": @@ -2072,6 +2104,12 @@ class TerminalController { return response } +#if DEBUG + func debugProcessV2Command(_ jsonLine: String) -> String { + processV2Command(jsonLine) + } +#endif + private func v2Capabilities() -> [String: Any] { var methods: [String] = [ "system.ping", @@ -2096,6 +2134,17 @@ 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", @@ -2276,11 +2325,16 @@ 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), @@ -2303,15 +2357,21 @@ 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) + "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) ?? "" ] if let surfaceId, ws.panels[surfaceId] != nil { @@ -2457,6 +2517,58 @@ 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] = [:] @@ -2512,7 +2624,7 @@ class TerminalController { } let focusedPaneId = workspace.bonsplitController.focusedPaneId - let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in + return 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) @@ -2531,16 +2643,57 @@ class TerminalController { "surfaces": surfacesByPane[paneId.id] ?? [] ] } + } - return [ - "id": workspace.id.uuidString, - "ref": v2Ref(kind: .workspace, uuid: workspace.id), - "index": index, - "title": workspace.title, - "selected": selected, - "pinned": workspace.isPinned, - "panes": panes - ] + 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) + } } // MARK: - V2 Helpers (encoding + result plumbing) @@ -2657,6 +2810,9 @@ 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) } @@ -2761,6 +2917,11 @@ class TerminalController { return tm } } + if let pageId = v2UUID(params, "page_id") { + if let tm = v2MainSync({ self.v2LocatePage(pageId)?.tabManager }) { + return tm + } + } return tabManager } @@ -3334,6 +3495,376 @@ 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) @@ -3609,6 +4140,9 @@ 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 }) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7a4bb58a..f3d841d0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -106,6 +106,90 @@ 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) @@ -150,11 +234,7 @@ extension Workspace { SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) } - return SessionWorkspaceSnapshot( - processTitle: processTitle, - customTitle: customTitle, - customColor: customColor, - isPinned: isPinned, + return SessionWorkspacePageStateSnapshot( currentDirectory: currentDirectory, focusedPanelId: focusedPanelId, layout: layout, @@ -166,8 +246,11 @@ extension Workspace { ) } - func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + private func restoreSessionPageState(_ snapshot: SessionWorkspacePageStateSnapshot) { restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + metadataBlocks = [:] + pullRequest = nil + panelPullRequests = [:] let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCurrentDirectory.isEmpty { @@ -190,10 +273,15 @@ extension Workspace { pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) - applyProcessTitle(snapshot.processTitle) - setCustomTitle(snapshot.customTitle) - setCustomColor(snapshot.customColor) - isPinned = snapshot.isPinned + 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) + } + } statusEntries = Dictionary( uniqueKeysWithValues: snapshot.statusEntries.map { entry in @@ -233,6 +321,32 @@ 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): @@ -908,16 +1022,55 @@ 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 @@ -1002,6 +1155,8 @@ 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" @@ -1106,11 +1261,20 @@ 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 @@ -1208,6 +1372,455 @@ 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 @@ -2855,6 +3468,8 @@ 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 { @@ -3556,6 +4171,29 @@ 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 } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 99ea9f8f..18bb18ab 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -37,6 +37,11 @@ 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() { @@ -587,6 +592,103 @@ 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) } @@ -774,6 +876,41 @@ 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) } @@ -829,6 +966,34 @@ 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" { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 63ff111f..9832f13d 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -899,6 +899,495 @@ 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") @@ -2253,6 +2742,71 @@ 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 }) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index df3ecd42..7cf85a29 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5812,6 +5812,243 @@ 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] = [ diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 88d8f11c..14296553 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -65,6 +65,42 @@ 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) @@ -732,6 +768,19 @@ 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 { diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 6e8d62e3..14e13684 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -47,3 +47,85 @@ 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" + ) + } +} diff --git a/cmuxUITests/WorkspacePagesUITests.swift b/cmuxUITests/WorkspacePagesUITests.swift new file mode 100644 index 00000000..e65a1d32 --- /dev/null +++ b/cmuxUITests/WorkspacePagesUITests.swift @@ -0,0 +1,232 @@ +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 + } +} diff --git a/docs/workspace-pages-spec.md b/docs/workspace-pages-spec.md new file mode 100644 index 00000000..709e2251 --- /dev/null +++ b/docs/workspace-pages-spec.md @@ -0,0 +1,453 @@ +# 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. diff --git a/tests_v2/test_page_cli_socket_parity.py b/tests_v2/test_page_cli_socket_parity.py new file mode 100644 index 00000000..4d53bd3c --- /dev/null +++ b/tests_v2/test_page_cli_socket_parity.py @@ -0,0 +1,210 @@ +#!/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())