diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6dff2216..44989796 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1088,33 +1088,6 @@ struct CMUXCLI { } } - case "list-pages": - let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let payload = try client.sendV2(method: "page.list", params: params) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) - } else { - let pages = payload["pages"] as? [[String: Any]] ?? [] - if pages.isEmpty { - print("No pages") - } else { - for page in pages { - let selected = (page["selected"] as? Bool) == true - let handle = textHandle(page, idFormat: idFormat) - let title = (page["title"] as? String) ?? "" - let paneCount = intFromAny(page["pane_count"]) ?? 0 - let surfaceCount = intFromAny(page["surface_count"]) ?? 0 - let prefix = selected ? "* " : " " - let selectedTag = selected ? " [selected]" : "" - let titlePart = title.isEmpty ? "" : " \(title)" - print("\(prefix)\(handle)\(titlePart) [\(paneCount) pane\(paneCount == 1 ? "" : "s"), \(surfaceCount) surface\(surfaceCount == 1 ? "" : "s")]\(selectedTag)") - } - } - } - case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd") @@ -1137,35 +1110,6 @@ struct CMUXCLI { _ = try client.sendV2(method: "surface.send_text", params: sendParams) } - case "new-page": - let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") - let (titleOpt, rem1) = parseOption(rem0, name: "--title") - let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let trailingTitle = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) - let title = titleOpt ?? (trailingTitle.isEmpty ? nil : trailingTitle) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - if let title, !title.isEmpty { params["title"] = title } - let payload = try client.sendV2(method: "page.create", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - - case "duplicate-page": - let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") - let (pageOpt, rem1) = parseOption(rem0, name: "--page") - let (titleOpt, rem2) = parseOption(rem1, name: "--title") - let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let trailingTitle = rem2.dropFirst(rem2.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) - let title = titleOpt ?? (trailingTitle.isEmpty ? nil : trailingTitle) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let pageId = try normalizePageHandle(pageOpt, client: client, workspaceHandle: wsId, allowCurrent: true) - if let pageId { params["page_id"] = pageId } - if let title, !title.isEmpty { params["title"] = title } - let payload = try client.sendV2(method: "page.duplicate", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - case "new-split": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (panelArg, rem1) = parseOption(rem0, name: "--panel") @@ -1396,18 +1340,6 @@ struct CMUXCLI { let payload = try client.sendV2(method: "workspace.close", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) - case "close-page": - let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) - let pageRaw = optionValue(commandArgs, name: "--page") ?? commandArgs.first - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let pageId = try normalizePageHandle(pageRaw, client: client, workspaceHandle: wsId, allowCurrent: true) - if let pageId { params["page_id"] = pageId } - params["force"] = true - let payload = try client.sendV2(method: "page.close", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - case "select-workspace": guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "select-workspace requires --workspace") @@ -1418,21 +1350,6 @@ struct CMUXCLI { let payload = try client.sendV2(method: "workspace.select", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) - case "select-page": - let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") - let pageRaw = optionValue(rem0, name: "--page") ?? rem0.first - guard let pageRaw else { - throw CLIError(message: "select-page requires --page ") - } - var params: [String: Any] = [:] - let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let pageId = try normalizePageHandle(pageRaw, client: client, workspaceHandle: wsId) - if let pageId { params["page_id"] = pageId } - let payload = try client.sendV2(method: "page.select", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - case "rename-workspace", "rename-window": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) @@ -1454,58 +1371,6 @@ struct CMUXCLI { print(response) } - case "current-page": - let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let payload = try client.sendV2(method: "page.current", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - - case "rename-page": - let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") - let (pageOpt, rem1) = parseOption(rem0, name: "--page") - let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let titleArgs = rem1.dropFirst(rem1.first == "--" ? 1 : 0) - let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty else { - throw CLIError(message: "rename-page requires a title") - } - var params: [String: Any] = ["title": title] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let pageId = try normalizePageHandle(pageOpt, client: client, workspaceHandle: wsId, allowCurrent: true) - if let pageId { params["page_id"] = pageId } - let payload = try client.sendV2(method: "page.rename", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - - case "reorder-page": - try runReorderPage(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) - - case "next-page": - let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let payload = try client.sendV2(method: "page.next", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - - case "previous-page": - let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let payload = try client.sendV2(method: "page.previous", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - - case "last-page": - let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) - var params: [String: Any] = [:] - let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - if let wsId { params["workspace_id"] = wsId } - let payload = try client.sendV2(method: "page.last", params: params) - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["page", "workspace"])) - case "read-screen": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") @@ -2186,7 +2051,7 @@ struct CMUXCLI { let pieces = value.split(separator: ":", omittingEmptySubsequences: false) guard pieces.count == 2 else { return false } let kind = String(pieces[0]).lowercased() - guard ["window", "workspace", "page", "pane", "surface"].contains(kind) else { return false } + guard ["window", "workspace", "pane", "surface"].contains(kind) else { return false } return Int(String(pieces[1])) != nil } @@ -2247,43 +2112,6 @@ struct CMUXCLI { throw CLIError(message: "Workspace index not found") } - private func normalizePageHandle( - _ raw: String?, - client: SocketClient, - workspaceHandle: String? = nil, - allowCurrent: Bool = false - ) throws -> String? { - guard let raw else { - if !allowCurrent { return nil } - var params: [String: Any] = [:] - if let workspaceHandle { - params["workspace_id"] = workspaceHandle - } - let current = try client.sendV2(method: "page.current", params: params) - return (current["page_ref"] as? String) ?? (current["page_id"] as? String) - } - - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if isUUID(trimmed) || isHandleRef(trimmed) { - return trimmed - } - guard let wantedIndex = Int(trimmed) else { - throw CLIError(message: "Invalid page handle: \(trimmed) (expected UUID, ref like page:1, or index)") - } - - var params: [String: Any] = [:] - if let workspaceHandle { - params["workspace_id"] = workspaceHandle - } - let listed = try client.sendV2(method: "page.list", params: params) - let items = listed["pages"] as? [[String: Any]] ?? [] - for item in items where intFromAny(item["index"]) == wantedIndex { - return (item["ref"] as? String) ?? (item["id"] as? String) - } - throw CLIError(message: "Page index not found") - } - private func normalizePaneHandle( _ raw: String?, client: SocketClient, @@ -2587,45 +2415,6 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } - private func runReorderPage( - commandArgs: [String], - client: SocketClient, - jsonOutput: Bool, - idFormat: CLIIDFormat, - windowOverride: String? - ) throws { - let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") - let pageRaw = optionValue(rem0, name: "--page") ?? rem0.first - guard let pageRaw else { - throw CLIError(message: "reorder-page requires --page ") - } - - let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let workspaceHandle = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) - let pageHandle = try normalizePageHandle(pageRaw, client: client, workspaceHandle: workspaceHandle) - - let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-page") - let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-page") - let beforeHandle = try normalizePageHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle) - let afterHandle = try normalizePageHandle(afterRaw, client: client, workspaceHandle: workspaceHandle) - - var params: [String: Any] = [:] - if let workspaceHandle { params["workspace_id"] = workspaceHandle } - if let pageHandle { params["page_id"] = pageHandle } - if let beforeHandle { params["before_page_id"] = beforeHandle } - if let afterHandle { params["after_page_id"] = afterHandle } - if let indexRaw = optionValue(commandArgs, name: "--index") { - guard let index = Int(indexRaw) else { - throw CLIError(message: "--index must be an integer") - } - params["index"] = index - } - - let payload = try client.sendV2(method: "page.reorder", params: params) - let summary = "OK page=\(formatHandle(payload, kind: "page", idFormat: idFormat) ?? "unknown") workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown") index=\(payload["index"] ?? payload["page_index"] ?? "?")" - printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) - } - private func runWorkspaceAction( commandArgs: [String], client: SocketClient, @@ -4498,138 +4287,6 @@ struct CMUXCLI { Example: cmux list-workspaces """ - case "list-pages": - return """ - Usage: cmux list-pages [--workspace ] - - 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] @@ -4676,7 +4333,7 @@ struct CMUXCLI { return """ Usage: cmux tree [flags] - Print the hierarchy of windows, workspaces, pages, panes, and surfaces. + Print the hierarchy of windows, workspaces, panes, and surfaces. Flags: --all Include all windows (default: current window only) @@ -4688,7 +4345,6 @@ struct CMUXCLI { - ◀ active (true focused window/workspace/pane/surface path) - ◀ here (caller surface where `cmux tree` was invoked) - workspace [selected] - - page [selected] - pane [focused] - surface [selected] Browser surfaces also include their current URL. @@ -5543,7 +5199,6 @@ struct CMUXCLI { private struct TreePath { let windowHandle: String? let workspaceHandle: String? - let pageHandle: String? let paneHandle: String? let surfaceHandle: String? } @@ -5821,7 +5476,6 @@ struct CMUXCLI { return TreePath( windowHandle: treeRelatedHandle(payload, refKey: "window_ref", idKey: "window_id"), workspaceHandle: treeRelatedHandle(payload, refKey: "workspace_ref", idKey: "workspace_id"), - pageHandle: treeRelatedHandle(payload, refKey: "page_ref", idKey: "page_id"), paneHandle: treeRelatedHandle(payload, refKey: "pane_ref", idKey: "pane_id"), surfaceHandle: treeRelatedHandle(payload, refKey: "surface_ref", idKey: "surface_id") ) @@ -5875,20 +5529,21 @@ struct CMUXCLI { workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) let panes = workspace["panes"] as? [[String: Any]] ?? [] - workspaceNode["panes"] = panes.map { - treeApplyMarkers(pane: $0, activePath: activePath, callerPath: callerPath) + let paneNodes = panes.map { pane in + var paneNode = pane + paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) + + let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] + paneNode["surfaces"] = surfaces.map { surface in + var surfaceNode = surface + surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) + surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle) + return surfaceNode + } + return paneNode } - let pages = workspace["pages"] as? [[String: Any]] ?? [] - workspaceNode["pages"] = pages.map { page in - var pageNode = page - pageNode["active"] = treeItemMatchesHandle(pageNode, handle: activePath.pageHandle) - let pagePanes = page["panes"] as? [[String: Any]] ?? [] - pageNode["panes"] = pagePanes.map { - treeApplyMarkers(pane: $0, activePath: activePath, callerPath: callerPath) - } - return pageNode - } + workspaceNode["panes"] = paneNodes return workspaceNode } @@ -5897,24 +5552,6 @@ struct CMUXCLI { } } - private func treeApplyMarkers( - pane: [String: Any], - activePath: TreePath, - callerPath: TreePath - ) -> [String: Any] { - var paneNode = pane - paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) - - let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] - paneNode["surfaces"] = surfaces.map { surface in - var surfaceNode = surface - surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) - surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle) - return surfaceNode - } - return paneNode - } - private func fetchTreeBrowserURLs( workspaceHandle: String, surfaces: [[String: Any]], @@ -6001,24 +5638,19 @@ struct CMUXCLI { let workspaceIndent = workspaceIsLast ? " " : "│ " lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, idFormat: idFormat))") - let pages = workspace["pages"] as? [[String: Any]] ?? [] - if !pages.isEmpty { - for (pageIndex, page) in pages.enumerated() { - let pageIsLast = pageIndex == pages.count - 1 - let pageBranch = pageIsLast ? "└── " : "├── " - let pageIndent = pageIsLast ? " " : "│ " - lines.append("\(workspaceIndent)\(pageBranch)\(treePageLabel(page, idFormat: idFormat))") - let panes = page["panes"] as? [[String: Any]] ?? [] - appendTreePanes( - panes, - to: &lines, - prefix: workspaceIndent + pageIndent, - idFormat: idFormat - ) + let panes = workspace["panes"] as? [[String: Any]] ?? [] + for (paneIndex, pane) in panes.enumerated() { + let paneIsLast = paneIndex == panes.count - 1 + let paneBranch = paneIsLast ? "└── " : "├── " + let paneIndent = paneIsLast ? " " : "│ " + lines.append("\(workspaceIndent)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))") + + let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] + for (surfaceIndex, surface) in surfaces.enumerated() { + let surfaceIsLast = surfaceIndex == surfaces.count - 1 + let surfaceBranch = surfaceIsLast ? "└── " : "├── " + lines.append("\(workspaceIndent)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))") } - } else { - let panes = workspace["panes"] as? [[String: Any]] ?? [] - appendTreePanes(panes, to: &lines, prefix: workspaceIndent, idFormat: idFormat) } } } @@ -6026,42 +5658,8 @@ struct CMUXCLI { return lines.joined(separator: "\n") } - private func appendTreePanes( - _ panes: [[String: Any]], - to lines: inout [String], - prefix: String, - idFormat: CLIIDFormat - ) { - for (paneIndex, pane) in panes.enumerated() { - let paneIsLast = paneIndex == panes.count - 1 - let paneBranch = paneIsLast ? "└── " : "├── " - let paneIndent = paneIsLast ? " " : "│ " - lines.append("\(prefix)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))") - - let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] - for (surfaceIndex, surface) in surfaces.enumerated() { - let surfaceIsLast = surfaceIndex == surfaces.count - 1 - let surfaceBranch = surfaceIsLast ? "└── " : "├── " - lines.append("\(prefix)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))") - } - } - } - - private func treeDisplayHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String { - if treeItemHandle(item) != nil { - let handle = textHandle(item, idFormat: idFormat) - if !handle.isEmpty { - return handle - } - } - if let index = intFromAny(item["index"]) { - return "#\(index)" - } - return "?" - } - private func treeWindowLabel(_ window: [String: Any], idFormat: CLIIDFormat) -> String { - var parts = ["window \(treeDisplayHandle(window, idFormat: idFormat))"] + var parts = ["window \(textHandle(window, idFormat: idFormat))"] if (window["current"] as? Bool) == true { parts.append("[current]") } @@ -6072,7 +5670,7 @@ struct CMUXCLI { } private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String { - var parts = ["workspace \(treeDisplayHandle(workspace, idFormat: idFormat))"] + var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"] let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { parts.append("\"\(title)\"") @@ -6086,23 +5684,8 @@ struct CMUXCLI { return parts.joined(separator: " ") } - private func treePageLabel(_ page: [String: Any], idFormat: CLIIDFormat) -> String { - var parts = ["page \(treeDisplayHandle(page, idFormat: idFormat))"] - let title = (page["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !title.isEmpty { - parts.append("\"\(title)\"") - } - if (page["selected"] as? Bool) == true { - parts.append("[selected]") - } - if (page["active"] as? Bool) == true { - parts.append("◀ active") - } - return parts.joined(separator: " ") - } - private func treePaneLabel(_ pane: [String: Any], idFormat: CLIIDFormat) -> String { - var parts = ["pane \(treeDisplayHandle(pane, idFormat: idFormat))"] + var parts = ["pane \(textHandle(pane, idFormat: idFormat))"] if (pane["focused"] as? Bool) == true { parts.append("[focused]") } @@ -6115,7 +5698,7 @@ struct CMUXCLI { private func treeSurfaceLabel(_ surface: [String: Any], idFormat: CLIIDFormat) -> String { let rawType = ((surface["type"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let surfaceType = rawType.isEmpty ? "unknown" : rawType - var parts = ["surface \(treeDisplayHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] + var parts = ["surface \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { parts.append("\"\(title)\"") @@ -7323,7 +6906,7 @@ struct CMUXCLI { cmux [global-options] <command> [options] Handle Inputs: - For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/page:3/pane:4/surface:5), or indexes. + For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. `tab-action` also accepts `tab:<n>` in addition to `surface:<n>`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. @@ -7345,17 +6928,6 @@ struct CMUXCLI { workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] list-workspaces new-workspace [--cwd <path>] [--command <text>] - list-pages [--workspace <id|ref|index>] - new-page [--workspace <id|ref|index>] [--title <text>] - duplicate-page [--workspace <id|ref|index>] [--page <id|ref|index>] [--title <text>] - current-page [--workspace <id|ref|index>] - select-page [--workspace <id|ref|index>] (--page <id|ref|index> | <id|ref|index>) - rename-page [--workspace <id|ref|index>] [--page <id|ref|index>] <title> - close-page [--workspace <id|ref|index>] [--page <id|ref|index> | <id|ref|index>] - reorder-page [--workspace <id|ref|index>] (--page <id|ref|index> | <id|ref|index>) (--index <n> | --before <id|ref|index> | --after <id|ref|index>) - next-page [--workspace <id|ref|index>] - previous-page [--workspace <id|ref|index>] - last-page [--workspace <id|ref|index>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7b943ae5..fade9fc0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -82,7 +82,6 @@ D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; - E2000000A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */; }; F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; @@ -224,7 +223,6 @@ D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; }; - E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePagesUITests.swift; sourceTree = "<group>"; }; F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; }; @@ -446,7 +444,6 @@ D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, - E2000001A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift */, ); path = cmuxUITests; sourceTree = "<group>"; @@ -683,7 +680,6 @@ D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, - E2000000A1B2C3D4E5F60718 /* WorkspacePagesUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 666521c2..dd1a936a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -115,642 +115,6 @@ } } }, - "command.closeOtherPages.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Other Pages" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ほかのページを閉じる" - } - } - } - }, - "command.closePage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを閉じる" - } - } - } - }, - "command.duplicatePage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Duplicate Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを複製" - } - } - } - }, - "command.movePageLeft.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page Navigation" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページナビゲーション" - } - } - } - }, - "command.movePageLeft.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move Page Left" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを左へ移動" - } - } - } - }, - "command.movePageRight.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page Navigation" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページナビゲーション" - } - } - } - }, - "command.movePageRight.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move Page Right" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを右へ移動" - } - } - } - }, - "command.newPage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいページ" - } - } - } - }, - "command.nextPage.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page Navigation" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページナビゲーション" - } - } - } - }, - "command.nextPage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Next Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "次のページ" - } - } - } - }, - "command.previousPage.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page Navigation" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページナビゲーション" - } - } - } - }, - "command.previousPage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Previous Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "前のページ" - } - } - } - }, - "command.renamePage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Page…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を変更…" - } - } - } - }, - "command.selectPage.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page Navigation" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページナビゲーション" - } - } - } - }, - "commandPalette.rename.pageConfirmHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Press Enter to apply this page name, or Escape to cancel." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Enterでこのページ名を適用し、Escapeでキャンセルします。" - } - } - } - }, - "commandPalette.rename.pageDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a page name." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を入力します。" - } - } - } - }, - "commandPalette.rename.pageInputHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter a page name. Press Enter to rename, Escape to cancel." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を入力します。Enterで変更し、Escapeでキャンセルします。" - } - } - } - }, - "commandPalette.rename.pagePlaceholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page name" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名" - } - } - } - }, - "commandPalette.rename.pageTitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を変更" - } - } - } - }, - "dialog.closePage.message": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This will close the page and all of its panels." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このページとその中のすべてのパネルを閉じます。" - } - } - } - }, - "dialog.closePage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close page?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを閉じますか?" - } - } - } - }, - "dialog.renamePage.message": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter a name for this page." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このページの名前を入力します。" - } - } - } - }, - "dialog.renamePage.placeholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Page name" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名" - } - } - } - }, - "dialog.renamePage.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を変更" - } - } - } - }, - "shortcut.closePage.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを閉じる" - } - } - } - }, - "shortcut.newPage.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいページ" - } - } - } - }, - "shortcut.nextPage.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Next Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "次のページ" - } - } - } - }, - "shortcut.previousPage.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Previous Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "前のページ" - } - } - } - }, - "shortcut.renamePage.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を変更" - } - } - } - }, - "shortcut.selectLastPage.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Select Last Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "最後のページを選択" - } - } - } - }, - "shortcut.selectPage1.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 1" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ1を選択" } } - } - }, - "shortcut.selectPage2.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 2" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ2を選択" } } - } - }, - "shortcut.selectPage3.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 3" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ3を選択" } } - } - }, - "shortcut.selectPage4.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 4" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ4を選択" } } - } - }, - "shortcut.selectPage5.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 5" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ5を選択" } } - } - }, - "shortcut.selectPage6.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 6" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ6を選択" } } - } - }, - "shortcut.selectPage7.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 7" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ7を選択" } } - } - }, - "shortcut.selectPage8.label": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select Page 8" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ8を選択" } } - } - }, - "workspace.page.context.close": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Close Page" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページを閉じる" } } - } - }, - "workspace.page.context.closeOthers": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Close Other Pages" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ほかのページを閉じる" } } - } - }, - "workspace.page.context.duplicate": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Duplicate Page" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページを複製" } } - } - }, - "workspace.page.context.moveLeft": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Move Page Left" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページを左へ移動" } } - } - }, - "workspace.page.context.moveRight": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Move Page Right" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページを右へ移動" } } - } - }, - "workspace.page.context.new": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "New Page" } }, - "ja": { "stringUnit": { "state": "translated", "value": "新しいページ" } } - } - }, - "workspace.page.context.rename": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Rename Page" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ名を変更" } } - } - }, - "workspace.page.defaultTitleFormat": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Page %lld" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ページ %lld" } } - } - }, - "workspace.page.duplicateTitleFormat": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ Copy" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@ のコピー" } } - } - }, - "workspace.page.new.tooltip": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "New Page" } }, - "ja": { "stringUnit": { "state": "translated", "value": "新しいページ" } } - } - }, "about.build": { "extractionState": "manual", "localizations": { @@ -37489,125 +36853,6 @@ } } }, - "menu.view.closePage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを閉じる" - } - } - } - }, - "menu.view.duplicatePage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Duplicate Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを複製" - } - } - } - }, - "menu.view.newPage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいページ" - } - } - } - }, - "menu.view.nextPage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Next Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "次のページ" - } - } - } - }, - "menu.view.previousPage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Previous Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "前のページ" - } - } - } - }, - "menu.view.selectPage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Select Page" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページを選択" - } - } - } - }, - "menu.view.renamePage": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Page…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ページ名を変更…" - } - } - } - }, "menu.view.renameWorkspace": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5e0b6250..fa539d3d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -624,14 +624,6 @@ enum WorkspaceShortcutMapper { } return nil } - - static func pageIndex(forOptionDigit digit: Int, pageCount: Int) -> Int? { - workspaceIndex(forCommandDigit: digit, workspaceCount: pageCount) - } - - static func optionDigitForPage(at index: Int, pageCount: Int) -> Int? { - commandDigitForWorkspace(at: index, workspaceCount: pageCount) - } } struct CmuxCLIPathInstaller { @@ -6851,61 +6843,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return handled } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newPage)) { - _ = tabManager?.selectedWorkspace?.newPage(select: true) - return true - } - - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renamePage)) { - guard let workspace = tabManager?.selectedWorkspace, - let pageId = workspace.activePage?.id else { - return false - } - workspace.promptRenamePage(pageId: pageId) - return true - } - - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closePage)) { - guard let workspace = tabManager?.selectedWorkspace, - let pageId = workspace.activePage?.id else { - return false - } - workspace.closePage(pageId) - return true - } - - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextPage)) { - tabManager?.selectedWorkspace?.selectNextPage() - return true - } - - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .previousPage)) { - tabManager?.selectedWorkspace?.selectPreviousPage() - return true - } - - let pageSelectionShortcuts: [(KeyboardShortcutSettings.Action, Int?)] = [ - (.selectPage1, 0), - (.selectPage2, 1), - (.selectPage3, 2), - (.selectPage4, 3), - (.selectPage5, 4), - (.selectPage6, 5), - (.selectPage7, 6), - (.selectPage8, 7), - (.selectLastPage, nil), - ] - for (action, pageIndex) in pageSelectionShortcuts { - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: action)) { - if let pageIndex { - tabManager?.selectedWorkspace?.selectPage(at: pageIndex) - } else { - tabManager?.selectedWorkspace?.selectLastPage() - } - return true - } - } - // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { #if DEBUG @@ -7830,14 +7767,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for - // option-only digit/punctuation shortcuts, plus command punctuation shortcuts, since - // non-US layouts can report different characters for the same physical key even when - // the shortcut should still track the ANSI digit/punctuation position. + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. let allowANSIKeyCodeFallback = flags.contains(.control) - || (!flags.contains(.command) - && flags.contains(.option) - && !flags.contains(.control) - && !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey)) || (flags.contains(.command) && !flags.contains(.control) && ( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e086282..a592e491 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1248,20 +1248,6 @@ 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, @@ -1311,13 +1297,6 @@ 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? @@ -1354,9 +1333,6 @@ 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) @@ -1389,7 +1365,6 @@ 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 @@ -1401,8 +1376,6 @@ 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") } } @@ -1412,8 +1385,6 @@ 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.") } } @@ -1423,8 +1394,6 @@ 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") } } } @@ -2015,41 +1984,6 @@ 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, @@ -2079,12 +2013,17 @@ struct ContentView: View { fullscreenControls } - if let workspace = tabManager.selectedWorkspace { - titlebarPageStrip(workspace: workspace) - } else { - Spacer(minLength: 0) + // Draggable folder icon + focused command name + if let directory = focusedDirectory { + DraggableFolderIcon(directory: directory) } + Text(titlebarText) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(fakeTitlebarTextColor) + .lineLimit(1) + .allowsHitTesting(false) + Spacer() } @@ -2093,12 +2032,6 @@ 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()) @@ -2134,278 +2067,6 @@ 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() @@ -2527,7 +2188,6 @@ struct ContentView: View { reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() - titlebarPageShortcutHintMonitor.start() let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) if abs(sidebarWidth - restoredWidth) > 0.5 { sidebarWidth = restoredWidth @@ -2919,11 +2579,9 @@ 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 @@ -3160,15 +2818,9 @@ 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() @@ -3178,7 +2830,6 @@ struct ContentView: View { workspaceHandoffGeneration &+= 1 let generation = workspaceHandoffGeneration - hidePortalViewsForWorkspace(staleRetiringWorkspaceId, reason: "superseded") retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() @@ -3225,7 +2876,10 @@ 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. - hidePortalViewsForWorkspace(retiring, reason: reason) + if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { + workspace.hideAllTerminalPortalViews() + workspace.hideAllBrowserPortalViews() + } retiringWorkspaceId = nil tabManager.completePendingWorkspaceUnfocus(reason: reason) @@ -3241,24 +2895,6 @@ 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) @@ -3585,8 +3221,6 @@ 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.") } } @@ -3596,8 +3230,6 @@ 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.") } } @@ -4235,65 +3867,9 @@ 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 @@ -4336,16 +3912,6 @@ 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": @@ -4726,89 +4292,6 @@ 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", @@ -5284,53 +4767,6 @@ 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() @@ -6344,19 +5780,6 @@ 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) @@ -6383,12 +5806,6 @@ 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() @@ -7501,7 +6918,7 @@ struct VerticalTabsSidebar: View { @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set<UUID> @Binding var lastSidebarSelectionIndex: Int? - @StateObject private var modifierKeyMonitor = ShortcutHintModifierMonitor(requiredModifierFlags: [.command]) + @StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @@ -7641,17 +7058,13 @@ enum ShortcutHintModifierPolicy { static func shouldShowHints( for modifierFlags: NSEvent.ModifierFlags, - requiredModifierFlags: NSEvent.ModifierFlags = [.command], defaults: UserDefaults = .standard ) -> Bool { let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) - guard normalized == requiredModifierFlags else { + guard normalized == [.command] else { return false } - if requiredModifierFlags == [.command] { - return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) - } - return true + return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) } static func isCurrentWindow( @@ -7673,14 +7086,9 @@ enum ShortcutHintModifierPolicy { hostWindowIsKey: Bool, eventWindowNumber: Int?, keyWindowNumber: Int?, - requiredModifierFlags: NSEvent.ModifierFlags = [.command], defaults: UserDefaults = .standard ) -> Bool { - shouldShowHints( - for: modifierFlags, - requiredModifierFlags: requiredModifierFlags, - defaults: defaults - ) && + shouldShowHints(for: modifierFlags, defaults: defaults) && isCurrentWindow( hostWindowNumber: hostWindowNumber, hostWindowIsKey: hostWindowIsKey, @@ -8280,10 +7688,9 @@ private struct SidebarExternalDropDelegate: DropDelegate { } @MainActor -private final class ShortcutHintModifierMonitor: ObservableObject { +private final class SidebarShortcutHintModifierMonitor: 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? @@ -8292,10 +7699,6 @@ private final class ShortcutHintModifierMonitor: 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() @@ -8394,8 +7797,7 @@ private final class ShortcutHintModifierMonitor: ObservableObject { hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, - keyWindowNumber: NSApp.keyWindow?.windowNumber, - requiredModifierFlags: requiredModifierFlags + keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { cancelPendingHintShow(resetVisible: true) return @@ -8416,8 +7818,7 @@ private final class ShortcutHintModifierMonitor: ObservableObject { hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, - keyWindowNumber: NSApp.keyWindow?.windowNumber, - requiredModifierFlags: self.requiredModifierFlags + keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } self.isModifierPressed = true } @@ -10967,387 +10368,6 @@ 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 a07bc2c8..f06c255b 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -25,20 +25,6 @@ enum KeyboardShortcutSettings { case closeWorkspace case newSurface case toggleTerminalCopyMode - case newPage - case renamePage - case closePage - case nextPage - case previousPage - case selectPage1 - case selectPage2 - case selectPage3 - case selectPage4 - case selectPage5 - case selectPage6 - case selectPage7 - case selectPage8 - case selectLastPage // Panes / splits case focusLeft @@ -78,20 +64,6 @@ enum KeyboardShortcutSettings { case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace") case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface") case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode") - case .newPage: return String(localized: "shortcut.newPage.label", defaultValue: "New Page") - case .renamePage: return String(localized: "shortcut.renamePage.label", defaultValue: "Rename Page") - case .closePage: return String(localized: "shortcut.closePage.label", defaultValue: "Close Page") - case .nextPage: return String(localized: "shortcut.nextPage.label", defaultValue: "Next Page") - case .previousPage: return String(localized: "shortcut.previousPage.label", defaultValue: "Previous Page") - case .selectPage1: return String(localized: "shortcut.selectPage1.label", defaultValue: "Select Page 1") - case .selectPage2: return String(localized: "shortcut.selectPage2.label", defaultValue: "Select Page 2") - case .selectPage3: return String(localized: "shortcut.selectPage3.label", defaultValue: "Select Page 3") - case .selectPage4: return String(localized: "shortcut.selectPage4.label", defaultValue: "Select Page 4") - case .selectPage5: return String(localized: "shortcut.selectPage5.label", defaultValue: "Select Page 5") - case .selectPage6: return String(localized: "shortcut.selectPage6.label", defaultValue: "Select Page 6") - case .selectPage7: return String(localized: "shortcut.selectPage7.label", defaultValue: "Select Page 7") - case .selectPage8: return String(localized: "shortcut.selectPage8.label", defaultValue: "Select Page 8") - case .selectLastPage: return String(localized: "shortcut.selectLastPage.label", defaultValue: "Select Last Page") case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left") case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right") case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up") @@ -127,20 +99,6 @@ enum KeyboardShortcutSettings { case .focusRight: return "shortcut.focusRight" case .focusUp: return "shortcut.focusUp" case .focusDown: return "shortcut.focusDown" - case .newPage: return "shortcut.newPage" - case .renamePage: return "shortcut.renamePage" - case .closePage: return "shortcut.closePage" - case .nextPage: return "shortcut.nextPage" - case .previousPage: return "shortcut.previousPage" - case .selectPage1: return "shortcut.selectPage1" - case .selectPage2: return "shortcut.selectPage2" - case .selectPage3: return "shortcut.selectPage3" - case .selectPage4: return "shortcut.selectPage4" - case .selectPage5: return "shortcut.selectPage5" - case .selectPage6: return "shortcut.selectPage6" - case .selectPage7: return "shortcut.selectPage7" - case .selectPage8: return "shortcut.selectPage8" - case .selectLastPage: return "shortcut.selectLastPage" case .splitRight: return "shortcut.splitRight" case .splitDown: return "shortcut.splitDown" case .toggleSplitZoom: return "shortcut.toggleSplitZoom" @@ -186,34 +144,6 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) case .closeWorkspace: return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) - case .newPage: - return StoredShortcut(key: "n", command: true, shift: false, option: true, control: false) - case .renamePage: - return StoredShortcut(key: "r", command: true, shift: false, option: true, control: false) - case .closePage: - return StoredShortcut(key: "w", command: true, shift: false, option: true, control: false) - case .nextPage: - return StoredShortcut(key: "]", command: false, shift: false, option: true, control: false) - case .previousPage: - return StoredShortcut(key: "[", command: false, shift: false, option: true, control: false) - case .selectPage1: - return StoredShortcut(key: "1", command: false, shift: false, option: true, control: false) - case .selectPage2: - return StoredShortcut(key: "2", command: false, shift: false, option: true, control: false) - case .selectPage3: - return StoredShortcut(key: "3", command: false, shift: false, option: true, control: false) - case .selectPage4: - return StoredShortcut(key: "4", command: false, shift: false, option: true, control: false) - case .selectPage5: - return StoredShortcut(key: "5", command: false, shift: false, option: true, control: false) - case .selectPage6: - return StoredShortcut(key: "6", command: false, shift: false, option: true, control: false) - case .selectPage7: - return StoredShortcut(key: "7", command: false, shift: false, option: true, control: false) - case .selectPage8: - return StoredShortcut(key: "8", command: false, shift: false, option: true, control: false) - case .selectLastPage: - return StoredShortcut(key: "9", command: false, shift: false, option: true, control: false) case .focusLeft: return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) case .focusRight: @@ -302,20 +232,6 @@ enum KeyboardShortcutSettings { static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) } static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) } static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) } - static func newPageShortcut() -> StoredShortcut { shortcut(for: .newPage) } - static func renamePageShortcut() -> StoredShortcut { shortcut(for: .renamePage) } - static func closePageShortcut() -> StoredShortcut { shortcut(for: .closePage) } - static func nextPageShortcut() -> StoredShortcut { shortcut(for: .nextPage) } - static func previousPageShortcut() -> StoredShortcut { shortcut(for: .previousPage) } - static func selectPage1Shortcut() -> StoredShortcut { shortcut(for: .selectPage1) } - static func selectPage2Shortcut() -> StoredShortcut { shortcut(for: .selectPage2) } - static func selectPage3Shortcut() -> StoredShortcut { shortcut(for: .selectPage3) } - static func selectPage4Shortcut() -> StoredShortcut { shortcut(for: .selectPage4) } - static func selectPage5Shortcut() -> StoredShortcut { shortcut(for: .selectPage5) } - static func selectPage6Shortcut() -> StoredShortcut { shortcut(for: .selectPage6) } - static func selectPage7Shortcut() -> StoredShortcut { shortcut(for: .selectPage7) } - static func selectPage8Shortcut() -> StoredShortcut { shortcut(for: .selectPage8) } - static func selectLastPageShortcut() -> StoredShortcut { shortcut(for: .selectLastPage) } static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) } static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index c0bb77af..53eb995e 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -339,25 +339,6 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var logEntries: [SessionLogEntrySnapshot] var progress: SessionProgressSnapshot? var gitBranch: SessionGitBranchSnapshot? - var activePageId: UUID? - var pages: [SessionWorkspacePageSnapshot]? -} - -struct SessionWorkspacePageStateSnapshot: Codable, Sendable { - var currentDirectory: String - var focusedPanelId: UUID? - var layout: SessionWorkspaceLayoutSnapshot - var panels: [SessionPanelSnapshot] - var statusEntries: [SessionStatusEntrySnapshot] - var logEntries: [SessionLogEntrySnapshot] - var progress: SessionProgressSnapshot? - var gitBranch: SessionGitBranchSnapshot? -} - -struct SessionWorkspacePageSnapshot: Codable, Sendable { - var id: UUID - var title: String - var state: SessionWorkspacePageStateSnapshot } struct SessionTabManagerSnapshot: Codable, Sendable { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 901aefae..7da9f856 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -92,10 +92,6 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", - "page.select", - "page.next", - "page.previous", - "page.last", "surface.focus", "pane.focus", "pane.last", @@ -110,7 +106,6 @@ class TerminalController { private enum V2HandleKind: String, CaseIterable { case window case workspace - case page case pane case surface } @@ -118,21 +113,18 @@ class TerminalController { private var v2NextHandleOrdinal: [V2HandleKind: Int] = [ .window: 1, .workspace: 1, - .page: 1, .pane: 1, .surface: 1, ] private var v2RefByUUID: [V2HandleKind: [UUID: String]] = [ .window: [:], .workspace: [:], - .page: [:], .pane: [:], .surface: [:], ] private var v2UUIDByRef: [V2HandleKind: [String: UUID]] = [ .window: [:], .workspace: [:], - .page: [:], .pane: [:], .surface: [:], ] @@ -1745,30 +1737,6 @@ class TerminalController { case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) - // Pages - case "page.list": - return v2Result(id: id, self.v2PageList(params: params)) - case "page.create": - return v2Result(id: id, self.v2PageCreate(params: params)) - case "page.duplicate": - return v2Result(id: id, self.v2PageDuplicate(params: params)) - case "page.select": - return v2Result(id: id, self.v2PageSelect(params: params)) - case "page.current": - return v2Result(id: id, self.v2PageCurrent(params: params)) - case "page.close": - return v2Result(id: id, self.v2PageClose(params: params)) - case "page.reorder": - return v2Result(id: id, self.v2PageReorder(params: params)) - case "page.rename": - return v2Result(id: id, self.v2PageRename(params: params)) - case "page.next": - return v2Result(id: id, self.v2PageNext(params: params)) - case "page.previous": - return v2Result(id: id, self.v2PagePrevious(params: params)) - case "page.last": - return v2Result(id: id, self.v2PageLast(params: params)) - // Surfaces / input case "surface.list": @@ -2104,12 +2072,6 @@ class TerminalController { return response } -#if DEBUG - func debugProcessV2Command(_ jsonLine: String) -> String { - processV2Command(jsonLine) - } -#endif - private func v2Capabilities() -> [String: Any] { var methods: [String] = [ "system.ping", @@ -2134,17 +2096,6 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", - "page.list", - "page.create", - "page.duplicate", - "page.select", - "page.current", - "page.close", - "page.reorder", - "page.rename", - "page.next", - "page.previous", - "page.last", "surface.list", "surface.current", "surface.focus", @@ -2325,16 +2276,11 @@ class TerminalController { let ws = tabManager.tabs.first(where: { $0.id == wsId }) { let paneUUID = ws.bonsplitController.focusedPaneId?.id let surfaceUUID = ws.focusedPanelId - let pageId = ws.activePageId focused = [ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId), - "page_index": v2OrNull(ws.pageIndex(pageId: pageId)), - "page_title": ws.pageTitle(pageId: pageId) ?? "", "pane_id": v2OrNull(paneUUID?.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), "surface_id": v2OrNull(surfaceUUID?.uuidString), @@ -2357,21 +2303,15 @@ class TerminalController { if let callerObj = params["caller"] as? [String: Any], let wsId = v2UUIDAny(callerObj["workspace_id"]) { let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"]) - let callerPageId = v2UUIDAny(callerObj["page_id"]) v2MainSync { let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) { let callerWindowId = v2ResolveWindowId(tabManager: callerTabManager) - let pageId = callerPageId.flatMap { ws.pageIndex(pageId: $0) != nil ? $0 : nil } ?? ws.activePageId var payload: [String: Any] = [ "window_id": v2OrNull(callerWindowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: callerWindowId), "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId), - "page_index": v2OrNull(ws.pageIndex(pageId: pageId)), - "page_title": ws.pageTitle(pageId: pageId) ?? "" + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) ] if let surfaceId, ws.panels[surfaceId] != nil { @@ -2517,58 +2457,6 @@ class TerminalController { index: Int, selected: Bool ) -> [String: Any] { - let pageNodes: [[String: Any]] = workspace.pages.enumerated().compactMap { pageIndex, page in - guard let snapshot = workspace.pageStateSnapshot(pageId: page.id, includeScrollback: false) else { - return nil - } - return v2TreePageNode( - workspace: workspace, - page: page, - index: pageIndex, - snapshot: snapshot - ) - } - let selectedPagePanes = pageNodes.first(where: { ($0["selected"] as? Bool) == true })?["panes"] as? [[String: Any]] ?? [] - - return [ - "id": workspace.id.uuidString, - "ref": v2Ref(kind: .workspace, uuid: workspace.id), - "index": index, - "title": workspace.title, - "selected": selected, - "pinned": workspace.isPinned, - "selected_page_id": workspace.activePageId.uuidString, - "selected_page_ref": v2Ref(kind: .page, uuid: workspace.activePageId), - "page_count": pageNodes.count, - "pages": pageNodes, - // Preserve the selected page's panes at workspace.panes for older tree consumers. - "panes": selectedPagePanes - ] - } - - private func v2TreePageNode( - workspace: Workspace, - page: WorkspacePage, - index: Int, - snapshot: SessionWorkspacePageStateSnapshot - ) -> [String: Any] { - let panes = page.id == workspace.activePageId - ? v2TreeLivePagePanes(workspace: workspace) - : v2TreeStoredPagePanes(snapshot: snapshot) - - return [ - "id": page.id.uuidString, - "ref": v2Ref(kind: .page, uuid: page.id), - "index": index, - "title": page.title, - "selected": page.id == workspace.activePageId, - "pane_count": panes.count, - "surface_count": snapshot.panels.count, - "panes": panes - ] - } - - private func v2TreeLivePagePanes(workspace: Workspace) -> [[String: Any]] { var paneByPanelId: [UUID: UUID] = [:] var indexInPaneByPanelId: [UUID: Int] = [:] var selectedInPaneByPanelId: [UUID: Bool] = [:] @@ -2624,7 +2512,7 @@ class TerminalController { } let focusedPaneId = workspace.bonsplitController.focusedPaneId - return paneIds.enumerated().map { paneIndex, paneId in + let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in let tabs = workspace.bonsplitController.tabs(inPane: paneId) let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) } let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) @@ -2643,57 +2531,16 @@ class TerminalController { "surfaces": surfacesByPane[paneId.id] ?? [] ] } - } - private func v2TreeStoredPagePanes(snapshot: SessionWorkspacePageStateSnapshot) -> [[String: Any]] { - let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) - return v2TreePaneSnapshots(in: snapshot.layout).enumerated().map { paneIndex, pane in - let surfaces: [[String: Any]] = pane.panelIds.enumerated().compactMap { surfaceIndex, panelId in - guard let panel = panelSnapshotsById[panelId] else { return nil } - var item: [String: Any] = [ - "id": NSNull(), - "ref": NSNull(), - "index": surfaceIndex, - "type": panel.type.rawValue, - "title": panel.customTitle ?? panel.title ?? "", - "focused": panelId == snapshot.focusedPanelId, - "selected": pane.selectedPanelId == panelId, - "selected_in_pane": v2OrNull(pane.selectedPanelId == panelId), - "pane_id": NSNull(), - "pane_ref": NSNull(), - "index_in_pane": surfaceIndex - ] - if panel.type == .browser { - item["url"] = panel.browser?.urlString ?? "" - } else { - item["url"] = NSNull() - } - return item - } - - let focused = surfaces.contains { ($0["focused"] as? Bool) == true } - return [ - "id": NSNull(), - "ref": NSNull(), - "index": paneIndex, - "focused": focused, - "surface_ids": [], - "surface_refs": [], - "selected_surface_id": NSNull(), - "selected_surface_ref": NSNull(), - "surface_count": surfaces.count, - "surfaces": surfaces - ] - } - } - - private func v2TreePaneSnapshots(in layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneLayoutSnapshot] { - switch layout { - case .pane(let pane): - return [pane] - case .split(let split): - return v2TreePaneSnapshots(in: split.first) + v2TreePaneSnapshots(in: split.second) - } + return [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "index": index, + "title": workspace.title, + "selected": selected, + "pinned": workspace.isPinned, + "panes": panes + ] } // MARK: - V2 Helpers (encoding + result plumbing) @@ -2810,9 +2657,6 @@ class TerminalController { if let tm = app.tabManagerFor(windowId: item.windowId) { for ws in tm.tabs { _ = v2EnsureHandleRef(kind: .workspace, uuid: ws.id) - for page in ws.pages { - _ = v2EnsureHandleRef(kind: .page, uuid: page.id) - } for paneId in ws.bonsplitController.allPaneIds { _ = v2EnsureHandleRef(kind: .pane, uuid: paneId.id) } @@ -2917,11 +2761,6 @@ class TerminalController { return tm } } - if let pageId = v2UUID(params, "page_id") { - if let tm = v2MainSync({ self.v2LocatePage(pageId)?.tabManager }) { - return tm - } - } return tabManager } @@ -3495,376 +3334,6 @@ class TerminalController { return result } - // MARK: - V2 Page Methods - - private func v2LocatePage(_ pageUUID: UUID) -> (windowId: UUID, tabManager: TabManager, workspace: Workspace, pageIndex: Int)? { - guard let app = AppDelegate.shared else { return nil } - let windows = app.listMainWindowSummaries() - for item in windows { - guard let tm = app.tabManagerFor(windowId: item.windowId) else { continue } - for ws in tm.tabs { - if let pageIndex = ws.pageIndex(pageId: pageUUID) { - return (item.windowId, tm, ws, pageIndex) - } - } - } - return nil - } - - private func v2PagePayload( - workspace: Workspace, - pageId: UUID, - index: Int, - selected: Bool - ) -> [String: Any] { - let summary = workspace.pageStructureSummary(pageId: pageId) - return [ - "id": pageId.uuidString, - "ref": v2Ref(kind: .page, uuid: pageId), - "index": index, - "title": workspace.pageTitle(pageId: pageId) ?? "", - "selected": selected, - "pane_count": summary?.paneCount ?? 0, - "surface_count": summary?.surfaceCount ?? 0 - ] - } - - private func v2PageResultPayload( - tabManager: TabManager, - workspace: Workspace, - pageId: UUID - ) -> [String: Any] { - let windowId = v2ResolveWindowId(tabManager: tabManager) - let pageIndex = workspace.pageIndex(pageId: pageId) - return [ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId), - "page_index": v2OrNull(pageIndex), - "page_title": workspace.pageTitle(pageId: pageId) ?? "" - ] - } - - private func v2PageList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var payload: [String: Any]? - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - let pages = workspace.pages.enumerated().map { index, page in - v2PagePayload( - workspace: workspace, - pageId: page.id, - index: index, - selected: page.id == workspace.activePageId - ) - } - payload = v2PageResultPayload( - tabManager: tabManager, - workspace: workspace, - pageId: workspace.activePageId - ) - payload?["pages"] = pages - } - - guard let payload else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - return .ok(payload) - } - - private func v2PageCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - let title = v2String(params, "title") - let select = v2FocusAllowed(requested: v2Bool(params, "select") ?? true) - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - if select { - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: workspace) - } - let page = workspace.newPage(select: select) - if let title { - workspace.setPageTitle(pageId: page.id, title: title) - } - result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: page.id)) - } - - return result - } - - private func v2PageDuplicate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - let requestedPageId = v2UUID(params, "page_id") - let title = v2String(params, "title") - let select = v2FocusAllowed(requested: v2Bool(params, "select") ?? true) - var result: V2CallResult = .err(code: "not_found", message: "Page not found", data: nil) - - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - let sourcePageId = requestedPageId.flatMap { candidate in - workspace.pageIndex(pageId: candidate) != nil ? candidate : nil - } ?? workspace.activePageId - - guard workspace.pageIndex(pageId: sourcePageId) != nil else { - result = .err(code: "not_found", message: "Page not found", data: [ - "page_id": v2OrNull(requestedPageId?.uuidString), - "page_ref": v2Ref(kind: .page, uuid: requestedPageId) - ]) - return - } - - if select { - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: workspace) - } - - guard let page = workspace.duplicatePage( - sourcePageId: sourcePageId, - select: select, - title: title - ) else { - result = .err(code: "internal_error", message: "Failed to duplicate page", data: [ - "page_id": sourcePageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: sourcePageId) - ]) - return - } - - result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: page.id)) - } - - return result - } - - private func v2PageSelect(params: [String: Any]) -> V2CallResult { - guard let pageId = v2UUID(params, "page_id") else { - return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Page not found", data: [ - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId) - ]) - - v2MainSync { - if let explicitTabManager = v2ResolveTabManager(params: params), - let workspace = v2ResolveWorkspace(params: params, tabManager: explicitTabManager), - workspace.pageIndex(pageId: pageId) != nil { - v2MaybeFocusWindow(for: explicitTabManager) - v2MaybeSelectWorkspace(explicitTabManager, workspace: workspace) - workspace.selectPage(pageId) - result = .ok(v2PageResultPayload(tabManager: explicitTabManager, workspace: workspace, pageId: pageId)) - return - } - - guard let located = v2LocatePage(pageId) else { return } - v2MaybeFocusWindow(for: located.tabManager) - v2MaybeSelectWorkspace(located.tabManager, workspace: located.workspace) - located.workspace.selectPage(pageId) - result = .ok(v2PageResultPayload(tabManager: located.tabManager, workspace: located.workspace, pageId: pageId)) - } - - return result - } - - private func v2PageCurrent(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil) - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId)) - } - return result - } - - private func v2PageClose(params: [String: Any]) -> V2CallResult { - guard let pageId = v2UUID(params, "page_id") else { - return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil) - } - - let force = v2Bool(params, "force") ?? true - var result: V2CallResult = .err(code: "not_found", message: "Page not found", data: [ - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId) - ]) - - v2MainSync { - let located: (windowId: UUID?, tabManager: TabManager, workspace: Workspace, pageIndex: Int)? - if let explicitTabManager = v2ResolveTabManager(params: params), - let workspace = v2ResolveWorkspace(params: params, tabManager: explicitTabManager), - let pageIndex = workspace.pageIndex(pageId: pageId) { - located = (v2ResolveWindowId(tabManager: explicitTabManager), explicitTabManager, workspace, pageIndex) - } else { - let resolved = v2LocatePage(pageId) - located = resolved.map { ($0.windowId, $0.tabManager, $0.workspace, $0.pageIndex) } - } - - guard let located else { return } - guard located.workspace.canClosePage(pageId) else { - result = .err(code: "invalid_state", message: "Cannot close the last page in a workspace", data: [ - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId) - ]) - return - } - located.workspace.closePage(pageId, skipConfirmation: force) - result = .ok([ - "window_id": v2OrNull(located.windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: located.windowId), - "workspace_id": located.workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: located.workspace.id), - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId), - "selected_page_id": located.workspace.activePageId.uuidString, - "selected_page_ref": v2Ref(kind: .page, uuid: located.workspace.activePageId) - ]) - } - - return result - } - - private func v2PageReorder(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let pageId = v2UUID(params, "page_id") else { - return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil) - } - - let index = v2Int(params, "index") - let beforeId = v2UUID(params, "before_page_id") - let afterId = v2UUID(params, "after_page_id") - let targetCount = (index != nil ? 1 : 0) + (beforeId != nil ? 1 : 0) + (afterId != nil ? 1 : 0) - if targetCount != 1 { - return .err( - code: "invalid_params", - message: "Specify exactly one target: index, before_page_id, or after_page_id", - data: nil - ) - } - - var moved = false - var newIndex: Int? - v2MainSync { - guard workspace.pageIndex(pageId: pageId) != nil else { return } - if let index { - moved = workspace.movePage(pageId: pageId, toIndex: index) - } else if let beforeId, let beforeIndex = workspace.pageIndex(pageId: beforeId) { - moved = workspace.movePage(pageId: pageId, toIndex: beforeIndex) - } else if let afterId, let afterIndex = workspace.pageIndex(pageId: afterId) { - moved = workspace.movePage(pageId: pageId, toIndex: afterIndex) - } - newIndex = workspace.pageIndex(pageId: pageId) - } - - guard moved else { - return .err(code: "not_found", message: "Page not found", data: [ - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId) - ]) - } - - var payload = v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: pageId) - payload["index"] = v2OrNull(newIndex) - return .ok(payload) - } - - private func v2PageRename(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let pageId = v2UUID(params, "page_id") else { - return .err(code: "invalid_params", message: "Missing or invalid page_id", data: nil) - } - guard let title = v2String(params, "title") else { - return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) - } - - var found = false - v2MainSync { - guard workspace.pageIndex(pageId: pageId) != nil else { return } - workspace.setPageTitle(pageId: pageId, title: title) - found = true - } - - guard found else { - return .err(code: "not_found", message: "Page not found", data: [ - "page_id": pageId.uuidString, - "page_ref": v2Ref(kind: .page, uuid: pageId) - ]) - } - return .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: pageId)) - } - - private func v2PageNext(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil) - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: workspace) - workspace.selectNextPage() - result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId)) - } - return result - } - - private func v2PagePrevious(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil) - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: workspace) - workspace.selectPreviousPage() - result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId)) - } - return result - } - - private func v2PageLast(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - var result: V2CallResult = .err(code: "not_found", message: "No page selected", data: nil) - v2MainSync { - guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager), - let lastPage = workspace.pages.last else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: workspace) - workspace.selectPage(lastPage.id) - result = .ok(v2PageResultPayload(tabManager: tabManager, workspace: workspace, pageId: workspace.activePageId)) - } - return result - } - private func v2TabAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -4140,9 +3609,6 @@ class TerminalController { if let wsId = v2UUID(params, "workspace_id") { return tabManager.tabs.first(where: { $0.id == wsId }) } - if let pageId = v2UUID(params, "page_id") { - return tabManager.tabs.first(where: { $0.pageIndex(pageId: pageId) != nil }) - } if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f3d841d0..7a4bb58a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -106,90 +106,6 @@ private struct SessionPaneRestoreEntry { extension Workspace { func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { - let activePageSnapshot = currentPageSessionStateSnapshot(includeScrollback: includeScrollback) - let pageSnapshots = pages.map { page in - let state: SessionWorkspacePageStateSnapshot - if page.id == activePageId { - state = activePageSnapshot - } else if let storedState = storedPageStates[page.id] { - state = storedState.sessionState - } else { - state = emptyPageSessionStateSnapshot(currentDirectory: currentDirectory) - } - return SessionWorkspacePageSnapshot(id: page.id, title: page.title, state: state) - } - - return SessionWorkspaceSnapshot( - processTitle: processTitle, - customTitle: customTitle, - customColor: customColor, - isPinned: isPinned, - currentDirectory: activePageSnapshot.currentDirectory, - focusedPanelId: activePageSnapshot.focusedPanelId, - layout: activePageSnapshot.layout, - panels: activePageSnapshot.panels, - statusEntries: activePageSnapshot.statusEntries, - logEntries: activePageSnapshot.logEntries, - progress: activePageSnapshot.progress, - gitBranch: activePageSnapshot.gitBranch, - activePageId: activePageId, - pages: pageSnapshots - ) - } - - func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { - let restoredPages: [SessionWorkspacePageSnapshot] = { - if let pages = snapshot.pages, !pages.isEmpty { - return pages - } - let initialPageId = pages.first?.id ?? activePageId - return [ - SessionWorkspacePageSnapshot( - id: initialPageId, - title: pages.first?.title ?? String( - format: String(localized: "workspace.page.defaultTitleFormat", defaultValue: "Page %lld"), - 1 - ), - state: legacyPageStateSnapshot(from: snapshot) - ) - ] - }() - - let pageModels = restoredPages.enumerated().map { offset, page in - WorkspacePage( - id: page.id, - title: normalizedPageTitle(page.title, fallbackIndex: offset + 1) - ) - } - let restoredActivePageId = snapshot.activePageId.flatMap { candidate in - pageModels.contains(where: { $0.id == candidate }) ? candidate : nil - } ?? pageModels.first?.id ?? activePageId - - storedPageStates.removeAll(keepingCapacity: false) - pages = pageModels - activePageId = restoredActivePageId - nextAutoPageNumber = max(nextAutoPageNumber, pageModels.count + 1) - - if let activePageSnapshot = restoredPages.first(where: { $0.id == restoredActivePageId }) { - restoreSessionPageState(activePageSnapshot.state) - } else if let fallbackPageSnapshot = restoredPages.first { - activePageId = fallbackPageSnapshot.id - restoreSessionPageState(fallbackPageSnapshot.state) - } else { - restoreSessionPageState(emptyPageSessionStateSnapshot(currentDirectory: currentDirectory)) - } - - for page in restoredPages where page.id != activePageId { - storedPageStates[page.id] = StoredPageState(sessionState: page.state, runtimeState: nil) - } - - applyProcessTitle(snapshot.processTitle) - setCustomTitle(snapshot.customTitle) - setCustomColor(snapshot.customColor) - isPinned = snapshot.isPinned - } - - private func currentPageSessionStateSnapshot(includeScrollback: Bool) -> SessionWorkspacePageStateSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -234,7 +150,11 @@ extension Workspace { SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) } - return SessionWorkspacePageStateSnapshot( + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + customColor: customColor, + isPinned: isPinned, currentDirectory: currentDirectory, focusedPanelId: focusedPanelId, layout: layout, @@ -246,11 +166,8 @@ extension Workspace { ) } - private func restoreSessionPageState(_ snapshot: SessionWorkspacePageStateSnapshot) { + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) - metadataBlocks = [:] - pullRequest = nil - panelPullRequests = [:] let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCurrentDirectory.isEmpty { @@ -273,15 +190,10 @@ extension Workspace { pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) - if panels.isEmpty { - let replacement = createReplacementTerminalPanel() - if let replacementTabId = surfaceIdFromPanelId(replacement.id), - let replacementPane = bonsplitController.allPaneIds.first { - bonsplitController.focusPane(replacementPane) - bonsplitController.selectTab(replacementTabId) - applyTabSelection(tabId: replacementTabId, inPane: replacementPane) - } - } + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + setCustomColor(snapshot.customColor) + isPinned = snapshot.isPinned statusEntries = Dictionary( uniqueKeysWithValues: snapshot.statusEntries.map { entry in @@ -321,32 +233,6 @@ extension Workspace { } } - private func legacyPageStateSnapshot(from snapshot: SessionWorkspaceSnapshot) -> SessionWorkspacePageStateSnapshot { - SessionWorkspacePageStateSnapshot( - currentDirectory: snapshot.currentDirectory, - focusedPanelId: snapshot.focusedPanelId, - layout: snapshot.layout, - panels: snapshot.panels, - statusEntries: snapshot.statusEntries, - logEntries: snapshot.logEntries, - progress: snapshot.progress, - gitBranch: snapshot.gitBranch - ) - } - - private func emptyPageSessionStateSnapshot(currentDirectory: String) -> SessionWorkspacePageStateSnapshot { - SessionWorkspacePageStateSnapshot( - currentDirectory: currentDirectory, - focusedPanelId: nil, - layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), - panels: [], - statusEntries: [], - logEntries: [], - progress: nil, - gitBranch: nil - ) - } - private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { switch node { case .pane(let pane): @@ -1022,55 +908,16 @@ struct ClosedBrowserPanelRestoreSnapshot { let fallbackAnchorPaneId: UUID? } -struct WorkspacePage: Identifiable, Equatable { - let id: UUID - var title: String -} - /// Workspace represents a sidebar tab. /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor final class Workspace: Identifiable, ObservableObject { - private struct StoredPageState { - struct RuntimeState { - var currentDirectory: String - var focusedPanelId: UUID? - var layout: SessionWorkspaceLayoutSnapshot - var detachedSurfaces: [UUID: DetachedSurfaceTransfer] - var statusEntries: [String: SidebarStatusEntry] - var metadataBlocks: [String: SidebarMetadataBlock] - var logEntries: [SidebarLogEntry] - var progress: SidebarProgressState? - var gitBranch: SidebarGitBranchState? - var pullRequest: SidebarPullRequestState? - var panelDirectories: [UUID: String] - var panelTitles: [UUID: String] - var panelCustomTitles: [UUID: String] - var pinnedPanelIds: Set<UUID> - var manualUnreadPanelIds: Set<UUID> - var manualUnreadMarkedAt: [UUID: Date] - var panelGitBranches: [UUID: SidebarGitBranchState] - var panelPullRequests: [UUID: SidebarPullRequestState] - var surfaceListeningPorts: [UUID: [Int]] - var surfaceTTYNames: [UUID: String] - var restoredTerminalScrollbackByPanelId: [UUID: String] - var lastTerminalConfigInheritancePanelId: UUID? - var lastTerminalConfigInheritanceFontPoints: Float? - var terminalInheritanceFontPointsByPanelId: [UUID: Float] - } - - var sessionState: SessionWorkspacePageStateSnapshot - var runtimeState: RuntimeState? - } - let id: UUID @Published var title: String @Published var customTitle: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String - @Published private(set) var pages: [WorkspacePage] - @Published private(set) var activePageId: UUID /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 @@ -1155,8 +1002,6 @@ final class Workspace: Identifiable, ObservableObject { } private var processTitle: String - private var storedPageStates: [UUID: StoredPageState] = [:] - private var nextAutoPageNumber: Int = 2 private enum SurfaceKind { static let terminal = "terminal" @@ -1261,20 +1106,11 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: Int = 0, configTemplate: ghostty_surface_config_s? = nil ) { - let initialPage = WorkspacePage( - id: UUID(), - title: String( - format: String(localized: "workspace.page.defaultTitleFormat", defaultValue: "Page %lld"), - 1 - ) - ) self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title self.title = title self.customTitle = nil - self.pages = [initialPage] - self.activePageId = initialPage.id let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -1372,455 +1208,6 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.configuration = configuration } - var activePage: WorkspacePage? { - pages.first(where: { $0.id == activePageId }) - } - - var activePageIndex: Int? { - pages.firstIndex(where: { $0.id == activePageId }) - } - - func pageIndex(pageId: UUID) -> Int? { - pages.firstIndex(where: { $0.id == pageId }) - } - - func pageTitle(pageId: UUID) -> String? { - pages.first(where: { $0.id == pageId })?.title - } - - func pageStateSnapshot( - pageId: UUID, - includeScrollback: Bool = false - ) -> SessionWorkspacePageStateSnapshot? { - guard pages.contains(where: { $0.id == pageId }) else { return nil } - if pageId == activePageId { - return currentPageSessionStateSnapshot(includeScrollback: includeScrollback) - } - return storedPageStates[pageId]?.sessionState - } - - func pageStructureSummary(pageId: UUID) -> (paneCount: Int, surfaceCount: Int)? { - guard let snapshot = pageStateSnapshot(pageId: pageId, includeScrollback: false) else { - return nil - } - return ( - paneCount: pageCount(in: snapshot.layout), - surfaceCount: snapshot.panels.count - ) - } - - func canClosePage(_ pageId: UUID) -> Bool { - pages.count > 1 && pages.contains(where: { $0.id == pageId }) - } - - @discardableResult - func newPage(select: Bool = true) -> WorkspacePage { - let page = WorkspacePage( - id: UUID(), - title: defaultPageTitle(number: nextAutoPageNumber) - ) - nextAutoPageNumber += 1 - pages.append(page) - - if select { - selectPage(page.id) - } else { - storedPageStates[page.id] = StoredPageState( - sessionState: emptyPageSessionStateSnapshot(currentDirectory: currentDirectory), - runtimeState: nil - ) - } - return page - } - - @discardableResult - func duplicatePage( - sourcePageId: UUID, - select: Bool = true, - title: String? = nil - ) -> WorkspacePage? { - guard let sourceIndex = pageIndex(pageId: sourcePageId), - let sourceTitle = pageTitle(pageId: sourcePageId), - let sourceSnapshot = pageStateSnapshot(pageId: sourcePageId, includeScrollback: true) else { - NSSound.beep() - return nil - } - - let duplicatedSnapshot = duplicatedPageStateSnapshot(sourceSnapshot) - let page = newPage(select: false) - storedPageStates[page.id] = StoredPageState(sessionState: duplicatedSnapshot, runtimeState: nil) - setPageTitle( - pageId: page.id, - title: title ?? duplicatedPageTitle(from: sourceTitle) - ) - _ = movePage(pageId: page.id, toIndex: sourceIndex + 1) - - if select { - selectPage(page.id) - } - - return page - } - - func selectPage(_ pageId: UUID) { - guard pageId != activePageId else { return } - guard pages.contains(where: { $0.id == pageId }) else { return } - - hideAllTerminalPortalViews() - hideAllBrowserPortalViews() - storedPageStates[activePageId] = captureActivePageStoredState(detachPanels: true) - restoreStoredPage(pageId) - activePageId = pageId - requestBackgroundTerminalSurfaceStartIfNeeded() - } - - func selectNextPage() { - guard let activePageIndex, !pages.isEmpty else { return } - let nextIndex = (activePageIndex + 1) % pages.count - selectPage(pages[nextIndex].id) - } - - func selectPreviousPage() { - guard let activePageIndex, !pages.isEmpty else { return } - let previousIndex = (activePageIndex - 1 + pages.count) % pages.count - selectPage(pages[previousIndex].id) - } - - func selectPage(at index: Int) { - guard index >= 0 && index < pages.count else { return } - selectPage(pages[index].id) - } - - func selectLastPage() { - guard let lastPage = pages.last else { return } - selectPage(lastPage.id) - } - - @discardableResult - func movePage(pageId: UUID, toIndex targetIndex: Int) -> Bool { - guard let currentIndex = pages.firstIndex(where: { $0.id == pageId }) else { return false } - guard pages.count > 1 else { return false } - let clampedIndex = max(0, min(targetIndex, pages.count - 1)) - guard currentIndex != clampedIndex else { return true } - let page = pages.remove(at: currentIndex) - pages.insert(page, at: clampedIndex) - return true - } - - @discardableResult - func movePageLeft(pageId: UUID) -> Bool { - guard let currentIndex = pages.firstIndex(where: { $0.id == pageId }) else { return false } - return movePage(pageId: pageId, toIndex: currentIndex - 1) - } - - @discardableResult - func movePageRight(pageId: UUID) -> Bool { - guard let currentIndex = pages.firstIndex(where: { $0.id == pageId }) else { return false } - return movePage(pageId: pageId, toIndex: currentIndex + 1) - } - - func setPageTitle(pageId: UUID, title: String?) { - guard let index = pages.firstIndex(where: { $0.id == pageId }) else { return } - let nextTitle = normalizedPageTitle(title, fallbackIndex: index + 1) - guard pages[index].title != nextTitle else { return } - pages[index].title = nextTitle - } - - func closePage(_ pageId: UUID, skipConfirmation: Bool = false) { - guard canClosePage(pageId) else { - NSSound.beep() - return - } - let shouldSkipConfirmation = - skipConfirmation || ProcessInfo.processInfo.environment["CMUX_UI_TEST_SKIP_CONFIRM_CLOSE_PAGE"] == "1" - guard shouldSkipConfirmation || !pageNeedsConfirmClose(pageId) || confirmClosePage(pageId: pageId) else { return } - guard let index = pages.firstIndex(where: { $0.id == pageId }) else { return } - - let replacementPageId: UUID? = { - if index + 1 < pages.count { - return pages[index + 1].id - } - if index > 0 { - return pages[index - 1].id - } - return nil - }() - - if pageId == activePageId { - hideAllTerminalPortalViews() - hideAllBrowserPortalViews() - let closedState = captureActivePageStoredState(detachPanels: true) - teardownStoredPageState(closedState) - pages.remove(at: index) - storedPageStates.removeValue(forKey: pageId) - - if let replacementPageId { - restoreStoredPage(replacementPageId) - activePageId = replacementPageId - } - } else { - if let storedState = storedPageStates.removeValue(forKey: pageId) { - teardownStoredPageState(storedState) - } - pages.remove(at: index) - } - } - - func closeOtherPages(keeping pageId: UUID? = nil) { - let keepPageId = pageId ?? activePageId - guard pages.count > 1 else { return } - - let removableIds = pages.map(\.id).filter { $0 != keepPageId } - for removableId in removableIds.reversed() { - closePage(removableId) - } - } - - private func defaultPageTitle(number: Int) -> String { - String( - format: String(localized: "workspace.page.defaultTitleFormat", defaultValue: "Page %lld"), - max(1, number) - ) - } - - private func duplicatedPageTitle(from sourceTitle: String) -> String { - String( - format: String(localized: "workspace.page.duplicateTitleFormat", defaultValue: "%@ Copy"), - sourceTitle - ) - } - - private func normalizedPageTitle(_ rawTitle: String?, fallbackIndex: Int) -> String { - let trimmed = rawTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - return defaultPageTitle(number: fallbackIndex) - } - return trimmed - } - - private func pageCount(in layout: SessionWorkspaceLayoutSnapshot) -> Int { - switch layout { - case .pane: - return 1 - case .split(let split): - return pageCount(in: split.first) + pageCount(in: split.second) - } - } - - private func pageNeedsConfirmClose(_ pageId: UUID) -> Bool { - if pageId == activePageId { - return needsConfirmClose() - } - guard let runtimeState = storedPageStates[pageId]?.runtimeState else { return false } - return runtimeState.detachedSurfaces.values.contains { transfer in - guard let terminalPanel = transfer.panel as? TerminalPanel else { return false } - return terminalPanel.needsConfirmClose() - } - } - - private func confirmClosePage(pageId: UUID) -> Bool { - let alert = NSAlert() - alert.messageText = String(localized: "dialog.closePage.title", defaultValue: "Close page?") - alert.informativeText = String(localized: "dialog.closePage.message", defaultValue: "This will close the page and all of its panels.") - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - return alert.runModal() == .alertFirstButtonReturn - } - - private func duplicatedPageStateSnapshot( - _ snapshot: SessionWorkspacePageStateSnapshot - ) -> SessionWorkspacePageStateSnapshot { - let panelIdMap = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, UUID()) }) - let remappedPanels = snapshot.panels.map { panel in - var remappedPanel = panel - remappedPanel.id = panelIdMap[panel.id] ?? panel.id - return remappedPanel - } - - return SessionWorkspacePageStateSnapshot( - currentDirectory: snapshot.currentDirectory, - focusedPanelId: snapshot.focusedPanelId.flatMap { panelIdMap[$0] }, - layout: duplicatedPageLayoutSnapshot(snapshot.layout, panelIdMap: panelIdMap), - panels: remappedPanels, - statusEntries: snapshot.statusEntries, - logEntries: snapshot.logEntries, - progress: snapshot.progress, - gitBranch: snapshot.gitBranch - ) - } - - private func duplicatedPageLayoutSnapshot( - _ layout: SessionWorkspaceLayoutSnapshot, - panelIdMap: [UUID: UUID] - ) -> SessionWorkspaceLayoutSnapshot { - switch layout { - case .pane(let pane): - return .pane( - SessionPaneLayoutSnapshot( - panelIds: pane.panelIds.map { panelIdMap[$0] ?? $0 }, - selectedPanelId: pane.selectedPanelId.flatMap { panelIdMap[$0] } - ) - ) - case .split(let split): - return .split( - SessionSplitLayoutSnapshot( - orientation: split.orientation, - dividerPosition: split.dividerPosition, - first: duplicatedPageLayoutSnapshot(split.first, panelIdMap: panelIdMap), - second: duplicatedPageLayoutSnapshot(split.second, panelIdMap: panelIdMap) - ) - ) - } - } - - private func captureActivePageStoredState(detachPanels: Bool) -> StoredPageState { - let sessionState = currentPageSessionStateSnapshot(includeScrollback: true) - let runtimeState = StoredPageState.RuntimeState( - currentDirectory: currentDirectory, - focusedPanelId: focusedPanelId, - layout: sessionState.layout, - detachedSurfaces: detachPanels ? detachAllLivePanelsForPageSwitch() : [:], - statusEntries: statusEntries, - metadataBlocks: metadataBlocks, - logEntries: logEntries, - progress: progress, - gitBranch: gitBranch, - pullRequest: pullRequest, - panelDirectories: panelDirectories, - panelTitles: panelTitles, - panelCustomTitles: panelCustomTitles, - pinnedPanelIds: pinnedPanelIds, - manualUnreadPanelIds: manualUnreadPanelIds, - manualUnreadMarkedAt: manualUnreadMarkedAt, - panelGitBranches: panelGitBranches, - panelPullRequests: panelPullRequests, - surfaceListeningPorts: surfaceListeningPorts, - surfaceTTYNames: surfaceTTYNames, - restoredTerminalScrollbackByPanelId: restoredTerminalScrollbackByPanelId, - lastTerminalConfigInheritancePanelId: lastTerminalConfigInheritancePanelId, - lastTerminalConfigInheritanceFontPoints: lastTerminalConfigInheritanceFontPoints, - terminalInheritanceFontPointsByPanelId: terminalInheritanceFontPointsByPanelId - ) - return StoredPageState(sessionState: sessionState, runtimeState: runtimeState) - } - - private func detachAllLivePanelsForPageSwitch() -> [UUID: DetachedSurfaceTransfer] { - var orderedPanelIds = sidebarOrderedPanelIds() - var seen = Set(orderedPanelIds) - for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { - orderedPanelIds.append(panelId) - } - - var detachedTransfers: [UUID: DetachedSurfaceTransfer] = [:] - for panelId in orderedPanelIds.reversed() { - guard let detached = detachSurface(panelId: panelId) else { continue } - detachedTransfers[panelId] = detached - } - return detachedTransfers - } - - private func restoreStoredPage(_ pageId: UUID) { - let storedState = storedPageStates.removeValue(forKey: pageId) ?? StoredPageState( - sessionState: emptyPageSessionStateSnapshot(currentDirectory: currentDirectory), - runtimeState: nil - ) - - if let runtimeState = storedState.runtimeState { - restoreRuntimePageState(runtimeState) - } else { - restoreSessionPageState(storedState.sessionState) - } - } - - private func restoreRuntimePageState(_ runtimeState: StoredPageState.RuntimeState) { - currentDirectory = runtimeState.currentDirectory - statusEntries = runtimeState.statusEntries - metadataBlocks = runtimeState.metadataBlocks - logEntries = runtimeState.logEntries - progress = runtimeState.progress - gitBranch = runtimeState.gitBranch - pullRequest = runtimeState.pullRequest - - if runtimeState.detachedSurfaces.isEmpty { - restoreSessionPageState(emptyPageSessionStateSnapshot(currentDirectory: runtimeState.currentDirectory)) - return - } - - let leafEntries = restoreSessionLayout(runtimeState.layout) - for entry in leafEntries { - let placeholderPanelIds = bonsplitController - .tabs(inPane: entry.paneId) - .compactMap { panelIdFromSurfaceId($0.id) } - - let desiredPanelIds = entry.snapshot.panelIds.filter { runtimeState.detachedSurfaces[$0] != nil } - var attachedPanelIds: [UUID] = [] - for desiredPanelId in desiredPanelIds { - guard let detached = runtimeState.detachedSurfaces[desiredPanelId] else { continue } - guard let attachedPanelId = attachDetachedSurface(detached, inPane: entry.paneId, focus: false) else { continue } - attachedPanelIds.append(attachedPanelId) - } - - for placeholderPanelId in placeholderPanelIds { - _ = closePanel(placeholderPanelId, force: true) - } - - for (targetIndex, attachedPanelId) in attachedPanelIds.enumerated() { - _ = reorderSurface(panelId: attachedPanelId, toIndex: targetIndex) - } - - let selectedPanelId = entry.snapshot.selectedPanelId.flatMap { desiredPanelIds.contains($0) ? $0 : nil } - ?? attachedPanelIds.first - if let selectedPanelId, - let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { - bonsplitController.focusPane(entry.paneId) - bonsplitController.selectTab(selectedTabId) - } - } - - panelDirectories = runtimeState.panelDirectories - panelTitles = runtimeState.panelTitles - panelCustomTitles = runtimeState.panelCustomTitles - pinnedPanelIds = runtimeState.pinnedPanelIds - manualUnreadPanelIds = runtimeState.manualUnreadPanelIds - manualUnreadMarkedAt = runtimeState.manualUnreadMarkedAt - panelGitBranches = runtimeState.panelGitBranches - panelPullRequests = runtimeState.panelPullRequests - surfaceListeningPorts = runtimeState.surfaceListeningPorts - surfaceTTYNames = runtimeState.surfaceTTYNames - restoredTerminalScrollbackByPanelId = runtimeState.restoredTerminalScrollbackByPanelId - lastTerminalConfigInheritancePanelId = runtimeState.lastTerminalConfigInheritancePanelId - lastTerminalConfigInheritanceFontPoints = runtimeState.lastTerminalConfigInheritanceFontPoints - terminalInheritanceFontPointsByPanelId = runtimeState.terminalInheritanceFontPointsByPanelId - - pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) - applySessionDividerPositions(snapshotNode: runtimeState.layout, liveNode: bonsplitController.treeSnapshot()) - - for paneId in bonsplitController.allPaneIds { - normalizePinnedTabs(in: paneId) - for tab in bonsplitController.tabs(inPane: paneId) { - if let panelId = panelIdFromSurfaceId(tab.id) { - syncUnreadBadgeStateForPanel(panelId) - } - } - } - - recomputeListeningPorts() - - if let focusedPanelId = runtimeState.focusedPanelId, panels[focusedPanelId] != nil { - focusPanel(focusedPanelId) - } else { - scheduleFocusReconcile() - } - } - - private func teardownStoredPageState(_ storedState: StoredPageState) { - guard let runtimeState = storedState.runtimeState else { return } - for transfer in runtimeState.detachedSurfaces.values { - transfer.panel.close() - } - } - // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID @@ -3468,8 +2855,6 @@ final class Workspace: Identifiable, ObservableObject { } else if let browserPanel = detached.panel as? BrowserPanel { browserPanel.updateWorkspaceId(id) installBrowserPanelSubscription(browserPanel) - } else if let markdownPanel = detached.panel as? MarkdownPanel { - installMarkdownPanelSubscription(markdownPanel) } if let directory = detached.directory { @@ -4171,29 +3556,6 @@ final class Workspace: Identifiable, ObservableObject { createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL) } - func promptRenamePage(pageId: UUID) { - guard let pageIndex = pages.firstIndex(where: { $0.id == pageId }) else { return } - - let alert = NSAlert() - alert.messageText = String(localized: "dialog.renamePage.title", defaultValue: "Rename Page") - alert.informativeText = String(localized: "dialog.renamePage.message", defaultValue: "Enter a name for this page.") - let input = NSTextField(string: pages[pageIndex].title) - input.placeholderString = String(localized: "dialog.renamePage.placeholder", defaultValue: "Page name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - setPageTitle(pageId: pageId, title: input.stringValue) - } - private func promptRenamePanel(tabId: TabID) { guard let panelId = panelIdFromSurfaceId(tabId), let panel = panels[panelId] else { return } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 18bb18ab..99ea9f8f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -37,11 +37,6 @@ struct cmuxApp: App { @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.newPage.defaultsKey) private var newPageShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.renamePage.defaultsKey) private var renamePageShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.closePage.defaultsKey) private var closePageShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.nextPage.defaultsKey) private var nextPageShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.previousPage.defaultsKey) private var previousPageShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -592,103 +587,6 @@ struct cmuxApp: App { Divider() - splitCommandButton(title: String(localized: "menu.view.newPage", defaultValue: "New Page"), shortcut: newPageMenuShortcut) { - _ = activeTabManager.selectedWorkspace?.newPage(select: true) - } - - Button(String(localized: "menu.view.duplicatePage", defaultValue: "Duplicate Page")) { - guard let workspace = activeTabManager.selectedWorkspace, - let pageId = workspace.activePage?.id else { - NSSound.beep() - return - } - _ = workspace.duplicatePage(sourcePageId: pageId, select: true) - } - - splitCommandButton(title: String(localized: "menu.view.renamePage", defaultValue: "Rename Page…"), shortcut: renamePageMenuShortcut) { - guard let workspace = activeTabManager.selectedWorkspace, - let pageId = workspace.activePage?.id else { - NSSound.beep() - return - } - workspace.promptRenamePage(pageId: pageId) - } - - splitCommandButton(title: String(localized: "menu.view.closePage", defaultValue: "Close Page"), shortcut: closePageMenuShortcut) { - guard let workspace = activeTabManager.selectedWorkspace, - let pageId = workspace.activePage?.id else { - NSSound.beep() - return - } - workspace.closePage(pageId) - } - - splitCommandButton(title: String(localized: "menu.view.nextPage", defaultValue: "Next Page"), shortcut: nextPageMenuShortcut) { - activeTabManager.selectedWorkspace?.selectNextPage() - } - - splitCommandButton(title: String(localized: "menu.view.previousPage", defaultValue: "Previous Page"), shortcut: previousPageMenuShortcut) { - activeTabManager.selectedWorkspace?.selectPreviousPage() - } - - Button(String(localized: "workspace.page.context.closeOthers", defaultValue: "Close Other Pages")) { - guard let workspace = activeTabManager.selectedWorkspace, - let pageId = workspace.activePage?.id else { - NSSound.beep() - return - } - workspace.closeOtherPages(keeping: pageId) - } - .disabled((activeTabManager.selectedWorkspace?.pages.count ?? 0) <= 1) - - Button(String(localized: "workspace.page.context.moveLeft", defaultValue: "Move Page Left")) { - guard let workspace = activeTabManager.selectedWorkspace, - let pageId = workspace.activePage?.id else { - NSSound.beep() - return - } - _ = workspace.movePageLeft(pageId: pageId) - } - .disabled((activeTabManager.selectedWorkspace?.activePageIndex ?? 0) <= 0) - - Button(String(localized: "workspace.page.context.moveRight", defaultValue: "Move Page Right")) { - guard let workspace = activeTabManager.selectedWorkspace, - let pageId = workspace.activePage?.id else { - NSSound.beep() - return - } - _ = workspace.movePageRight(pageId: pageId) - } - .disabled({ - guard let workspace = activeTabManager.selectedWorkspace, - let activeIndex = workspace.activePageIndex else { - return true - } - return activeIndex >= workspace.pages.count - 1 - }()) - - Menu(String(localized: "menu.view.selectPage", defaultValue: "Select Page")) { - if let workspace = activeTabManager.selectedWorkspace { - let activePageId = workspace.activePage?.id - ForEach(Array(workspace.pages.enumerated()), id: \.element.id) { index, page in - if let shortcut = pageSelectionMenuShortcut(index: index, pageCount: workspace.pages.count) { - splitCommandButton(title: page.title, shortcut: shortcut) { - workspace.selectPage(page.id) - } - .disabled(page.id == activePageId) - } else { - Button(page.title) { - workspace.selectPage(page.id) - } - .disabled(page.id == activePageId) - } - } - } - } - .disabled((activeTabManager.selectedWorkspace?.pages.isEmpty ?? true)) - - Divider() - splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: splitRightMenuShortcut) { performSplitFromMenu(direction: .right) } @@ -876,41 +774,6 @@ struct cmuxApp: App { ) } - private var newPageMenuShortcut: StoredShortcut { - decodeShortcut( - from: newPageShortcutData, - fallback: KeyboardShortcutSettings.Action.newPage.defaultShortcut - ) - } - - private var renamePageMenuShortcut: StoredShortcut { - decodeShortcut( - from: renamePageShortcutData, - fallback: KeyboardShortcutSettings.Action.renamePage.defaultShortcut - ) - } - - private var closePageMenuShortcut: StoredShortcut { - decodeShortcut( - from: closePageShortcutData, - fallback: KeyboardShortcutSettings.Action.closePage.defaultShortcut - ) - } - - private var nextPageMenuShortcut: StoredShortcut { - decodeShortcut( - from: nextPageShortcutData, - fallback: KeyboardShortcutSettings.Action.nextPage.defaultShortcut - ) - } - - private var previousPageMenuShortcut: StoredShortcut { - decodeShortcut( - from: previousPageShortcutData, - fallback: KeyboardShortcutSettings.Action.previousPage.defaultShortcut - ) - } - private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -966,34 +829,6 @@ struct cmuxApp: App { } } - private func pageSelectionMenuShortcut(index: Int, pageCount: Int) -> StoredShortcut? { - switch index { - case 0: - return KeyboardShortcutSettings.shortcut(for: .selectPage1) - case 1: - return KeyboardShortcutSettings.shortcut(for: .selectPage2) - case 2: - return KeyboardShortcutSettings.shortcut(for: .selectPage3) - case 3: - return KeyboardShortcutSettings.shortcut(for: .selectPage4) - case 4: - return KeyboardShortcutSettings.shortcut(for: .selectPage5) - case 5: - return KeyboardShortcutSettings.shortcut(for: .selectPage6) - case 6: - return KeyboardShortcutSettings.shortcut(for: .selectPage7) - case 7: - return KeyboardShortcutSettings.shortcut(for: .selectPage8) - default: - break - } - - if index == pageCount - 1 { - return KeyboardShortcutSettings.shortcut(for: .selectLastPage) - } - return nil - } - private func closePanelOrWindow() { if let window = NSApp.keyWindow, window.identifier?.rawValue == "cmux.settings" { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 9832f13d..63ff111f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -899,495 +899,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } - func testOptionDigitPageShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id else { - XCTFail("Expected test window and workspace") - return - } - - _ = workspace.newPage(select: true) - let selectedBeforeShortcut = workspace.activePage?.id - XCTAssertNotEqual(selectedBeforeShortcut, firstPageId) - - withTemporaryShortcut(action: .selectPage1) { - // Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key. - // Option+1 page selection should still match via keyCode fallback. - guard let event = makeKeyDownEvent( - key: "&", - modifiers: [.option], - keyCode: 18, // kVK_ANSI_1 - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct Option+1 event on ANSI 1 key") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - } - - XCTAssertEqual(workspace.activePage?.id, firstPageId) - XCTAssertNotEqual(workspace.activePage?.id, selectedBeforeShortcut) - } - - func testOption9SelectsLastPageInEventWindowWhenActiveManagerIsStale() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let firstWindowId = appDelegate.createMainWindow() - let secondWindowId = appDelegate.createMainWindow() - - defer { - closeWindow(withId: firstWindowId) - closeWindow(withId: secondWindowId) - } - - guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), - let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), - let secondWindow = window(withId: secondWindowId), - let firstWorkspace = firstManager.selectedWorkspace, - let secondWorkspace = secondManager.selectedWorkspace, - let firstWorkspaceFirstPageId = firstWorkspace.activePage?.id, - let secondWorkspaceFirstPageId = secondWorkspace.activePage?.id else { - XCTFail("Expected both window contexts to exist") - return - } - - _ = firstWorkspace.newPage(select: true) - firstWorkspace.selectPage(firstWorkspaceFirstPageId) - - _ = secondWorkspace.newPage(select: true) - let secondWorkspaceLastPage = secondWorkspace.newPage(select: true) - secondWorkspace.selectPage(secondWorkspaceFirstPageId) - - appDelegate.tabManager = firstManager - XCTAssertTrue(appDelegate.tabManager === firstManager) - - guard let event = makeKeyDownEvent( - key: "9", - modifiers: [.option], - keyCode: 25, // kVK_ANSI_9 - windowNumber: secondWindow.windowNumber - ) else { - XCTFail("Failed to construct Option+9 event") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - XCTAssertEqual( - firstWorkspace.activePage?.id, - firstWorkspaceFirstPageId, - "Option+9 must not select a page in the stale active window" - ) - XCTAssertEqual( - secondWorkspace.activePage?.id, - secondWorkspaceLastPage.id, - "Option+9 should select the last page in the event window" - ) - XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") - } - - func testCustomSelectLastPageShortcutOverrideIsHonored() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id else { - XCTFail("Expected test window and workspace") - return - } - - _ = workspace.newPage(select: true) - let lastPage = workspace.newPage(select: true) - workspace.selectPage(firstPageId) - - withTemporaryShortcut( - action: .selectLastPage, - shortcut: StoredShortcut(key: "0", command: false, shift: false, option: true, control: false) - ) { - guard let event = makeKeyDownEvent( - key: "0", - modifiers: [.option], - keyCode: 29, // kVK_ANSI_0 - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct custom Option+0 event") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - } - - XCTAssertEqual(workspace.activePage?.id, lastPage.id) - XCTAssertNotEqual(workspace.activePage?.id, firstPageId) - } - - func testOptionRightBracketPageShortcutFallsBackByKeyCodeOnNonUSLayouts() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId), - let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id else { - XCTFail("Expected test window and workspace") - return - } - - let secondPage = workspace.newPage(select: false) - workspace.selectPage(firstPageId) - - withTemporaryShortcut(action: .nextPage) { - // Some non-US layouts can report unrelated symbols for the ANSI ] key. - // Option+] should still work via keyCode fallback. - guard let event = makeKeyDownEvent( - key: "*", - modifiers: [.option], - keyCode: 30, // kVK_ANSI_RightBracket - windowNumber: window.windowNumber - ) else { - XCTFail("Failed to construct Option+] event on ANSI ] key") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - } - - XCTAssertEqual(workspace.activePage?.id, secondPage.id) - XCTAssertNotEqual(workspace.activePage?.id, firstPageId) - } - - func testCmdOptionNCreatesPageInEventWindowWhenActiveManagerIsStale() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let firstWindowId = appDelegate.createMainWindow() - let secondWindowId = appDelegate.createMainWindow() - - defer { - closeWindow(withId: firstWindowId) - closeWindow(withId: secondWindowId) - } - - guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), - let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), - let secondWindow = window(withId: secondWindowId), - let firstWorkspace = firstManager.selectedWorkspace, - let secondWorkspace = secondManager.selectedWorkspace else { - XCTFail("Expected both window contexts to exist") - return - } - - let firstPageCount = firstWorkspace.pages.count - let secondPageCount = secondWorkspace.pages.count - - appDelegate.tabManager = firstManager - XCTAssertTrue(appDelegate.tabManager === firstManager) - - guard let event = makeKeyDownEvent( - key: "n", - modifiers: [.command, .option], - keyCode: 45, // kVK_ANSI_N - windowNumber: secondWindow.windowNumber - ) else { - XCTFail("Failed to construct Cmd+Option+N event") - return - } - -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - - XCTAssertEqual(firstWorkspace.pages.count, firstPageCount, "Cmd+Option+N must not create a page in stale active window") - XCTAssertEqual(secondWorkspace.pages.count, secondPageCount + 1, "Cmd+Option+N should create a page in the event window") - XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") - } - - func testDuplicatePagePreservesStructureAndRemapsPanelIdentity() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let sourcePageId = workspace.activePage?.id else { - XCTFail("Expected test window and workspace") - return - } - - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - - guard let sourceStructure = workspace.pageStructureSummary(pageId: sourcePageId), - let sourceSnapshot = workspace.pageStateSnapshot(pageId: sourcePageId, includeScrollback: false) else { - XCTFail("Expected source page snapshot") - return - } - - guard let duplicatePage = workspace.duplicatePage(sourcePageId: sourcePageId, select: false) else { - XCTFail("Expected duplicate page") - return - } - - guard let duplicateStructure = workspace.pageStructureSummary(pageId: duplicatePage.id), - let duplicateSnapshot = workspace.pageStateSnapshot(pageId: duplicatePage.id, includeScrollback: false) else { - XCTFail("Expected duplicate page snapshot") - return - } - - XCTAssertEqual(duplicateStructure.paneCount, sourceStructure.paneCount) - XCTAssertEqual(duplicateStructure.surfaceCount, sourceStructure.surfaceCount) - XCTAssertEqual(duplicatePage.title, "Page 1 Copy") - XCTAssertEqual(sourceSnapshot.panels.count, duplicateSnapshot.panels.count) - - if let sourcePanelId = sourceSnapshot.panels.first?.id, - let duplicatePanelId = duplicateSnapshot.panels.first?.id { - XCTAssertNotEqual(sourcePanelId, duplicatePanelId, "Duplicated pages should remap panel identities") - } else { - XCTFail("Expected duplicated page snapshots to include at least one panel") - } - } - - func testClosingActivePageSelectsNearestRightNeighbor() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id else { - XCTFail("Expected test window and workspace") - return - } - - let middlePage = workspace.newPage(select: false) - let rightPage = workspace.newPage(select: false) - workspace.selectPage(middlePage.id) - - workspace.closePage(middlePage.id, skipConfirmation: true) - - XCTAssertEqual(workspace.activePage?.id, rightPage.id) - XCTAssertEqual(workspace.pages.map(\.id), [firstPageId, rightPage.id]) - } - - func testV2PageListIncludesStoredAndActivePageStructureCounts() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { - TerminalController.shared.setActiveTabManager(nil) - closeWindow(withId: windowId) - } - - guard let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id, - let firstPaneId = workspace.bonsplitController.allPaneIds.first else { - XCTFail("Expected test window and workspace") - return - } - - workspace.setPageTitle(pageId: firstPageId, title: "Agents") - XCTAssertNotNil(workspace.newTerminalSurface(inPane: firstPaneId, focus: false)) - - let secondPage = workspace.newPage(select: true) - workspace.setPageTitle(pageId: secondPage.id, title: "Editor") - TerminalController.shared.setActiveTabManager(manager) - - let result = v2Result( - method: "page.list", - params: ["workspace_id": workspace.id.uuidString] - ) - let pages = result["pages"] as? [[String: Any]] - - XCTAssertEqual(result["page_id"] as? String, secondPage.id.uuidString) - XCTAssertEqual(result["page_title"] as? String, "Editor") - XCTAssertEqual(pages?.count, 2) - - let pagesByTitle = Dictionary( - uniqueKeysWithValues: (pages ?? []).compactMap { page -> (String, [String: Any])? in - guard let title = page["title"] as? String else { return nil } - return (title, page) - } - ) - - XCTAssertEqual(pagesByTitle["Agents"]?["pane_count"] as? Int, 1) - XCTAssertEqual(pagesByTitle["Agents"]?["surface_count"] as? Int, 2) - XCTAssertEqual(pagesByTitle["Agents"]?["selected"] as? Bool, false) - XCTAssertEqual(pagesByTitle["Editor"]?["pane_count"] as? Int, 1) - XCTAssertEqual(pagesByTitle["Editor"]?["surface_count"] as? Int, 1) - XCTAssertEqual(pagesByTitle["Editor"]?["selected"] as? Bool, true) - } - - func testV2PageSelectAndCurrentReturnUpdatedSelection() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { - TerminalController.shared.setActiveTabManager(nil) - closeWindow(withId: windowId) - } - - guard let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id else { - XCTFail("Expected test window and workspace") - return - } - - workspace.setPageTitle(pageId: firstPageId, title: "Agents") - let secondPage = workspace.newPage(select: false) - workspace.setPageTitle(pageId: secondPage.id, title: "Editor") - workspace.selectPage(firstPageId) - TerminalController.shared.setActiveTabManager(manager) - - let selectResult = v2Result( - method: "page.select", - params: [ - "workspace_id": workspace.id.uuidString, - "page_id": secondPage.id.uuidString - ] - ) - - XCTAssertEqual(workspace.activePageId, secondPage.id) - XCTAssertEqual(selectResult["page_id"] as? String, secondPage.id.uuidString) - XCTAssertEqual(selectResult["page_title"] as? String, "Editor") - XCTAssertEqual(selectResult["workspace_id"] as? String, workspace.id.uuidString) - - let currentResult = v2Result( - method: "page.current", - params: ["workspace_id": workspace.id.uuidString] - ) - XCTAssertEqual(currentResult["page_id"] as? String, secondPage.id.uuidString) - XCTAssertEqual(currentResult["page_title"] as? String, "Editor") - } - - func testV2SystemTreeIncludesPagesAndSelectedPagePaneMirror() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { - TerminalController.shared.setActiveTabManager(nil) - closeWindow(withId: windowId) - } - - guard let manager = appDelegate.tabManagerFor(windowId: windowId), - let workspace = manager.selectedWorkspace, - let firstPageId = workspace.activePage?.id, - let firstPaneId = workspace.bonsplitController.allPaneIds.first else { - XCTFail("Expected test window and workspace") - return - } - - workspace.setPageTitle(pageId: firstPageId, title: "Agents") - XCTAssertNotNil(workspace.newTerminalSurface(inPane: firstPaneId, focus: false)) - - let secondPage = workspace.newPage(select: true) - workspace.setPageTitle(pageId: secondPage.id, title: "Editor") - TerminalController.shared.setActiveTabManager(manager) - - let result = v2Result( - method: "system.tree", - params: ["workspace_id": workspace.id.uuidString] - ) - - guard let windows = result["windows"] as? [[String: Any]], - let window = windows.first, - let workspaces = window["workspaces"] as? [[String: Any]], - let workspaceNode = workspaces.first, - let pages = workspaceNode["pages"] as? [[String: Any]] else { - XCTFail("Expected system.tree page hierarchy") - return - } - - XCTAssertEqual(windows.count, 1) - XCTAssertEqual(workspaceNode["selected_page_id"] as? String, secondPage.id.uuidString) - XCTAssertEqual(pages.count, 2) - - let pagesByTitle = Dictionary( - uniqueKeysWithValues: pages.compactMap { page -> (String, [String: Any])? in - guard let title = page["title"] as? String else { return nil } - return (title, page) - } - ) - - let agentsPage = pagesByTitle["Agents"] - let editorPage = pagesByTitle["Editor"] - XCTAssertEqual(agentsPage?["selected"] as? Bool, false) - XCTAssertEqual(editorPage?["selected"] as? Bool, true) - XCTAssertEqual((agentsPage?["panes"] as? [[String: Any]])?.count, 1) - XCTAssertEqual((editorPage?["panes"] as? [[String: Any]])?.count, 1) - - let agentsSurfaceCount = ((agentsPage?["panes"] as? [[String: Any]])?.first?["surfaces"] as? [[String: Any]])?.count - let editorSurfaceCount = ((editorPage?["panes"] as? [[String: Any]])?.first?["surfaces"] as? [[String: Any]])?.count - let mirroredSurfaceCount = ((workspaceNode["panes"] as? [[String: Any]])?.first?["surfaces"] as? [[String: Any]])?.count - - XCTAssertEqual(agentsSurfaceCount, 2) - XCTAssertEqual(editorSurfaceCount, 1) - XCTAssertEqual(mirroredSurfaceCount, editorSurfaceCount) - } - func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -2742,71 +2253,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } - private func v2Result( - method: String, - params: [String: Any] = [:], - file: StaticString = #filePath, - line: UInt = #line - ) -> [String: Any] { - let request: [String: Any] = [ - "id": "test", - "method": method, - "params": params - ] - - guard JSONSerialization.isValidJSONObject(request) else { - XCTFail("Expected valid JSON request", file: file, line: line) - return [:] - } - - let requestData: Data - do { - requestData = try JSONSerialization.data(withJSONObject: request, options: []) - } catch { - XCTFail("Failed to encode request JSON: \(error)", file: file, line: line) - return [:] - } - - guard let requestString = String(data: requestData, encoding: .utf8) else { - XCTFail("Failed to encode UTF-8 request", file: file, line: line) - return [:] - } - - let responseString: String -#if DEBUG - responseString = TerminalController.shared.debugProcessV2Command(requestString) -#else - XCTFail("debugProcessV2Command is only available in DEBUG", file: file, line: line) - return [:] -#endif - - guard let responseData = responseString.data(using: .utf8) else { - XCTFail("Failed to decode UTF-8 response", file: file, line: line) - return [:] - } - - let responseObject: Any - do { - responseObject = try JSONSerialization.jsonObject(with: responseData, options: []) - } catch { - XCTFail("Failed to decode response JSON: \(error)", file: file, line: line) - return [:] - } - - guard let response = responseObject as? [String: Any] else { - XCTFail("Expected JSON object response", file: file, line: line) - return [:] - } - - let isOK = (response["ok"] as? Bool) == true - XCTAssertTrue(isOK, "Expected successful v2 response: \(responseString)", file: file, line: line) - guard let result = response["result"] as? [String: Any] else { - XCTFail("Expected result payload in response: \(responseString)", file: file, line: line) - return [:] - } - return result - } - private func window(withId windowId: UUID) -> NSWindow? { let identifier = "cmux.main.\(windowId.uuidString)" return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7cf85a29..df3ecd42 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5812,243 +5812,6 @@ final class SidebarDragAutoScrollPlannerTests: XCTestCase { } } -final class TitlebarPageDropPlannerTests: XCTestCase { - func testNoIndicatorForNoOpEdges() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - XCTAssertNil( - TitlebarPageDropPlanner.indicator( - draggedPageId: first, - targetPageId: first, - pageIds: pageIds - ) - ) - XCTAssertNil( - TitlebarPageDropPlanner.indicator( - draggedPageId: third, - targetPageId: nil, - pageIds: pageIds - ) - ) - } - - func testNoIndicatorWhenOnlyOnePageExists() { - let only = UUID() - - XCTAssertNil( - TitlebarPageDropPlanner.indicator( - draggedPageId: only, - targetPageId: nil, - pageIds: [only] - ) - ) - XCTAssertNil( - TitlebarPageDropPlanner.indicator( - draggedPageId: only, - targetPageId: only, - pageIds: [only] - ) - ) - } - - func testIndicatorAppearsForRealMoveToEnd() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - let indicator = TitlebarPageDropPlanner.indicator( - draggedPageId: second, - targetPageId: nil, - pageIds: pageIds - ) - XCTAssertEqual(indicator?.pageId, nil) - XCTAssertEqual(indicator?.edge, .trailing) - } - - func testTargetIndexForMoveToEndFromMiddle() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - let index = TitlebarPageDropPlanner.targetIndex( - draggedPageId: second, - targetPageId: nil, - indicator: TitlebarPageDropIndicator(pageId: nil, edge: .trailing), - pageIds: pageIds - ) - XCTAssertEqual(index, 2) - } - - func testPointerLeadingEdgeSuppressesNoOpWhenDraggingFirstOverSecond() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - XCTAssertNil( - TitlebarPageDropPlanner.indicator( - draggedPageId: first, - targetPageId: second, - pageIds: pageIds, - pointerX: 2, - targetWidth: 40 - ) - ) - } - - func testPointerTrailingEdgeAllowsMoveWhenDraggingFirstOverSecond() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - let indicator = TitlebarPageDropPlanner.indicator( - draggedPageId: first, - targetPageId: second, - pageIds: pageIds, - pointerX: 38, - targetWidth: 40 - ) - XCTAssertEqual(indicator?.pageId, third) - XCTAssertEqual(indicator?.edge, .leading) - XCTAssertEqual( - TitlebarPageDropPlanner.targetIndex( - draggedPageId: first, - targetPageId: second, - indicator: indicator, - pageIds: pageIds - ), - 1 - ) - } - - func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - let fromTrailingEdgeOfSecond = TitlebarPageDropPlanner.indicator( - draggedPageId: first, - targetPageId: second, - pageIds: pageIds, - pointerX: 38, - targetWidth: 40 - ) - let fromLeadingEdgeOfThird = TitlebarPageDropPlanner.indicator( - draggedPageId: first, - targetPageId: third, - pageIds: pageIds, - pointerX: 2, - targetWidth: 40 - ) - - XCTAssertEqual(fromTrailingEdgeOfSecond?.pageId, third) - XCTAssertEqual(fromTrailingEdgeOfSecond?.edge, .leading) - XCTAssertEqual(fromLeadingEdgeOfThird?.pageId, third) - XCTAssertEqual(fromLeadingEdgeOfThird?.edge, .leading) - } - - func testPointerTrailingEdgeSuppressesNoOpWhenDraggingLastOverSecond() { - let first = UUID() - let second = UUID() - let third = UUID() - let pageIds = [first, second, third] - - XCTAssertNil( - TitlebarPageDropPlanner.indicator( - draggedPageId: third, - targetPageId: second, - pageIds: pageIds, - pointerX: 38, - targetWidth: 40 - ) - ) - } -} - -final class TitlebarPageDragAutoScrollPlannerTests: XCTestCase { - func testAutoScrollPlanTriggersNearLeadingAndTrailingEdgesOnly() { - let leadingPlan = TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: 4, - distanceToTrailing: 96, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - XCTAssertEqual(leadingPlan?.direction, .left) - XCTAssertNotNil(leadingPlan) - - let trailingPlan = TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: 96, - distanceToTrailing: 4, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - XCTAssertEqual(trailingPlan?.direction, .right) - XCTAssertNotNil(trailingPlan) - - XCTAssertNil( - TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: 60, - distanceToTrailing: 60, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - ) - } - - func testAutoScrollPlanSpeedsUpCloserToEdge() { - let nearLeading = TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: 1, - distanceToTrailing: 99, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - let midLeading = TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: 22, - distanceToTrailing: 78, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - - XCTAssertNotNil(nearLeading) - XCTAssertNotNil(midLeading) - XCTAssertGreaterThan(nearLeading?.pointsPerTick ?? 0, midLeading?.pointsPerTick ?? 0) - } - - func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() { - let pastLeading = TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: -500, - distanceToTrailing: 600, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - XCTAssertEqual(pastLeading?.direction, .left) - XCTAssertEqual(pastLeading?.pointsPerTick, 12) - - let pastTrailing = TitlebarPageDragAutoScrollPlanner.plan( - distanceToLeading: 600, - distanceToTrailing: -500, - edgeInset: 44, - minStep: 2, - maxStep: 12 - ) - XCTAssertEqual(pastTrailing?.direction, .right) - XCTAssertEqual(pastTrailing?.pointsPerTick, 12) - } -} - final class FinderServicePathResolverTests: XCTestCase { func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() { let input: [URL] = [ diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 14296553..88d8f11c 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -65,42 +65,6 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor) } - func testSaveAndLoadRoundTripPreservesWorkspacePagesAndActivePageSelection() { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - - let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) - var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) - let firstPageId = UUID() - let secondPageId = UUID() - snapshot.windows[0].tabManager.workspaces[0].activePageId = secondPageId - snapshot.windows[0].tabManager.workspaces[0].pages = [ - SessionWorkspacePageSnapshot( - id: firstPageId, - title: "Agents", - state: makePageStateSnapshot(currentDirectory: "/tmp/project/agents") - ), - SessionWorkspacePageSnapshot( - id: secondPageId, - title: "Editor", - state: makePageStateSnapshot(currentDirectory: "/tmp/project/editor") - ), - ] - - XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) - - let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) - let workspace = loaded?.windows.first?.tabManager.workspaces.first - XCTAssertEqual(workspace?.activePageId, secondPageId) - XCTAssertEqual(workspace?.pages?.map(\.title), ["Agents", "Editor"]) - XCTAssertEqual(workspace?.pages?.map(\.state.currentDirectory), [ - "/tmp/project/agents", - "/tmp/project/editor", - ]) - } - func testLoadRejectsSchemaVersionMismatch() { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) @@ -768,19 +732,6 @@ final class SessionPersistenceTests: XCTestCase { windows: [window] ) } - - private func makePageStateSnapshot(currentDirectory: String) -> SessionWorkspacePageStateSnapshot { - SessionWorkspacePageStateSnapshot( - currentDirectory: currentDirectory, - focusedPanelId: nil, - layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), - panels: [], - statusEntries: [], - logEntries: [], - progress: nil, - gitBranch: nil - ) - } } final class SocketListenerAcceptPolicyTests: XCTestCase { diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 14e13684..6e8d62e3 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -47,85 +47,3 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase { ) } } - -final class WorkspaceHandoffPolicyTests: XCTestCase { - func testStaleRetiringWorkspaceReturnsSupersededWorkspace() { - let currentRetiring = UUID() - let nextRetiring = UUID() - let selected = UUID() - - XCTAssertEqual( - WorkspaceHandoffPolicy.staleRetiringWorkspaceId( - currentRetiring: currentRetiring, - nextRetiring: nextRetiring, - selected: selected - ), - currentRetiring - ) - } - - func testStaleRetiringWorkspaceDoesNotHideNewlySelectedWorkspace() { - let currentRetiring = UUID() - let nextRetiring = UUID() - - XCTAssertNil( - WorkspaceHandoffPolicy.staleRetiringWorkspaceId( - currentRetiring: currentRetiring, - nextRetiring: nextRetiring, - selected: currentRetiring - ) - ) - } - - func testStaleRetiringWorkspaceDoesNotHideNextRetiringWorkspace() { - let nextRetiring = UUID() - - XCTAssertNil( - WorkspaceHandoffPolicy.staleRetiringWorkspaceId( - currentRetiring: nextRetiring, - nextRetiring: nextRetiring, - selected: UUID() - ) - ) - } -} - -@MainActor -final class WorkspacePageLifecycleTests: XCTestCase { - func testSwitchingPagesPreservesLivePanelIdentityAcrossDetachAndReattach() throws { - let workspace = Workspace() - let firstPageId = workspace.activePageId - let firstPaneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - - XCTAssertNotNil(workspace.newTerminalSurface(inPane: firstPaneId, focus: false)) - let firstPagePanelIds = Set(workspace.panels.keys) - XCTAssertEqual(firstPagePanelIds.count, 2) - - let secondPage = workspace.newPage(select: true) - XCTAssertEqual(workspace.activePageId, secondPage.id) - - let secondPagePanelIds = Set(workspace.panels.keys) - XCTAssertEqual( - secondPagePanelIds.count, - 1, - "A fresh page should mount its own placeholder terminal" - ) - XCTAssertNotEqual(firstPagePanelIds, secondPagePanelIds) - - workspace.selectPage(firstPageId) - XCTAssertEqual(workspace.activePageId, firstPageId) - XCTAssertEqual( - Set(workspace.panels.keys), - firstPagePanelIds, - "Returning to the first page should reattach the parked live panels" - ) - - workspace.selectPage(secondPage.id) - XCTAssertEqual(workspace.activePageId, secondPage.id) - XCTAssertEqual( - Set(workspace.panels.keys), - secondPagePanelIds, - "Returning to the second page should reuse its parked live panel instead of rebuilding a new one" - ) - } -} diff --git a/cmuxUITests/WorkspacePagesUITests.swift b/cmuxUITests/WorkspacePagesUITests.swift deleted file mode 100644 index e65a1d32..00000000 --- a/cmuxUITests/WorkspacePagesUITests.swift +++ /dev/null @@ -1,232 +0,0 @@ -import XCTest - -private func workspacePagesPollUntil( - timeout: TimeInterval, - pollInterval: TimeInterval = 0.05, - condition: () -> Bool -) -> Bool { - let start = ProcessInfo.processInfo.systemUptime - while true { - if condition() { - return true - } - if (ProcessInfo.processInfo.systemUptime - start) >= timeout { - return false - } - RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) - } -} - -final class WorkspacePagesUITests: XCTestCase { - private let launchTag = "ui-tests-workspace-pages" - private var interruptionMonitor: NSObjectProtocol? - - override func setUp() { - super.setUp() - continueAfterFailure = false - interruptionMonitor = addUIInterruptionMonitor(withDescription: "Notification Center") { dialog in - Self.dismissInterruptingDialog(dialog) - } - } - - override func tearDown() { - if let interruptionMonitor { - removeUIInterruptionMonitor(interruptionMonitor) - } - interruptionMonitor = nil - super.tearDown() - } - - func testTitlebarPageStripCreateSelectCloseAndHintFlow() { - let app = configuredApp() - app.launch() - - XCTAssertTrue( - ensureForegroundAfterLaunch(app, timeout: 12.0), - "Expected app to launch for workspace pages UI test. state=\(app.state.rawValue)" - ) - XCTAssertTrue(waitForPageButtonCount(1, app: app, timeout: 8.0)) - - guard let firstPageToken = activePageToken(in: app) else { - XCTFail("Expected initial active titlebar page button") - return - } - - XCTAssertTrue(waitForElementVisible(app.staticTexts["titlebarPageHint.1"], timeout: 6.0)) - - app.typeKey("n", modifierFlags: [.command, .option]) - - XCTAssertTrue(waitForPageButtonCount(2, app: app, timeout: 8.0)) - guard let secondPageToken = activePageToken(in: app) else { - XCTFail("Expected created page to become active") - return - } - XCTAssertNotEqual(secondPageToken, firstPageToken) - XCTAssertTrue(waitForElementVisible(app.staticTexts["titlebarPageHint.2"], timeout: 6.0)) - - let firstPageButton = app.buttons["titlebarPageButton.\(firstPageToken)"] - XCTAssertTrue(waitForElementExists(firstPageButton, timeout: 6.0)) - firstPageButton.click() - - XCTAssertTrue(waitForActivePageToken(firstPageToken, app: app, timeout: 6.0)) - - let closeButton = app.buttons["titlebarPageCloseButton.\(firstPageToken)"] - XCTAssertTrue(waitForElementExists(closeButton, timeout: 6.0)) - XCTAssertTrue(clickElementHandlingInterruptions( - closeButton, - app: app, - successCondition: { self.waitForPageButtonCount(1, app: app, timeout: 1.0) } - )) - - XCTAssertTrue(waitForPageButtonCount(1, app: app, timeout: 8.0)) - XCTAssertTrue(waitForActivePageToken(secondPageToken, app: app, timeout: 6.0)) - } - - private func configuredApp() -> XCUIApplication { - let app = XCUIApplication() - app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" - app.launchEnvironment["CMUX_TAG"] = launchTag - app.launchEnvironment["CMUX_UI_TEST_SKIP_CONFIRM_CLOSE_PAGE"] = "1" - app.launchArguments += ["-shortcutHintAlwaysShow", "YES"] - app.launchArguments += ["-shortcutHintTitlebarXOffset", "4"] - app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"] - return app - } - - private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { - if app.wait(for: .runningForeground, timeout: timeout) { - return true - } - if app.state == .runningBackground { - app.activate() - return app.wait(for: .runningForeground, timeout: 6.0) - } - return false - } - - private func waitForPageButtonCount(_ count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - workspacePagesPollUntil(timeout: timeout) { - pageButtons(in: app).count == count - } - } - - private func waitForActivePageToken(_ token: String, app: XCUIApplication, timeout: TimeInterval) -> Bool { - workspacePagesPollUntil(timeout: timeout) { - activePageToken(in: app) == token - } - } - - private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - workspacePagesPollUntil(timeout: timeout) { - guard element.exists else { return false } - let frame = element.frame - return frame.width > 1 && frame.height > 1 - } - } - - private func waitForElementExists(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - workspacePagesPollUntil(timeout: timeout) { - element.exists - } - } - - private func clickElementHandlingInterruptions( - _ element: XCUIElement, - app: XCUIApplication, - attempts: Int = 2, - successCondition: () -> Bool - ) -> Bool { - for attempt in 0..<attempts { - dismissNotificationCenterIfPresent() - if app.state != .runningForeground { - app.activate() - _ = app.wait(for: .runningForeground, timeout: 4.0) - } - guard element.exists else { return false } - element.click() - if successCondition() { - return true - } - let dismissedInterruption = dismissNotificationCenterIfPresent() - if successCondition() { - return true - } - guard dismissedInterruption, attempt + 1 < attempts else { continue } - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - } - return successCondition() - } - - @discardableResult - private func dismissNotificationCenterIfPresent() -> Bool { - let notificationCenter = XCUIApplication(bundleIdentifier: "com.apple.UserNotificationCenter") - let dialog = notificationCenter.dialogs.firstMatch - if dialog.exists || dialog.waitForExistence(timeout: 0.2) { - return Self.dismissInterruptingDialog(dialog) - } - let sheet = notificationCenter.sheets.firstMatch - if sheet.exists || sheet.waitForExistence(timeout: 0.2) { - return Self.dismissInterruptingDialog(sheet) - } - return false - } - - private static func dismissInterruptingDialog(_ dialog: XCUIElement) -> Bool { - let preferredButtonIDs = [ - "Close", - "Dismiss", - "Clear", - "Later", - "Not Now", - "OK", - "Cancel", - "action-button-3", - "action-button-2", - "action-button-1", - "action-button-0", - ] - for buttonID in preferredButtonIDs { - let button = dialog.buttons[buttonID] - if button.exists { - button.click() - return true - } - } - let buttons = dialog.descendants(matching: .button).allElementsBoundByIndex - if let fallback = buttons.reversed().first(where: { $0.exists && $0.isHittable }) { - fallback.click() - return true - } - if let fallback = buttons.first(where: { $0.exists }) { - fallback.click() - return true - } - return false - } - - private func activePageToken(in app: XCUIApplication) -> String? { - let query = activePageButtons(in: app) - guard query.count == 1 else { return nil } - return pageToken(from: query.element(boundBy: 0).identifier) - } - - private func pageButtons(in app: XCUIApplication) -> XCUIElementQuery { - let predicate = NSPredicate(format: "identifier BEGINSWITH %@", "titlebarPageButton.") - return app.descendants(matching: .button).matching(predicate) - } - - private func activePageButtons(in app: XCUIApplication) -> XCUIElementQuery { - let predicate = NSPredicate(format: "identifier BEGINSWITH %@", "titlebarPageButton.active.") - return app.descendants(matching: .button).matching(predicate) - } - - private func pageToken(from identifier: String) -> String? { - if identifier.hasPrefix("titlebarPageButton.active.") { - return String(identifier.dropFirst("titlebarPageButton.active.".count)) - } - if identifier.hasPrefix("titlebarPageButton.") { - return String(identifier.dropFirst("titlebarPageButton.".count)) - } - return nil - } -} diff --git a/docs/workspace-pages-spec.md b/docs/workspace-pages-spec.md deleted file mode 100644 index 709e2251..00000000 --- a/docs/workspace-pages-spec.md +++ /dev/null @@ -1,453 +0,0 @@ -# Workspace Pages Spec - -Last updated: March 7, 2026 -Related issue: https://github.com/manaflow-ai/cmux/issues/569 - -## Problem - -Today a workspace owns exactly one Bonsplit layout. That forces users to either: - -1. Keep editor or database panes full-width in a separate workspace. -2. Keep everything in one workspace and accept squeezed layouts. - -The requested hierarchy is: - -1. `workspace` -2. `page` -3. `pane` -4. `surface` - -A workspace stays "one project or repo". Pages let that project hold multiple full-layout views in the same workspace. - -## Naming - -Recommended public name: `page` - -Canonical terms: - -1. `workspace`: the vertical sidebar item for a project/task. -2. `page`: a titlebar-level layout inside a workspace. -3. `pane`: a split region inside a page. -4. `surface`: a tab inside a pane. - -Why `page`: - -1. `tab` is already overloaded in cmux for workspaces, Bonsplit tabs, and browser tabs. -2. `layout` sounds static, but this object is navigated, renamed, closed, and reordered. -3. `scene` is distinctive but reads too novel for a terminal app. -4. `page` is short, works in menus and shortcuts, and fits the titlebar text-strip UI. - -Rejected names for now: - -1. `workspace tab` -2. `top-level tab` -3. `scene` -4. `layout` - -## Product Shape - -Each workspace owns an ordered list of pages. Each page owns one full Bonsplit tree plus its page-local focus state. - -The active page is shown in a horizontal titlebar strip. Switching pages swaps the active Bonsplit layout without changing the selected workspace. - -The sidebar continues to represent workspaces only. V1 does not add a second sidebar layer. - -## Implementation Status - -Implemented on this branch: - -1. `Workspace` now owns an ordered `pages` list plus an active page selection. -2. Page order, titles, active selection, and page-local layouts persist through session restore. -3. The fake titlebar now shows a horizontal page strip instead of the folder icon. -4. The page `+` is hover-only, pinned on the far right, and does not steal drag space while hidden. -5. Page close-button visibility follows the active/hover rules in the titlebar strip. -6. Page context menus support create, duplicate, rename, close, close others, move left, and move right. -7. Page switching detaches inactive Ghostty and WKWebView-backed panels from the live hierarchy instead of killing PTYs or browser state. -8. Holding Option reveals direct-select page shortcut badges in the titlebar strip, using the existing shortcut-hint pattern. -9. Customizable page shortcuts exist in `KeyboardShortcutSettings`, and the default bindings are wired through app-level shortcut handling. -10. `Cmd+Shift+P` exposes page create, duplicate, rename, close, close others, next/previous, move left/right, and direct page selection commands. -11. The app menu exposes page create, duplicate, rename, close, close others, move left/right, next/previous, and direct page selection actions. -12. The page strip supports drag-and-drop reordering with horizontal auto-scroll. -13. Socket v2 page APIs exist for list/current/create/duplicate/select/rename/close/reorder/next/previous/last. -14. CLI page commands exist for `list-pages`, `new-page`, `duplicate-page`, `current-page`, `select-page`, `rename-page`, `close-page`, `reorder-page`, `next-page`, `previous-page`, and `last-page`. -15. `system.identify` now includes focused page identity via `page_id`, `page_ref`, `page_index`, and `page_title`. -16. `system.tree` and `cmux tree` now render `workspace -> page -> pane -> surface`, while keeping the selected page mirrored into the legacy workspace-level `panes` field for older consumers. -17. Unit coverage now exists for page drag-drop planner behavior, page-strip autoscroll planning, page persistence round-trips, shortcut routing, duplicate-page structure preservation, active-page close-neighbor selection, runtime page detach/reattach identity across switches, and the v2 JSON page/tree path for `page.list`, `page.select`, `page.current`, and `system.tree`. -18. Dedicated UI automation now exists for the titlebar page strip create/select/close and shortcut-hint flow. -19. A `tests_v2` regression now exists for external CLI and socket page parity across create, select, reorder, current, last, and close flows. - -Not implemented yet: - -1. The deeper model refactor where each page owns its own `bonsplitController` and live panel map directly. -2. CI execution and stabilization for the new page UI automation and external page API regressions still needs to be wired and kept green on this branch. - -## Titlebar UX - -Replace the current folder icon and single titlebar label area with a text-only page strip. - -V1 strip rules: - -1. Page items render as text only. -2. The active page is visually distinct and keeps its close button visible. -3. Inactive pages reveal their close button on hover. -4. When a workspace has only one page, the active page still reserves the close slot, but the close button is disabled so a workspace never reaches zero pages. -5. A page `+` control sits at the far right of the fake titlebar lane, outside the scrollable page list. -6. Right click on a page opens its context menu. -7. Empty titlebar space remains draggable. -8. Holding Option should reveal the direct-select shortcut labels for visible pages, using the existing shortcut-hint pattern instead of adding permanent chrome. -9. The page `+` control is only visible while hovering the fake titlebar. - -The current titlebar folder icon goes away in V1. `Open Folder` remains available through existing menu, command palette, and shortcut paths. - -## Page UI Detail - -The page strip should feel like part of the macOS titlebar, not like a second toolbar. - -Visual direction: - -1. Text-first, not boxed tabs. -2. No persistent pill backgrounds, segmented control borders, or folder/file chrome. -3. Typography should be close to the current titlebar label treatment, with the active page using stronger weight and opacity. -4. Hover can add a very light background wash, but the default state should read as text in the titlebar. - -Titlebar layout: - -1. Traffic lights stay where they are now. -2. The page strip replaces the current folder-icon-plus-title area. -3. Existing titlebar controls on the trailing side stay separate from the page strip. -4. The strip should consume available width before squeezing the trailing controls. -5. Any leftover titlebar gap outside page hit targets remains window-drag space. -6. The page list itself is a scrollable lane. -7. The page `+` control is pinned to the far right of the fake titlebar lane and is not part of the scrolling content. - -Page item anatomy: - -1. Page title text. -2. Reserved close-button slot on the trailing edge of the item. -3. Hover/active hit area large enough to be easy to target in the titlebar. - -Page item state rules: - -1. Active page: - - stronger text weight - - higher contrast - - close button always visible -2. Inactive page: - - lighter text treatment - - close button hidden until hover -3. Hovered page: - - subtle background wash is allowed - - close button becomes visible -4. Pressed page: - - same layout, just a stronger hover/pressed wash -5. Single remaining page: - - keeps the close slot visible for layout stability - - close button is disabled - -Close-button behavior: - -1. Use an `x` or close glyph sized for titlebar density, not a large filled control. -2. The close button must not shift page text when it appears. -3. Clicking the close button closes only that page. -4. Closing the active page selects the nearest surviving neighbor, preferring the page to the right. - -Sizing and truncation: - -1. Single-line titles only. -2. Tail truncation when a title is too long. -3. Each page item keeps a stable minimum clickable width even for short names. -4. The active page gets slightly higher layout priority before truncation. - -Overflow behavior: - -1. The strip stays single-row and never wraps. -2. When pages exceed available width, the strip becomes horizontally scrollable. -3. Selecting, creating, or moving to a page should auto-scroll it into view. -4. The pinned page `+` control stays visible on the far right while the page list scrolls underneath its own lane. -5. Leading and trailing fade hints are acceptable if needed, but V1 should avoid adding heavy chrome. - -Interaction details: - -1. Left click selects the page. -2. Right click opens the page context menu for the clicked page. -3. Right click should not require activating the page first. -4. Double click rename can wait until later; V1 can use menu, command palette, and shortcut-driven rename only. -5. The context menu and close button must not break titlebar drag behavior outside their hit regions. -6. The fake titlebar should still drag the window anywhere that is not an actual page hit target or the visible page `+` hit target. - -Creation affordance: - -1. The page `+` control is pinned to the far right of the fake titlebar lane. -2. It should visually match the text-first style instead of looking like a toolbar button. -3. It is hidden by default. -4. It fades in only while hovering the fake titlebar region. -5. When hidden, that area should behave like normal titlebar drag space rather than a dead zone. -6. Only the visible glyph and its small padded hit target become clickable. -7. It should stay easy to hit without competing with the existing `New Workspace` titlebar control. - -Tooltips and hints: - -1. Hovering a page should show the full page title when truncated. -2. Hovering the `+` affordance should show `New Page` plus its effective shortcut. -3. Holding Option should show page-index shortcut hints in the strip, following the same “hold modifier to reveal hints” idea already used elsewhere in cmux. - -## Page Behavior - -Each page preserves its own: - -1. Split topology. -2. Surface order inside each pane. -3. Focused pane. -4. Selected surface per pane. -5. Scrollback and restore state already tracked by the current workspace/session model. - -Workspace-level state remains shared: - -1. Sidebar row identity and ordering. -2. Workspace name and color. -3. Notification aggregation and unread state. -4. Workspace-level commands such as rename, move, and close workspace. - -For single-value sidebar metadata in V1, use the active page as the source of truth. We can revisit cross-page aggregation later if this feels misleading. - -## Efficiency And Lifecycle - -Pages should not behave like multiple fully mounted workspaces stacked on top of each other. - -Lifecycle policy: - -1. Only the active page in the selected workspace keeps its Ghostty terminal views and WKWebViews mounted in the live window hierarchy. -2. When a page becomes inactive, its terminal portal views and browser portal views should be hidden or detached through the same kind of unmount path cmux already uses for workspace switches. -3. Switching pages must not kill PTYs, throw away scrollback, or reload browser state just because the page is inactive. -4. Re-activating a page should reattach its existing panels instead of reconstructing the whole layout from scratch. -5. Hidden pages should not keep participating in hit testing, layout, or display-driven redraw work. -6. Rapid workspace switching must also hide portal-hosted views for superseded retiring workspaces immediately, so deferred handoff cleanup cannot leave stale terminal or browser portals alive after churn. - -Performance rule: - -1. There should never be more than one visible page worth of portal-hosted Ghostty surfaces or WKWebViews for a workspace at once. -2. The selected page should remount fast enough that page switches feel like view changes, not restore flows. -3. If later measurement shows browser-heavy workspaces still consume too much memory, add a follow-on cold-parking policy for long-idle pages instead of forcing that complexity into the first implementation. - -## Commands And Shortcuts - -Required page actions: - -1. `New Page` -2. `Rename Page` -3. `Close Page` -4. `Close Other Pages` -5. `Next Page` -6. `Previous Page` -7. `Select Page 1` through `Select Page 8` -8. `Select Last Page` -9. `Duplicate Page` -10. `Move Page Left` -11. `Move Page Right` - -Default shortcuts: - -1. `Command+Option+N`: new page. -2. `Command+Option+R`: rename page. -3. `Command+Option+W`: close page. -4. `Option+1` through `Option+8`: select page by index. -5. `Option+9`: select the last page. -6. `Option+]`: next page. -7. `Option+[`: previous page. - -All page shortcuts must be first-class `KeyboardShortcutSettings` actions so they appear in Settings and can be customized. - -The same actions should also appear in the command palette and the app menu. - -Implementation note: - -Direct page selection should route by physical digit intent, not by text produced after Option modifies the character, so `Option+digit` keeps working across keyboard layouts. - -## Cmd+Shift+P Commands - -`Cmd+Shift+P` should expose page actions as first-class commands, not as hidden side effects. - -Required command-palette entries: - -1. `New Page` -2. `Duplicate Page` -3. `Rename Page…` -4. `Close Page` -5. `Close Other Pages` -6. `Next Page` -7. `Previous Page` -8. `Move Page Left` -9. `Move Page Right` -10. `Select Page <title>` - -Command-palette behavior: - -1. `Rename Page…` should use the same inline rename flow style already used for rename-oriented palette actions. -2. Page commands should resolve against the active window, active workspace, and selected page unless the command explicitly targets another page. -3. Palette results should show current shortcut hints where they exist. -4. Dynamic `Select Page <title>` results should make it easy to jump directly to any page even when there are more than nine. - -## Context Menu - -Right-clicking a page should expose: - -1. `New Page` -2. `Rename Page…` -3. `Move Left` -4. `Move Right` -5. `Close Page` -6. `Close Other Pages` - -Current branch status: - -1. Implemented. - -## Drag And Drop Reordering - -The page strip should support drag-and-drop reordering, not just menu-based movement. - -Required behavior: - -1. Dragging starts from the page item, not from its close button. -2. The reorder indicator should be a single insertion gap, similar to the sidebar workspace reordering model. -3. If the strip is horizontally scrolled, dragging near the left or right edge should auto-scroll it. -4. Dragging a page must never drag the window. -5. Reordering stays within the current workspace in V1. -6. Context-menu move actions remain as keyboard and accessibility fallback. - -Current branch status: - -1. Implemented. - -## Page Naming - -V1 default names: - -1. `Page 1` -2. `Page 2` -3. `Page 3` - -User rename is the primary naming path. Automatic labels based on the active process or focused surface can be added later if the default names feel too generic. - -## Model Direction - -The current `Workspace` object in `Sources/Workspace.swift` still mixes project-level identity with page-level layout state. - -Long-term direction: - -1. Keep `Workspace` as the sidebar/project container. -2. Add a `WorkspacePage` model under `Workspace`. -3. Move `bonsplitController` into `WorkspacePage`. -4. Move page-local `panels` into `WorkspacePage`. -5. Move page-local focus and selected-surface state into `WorkspacePage`. -6. Move page-local session snapshot data into `WorkspacePage`. -7. Keep workspace-level sidebar and metadata state on `Workspace`. - -This is the cleanest long-term shape for `workspace -> page -> pane -> surface`. - -Current branch status: - -1. Only the first two steps are implemented. -2. The branch intentionally keeps the existing single `bonsplitController` on `Workspace` and swaps page state in and out around it. - -## Persistence - -Session restore should persist: - -1. page order -2. selected page per workspace -3. each page's Bonsplit snapshot -4. page custom titles - -Workspace restore should reopen the last selected page, then restore page-local focus within that page. - -Current branch status: - -1. Implemented. - -## Socket And CLI APIs - -Pages need first-class API support because cmux is scriptable and page state will sit between workspace and pane. - -Implemented v2 API surface: - -1. `page.list` -2. `page.current` -3. `page.create` -4. `page.duplicate` -5. `page.select` -6. `page.rename` -7. `page.close` -8. `page.reorder` -9. `page.next` -10. `page.previous` -11. `page.last` - -Identity and targeting: - -1. `system.identify` includes `focused.page_id`, `focused.page_ref`, `focused.page_index`, and `focused.page_title`. -2. Short refs support `page:<n>`. -3. Commands that target panes or surfaces without an explicit page should resolve against the currently selected page in the targeted workspace. - -Implemented CLI surface: - -1. `list-pages [--workspace <id|ref>]` -2. `current-page [--workspace <id|ref>]` -3. `new-page [--workspace <id|ref>] [--title <text>]` -4. `duplicate-page [--workspace <id|ref>] [--page <id|ref>] [--title <text>]` -5. `select-page --page <id|ref|index> [--workspace <id|ref>]` -6. `rename-page [--workspace <id|ref>] [--page <id|ref>] <title>` -7. `close-page [--page <id|ref>] [--workspace <id|ref>]` -8. `reorder-page --page <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--workspace <id|ref>]` -9. `next-page [--workspace <id|ref>]` -10. `previous-page [--workspace <id|ref>]` -11. `last-page [--workspace <id|ref>]` - -## Non-Goals For V1 - -1. Page-level badges, git metadata, or notification chips in the titlebar strip. -2. Cross-workspace page moves. -3. Nested page groups. -4. Aggressively destroying inactive PTYs or browser sessions on every page switch. - -## Acceptance Criteria - -The first implementation should feel complete if all of this is true: - -1. A workspace can hold multiple pages with independent pane/tab layouts. -2. The titlebar strip replaces the folder icon area and is usable with mouse only. -3. `Option+1..9` works by default and is customizable in Settings. -4. Right click works on page items without breaking window dragging or terminal focus. -5. Active-page close button visibility matches the rules above. -6. Inactive pages unmount from the live UI so only the active page's terminal and browser views stay mounted. -7. Drag-and-drop page reordering works, including edge auto-scroll for overflowed strips. -8. `Cmd+Shift+P` exposes page commands and inline rename behavior. -9. Socket and CLI page APIs exist, including `system.identify` page context. -10. App relaunch restores page order, selection, and layout. -11. Existing workspace and pane navigation continue to behave as before. - -Current branch status: - -1. The V1 acceptance list is implemented. -2. The remaining work is follow-on coverage and the deeper per-page controller refactor described above. - -## Test Expectations - -Once implementation starts, add coverage for: - -1. titlebar hit testing, page item interaction, and empty-space drag behavior -2. page switching preserving per-page Bonsplit state -3. `Option+1..9` routing, including `9 -> last` -4. custom shortcut overrides for page actions -5. `Cmd+Shift+P` page commands and rename flow -6. page context menu actions -7. inactive-page terminal and browser unmount behavior -8. page drag reordering, including overflow auto-scroll -9. session restore of page order and selected page -10. socket and CLI page commands, including `system.identify` page fields - -Current branch status: - -1. Unit coverage now exists for page persistence round-trips and page shortcut routing, including `Option+9 -> last page`, `Option+]`, `Cmd+Option+N`, and symbol-first layout fallback for page shortcuts. -2. Unit coverage also exists for duplicate-page structure preservation and active-page close-neighbor selection. -3. UI and end-to-end coverage for titlebar hit testing, drag behavior, and page lifecycle still needs to be added. diff --git a/tests_v2/test_page_cli_socket_parity.py b/tests_v2/test_page_cli_socket_parity.py deleted file mode 100644 index 4d53bd3c..00000000 --- a/tests_v2/test_page_cli_socket_parity.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: page CLI and socket v2 stay in sync.""" - -import glob -import json -import os -import subprocess -import sys -from pathlib import Path -from typing import Dict, List, Tuple - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli(cli: str, args: List[str], json_output: bool) -> str: - env = dict(os.environ) - env.pop("CMUX_WORKSPACE_ID", None) - env.pop("CMUX_SURFACE_ID", None) - env.pop("CMUX_TAB_ID", None) - - cmd = [cli, "--socket", SOCKET_PATH] - if json_output: - cmd.append("--json") - cmd.extend(args) - - proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) - if proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") - return proc.stdout - - -def _run_cli_json(cli: str, args: List[str]) -> Dict: - output = _run_cli(cli, args, json_output=True) - try: - return json.loads(output or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") - - -def _page_titles_and_selected(payload: Dict) -> Tuple[List[str], List[str]]: - pages = payload.get("pages") or [] - titles = [str(page.get("title") or "") for page in pages] - selected = [str(page.get("title") or "") for page in pages if bool(page.get("selected"))] - return titles, selected - - -def _workspace_node(tree: Dict, workspace_id: str) -> Dict: - windows = tree.get("windows") or [] - for window in windows: - for workspace in window.get("workspaces") or []: - if str(workspace.get("id") or "") == workspace_id: - return workspace - raise cmuxError(f"Workspace {workspace_id} not present in system.tree: {tree}") - - -def main() -> int: - cli = _find_cli_binary() - - help_text = _run_cli(cli, ["list-pages", "--help"], json_output=False) - _must("page:<n>" in help_text, "list-pages --help should mention page:<n> refs") - _must("current-page" in help_text, "list-pages --help should mention related page commands") - - with cmux(SOCKET_PATH) as c: - created = c._call("workspace.create", {}) or {} - workspace_id = str(created.get("workspace_id") or "") - _must(bool(workspace_id), f"workspace.create returned no workspace_id: {created}") - - try: - c._call("workspace.select", {"workspace_id": workspace_id}) - - initial = c._call("page.current", {"workspace_id": workspace_id}) or {} - first_page_id = str(initial.get("page_id") or "") - first_page_ref = str(initial.get("page_ref") or "") - _must(bool(first_page_id) and bool(first_page_ref), f"page.current returned no initial page handle: {initial}") - - renamed = _run_cli_json( - cli, - ["rename-page", "--workspace", workspace_id, "--page", first_page_ref, "agents"], - ) - _must(str(renamed.get("page_id") or "") == first_page_id, f"rename-page targeted wrong page: {renamed}") - _must(str(renamed.get("page_title") or "") == "agents", f"rename-page did not set title: {renamed}") - - created_page = _run_cli_json( - cli, - ["new-page", "--workspace", workspace_id, "--title", "editor"], - ) - second_page_id = str(created_page.get("page_id") or "") - second_page_ref = str(created_page.get("page_ref") or "") - _must( - bool(second_page_id) and second_page_id != first_page_id, - f"new-page did not create a distinct page: {created_page}", - ) - _must(str(created_page.get("page_title") or "") == "editor", f"new-page did not set title: {created_page}") - - listed = c._call("page.list", {"workspace_id": workspace_id}) or {} - titles, selected_titles = _page_titles_and_selected(listed) - _must(titles == ["agents", "editor"], f"page.list returned unexpected titles after create: {listed}") - _must(selected_titles == ["editor"], f"page.list should report editor selected after create: {listed}") - _must(str(listed.get("page_id") or "") == second_page_id, f"page.list should mirror active page: {listed}") - - selected = _run_cli_json( - cli, - ["select-page", "--workspace", workspace_id, "--page", first_page_ref], - ) - _must(str(selected.get("page_id") or "") == first_page_id, f"select-page targeted wrong page: {selected}") - - current_after_select = c._call("page.current", {"workspace_id": workspace_id}) or {} - _must( - str(current_after_select.get("page_id") or "") == first_page_id, - f"page.current disagrees with select-page: {current_after_select}", - ) - - duplicated = _run_cli_json( - cli, - ["duplicate-page", "--workspace", workspace_id, "--page", first_page_ref, "--title", "database"], - ) - duplicate_page_id = str(duplicated.get("page_id") or "") - duplicate_page_ref = str(duplicated.get("page_ref") or "") - _must( - bool(duplicate_page_id) and duplicate_page_id not in {first_page_id, second_page_id}, - f"duplicate-page did not create a distinct page: {duplicated}", - ) - _must(str(duplicated.get("page_title") or "") == "database", f"duplicate-page did not set title: {duplicated}") - - reordered = c._call( - "page.reorder", - {"workspace_id": workspace_id, "page_id": duplicate_page_id, "index": 0}, - ) or {} - _must(int(reordered.get("page_index", -1)) == 0, f"page.reorder did not move page to index 0: {reordered}") - - tree = c._call("system.tree", {"workspace_id": workspace_id}) or {} - workspace = _workspace_node(tree, workspace_id) - tree_titles = [str(page.get("title") or "") for page in (workspace.get("pages") or [])] - _must( - tree_titles == ["database", "agents", "editor"], - f"system.tree page order did not match reorder result: {workspace}", - ) - _must( - str(workspace.get("selected_page_id") or "") == duplicate_page_id, - f"system.tree selected page did not mirror active duplicated page: {workspace}", - ) - - last_page = c._call("page.last", {"workspace_id": workspace_id}) or {} - _must(str(last_page.get("page_id") or "") == second_page_id, f"page.last should select editor: {last_page}") - - current_cli = _run_cli_json(cli, ["current-page", "--workspace", workspace_id]) - _must( - str(current_cli.get("page_id") or "") == second_page_id, - f"current-page CLI should agree with page.last: {current_cli}", - ) - - closed = _run_cli_json( - cli, - ["close-page", "--workspace", workspace_id, "--page", duplicate_page_ref], - ) - _must(str(closed.get("page_id") or "") == duplicate_page_id, f"close-page closed wrong page: {closed}") - _must( - str(closed.get("selected_page_id") or "") == first_page_id, - f"close-page should select the nearest surviving neighbor after closing the leftmost active page: {closed}", - ) - - final_list = _run_cli_json(cli, ["list-pages", "--workspace", workspace_id]) - final_titles, final_selected = _page_titles_and_selected(final_list) - _must(final_titles == ["agents", "editor"], f"list-pages should reflect closed duplicate page: {final_list}") - _must(final_selected == ["agents"], f"list-pages should report agents selected after close: {final_list}") - _must(str(final_list.get("page_id") or "") == first_page_id, f"list-pages active page mismatch after close: {final_list}") - _must( - second_page_ref.startswith("page:"), - f"new-page should return a page ref handle: {created_page}", - ) - finally: - try: - c.close_workspace(workspace_id) - except Exception: - pass - - print("PASS: page CLI and socket APIs stay consistent across create/select/reorder/close flows") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())