Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
17606 Sources/AppDelegate.swift
16021 Sources/ContentView.swift
14100 Sources/TerminalController.swift
12695 Sources/Workspace.swift
12803 Sources/Workspace.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
11841 Sources/GhosttyTerminalView.swift
11411 Sources/Panels/BrowserPanel.swift
Expand Down Expand Up @@ -188,6 +188,7 @@
558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift
555 Sources/Panels/BrowserAutomation.swift
551 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift
548 cmuxTests/WorkspaceSidebarObservationTests.swift
541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift
540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift
539 CLI/CMUXCLI+Themes.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,17 @@ public import Observation
/// through a computed `get`/`set` pair, so every call site (`statusEntries[key]
/// = …`, `logEntries.append(…)`, `workspace.progress`) stays byte-identical.
///
/// Byte-identical observer parity: the legacy properties were `@Published`, and
/// the sidebar observation publishers (`Workspace.sidebarObservationPublisher`)
/// fused their `$projection`s through `CombineLatest` + `removeDuplicates()`.
/// To preserve that exactly, each property here mirrors its value into a
/// `CurrentValueSubject` in `didSet`; the matching `…Publisher` accessor
/// replaces the former `$property`. `CombineLatest` over current-value subjects
/// seeded with the initial values, then deduplicated, produces the identical
/// sequence of distinct fused states the `@Published` projections did, so the
/// debounced sidebar refresh fires at the same moments.
/// Observer parity: the legacy properties were `@Published`, and the sidebar
/// observation publishers (`Workspace.sidebarObservationPublisher`) fused their
/// `$projection`s through `CombineLatest` + `removeDuplicates()`. Each property
/// here mirrors its retained value into a `CurrentValueSubject` in `didSet`; the
/// matching `…Publisher` accessor replaces the former `$property`.
@MainActor
@Observable
public final class WorkspaceSidebarMetadataModel {
/// Upper bound on retained sidebar markdown metadata blocks per workspace.
public static let maxMetadataBlocks = 200

/// Sidebar status entries keyed by status key (legacy
/// `Workspace.statusEntries`).
public var statusEntries: [String: SidebarStatusEntry] = [:] {
Expand All @@ -34,7 +33,12 @@ public final class WorkspaceSidebarMetadataModel {
/// Sidebar markdown metadata blocks keyed by block key (legacy
/// `Workspace.metadataBlocks`).
public var metadataBlocks: [String: SidebarMetadataBlock] = [:] {
didSet { metadataBlocksSubject.send(metadataBlocks) }
didSet {
if metadataBlocks.count > Self.maxMetadataBlocks {
metadataBlocks = cappedMetadataBlocksForDisplay(metadataBlocks)
}
metadataBlocksSubject.send(metadataBlocks)
}
}

/// Recent sidebar log entries, oldest first, capped to the configured
Expand Down Expand Up @@ -210,4 +214,20 @@ public final class WorkspaceSidebarMetadataModel {
return lhs.key < rhs.key
}
}

private func cappedMetadataBlocksForDisplay(
_ blocks: [String: SidebarMetadataBlock]
) -> [String: SidebarMetadataBlock] {
let keptKeys = Set(
blocks
.sorted { lhs, rhs in
if lhs.value.priority != rhs.value.priority { return lhs.value.priority > rhs.value.priority }
if lhs.value.timestamp != rhs.value.timestamp { return lhs.value.timestamp > rhs.value.timestamp }
return lhs.key < rhs.key
}
.prefix(Self.maxMetadataBlocks)
.map(\.key)
)
return blocks.filter { keptKeys.contains($0.key) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ private struct FixedLogLimitProvider: SidebarLogEntryLimitProviding {
#expect(ordered.map(\.key) == ["c", "a", "b"])
}

@Test func metadataBlocksStayBoundedUnderUnboundedDistinctKeys() {
let model = makeModel()
let cap = WorkspaceSidebarMetadataModel.maxMetadataBlocks

for index in 0..<(cap * 3) {
model.metadataBlocks["key_\(index)"] = SidebarMetadataBlock(
key: "key_\(index)",
markdown: "block_\(index)",
priority: 0,
timestamp: Date(timeIntervalSince1970: TimeInterval(index))
)
}

#expect(model.metadataBlocks.count <= cap)
#expect(model.metadataBlocks["key_\(cap * 3 - 1)"] != nil)
#expect(model.metadataBlocks["key_0"] == nil)
}

@Test func metadataCapRetainsHighPriorityOverNewerLowPriorityFlood() {
let model = makeModel()
let cap = WorkspaceSidebarMetadataModel.maxMetadataBlocks

model.metadataBlocks["important"] = SidebarMetadataBlock(
key: "important",
markdown: "m",
priority: 100,
timestamp: Date(timeIntervalSince1970: 0)
)
for index in 0..<(cap * 2) {
model.metadataBlocks["low_\(index)"] = SidebarMetadataBlock(
key: "low_\(index)",
markdown: "m",
priority: 0,
timestamp: Date(timeIntervalSince1970: TimeInterval(index + 100))
)
}

#expect(model.metadataBlocks.count <= cap)
#expect(model.metadataBlocks["important"] != nil)
}

@Test func progressGitAndPullRequestUpdaters() {
let model = makeModel()
model.updateProgress(SidebarProgressState(value: 0.5, label: "half"))
Expand Down
8 changes: 4 additions & 4 deletions Sources/TerminalController+ControlSidebarContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ extension TerminalController: ControlSidebarContext {
) else {
// Still update PID tracking even if the status display hasn't changed.
if let pid {
tab.recordAgentPID(key: key, pid: pid, panelId: panelID)
tab.recordAgentPIDForSurvivingStatusKey(key, pid: pid, panelId: panelID)
}
return
}
tab.statusEntries[key] = SidebarStatusEntry(
tab.setSidebarStatusEntry(SidebarStatusEntry(
key: key,
value: value,
icon: icon,
Expand All @@ -58,9 +58,9 @@ extension TerminalController: ControlSidebarContext {
priority: priority,
format: appFormat,
timestamp: Date()
)
), allowingPIDHandoffGrace: pid != nil)
if let pid {
tab.recordAgentPID(key: key, pid: pid, panelId: panelID)
tab.recordAgentPIDForSurvivingStatusKey(key, pid: pid, panelId: panelID)
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12828,11 +12828,11 @@ class TerminalController {
) else {
// Still update PID tracking even if the status display hasn't changed.
if let pidValue {
tab.recordAgentPID(key: key, pid: pidValue, panelId: panelResolution.panelId)
tab.recordAgentPIDForSurvivingStatusKey(key, pid: pidValue, panelId: panelResolution.panelId)
}
return
}
tab.statusEntries[key] = SidebarStatusEntry(
tab.setSidebarStatusEntry(SidebarStatusEntry(
key: key,
value: value,
icon: icon,
Expand All @@ -12841,9 +12841,9 @@ class TerminalController {
priority: priority,
format: format,
timestamp: Date()
)
), allowingPIDHandoffGrace: pidValue != nil)
if let pidValue {
tab.recordAgentPID(key: key, pid: pidValue, panelId: panelResolution.panelId)
tab.recordAgentPIDForSurvivingStatusKey(key, pid: pidValue, panelId: panelResolution.panelId)
}
}
return "OK"
Expand Down
71 changes: 67 additions & 4 deletions Sources/Workspace+PanelLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ extension Workspace {
return didClearOtherStructuredAgentRuntime
}

/// Records a PID only when its status entry survived synchronous cap trimming.
@discardableResult
func recordAgentPIDForSurvivingStatusKey(_ key: String, pid: pid_t, panelId: UUID?) -> Bool {
guard statusEntries[key] != nil else { return false }
return recordAgentPID(key: key, pid: pid, panelId: panelId)
}

func suppressesRawTerminalNotification(panelId: UUID?) -> Bool {
guard let panelId else {
return false
Expand Down Expand Up @@ -250,6 +257,41 @@ extension Workspace {
recomputeListeningPorts()
}

/// Status keys backed by active agent runtime or lifecycle state.
func statusKeysWithCoupledAgentRuntime() -> Set<String> {
var keys = Set<String>()
for pidKey in agentPIDs.keys {
keys.insert(agentStatusKey(forAgentPIDKey: pidKey))
}
for pidKey in agentPIDPanelIdsByKey.keys {
keys.insert(agentStatusKey(forAgentPIDKey: pidKey))
}
for lifecycleStates in agentLifecycleStatesByPanelId.values {
keys.formUnion(lifecycleStates.keys)
}
return keys
}

/// Clears agent runtime state coupled to status keys evicted by the cap.
func purgeAgentRuntimeState(forEvictedStatusKeys evictedStatusKeys: Set<String>) {
guard !evictedStatusKeys.isEmpty else { return }
var didChange = false
let pidKeysToClear = Set(agentPIDs.keys)
.union(agentPIDPanelIdsByKey.keys)
.filter { evictedStatusKeys.contains(agentStatusKey(forAgentPIDKey: $0)) }
for pidKey in pidKeysToClear {
if clearAgentPID(key: pidKey, panelId: nil, clearStatus: false, refreshPorts: false) {
didChange = true
}
}
for statusKey in evictedStatusKeys where clearAgentLifecycle(key: statusKey) {
didChange = true
}
if didChange {
refreshTrackedAgentPorts()
}
}

@discardableResult
private func discardAgentRuntimeState(_ runtimeState: DetachedAgentRuntimeState?) -> Bool {
guard let runtimeState else { return false }
Expand All @@ -267,15 +309,36 @@ extension Workspace {

func adoptDetachedAgentRuntimeState(_ runtimeState: DetachedAgentRuntimeState?) {
guard let runtimeState else { return }
for (statusKey, statusEntry) in runtimeState.statusEntries {
statusEntries[statusKey] = statusEntry
func transferredStatusKey(forAgentPIDKey key: String) -> String? {
if runtimeState.statusEntries[key] != nil {
return key
}
guard let dotIndex = key.firstIndex(of: ".") else {
return nil
}
let statusKey = String(key[..<dotIndex])
return runtimeState.statusEntries[statusKey] != nil ? statusKey : nil
}
let transferredPIDKeys = Set(runtimeState.agentPIDKeys).union(runtimeState.agentPIDs.keys)
let pidHandoffGraceKeys = Set(transferredPIDKeys.compactMap(transferredStatusKey(forAgentPIDKey:)))
if !runtimeState.statusEntries.isEmpty {
var merged = statusEntries
for (statusKey, statusEntry) in runtimeState.statusEntries {
merged[statusKey] = statusEntry
}
replaceSidebarStatusEntries(merged, pidHandoffGraceKeys: pidHandoffGraceKeys)
}
func adoptedStatusSurvived(forAgentPIDKey key: String) -> Bool {
guard let statusKey = transferredStatusKey(forAgentPIDKey: key) else { return true }
return statusEntries[statusKey] != nil
}
var didAdoptAgentPID = false
for (key, pid) in runtimeState.agentPIDs {
for (key, pid) in runtimeState.agentPIDs where adoptedStatusSurvived(forAgentPIDKey: key) {
recordAgentPID(key: key, pid: pid, panelId: runtimeState.panelId, refreshPorts: false)
didAdoptAgentPID = true
}
for key in runtimeState.agentPIDKeys where runtimeState.agentPIDs[key] == nil {
for key in runtimeState.agentPIDKeys
where runtimeState.agentPIDs[key] == nil && adoptedStatusSurvived(forAgentPIDKey: key) {
recordAgentPIDOwnership(key: key, panelId: runtimeState.panelId)
}
if didAdoptAgentPID {
Expand Down
Loading
Loading