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
2 changes: 1 addition & 1 deletion Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1555,7 +1555,7 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [
),
.testTarget(
name: "CmuxNextSidebarTests",
dependencies: ["CmuxNextSidebar"],
dependencies: ["CmuxNextSidebar", "CmuxNextWakeups"],
exclude: ["Fixtures"],
swiftSettings: uiSwiftSettings
),
Expand Down
14 changes: 4 additions & 10 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,13 @@ final class SidebarRows {
}

/// Folds or opens `group` at once and keeps that look until `body`
/// replied and the home store holds it, or failed (then `failed`).
/// replied and the home store holds it; a slow reply is sent again
/// quietly, and only a refusal goes to `failed` (`SidebarRowEdits.saveFold`).
func fold(_ group: GroupID, collapsed: Bool, on home: DaemonService, failed: @escaping @MainActor (ActionWorkFailure) -> Void,
_ body: @escaping DaemonCommandBody) {
let settle = edits.fold(group, collapsed: collapsed)
let transaction = ClientTransactionID.generate()
// task-owner: one personal fold command; settles its fold
Task {
if let failure = await home.failure("update-personal-group", body) {
settle()
return failed(failure)
}
home.whenApplied(transaction, settle)
}
edits.saveFold(group, collapsed: collapsed, save: { await home.failure("update-personal-group", body) },
mayHaveApplied: { $0.mayHaveApplied }, refused: failed) { home.whenApplied(transaction, $0) }
}

/// Writes `group`'s fold into the live rows kept here (one group, no redraw).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
public import CmuxNextWakeups

/// How a group fold's save goes on after its command failed. A fold is this window's look: it shows on the
/// click and never waits for the daemon. A slow save (the reply missed its deadline, so it may still apply)
/// is sent again in the background a few times, with no notice; only a refusal puts the daemon's look back
/// and says why. Leo, 2026-11-03, on "Collapse/Expand Workspace Group did not apply: ... timed out".
public enum SidebarFoldPersist: Equatable, Sendable {
/// Send the same fold again (after the backoff's next delay).
case retry
/// The daemon refused it: drop the fold and report.
case refused
/// Still no reply after every attempt: drop the hold quietly; the store's next echo shows what it holds.
case giveUp

/// Sends after the first one.
public static let retries = 4
/// About 0.5, 1, 2 and 4 s between sends.
public static var backoff: Backoff { Backoff(initial: .milliseconds(500), maximum: .seconds(4)) }

/// What follows failed send `attempt` (0 for the first). `mayHaveApplied` is a missed deadline.
public static func after(attempt: Int, mayHaveApplied: Bool) -> SidebarFoldPersist {
guard mayHaveApplied else { return .refused }
return attempt < retries ? .retry : .giveUp
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CmuxNextCompat
import CmuxNextDesign
public import CmuxNextWakeups

/// A window's pending row edits and folds, held across live projections until their owner has them.
/// The app supplies redraw and confirmed-fold callbacks; this state has no daemon or window dependency.
Expand Down Expand Up @@ -55,6 +56,43 @@ public final class SidebarRowEdits {
}
}

/// Whether `group` still shows a pending fold to `collapsed`: a later click (or a settle) replaces it,
/// and then a retried save of this one must not go out.
public func holdsFold(_ group: GroupID, collapsed: Bool) -> Bool { folds[group]?.collapsed == collapsed }

/// Shows a fold now and saves it in the background: `save` returns nil once the daemon replied, and
/// `saved` then gets the settlement. A failure that only missed its deadline (`mayHaveApplied`) is sent
/// again with no notice (``SidebarFoldPersist``), unless a later click replaced the fold; a refusal
/// drops the fold and goes to `refused`.
public func saveFold<Failure: Sendable>(_ group: GroupID, collapsed: Bool, backoff: Backoff = SidebarFoldPersist.backoff,
save: @escaping @MainActor () async -> Failure?,
mayHaveApplied: @escaping @MainActor (Failure) -> Bool,
refused: @escaping @MainActor (Failure) -> Void,
saved: @escaping @MainActor (@escaping @MainActor () -> Void) -> Void) {
let settle = fold(group, collapsed: collapsed)
// task-owner: one fold save and at most SidebarFoldPersist.retries re-sends; ends at a reply, a refusal,
// the last retry, or a later click on the group
Task { [weak self] in
var backoff = backoff
var attempt = 0
while let failure = await save() {
switch SidebarFoldPersist.after(attempt: attempt, mayHaveApplied: mayHaveApplied(failure)) {
case .refused:
settle()
return refused(failure)
case .giveUp:
return settle() // the daemon may still apply it; its echo shows what it holds
case .retry:
// concurrency-allow: Backoff.wait is an async clock sleep (it suspends this task, never blocks the main thread)
do { try await backoff.wait(owner: "sidebar.foldSave") } catch { return settle() }
guard let self, holdsFold(group, collapsed: collapsed) else { return }
attempt += 1
}
}
saved(settle)
}
}

/// Shows a document fold until its echo reaches the live rows, or a refusal drops it.
public func foldShown(_ group: GroupID, collapsed: Bool) {
foldSerial += 1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
@testable import CmuxNextSidebar
import CmuxNextWakeups
import Testing

/// Collapsing a group is this window's look: a save whose reply is slow is sent again quietly, and only a
/// refusal undoes the fold with a notice (Leo, 2026-11-03: "Collapse/Expand Workspace Group did not apply:
/// update-personal-group: timed out: workspace_group.update (no reply within 2.0 seconds)").
@MainActor @Suite struct SidebarFoldPersistTests {
private struct Failure: Sendable { var timedOut: Bool }
private let group = GroupID("grp_fold")
private let quick = Backoff(initial: .milliseconds(1), maximum: .milliseconds(1), jitter: 0)

private func edits() -> (SidebarRowEdits, SidebarModel) {
let model = SidebarModel()
return (SidebarRowEdits(model: model, redraw: {}, confirmedFold: { _, _ in }), model)
}

/// What one fold save did: the sends that went out, the refusals reported, and whether it settled through `saved`.
@MainActor private final class Record {
var sends = 0, refused = 0, saved = false
var done: CheckedContinuation<Void, Never>?
func finish() { done?.resume(); done = nil }
}

/// Saves one fold with `answers` as the daemon's replies in order (nil: replied). `before` runs at the first send.
private func run(_ edits: SidebarRowEdits, _ answers: [Failure?], before: (@MainActor () -> Void)? = nil)
async -> (sends: Int, refused: Int, saved: Bool) {
let record = Record()
let group = group, quick = quick
await withCheckedContinuation { (done: CheckedContinuation<Void, Never>) in
record.done = done
edits.saveFold(group, collapsed: true, backoff: quick, save: {
let index = record.sends
record.sends += 1
if index == 0 { before?() }
let answer = index < answers.count ? answers[index] : nil
// The last scripted failure (or the one a later click supersedes) ends the run.
if answer != nil, index == answers.count - 1 || before != nil { record.finish() }
return answer
}, mayHaveApplied: { $0.timedOut }, refused: { _ in record.refused += 1; record.finish() },
saved: { settle in record.saved = true; settle(); record.finish() })
}
// Let a retry that should not happen have its chance.
try? await Task.sleep(for: .milliseconds(50))
return (record.sends, record.refused, record.saved)
}

@Test func aSlowReplyIsSentAgainWithNoNoticeAndTheFoldStays() async {
let (edits, model) = edits()
let result = await run(edits, [Failure(timedOut: true), Failure(timedOut: true), nil])
#expect(result.sends == 3)
#expect(result.refused == 0, "a slow save must not report a failure")
#expect(result.saved)
_ = model
}

@Test func aRefusalIsReportedOnceWithNoRetry() async {
let (edits, _) = edits()
let result = await run(edits, [Failure(timedOut: false)])
#expect(result.sends == 1)
#expect(result.refused == 1)
#expect(!edits.holdsFold(group, collapsed: true), "a refusal drops the fold")
}

@Test func aSaveThatNeverAnswersGivesUpQuietly() async {
let (edits, _) = edits()
let timeouts = Array(repeating: Failure?.some(Failure(timedOut: true)), count: SidebarFoldPersist.retries + 1)
let result = await run(edits, timeouts)
#expect(result.sends == SidebarFoldPersist.retries + 1)
#expect(result.refused == 0)
#expect(!result.saved)
}

@Test func aLaterClickStopsTheEarlierFoldsRetries() async {
let (edits, _) = edits()
let result = await run(edits, [Failure(timedOut: true)]) {
_ = edits.fold(group, collapsed: false)
}
#expect(result.sends == 1, "the stale fold must not be sent again over the newer click")
#expect(result.refused == 0)
#expect(edits.holdsFold(group, collapsed: false))
}

@Test func policy() {
#expect(SidebarFoldPersist.after(attempt: 0, mayHaveApplied: false) == .refused)
#expect(SidebarFoldPersist.after(attempt: 0, mayHaveApplied: true) == .retry)
#expect(SidebarFoldPersist.after(attempt: SidebarFoldPersist.retries, mayHaveApplied: true) == .giveUp)
}
}
Loading