From b10cddcb9b4de91755c59e6a32475f00ef892d52 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 20 Mar 2026 19:38:24 -0700 Subject: [PATCH] Protect pinned workspaces from close actions --- Resources/Localizable.xcstrings | 34 +++++++++++++++++++ Sources/ContentView.swift | 57 ++++++++++++++++++++++---------- Sources/TabManager.swift | 44 ++++++++++++++++++++---- Sources/TerminalController.swift | 33 ++++++++++++++++-- Sources/cmuxApp.swift | 5 ++- 5 files changed, 144 insertions(+), 29 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 83254ff5..d1f2735f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -62649,6 +62649,23 @@ } } }, + "sidebar.pinnedWorkspaceProtected.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinned workspace protected from closing. Unpin to close." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピン留めされたワークスペースは閉じられません。閉じるにはピンを外してください。" + } + } + } + }, "sidebar.folderIcon.dragHint": { "extractionState": "manual", "localizations": { @@ -74882,6 +74899,23 @@ } } }, + "workspace.closeProtected.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinned workspaces can't be closed while pinned. Unpin the workspace first." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピン留めされたワークスペースは閉じられません。先にピンを外してください。" + } + } + } + }, "workspace.placement.afterCurrent": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15d51723..c196282d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1664,6 +1664,7 @@ struct ContentView: View { static let workspaceHasCustomName = "workspace.hasCustomName" static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" static let workspaceShouldPin = "workspace.shouldPin" + static let workspaceCanClose = "workspace.canClose" static let workspaceHasPullRequests = "workspace.hasPullRequests" static let workspaceHasSplits = "workspace.hasSplits" static let workspaceHasPeers = "workspace.hasPeers" @@ -4949,6 +4950,7 @@ struct ContentView: View { snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) + snapshot.setBool(CommandPaletteContextKeys.workspaceCanClose, tabManager.canCloseWorkspace(workspace)) snapshot.setBool( CommandPaletteContextKeys.workspaceHasPullRequests, !workspace.sidebarPullRequestsInDisplayOrder().isEmpty @@ -5111,7 +5113,8 @@ struct ContentView: View { title: constant(String(localized: "command.closeWorkspace.title", defaultValue: "Close Workspace")), subtitle: constant(String(localized: "command.closeWorkspace.subtitle", defaultValue: "Workspace")), shortcutHint: "⌘⇧W", - keywords: ["close", "workspace"] + keywords: ["close", "workspace"], + enablement: { $0.bool(CommandPaletteContextKeys.workspaceCanClose) } ) ) contributions.append( @@ -10881,6 +10884,10 @@ private struct TabItemView: View, Equatable { var body: some View { let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") + let protectedWorkspaceTooltip = String( + localized: "sidebar.pinnedWorkspaceProtected.tooltip", + defaultValue: "Pinned workspace protected from closing. Unpin to close." + ) let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") @@ -10942,6 +10949,7 @@ private struct TabItemView: View, Equatable { Image(systemName: "pin.fill") .font(.system(size: 9, weight: .semibold)) .foregroundColor(activeSecondaryColor(0.8)) + .safeHelp(protectedWorkspaceTooltip) } Text(tab.title) @@ -10954,21 +10962,30 @@ private struct TabItemView: View, Equatable { Spacer(minLength: 0) ZStack(alignment: .trailing) { - Button(action: { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - }) { - Image(systemName: "xmark") + if tab.isPinned { + Image(systemName: "lock.fill") .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.7)) + .foregroundColor(activeSecondaryColor(0.65)) + .safeHelp(protectedWorkspaceTooltip) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) + } else { + Button(action: { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.7)) + } + .buttonStyle(.plain) + .safeHelp(KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip)) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) + .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) } - .buttonStyle(.plain) - .safeHelp(KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip)) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) - .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) - .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) if showsWorkspaceShortcutHint, let workspaceShortcutLabel { Text(workspaceShortcutLabel) @@ -11289,6 +11306,10 @@ private struct TabItemView: View, Equatable { multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), isMulti: isMulti) + let hasClosableTargets = targetIds.contains { workspaceId in + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return false } + return tabManager.canCloseWorkspace(workspace) + } let markReadLabel = contextMenuLabel( multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), @@ -11427,15 +11448,15 @@ private struct TabItemView: View, Equatable { if let key = closeWorkspaceShortcut.keyEquivalent { Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) + closeTabs(targetIds, allowPinned: false) } .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) - .disabled(targetIds.isEmpty) + .disabled(!hasClosableTargets) } else { Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) + closeTabs(targetIds, allowPinned: false) } - .disabled(targetIds.isEmpty) + .disabled(!hasClosableTargets) } Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf..26bb2ae5 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2201,7 +2201,7 @@ class TabManager: ObservableObject { #endif let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds() if sidebarSelectionIds.count > 1 { - closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true) + closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: false) return } guard let selectedId = selectedTabId, @@ -2209,13 +2209,24 @@ class TabManager: ObservableObject { closeWorkspaceWithConfirmation(workspace) } - func closeWorkspaceWithConfirmation(_ workspace: Workspace) { - closeWorkspaceIfRunningProcess(workspace) + func canCloseWorkspace(_ workspace: Workspace, allowPinned: Bool = false) -> Bool { + allowPinned || !workspace.isPinned } - func closeWorkspaceWithConfirmation(tabId: UUID) { - guard let workspace = tabs.first(where: { $0.id == tabId }) else { return } - closeWorkspaceWithConfirmation(workspace) + @discardableResult + func closeWorkspaceWithConfirmation(_ workspace: Workspace) -> Bool { + guard canCloseWorkspace(workspace) else { + signalProtectedWorkspaceCloseAttempt(workspace) + return false + } + closeWorkspaceIfRunningProcess(workspace) + return true + } + + @discardableResult + func closeWorkspaceWithConfirmation(tabId: UUID) -> Bool { + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return false } + return closeWorkspaceWithConfirmation(workspace) } func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set) { @@ -2225,7 +2236,15 @@ class TabManager: ObservableObject { func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) { let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned) - guard !workspaces.isEmpty else { return } + guard !workspaces.isEmpty else { + if !allowPinned, + workspaceIds.contains(where: { id in + tabs.first(where: { $0.id == id })?.isPinned == true + }) { + signalProtectedWorkspaceCloseAttempt() + } + return + } guard workspaces.count > 1 else { closeWorkspaceWithConfirmation(workspaces[0]) return @@ -2354,6 +2373,17 @@ class TabManager: ObservableObject { } } + private func signalProtectedWorkspaceCloseAttempt(_ workspace: Workspace? = nil) { +#if DEBUG + if let workspace { + dlog("workspace.close.skip workspace=\(workspace.id.uuidString.prefix(5)) reason=pinned") + } else { + dlog("workspace.close.skip reason=pinned") + } +#endif + NSSound.beep() + } + private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan { let willCloseWindow = workspaces.count == tabs.count let title = willCloseWindow diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b7e8c849..06335850 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3436,14 +3436,29 @@ class TerminalController { } var found = false + var protected = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { + guard tabManager.canCloseWorkspace(ws) else { + protected = true + found = true + return + } tabManager.closeWorkspace(ws) found = true } } let windowId = v2ResolveWindowId(tabManager: tabManager) + if protected { + return .err(code: "protected", message: workspaceCloseProtectedMessage(), data: [ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": wsId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), + "pinned": true + ]) + } return found ? .ok([ "window_id": v2OrNull(windowId?.uuidString), @@ -3456,6 +3471,14 @@ class TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) ]) } + + private func workspaceCloseProtectedMessage() -> String { + String( + localized: "workspace.closeProtected.message", + defaultValue: "Pinned workspaces can't be closed while pinned. Unpin the workspace first." + ) + } + private func v2WorkspaceMoveToWindow(params: [String: Any]) -> V2CallResult { guard let wsId = v2UUID(params, "workspace_id") else { return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) @@ -12958,14 +12981,18 @@ class TerminalController { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } guard let uuid = UUID(uuidString: tabId) else { return "ERROR: Invalid tab ID" } - var success = false + var result = "ERROR: Tab not found" DispatchQueue.main.sync { if let tab = tabManager.tabs.first(where: { $0.id == uuid }) { + guard tabManager.canCloseWorkspace(tab) else { + result = "ERROR: \(workspaceCloseProtectedMessage())" + return + } tabManager.closeTab(tab) - success = true + result = "OK" } } - return success ? "OK" : "ERROR: Tab not found" + return result } private func selectWorkspace(_ arg: String) -> String { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index debc6697..608fa08c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -644,6 +644,9 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: closeWorkspaceMenuShortcut) { closeTabOrWindow() } + .disabled( + activeTabManager.selectedWorkspace.map { !activeTabManager.canCloseWorkspace($0) } ?? true + ) Menu(String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace")) { workspaceCommandMenuContent(manager: activeTabManager) @@ -1171,7 +1174,7 @@ struct cmuxApp: App { Button(String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace")) { manager.closeCurrentWorkspaceWithConfirmation() } - .disabled(workspace == nil) + .disabled(workspace.map { !manager.canCloseWorkspace($0) } ?? true) Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { closeOtherSelectedWorkspacePeers(in: manager)