Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,7 @@ extension AppActions {
let lanes = WorkspaceLanes.shared
if invocation.target == nil, invocation["workspace"] == nil, let window,
lanes.closeNewestCreate(in: window, listed: { services.workspace(id: $0) != nil }, taken: PendingWorkspaceRows.shared.remove) { return }
guard let workspace = scope(services, invocation).workspace, let key = workspace.key,
!ClosingWorkspaces.shared.ids.contains(workspace.id), !WorkspaceCloseFlow.running.contains(workspace.id)
guard let workspace = scope(services, invocation).workspace, let key = workspace.key, WorkspaceSelectionCloseFlow.canStart(workspace.id)
else { return } // a second press during the close sends nothing
// A person's close of one of several selected rows closes them all, as the sidebar's Delete does.
if invocation.origin == .user, !invocation.closesRowOnly, let sidebar = services.windows.registry.value.owner(of: workspace.id).flatMap(services.windows.controller(for:))?.sidebar.model,
Expand All @@ -203,7 +202,7 @@ extension AppActions {
let flow = WorkspaceCloseFlow(
// Instant (ClosingWorkspaces): the row starts closing and the selection moves now, in one sidebar pass.
hide: {
let hidden = InstantClose.begin(id, in: owner, windows: services.windows)
let hidden = !InstantClose.begin([id], in: owner, windows: services.windows).isEmpty
selected.afterPress = controller?.state.workspaceID
return hidden
},
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ enum WorkspaceBulkClose {
}
for group in others { sidebar.handle(.closeGroup(group.id)) }
} else {
close(members)
WorkspaceClose.close(members)
}
guard invocation.origin == .user, members.count > 1, let window else { return }
WorkspaceGroupClose.offerUndo("workspaces-closed", closedToast(members.count), groups: groups, after: seen, in: window, services)
Expand Down Expand Up @@ -161,32 +161,6 @@ enum WorkspaceBulkClose {
return (sidebar, groups.filter { $0.id.rawValue != group.id.rawValue })
}

/// The one close path for several workspaces at once (every bulk close, Close Workspaces in
/// Group): on each daemon, the workspaces with tabs close in one `layout.bulk` request with
/// their terminals (`BulkClosePlan`), one closed group, so one Reopen Closed brings all of them
/// back; an empty workspace, or any on an older daemon, closes alone. `send` runs each daemon
/// command (the sidebar's resyncs its rows when one fails).
static func close(_ members: [Member],
send: (DaemonService, String, @escaping DaemonCommandBody) -> Void = { $0.send($1, $2) }) {
var daemons: [DaemonService] = []
for (_, daemon) in members where !daemons.contains(where: { $0 === daemon }) { daemons.append(daemon) }
for daemon in daemons {
let workspaces = members.filter { $0.1 === daemon && $0.0.key != nil }.map(\.0)
let plan = BulkClosePlan(workspaces, servesBulk: daemon.servesBulkClose, id: \.resourceID,
hasTabs: { $0.screens.contains { $0.panes.contains { !$0.tabs.isEmpty } } })
if !plan.bulk.isEmpty {
for (workspace, _) in plan.bulk { _ = WorkspaceClose.closing(workspace, on: daemon) }
let ids = plan.bulk.map(\.id)
send(daemon, "close-workspaces") { try await $0.state.closeBulk(.workspace, ids: ids, endTerminals: true) }
}
for workspace in plan.each {
guard let key = workspace.key else { continue }
let terminals = WorkspaceClose.closing(workspace, on: daemon)
send(daemon, "close-workspace") { try await WorkspaceClose.close(key, terminals: terminals, on: $0) }
}
}
}

/// The question before a person's run of `id`, or nil to close at once.
static func prompt(_ id: ActionID, _ invocation: ActionInvocation, _ context: AppActionContext, warn: Bool) async -> DestructiveConfirmation.Prompt? {
guard let (members, window) = try? plan(id, invocation, context) else { return nil }
Expand All @@ -199,7 +173,10 @@ enum WorkspaceBulkClose {
static func prompt(_ members: [Member], warn: Bool, askWithoutUndo: Bool = false, incognito: Bool = false,
title: String? = nil) async -> DestructiveConfirmation.Prompt? {
var programs: [String] = []
if warn { for (workspace, daemon) in members { programs += await DestructiveConfirmation.runningPrograms(in: workspace, on: daemon) } }
// Every workspace's terminals are asked at once: each answers within its own short deadline.
// task-owner: one process-info lookup per workspace, all awaited right here.
let lookups = (warn ? members : []).map { member in Task { @MainActor in await DestructiveConfirmation.runningPrograms(in: member.0, on: member.1) } }
for lookup in lookups { programs += await lookup.value }
guard let first = members.first, !programs.isEmpty || (askWithoutUndo && members.count > 1) else { return nil }
let count = String(localized: "confirm.closeWorkspaces.title", defaultValue: "Close \(members.count) workspaces?", table: "Handlers", bundle: .module)
// Incognito workspaces are never saved: nothing brings them back (WindowRegistry).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ enum WorkspaceGroupClose {
}

/// The close toast in `window`; its Undo reopens what this close recorded
/// (`reopen`). Also the bulk closes' toast (`WorkspaceBulkClose`, no group).
/// (`reopen`). Also the bulk closes' toast (`WorkspaceBulkClose`, no group). `undone` runs when its Undo does.
static func offerUndo(_ id: String, _ message: String, groups: [(String?, [String])], after seen: Set<String>,
in window: NSWindow, _ services: AppServices) {
in window: NSWindow, _ services: AppServices, undone: (@MainActor () -> Void)? = nil) {
// Too soon after the last one: Reopen Closed and History still bring the workspaces back.
if let undoToasts = services.closedTabs?.undoToasts, !CmuxToastCenter.shared.toasts(in: window).contains(where: { $0.id == id }),
!undoToasts.allowsNewToast(id) { return }
let handle = CmuxToastCenter.shared.show(CmuxToast(id: id, message: message, action: .undo()), in: window)
handle.onAction = { [weak services] in
undone?()
guard let services else { return }
// task-owner: one undo, bounded by `arrivalLimit`
Task { @MainActor in for (group, names) in groups { await reopen(group: group, names: names, after: seen, services) } }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import AppKit
import CmuxNextDaemonService
import CmuxNextDesign
import CmuxNextSettings

/// A person's close of several selected sidebar rows as one batch (`WorkspaceSelectionCloseFlow`,
/// Leo 2026-10-21: "it's really slow and it's missing a LOT... and it's also 2 lines tall"): the
/// App's wiring of its sidebar pass, question, toast and window.
@MainActor
enum WorkspaceSelectionClose {
/// Closes the workspaces `ids` that window `state` lists.
static func close(_ ids: [String], in state: WindowState, _ services: AppServices) {
let windows = services.windows, controller = windows.controller(for: state.id), shownAtPress = controller?.state.workspaceID
let members = ids.compactMap { services.machines.workspace(id: $0) }.filter { workspace, _ in
workspace.key != nil && !HomeRules.isHome(workspace) && WorkspaceSelectionCloseFlow.canStart(workspace.id)
}
let closing = members.map(\.0.id), undoWindow = WorkspaceBulkClose.survivor(closing: closing, AppActionContext(services: services))
let incognito = members.contains { windows.isIncognito(workspace: $0.0.id) }, selected = WorkspaceCloseSelection()
let seen = Set(DaemonClosedHistory.entries([.workspace], in: services).map(\.item.id)), warn = services.settings?.snapshot.warnBeforeClosingTab ?? CmuxConfigSnapshot.closeWarningFallback
let flow = WorkspaceSelectionCloseFlow(
hide: { ids in
defer { selected.afterPress = controller?.state.workspaceID }
return InstantClose.begin(ids, in: state.id, windows: windows)
},
question: {
guard var prompt = await WorkspaceBulkClose.prompt(members, warn: warn, askWithoutUndo: undoWindow == nil, incognito: incognito) else { return nil }
prompt.returnConfirms = false
return { await withCheckedContinuation { answer in
DestructiveConfirmation.present(prompt, in: controller?.window ?? windows.active?.window, settings: services.settings) { answer.resume(returning: $0) }
} }
},
restore: {
guard let shownAtPress, closing.contains(shownAtPress), services.workspace(id: shownAtPress) != nil, let controller,
controller.state.workspaceID == selected.afterPress else { return }
windows.show(workspaceID: shownAtPress, in: controller.state)
},
listed: { services.workspace(id: $0) != nil }, machines: { services.machines.daemons },
offerUndo: { names, undone in
guard names.count > 1, let undoWindow else { return }
WorkspaceGroupClose.offerUndo("workspaces-closed", WorkspaceBulkClose.closedToast(names.count), groups: [(nil, names)], after: seen, in: undoWindow, services, undone: undone)
},
window: state.id, toastWindow: controller?.window)
if let work = flow.start(members) { services.registry.track(work) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ extension SidebarBridge {
case .close(let ids):
// The row's close button is an entrypoint of `closeWorkspace`,
// so it asks the same confirmation and ends the terminals too.
for id in ids {
services.registry.perform("closeWorkspace", invocation: ActionInvocation(closingRow: id.rawValue))
}
// Several selected rows (Cmd-W, the row menu's Close, Delete) close as one batch.
if ids.count > 1 { return WorkspaceSelectionClose.close(ids.map(\.rawValue), in: state, services) }
for id in ids { services.registry.perform("closeWorkspace", invocation: ActionInvocation(closingRow: id.rawValue)) }
case .newWorkspace(let machine, let group):
// A double-click in a group's empty part, or its "+": the end of that group.
let daemon = machine.flatMap { services.machines.daemon(machine: $0.rawValue) }
Expand All @@ -98,7 +98,7 @@ extension SidebarBridge {
let members = (model.group(group)?.workspaces ?? []).compactMap { services.machines.workspace(id: $0.id.rawValue) }
model.apply(intent)
// One gesture, one closed group per daemon: one Reopen Closed brings the group's workspaces back.
WorkspaceBulkClose.close(members) { daemon, label, body in command(label, on: daemon, body) }
WorkspaceClose.close(members) { daemon, label, body in command(label, on: daemon, body) }
case .switchProfile(let profile):
services.windows.switchProfile(ProfileID(rawValue: profile.rawValue), in: state)
case .newProfile:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ import CmuxNextSidebar

/// Close Workspace at the press (Lawrence 2026-10-17: "this needs to be instant"; `ClosingWorkspaces` owns the state).
enum InstantClose {
/// A close the person asked for (`ClosingWorkspaces`): `id` leaves its window `window` now, unless no other row of
/// the room it shows would be left (then false). The selections move, and `window`'s sidebar (the row starts closing,
/// the next row is selected) and content show it in this turn, not an observation hop later.
static func begin(_ id: String, in window: String?, windows: WindowManager) -> Bool {
guard let window, let controller = windows.controller(for: window) else { return false }
/// A close the person asked for (`ClosingWorkspaces`): `ids` (one row, or a selection's, `WorkspaceSelectionClose`)
/// leave their window `window` now, in one sidebar pass, except a row whose leaving would leave no other row of the
/// room it shows (a window closes with its last workspace, never empty). Returns the ids that left. The selections
/// move, and `window`'s sidebar (the rows start closing, the next row is selected) and content show it in this turn,
/// not an observation hop later.
static func begin(_ ids: [String], in window: String?, windows: WindowManager) -> [String] {
guard let window, let controller = windows.controller(for: window) else { return [] }
let room = WindowProfiles.visible(windows.registry.members(of: window), profile: controller.state.profileID, machines: windows.services.machines)
guard ClosingWorkspaces.shared.begin(id, among: room) else { return false }
// Preserve the pre-close order. ClosingWorkspaces hides `id` before
let hidden = ids.filter { ClosingWorkspaces.shared.begin($0, among: room) }
guard !hidden.isEmpty else { return [] }
// Preserve the pre-close order. ClosingWorkspaces hides the rows before
// repairSelections runs; reconstructing the old order afterward can
// make a last-row close fall back to the first surviving workspace.
windows.repairSelections(previous: [window: room])
controller.sidebar.sameTurn.syncNow(rows: true)
controller.showWorkspace(requested: controller.state.workspaceID)
return true
return hidden
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import CmuxNextActions
typealias WorkspaceSpawn = CmuxNextAppCore.WorkspaceSpawn
typealias WorkspaceClose = CmuxNextAppCore.WorkspaceClose
typealias WorkspaceSpawnSidebar = CmuxNextAppCore.WorkspaceSpawnSidebar
typealias WorkspaceSelectionCloseFlow = CmuxNextAppCore.WorkspaceSelectionCloseFlow
typealias BulkClosePlan<Object> = CmuxNextAppCore.BulkClosePlan<Object>

@MainActor extension SidebarBridge: WorkspaceSpawnSidebar {
var sections: [SidebarRowSection] { model.sections }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
public import CmuxNextDaemon
public import CmuxNextDaemonService

/// How one close gesture over several screens or workspaces of one daemon
/// reaches it (Lawrence 2026-10-23: "if i close a bunch of tabs then cmd shift
/// t, it needs to bring all of them back. same for workspaces, panes,
/// everything"): the objects that have a public id and a tab close in ONE
/// `layout.bulk` request (`layout-bulk-v1`), which the daemon stores as one
/// closed group, so one Reopen Closed (Cmd-Shift-T) brings all of them back;
/// the rest (an empty workspace, which `layout.bulk` leaves open; any object
/// on an older daemon) close one by one, each its own group.
public struct BulkClosePlan<Object> {
/// The objects of the one bulk request, with their public ids.
public var bulk: [(object: Object, id: ResourceID)]
/// The objects that close one by one.
public var each: [Object]

/// Splits `objects` of one daemon. `servesBulk`: the daemon serves
/// `layout.bulk` and state resources. A bulk request needs two objects or
/// more; one alone keeps its own close.
public init(_ objects: [Object], servesBulk: Bool, id: (Object) -> ResourceID?, hasTabs: (Object) -> Bool) {
let candidates = servesBulk ? objects.compactMap { object in hasTabs(object) ? id(object).map { (object, $0) } : nil } : []
guard candidates.count > 1 else {
bulk = []
each = objects
return
}
let ids = Set(candidates.map(\.1))
bulk = candidates
each = objects.filter { object in id(object).map { !ids.contains($0) } ?? true }
}
}

@MainActor
extension DaemonService {
/// Whether this daemon takes a close gesture over several screens or
/// workspaces as one `layout.bulk` request (`BulkClosePlan`).
public var servesBulkClose: Bool {
store.servesStateResources && supports(DaemonCapability.layoutBulk.rawValue)
}
}

@MainActor
extension WorkspaceClose {
/// The one close path for several workspaces at once (every bulk close, Close Workspaces in
/// Group): on each daemon, the workspaces with tabs close in one `layout.bulk` request with
/// their terminals (`BulkClosePlan`), one closed group, so one Reopen Closed brings all of them
/// back; an empty workspace, or any on an older daemon, closes alone. `send` runs each daemon
/// command (the sidebar's resyncs its rows when one fails).
public static func close(_ members: [(WorkspaceModel, DaemonService)],
send: (DaemonService, String, @escaping DaemonCommandBody) -> Void = { $0.send($1, $2) }) {
var daemons: [DaemonService] = []
for (_, daemon) in members where !daemons.contains(where: { $0 === daemon }) { daemons.append(daemon) }
for daemon in daemons {
let workspaces = members.filter { $0.1 === daemon && $0.0.key != nil }.map(\.0)
let plan = BulkClosePlan(workspaces, servesBulk: daemon.servesBulkClose, id: \.resourceID,
hasTabs: { $0.screens.contains { $0.panes.contains { !$0.tabs.isEmpty } } })
if !plan.bulk.isEmpty {
for (workspace, _) in plan.bulk { _ = WorkspaceClose.closing(workspace, on: daemon) }
let ids = plan.bulk.map(\.id)
send(daemon, "close-workspaces") { try await $0.state.closeBulk(.workspace, ids: ids, endTerminals: true) }
}
for workspace in plan.each {
guard let key = workspace.key else { continue }
let terminals = WorkspaceClose.closing(workspace, on: daemon)
send(daemon, "close-workspace") { try await WorkspaceClose.close(key, terminals: terminals, on: $0) }
}
}
}
}
Loading
Loading