Protect pinned workspaces from close actions

This commit is contained in:
austinpower1258 2026-03-20 19:38:24 -07:00
parent d219a41dd0
commit b10cddcb9b
5 changed files with 144 additions and 29 deletions

View file

@ -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": {

View file

@ -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")) {

View file

@ -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<UUID>) {
@ -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

View file

@ -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 {

View file

@ -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)