Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ All notable changes to cmux are documented here.
- iOS (beta): Version 1.0.6 shows a one-time notice with the minimum Mac versions it needs, then opens the Mac pairing guide ([#14112](https://github.com/manaflow-ai/cmux/pull/14112)) -- thanks @azooz2003-bit!
- iOS (beta): Settings > Reset > Erase All Data on This Device signs out and returns the app to a fresh-install state ([#14140](https://github.com/manaflow-ai/cmux/pull/14140))
- Settings > App > Warn Before Closing Workspace (`app.warnBeforeClosingWorkspace`, on by default) turns off the "Close workspace?" prompts; pinned workspaces still ask ([#14979](https://github.com/manaflow-ai/cmux/pull/14979))
- Close confirmation dialogs for tabs, panes and workspaces have a "Don’t ask again" checkbox that turns off the warning behind that dialog; "Close pinned workspace?" still always asks ([#15052](https://github.com/manaflow-ai/cmux/pull/15052))

### Changed
- Each Settings toggle and picker row shows one fixed subtitle instead of text that changes with the selected value, and localized Settings titles and Feed, Dock, and Cloud Machines labels use corrected wording ([#14883](https://github.com/manaflow-ai/cmux/pull/14883)) -- thanks @agoodkind!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,36 @@ public protocol CloseTabWarningReading: Sendable {
}

extension CloseTabWarningReading {
/// Whether closing should show a confirmation dialog, combining the
/// caller's per-tab `requiresConfirmation` state with the warning
/// toggles per ``CloseTabCloseSource``.
/// The warning toggles that make this close ask first; empty when it
/// closes without a dialog. A dialog's "Don't ask again" checkbox turns
/// off exactly these.
///
/// Semantics are kept verbatim from the legacy
/// `CloseTabConfirmationPolicy` namespace: the shortcut path warns only
/// when the tab requires confirmation and the shortcut warning is
/// enabled; the X-button path additionally warns whenever the X-button
/// warning is enabled, regardless of the tab's state.
public func warningKinds(
requiresConfirmation: Bool,
source: CloseTabCloseSource
) -> CloseWarningKinds {
var kinds: CloseWarningKinds = []
if requiresConfirmation && warnsBeforeClosingTab {
kinds.insert(.tab)
}
if source == .tabCloseButton && warnsBeforeClosingTabXButton {
kinds.insert(.tabCloseButton)
}
return kinds
}

/// Whether closing should show a confirmation dialog, combining the
/// caller's per-tab `requiresConfirmation` state with the warning
/// toggles per ``CloseTabCloseSource``.
public func shouldConfirmClose(
requiresConfirmation: Bool,
source: CloseTabCloseSource
) -> Bool {
switch source {
case .shortcut:
return requiresConfirmation && warnsBeforeClosingTab
case .tabCloseButton:
return warnsBeforeClosingTabXButton
|| (requiresConfirmation && warnsBeforeClosingTab)
}
!warningKinds(requiresConfirmation: requiresConfirmation, source: source).isEmpty
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Foundation

/// Repository for the close-tab warning settings, persisted in
/// `UserDefaults` under the catalog's `app.warnBeforeClosingTab`,
/// `app.warnBeforeClosingTabXButton`, and `app.hideTabCloseButton` keys.
/// Repository for the close warning settings, persisted in `UserDefaults`
/// under the catalog's `app.warnBeforeClosingTab`,
/// `app.warnBeforeClosingTabXButton`, `app.warnBeforeClosingWorkspace`, and
/// `app.hideTabCloseButton` keys.
///
/// Isolation: a stateless `Sendable` struct, not an actor. Every reader is
/// synchronous code that cannot await (close-shortcut handling, tab chrome
Expand Down Expand Up @@ -34,4 +35,22 @@ public struct CloseTabWarningStore: CloseTabWarningReading {
public func setWarnsBeforeClosingTab(_ isEnabled: Bool) {
keys.warnBeforeClosingTab.set(isEnabled, in: defaults)
}

/// Whether "Close workspace?" prompts are enabled.
public var warnsBeforeClosingWorkspace: Bool {
keys.warnBeforeClosingWorkspace.value(in: defaults)
}

/// Turns off the given warnings, for a dialog's "Don't ask again" checkbox.
public func disableWarnings(_ kinds: CloseWarningKinds) {
if kinds.contains(.tab) {
keys.warnBeforeClosingTab.set(false, in: defaults)
}
if kinds.contains(.tabCloseButton) {
keys.warnBeforeClosingTabXButton.set(false, in: defaults)
}
if kinds.contains(.workspace) {
keys.warnBeforeClosingWorkspace.set(false, in: defaults)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// The close-warning toggles behind a close confirmation dialog.
///
/// A dialog's "Don't ask again" checkbox turns off exactly these toggles, so
/// the same close would not ask the next time.
public struct CloseWarningKinds: OptionSet, Sendable, Hashable {
public let rawValue: Int

public init(rawValue: Int) {
self.rawValue = rawValue
}

/// `app.warnBeforeClosingTab`: tabs that would lose a running process.
public static let tab = CloseWarningKinds(rawValue: 1 << 0)

/// `app.warnBeforeClosingTabXButton`: every close from a tab's X button.
public static let tabCloseButton = CloseWarningKinds(rawValue: 1 << 1)

/// `app.warnBeforeClosingWorkspace`: the "Close workspace?" prompts.
public static let workspace = CloseWarningKinds(rawValue: 1 << 2)
}
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,31 @@ struct CloseTabConfirmationPolicyTests {
#expect(!bothOff.shouldConfirmClose(requiresConfirmation: false, source: .tabCloseButton))
}

@Test func warningKindsNameEveryToggleBehindAPrompt() {
let both = FixedWarnings(warnsBeforeClosingTab: true, warnsBeforeClosingTabXButton: true)
#expect(both.warningKinds(requiresConfirmation: true, source: .shortcut) == [.tab])
#expect(both.warningKinds(requiresConfirmation: false, source: .shortcut) == [])
#expect(both.warningKinds(requiresConfirmation: true, source: .tabCloseButton) == [.tab, .tabCloseButton])
#expect(both.warningKinds(requiresConfirmation: false, source: .tabCloseButton) == [.tabCloseButton])
}

@Test func disableWarningsTurnsOffOnlyTheGivenToggles() {
let defaults = makeScratchDefaults()
let store = CloseTabWarningStore(defaults: defaults)
defaults.set(true, forKey: "warnBeforeClosingTabXButton")

store.disableWarnings([.tabCloseButton, .workspace])

#expect(store.warnsBeforeClosingTab)
#expect(!store.warnsBeforeClosingTabXButton)
#expect(!store.warnsBeforeClosingWorkspace)
#expect(defaults.object(forKey: "warnBeforeClosingWorkspace") as? Bool == false)

store.disableWarnings([.tab])
#expect(!store.warnsBeforeClosingTab)
#expect(defaults.object(forKey: "warnBeforeClosingTabShortcut") as? Bool == false)
}

@Test func liveStoreReadsTogglesFromDefaults() {
let defaults = makeScratchDefaults()
defaults.set(false, forKey: "warnBeforeClosingTabShortcut")
Expand Down
65 changes: 65 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -176476,6 +176476,71 @@
}
}
},
"dialog.close.dontAskAgain": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Don’t ask again"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "لا تسأل مرة أخرى"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Nicht mehr fragen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "No volver a preguntar"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ne plus demander"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "今後確認しない"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "다시 묻지 않기"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Більше не запитувати"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "不再询问"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "不再詢問"
}
}
}
},
"dialog.closeAnchor.message.lone": {
"extractionState": "manual",
"localizations": {
Expand Down
43 changes: 34 additions & 9 deletions Sources/DockSplitStore+CloseConfirmation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ extension DockSplitStore {
let closeWarningStore = CloseTabWarningStore(
defaults: confirmationManager?.closeTabWarningDefaults ?? .standard
)
guard closeWarningStore.shouldConfirmClose(
let warningKinds = closeWarningStore.warningKinds(
requiresConfirmation: dockPanelNeedsConfirmClose(panel),
source: closeSource
) else {
)
guard !warningKinds.isEmpty else {
if closeHistoryEligibleDockTabIds.contains(tab.id) {
stageDockClosedPanelHistory(
tabIds: Set([tab.id]),
Expand Down Expand Up @@ -83,6 +84,7 @@ extension DockSplitStore {
}
guard self.confirmCloseDockPanel(
panel,
dontAskAgain: warningKinds,
confirmationManager: confirmationManager
) else {
self.discardDockClosedPanelHistory(tabId: tabId)
Expand Down Expand Up @@ -120,15 +122,18 @@ extension DockSplitStore {
)
var paneTitles: [String] = []
var confirmableTabIds = Set<TabID>()
var warningKinds: CloseWarningKinds = []
for tab in tabs {
let panel = panel(for: tab.id)
paneTitles.append(CloseOtherTabsConfirmationPrompt.displayTitle(panel?.displayTitle ?? tab.title))
guard userCloseTabIds.contains(tab.id), let panel else { continue }
if closeWarningStore.shouldConfirmClose(
let tabWarningKinds = closeWarningStore.warningKinds(
requiresConfirmation: dockPanelNeedsConfirmClose(panel),
source: .shortcut
) {
)
if !tabWarningKinds.isEmpty {
confirmableTabIds.insert(tab.id)
warningKinds.formUnion(tabWarningKinds)
}
}
guard !confirmableTabIds.isEmpty else {
Expand All @@ -150,6 +155,7 @@ extension DockSplitStore {
defer { self.pendingCloseConfirmDockTabIds.subtract(confirmableTabIds) }
guard self.confirmCloseDockPane(
prompt,
dontAskAgain: warningKinds,
confirmationManager: confirmationManager
) else {
self.discardDockClosedPaneHistory(pane)
Expand Down Expand Up @@ -227,7 +233,11 @@ extension DockSplitStore {
return false
}

private func confirmCloseDockPanel(_ panel: any Panel, confirmationManager: TabManager?) -> Bool {
private func confirmCloseDockPanel(
_ panel: any Panel,
dontAskAgain: CloseWarningKinds,
confirmationManager: TabManager?
) -> Bool {
let title = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
let panelName = panel.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let message: String
Expand All @@ -236,14 +246,24 @@ extension DockSplitStore {
} else {
message = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
}
return confirmCloseDockPrompt(title: title, message: message, confirmationManager: confirmationManager)
return confirmCloseDockPrompt(
title: title,
message: message,
dontAskAgain: dontAskAgain,
confirmationManager: confirmationManager
)
}

private func confirmCloseDockPane(_ prompt: DockPaneCloseConfirmationPrompt, confirmationManager: TabManager?) -> Bool {
private func confirmCloseDockPane(
_ prompt: DockPaneCloseConfirmationPrompt,
dontAskAgain: CloseWarningKinds,
confirmationManager: TabManager?
) -> Bool {
confirmCloseDockPrompt(
title: prompt.title,
message: prompt.message,
scrollableDetails: prompt.details,
dontAskAgain: dontAskAgain,
confirmationManager: confirmationManager
)
}
Expand All @@ -252,14 +272,16 @@ extension DockSplitStore {
title: String,
message: String,
scrollableDetails: String? = nil,
dontAskAgain: CloseWarningKinds,
confirmationManager: TabManager?
) -> Bool {
if let confirmationManager {
return confirmationManager.confirmClose(
title: title,
message: message,
scrollableDetails: scrollableDetails,
acceptCmdD: false
acceptCmdD: false,
dontAskAgain: dontAskAgain
)
}

Expand All @@ -271,6 +293,9 @@ extension DockSplitStore {
let content = scrollableDetails.map {
CmuxAlertContent(flattenedText: message, separatingScrollableDetails: $0)
} ?? CmuxAlertContent(informativeText: message)
return alert.runCmuxModal(content: content) == .alertFirstButtonReturn
CloseDontAskAgainCheckbox.add(to: alert, offering: dontAskAgain)
let accepted = alert.runCmuxModal(content: content) == .alertFirstButtonReturn
CloseDontAskAgainCheckbox.apply(from: alert, offering: dontAskAgain, defaults: .standard)
return accepted
}
}
8 changes: 5 additions & 3 deletions Sources/DockSplitStore+TabContextActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,11 @@ extension DockSplitStore {
let warningStore = CloseTabWarningStore(
defaults: manager?.closeTabWarningDefaults ?? .standard
)
if warningStore.shouldConfirmClose(
let warningKinds = warningStore.warningKinds(
requiresConfirmation: needsConfirmation,
source: .shortcut
) {
)
if !warningKinds.isEmpty {
guard let manager else { return false }
let prompt = CloseOtherTabsConfirmationPrompt(
titles: candidates.map(\.title)
Expand All @@ -178,7 +179,8 @@ extension DockSplitStore {
title: prompt.title,
message: prompt.message,
scrollableDetails: prompt.details,
acceptCmdD: false
acceptCmdD: false,
dontAskAgain: warningKinds
) else {
return true
}
Expand Down
Loading
Loading