From 2e871c97c79367fb9ed4453d16cc8cdebdebd066 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 15 Jun 2026 12:30:49 -0700 Subject: [PATCH] Extract custom-sidebar data-context projection into CmuxSidebarLayout Pulls the interpreter data-context projection out of the VerticalTabsSidebar view in ContentView.swift into a new leaf package, CmuxSidebarLayout, as a pure value-typed builder with the live god-types inverted behind Sendable input snapshots. What moved: - CustomSidebarDataContextBuilder: owns all SwiftValue assembly, default values, and optional-field omission rules for the top-level context, per-workspace, and per-surface value objects (was the inline bodies of customSidebarDataContext / customSidebarWorkspaceValue / customSidebarSurfaceValues). - CustomSidebarContextSnapshot / CustomSidebarWorkspaceSnapshot (+ Progress / Remote) / CustomSidebarSurfaceSnapshot: Sendable, Equatable value-typed inputs. The seam: the app still reads live Workspace / TabManager / SidebarUnreadModel state, but only to project it into the snapshot value types; every SwiftValue field name, default, and omit-when-nil/empty rule now lives in the package, so no package type ever names a god type. The Calendar is constructor-injected (defaults to .current) for testability. Byte-identical: the produced [String: SwiftValue] tree is unchanged. Field names, the nil-or-empty-string omission rules (description/color/messages/ directory), the git/PR/progress/remote field sets, the clock formatting, and the surface pane-walk ordering are all preserved; surfaces without a panel id are still skipped at projection time. SwiftValue dict ordering never affected output (rendering sorts keys / looks up by key). Tests: 8 Testing-framework cases for the builder using fixed-calendar fakes and known snapshots (clock components, optional presence/omission, empty-string omission, progress-without-label, surface enrichment). est lines removed from ContentView.swift: 20 net (86 deleted, 66 thinner projection added); budget ratcheted 16674 -> 16654. NOTE: app build validated by CI. The new package builds and its tests pass locally (swift build / swift test in Packages/CmuxSidebarLayout). Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- Packages/CmuxSidebarLayout/Package.resolved | 15 + Packages/CmuxSidebarLayout/Package.swift | 41 +++ .../CustomSidebarContextSnapshot.swift | 37 +++ .../CustomSidebarDataContextBuilder.swift | 138 ++++++++ .../CustomSidebarSurfaceSnapshot.swift | 50 +++ .../CustomSidebarWorkspaceSnapshot.swift | 132 ++++++++ ...CustomSidebarDataContextBuilderTests.swift | 311 ++++++++++++++++++ Sources/ContentView.swift | 152 ++++----- cmux.xcodeproj/project.pbxproj | 13 + 10 files changed, 804 insertions(+), 87 deletions(-) create mode 100644 Packages/CmuxSidebarLayout/Package.resolved create mode 100644 Packages/CmuxSidebarLayout/Package.swift create mode 100644 Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarContextSnapshot.swift create mode 100644 Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarDataContextBuilder.swift create mode 100644 Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarSurfaceSnapshot.swift create mode 100644 Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarWorkspaceSnapshot.swift create mode 100644 Packages/CmuxSidebarLayout/Tests/CmuxSidebarLayoutTests/CustomSidebarDataContextBuilderTests.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index abe5cbe6275d..67fddea68802 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34202 CLI/cmux.swift 17778 Sources/AppDelegate.swift -16674 Sources/ContentView.swift +16654 Sources/ContentView.swift 14785 Sources/TerminalController.swift 13358 Sources/Panels/BrowserPanel.swift 12361 Sources/Workspace.swift diff --git a/Packages/CmuxSidebarLayout/Package.resolved b/Packages/CmuxSidebarLayout/Package.resolved new file mode 100644 index 000000000000..0eb22b8b09ae --- /dev/null +++ b/Packages/CmuxSidebarLayout/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "57502298373d5a992548044daf82c1bc640f56e0a46d4110db96d6275c391d18", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + } + ], + "version" : 3 +} diff --git a/Packages/CmuxSidebarLayout/Package.swift b/Packages/CmuxSidebarLayout/Package.swift new file mode 100644 index 000000000000..7862c8de464d --- /dev/null +++ b/Packages/CmuxSidebarLayout/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxSidebarLayout", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxSidebarLayout", + targets: ["CmuxSidebarLayout"] + ), + ], + dependencies: [ + .package(path: "../CmuxSwiftRender"), + ], + targets: [ + .target( + name: "CmuxSidebarLayout", + dependencies: [ + .product(name: "CmuxSwiftRender", package: "CmuxSwiftRender"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxSidebarLayoutTests", + dependencies: ["CmuxSidebarLayout"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarContextSnapshot.swift b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarContextSnapshot.swift new file mode 100644 index 000000000000..cf6c5f7357f9 --- /dev/null +++ b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarContextSnapshot.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// The full input the custom-sidebar interpreter data context is built from. +/// +/// The app assembles this from the live tab manager, the per-workspace state, +/// and the current wall-clock instant on each `TimelineView` tick. The +/// data-context builder maps it to the top-level interpreter dictionary +/// (`workspaces`, `workspaceCount`, `selectedTitle`, `selectedId`, +/// `unreadTotal`, `clock`). +public struct CustomSidebarContextSnapshot: Sendable, Equatable { + /// The ordered workspaces shown in the sidebar. + public let workspaces: [CustomSidebarWorkspaceSnapshot] + /// The selected workspace identifier, or `nil` when none is selected. + public let selectedWorkspaceId: UUID? + /// The selected workspace's display title, used for `selectedTitle`. Empty + /// when nothing is selected. + public let selectedWorkspaceTitle: String + /// The total unread count across all workspaces (`unreadTotal`). + public let totalUnreadCount: Int + /// The wall-clock instant the `clock` object is derived from. + public let now: Date + + /// Creates a context snapshot from already-resolved values. + public init( + workspaces: [CustomSidebarWorkspaceSnapshot], + selectedWorkspaceId: UUID?, + selectedWorkspaceTitle: String, + totalUnreadCount: Int, + now: Date + ) { + self.workspaces = workspaces + self.selectedWorkspaceId = selectedWorkspaceId + self.selectedWorkspaceTitle = selectedWorkspaceTitle + self.totalUnreadCount = totalUnreadCount + self.now = now + } +} diff --git a/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarDataContextBuilder.swift b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarDataContextBuilder.swift new file mode 100644 index 000000000000..07bcab9376ea --- /dev/null +++ b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarDataContextBuilder.swift @@ -0,0 +1,138 @@ +public import CmuxSwiftRender +public import Foundation + +/// Builds the interpreter data context for a custom sidebar from a +/// ``CustomSidebarContextSnapshot``. +/// +/// This is the pure, value-typed projection that used to live inline in the +/// sidebar view (`customSidebarDataContext` / `customSidebarWorkspaceValue` / +/// `customSidebarSurfaceValues`). It owns the exact field set, default values, +/// and optional-field omission rules of the interpreter data keys documented +/// in `docs/custom-sidebars.md`; the app feeds it value snapshots projected +/// from live workspace state and renders the resulting `SwiftValue` tree. The +/// builder performs no I/O and reads no live objects, so its output is a pure +/// function of the snapshot plus the injected calendar. +public struct CustomSidebarDataContextBuilder { + private let calendar: Calendar + + /// Creates a builder. + /// + /// - Parameter calendar: the calendar used to derive the `clock` object's + /// hour/minute/second/weekday components. Defaults to `Calendar.current`, + /// matching the original inline projection. + public init(calendar: Calendar = .current) { + self.calendar = calendar + } + + /// Projects the snapshot into the top-level interpreter data dictionary. + /// + /// Mirrors the original `customSidebarDataContext(now:)` output exactly: + /// `workspaces`, `workspaceCount`, `selectedTitle`, `selectedId`, + /// `unreadTotal`, and `clock`. + public func dataContext(for snapshot: CustomSidebarContextSnapshot) -> [String: SwiftValue] { + let workspaces: [SwiftValue] = snapshot.workspaces.map(workspaceValue(_:)) + let components = calendar.dateComponents( + [.hour, .minute, .second, .weekday], + from: snapshot.now + ) + let hour = components.hour ?? 0 + let minute = components.minute ?? 0 + let second = components.second ?? 0 + let clock: SwiftValue = .object([ + "time": .string(String(format: "%02d:%02d:%02d", hour, minute, second)), + "hour": .int(hour), + "minute": .int(minute), + "second": .int(second), + "weekday": .int(components.weekday ?? 0), + "epoch": .int(Int(snapshot.now.timeIntervalSince1970)), + ]) + return [ + "workspaces": .array(workspaces), + "workspaceCount": .int(snapshot.workspaces.count), + "selectedTitle": .string(snapshot.selectedWorkspaceTitle), + "selectedId": .string(snapshot.selectedWorkspaceId?.uuidString ?? ""), + "unreadTotal": .int(snapshot.totalUnreadCount), + "clock": clock, + ] + } + + /// Projects one workspace's snapshot into the interpreter value tree. + /// + /// Optional fields are omitted when absent so interpreted `if let` / + /// ternary truthiness behaves; always-present fields default sensibly. + public func workspaceValue(_ workspace: CustomSidebarWorkspaceSnapshot) -> SwiftValue { + var fields: [String: SwiftValue] = [ + "id": .string(workspace.id.uuidString), + "title": .string(workspace.title), + "selected": .bool(workspace.isSelected), + "pinned": .bool(workspace.isPinned), + "index": .int(workspace.index), + "directory": .string(workspace.directory), + "ports": .array(workspace.listeningPorts.map { .int($0) }), + "portCount": .int(workspace.listeningPorts.count), + "unread": .int(workspace.unreadCount), + "tabs": .array(workspace.surfaces.map(surfaceValue(_:))), + "tabCount": .int(workspace.surfaceCount), + ] + if let description = workspace.customDescription, !description.isEmpty { + fields["description"] = .string(description) + } + if let color = workspace.customColor, !color.isEmpty { + fields["color"] = .string(color) + } + if let branch = workspace.gitBranch { + fields["branch"] = .string(branch) + fields["dirty"] = .bool(workspace.gitIsDirty) + } + if let firstPullRequest = workspace.pullRequestValues.first { + fields["pr"] = firstPullRequest + fields["prs"] = .array(workspace.pullRequestValues) + } + if let progress = workspace.progress { + var progressFields: [String: SwiftValue] = ["value": .double(progress.value)] + if let label = progress.label { + progressFields["label"] = .string(label) + } + fields["progress"] = .object(progressFields) + } + if let message = workspace.latestConversationMessage, !message.isEmpty { + fields["latestMessage"] = .string(message) + } + if let prompt = workspace.latestSubmittedMessage, !prompt.isEmpty { + fields["latestPrompt"] = .string(prompt) + } + if let at = workspace.latestSubmittedAt { + fields["latestAt"] = .int(Int(at.timeIntervalSince1970)) + } + if let remote = workspace.remote { + fields["remote"] = .object([ + "target": .string(remote.target), + "state": .string(remote.stateRawValue), + "connected": .bool(remote.isConnected), + ]) + } + return .object(fields) + } + + /// Projects one surface snapshot into the interpreter value tree, enriched + /// with per-surface directory, pin, git, and ports where available. + public func surfaceValue(_ surface: CustomSidebarSurfaceSnapshot) -> SwiftValue { + var surfaceFields: [String: SwiftValue] = [ + "id": .string(surface.panelId.uuidString), + "title": .string(surface.title), + "focused": .bool(surface.isFocused), + "pinned": .bool(surface.isPinned), + ] + if let directory = surface.directory, !directory.isEmpty { + surfaceFields["directory"] = .string(directory) + } + if let branch = surface.gitBranch { + surfaceFields["branch"] = .string(branch) + surfaceFields["dirty"] = .bool(surface.gitIsDirty) + } + if !surface.listeningPorts.isEmpty { + surfaceFields["ports"] = .array(surface.listeningPorts.map { .int($0) }) + } + return .object(surfaceFields) + } +} diff --git a/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarSurfaceSnapshot.swift b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarSurfaceSnapshot.swift new file mode 100644 index 000000000000..dfb5435eed48 --- /dev/null +++ b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarSurfaceSnapshot.swift @@ -0,0 +1,50 @@ +public import Foundation + +/// One surface (terminal/browser/etc. tab) projected for the custom-sidebar +/// interpreter context, in the order it appears inside its workspace. +/// +/// The app builds this by walking a workspace's bonsplit panes; the +/// interpreter data-context builder maps it to the `tabs[i]` value object. +/// Optional fields are `nil` when absent so interpreted `if let` / ternary +/// truthiness behaves; `nil` and empty strings are both treated as absent. +public struct CustomSidebarSurfaceSnapshot: Sendable, Equatable { + /// The panel identifier, projected to the interpreter `tabs[i].id` string. + public let panelId: UUID + /// The surface title (`tabs[i].title`). + public let title: String + /// Whether this surface is the workspace's focused panel (`tabs[i].focused`). + public let isFocused: Bool + /// Whether the surface's panel is pinned (`tabs[i].pinned`). + public let isPinned: Bool + /// The surface's working directory, or `nil`/empty when unknown + /// (`tabs[i].directory`). + public let directory: String? + /// The surface's git branch name when known (`tabs[i].branch`). + public let gitBranch: String? + /// Whether the surface's git branch has uncommitted changes + /// (`tabs[i].dirty`); meaningful only when ``gitBranch`` is non-nil. + public let gitIsDirty: Bool + /// The surface's listening ports, or empty when none (`tabs[i].ports`). + public let listeningPorts: [Int] + + /// Creates a surface snapshot from already-resolved leaf values. + public init( + panelId: UUID, + title: String, + isFocused: Bool, + isPinned: Bool, + directory: String?, + gitBranch: String?, + gitIsDirty: Bool, + listeningPorts: [Int] + ) { + self.panelId = panelId + self.title = title + self.isFocused = isFocused + self.isPinned = isPinned + self.directory = directory + self.gitBranch = gitBranch + self.gitIsDirty = gitIsDirty + self.listeningPorts = listeningPorts + } +} diff --git a/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarWorkspaceSnapshot.swift b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarWorkspaceSnapshot.swift new file mode 100644 index 000000000000..1efae59b1b7e --- /dev/null +++ b/Packages/CmuxSidebarLayout/Sources/CmuxSidebarLayout/CustomSidebarWorkspaceSnapshot.swift @@ -0,0 +1,132 @@ +public import CmuxSwiftRender +public import Foundation + +/// One workspace projected for the custom-sidebar interpreter context, in +/// sidebar display order. +/// +/// The app resolves each field from live workspace state; the data-context +/// builder maps it to the `workspaces[i]` value object. Optional fields are +/// `nil` when absent so interpreted `if let` / ternary truthiness behaves, and +/// empty strings on the optional text fields are treated the same as `nil`. +public struct CustomSidebarWorkspaceSnapshot: Sendable, Equatable { + /// Progress projected to the `workspaces[i].progress` object. + public struct Progress: Sendable, Equatable { + /// The fractional progress value (`progress.value`). + public let value: Double + /// An optional human label (`progress.label`); omitted when `nil`. + public let label: String? + + /// Creates a progress projection. + public init(value: Double, label: String?) { + self.value = value + self.label = label + } + } + + /// Remote-connection state projected to the `workspaces[i].remote` object. + public struct Remote: Sendable, Equatable { + /// The remote display target (`remote.target`). + public let target: String + /// The raw connection-state string (`remote.state`). + public let stateRawValue: String + /// Whether the workspace is currently connected (`remote.connected`). + public let isConnected: Bool + + /// Creates a remote projection. + public init(target: String, stateRawValue: String, isConnected: Bool) { + self.target = target + self.stateRawValue = stateRawValue + self.isConnected = isConnected + } + } + + /// The workspace identifier, projected to `workspaces[i].id`. + public let id: UUID + /// The display title (custom title falling back to the live title). + public let title: String + /// Whether this workspace is the selected one (`workspaces[i].selected`). + public let isSelected: Bool + /// Whether the workspace is pinned (`workspaces[i].pinned`). + public let isPinned: Bool + /// The zero-based sidebar position (`workspaces[i].index`). + public let index: Int + /// The workspace's current directory (`workspaces[i].directory`). + public let directory: String + /// The workspace's listening ports (`workspaces[i].ports` / `.portCount`). + public let listeningPorts: [Int] + /// The workspace's unread count (`workspaces[i].unread`). + public let unreadCount: Int + /// Surfaces in pane order (`workspaces[i].tabs`). + public let surfaces: [CustomSidebarSurfaceSnapshot] + /// Total surface count across panes (`workspaces[i].tabCount`). + public let surfaceCount: Int + /// Custom description; omitted when `nil`/empty (`workspaces[i].description`). + public let customDescription: String? + /// Custom color hex; omitted when `nil`/empty (`workspaces[i].color`). + public let customColor: String? + /// The first sidebar git branch name (`workspaces[i].branch`). + public let gitBranch: String? + /// Whether that branch is dirty (`workspaces[i].dirty`); only meaningful + /// when ``gitBranch`` is non-nil. + public let gitIsDirty: Bool + /// Pull-request value objects already projected in display order, mapped to + /// `workspaces[i].pr` (first) and `workspaces[i].prs` (all). + public let pullRequestValues: [SwiftValue] + /// Progress projection; omitted when `nil` (`workspaces[i].progress`). + public let progress: Progress? + /// Latest conversation message; omitted when `nil`/empty + /// (`workspaces[i].latestMessage`). + public let latestConversationMessage: String? + /// Latest submitted prompt; omitted when `nil`/empty + /// (`workspaces[i].latestPrompt`). + public let latestSubmittedMessage: String? + /// Latest submission time; omitted when `nil` (`workspaces[i].latestAt`). + public let latestSubmittedAt: Date? + /// Remote projection; omitted when `nil` (`workspaces[i].remote`). + public let remote: Remote? + + /// Creates a workspace snapshot from already-resolved leaf values. + public init( + id: UUID, + title: String, + isSelected: Bool, + isPinned: Bool, + index: Int, + directory: String, + listeningPorts: [Int], + unreadCount: Int, + surfaces: [CustomSidebarSurfaceSnapshot], + surfaceCount: Int, + customDescription: String?, + customColor: String?, + gitBranch: String?, + gitIsDirty: Bool, + pullRequestValues: [SwiftValue], + progress: Progress?, + latestConversationMessage: String?, + latestSubmittedMessage: String?, + latestSubmittedAt: Date?, + remote: Remote? + ) { + self.id = id + self.title = title + self.isSelected = isSelected + self.isPinned = isPinned + self.index = index + self.directory = directory + self.listeningPorts = listeningPorts + self.unreadCount = unreadCount + self.surfaces = surfaces + self.surfaceCount = surfaceCount + self.customDescription = customDescription + self.customColor = customColor + self.gitBranch = gitBranch + self.gitIsDirty = gitIsDirty + self.pullRequestValues = pullRequestValues + self.progress = progress + self.latestConversationMessage = latestConversationMessage + self.latestSubmittedMessage = latestSubmittedMessage + self.latestSubmittedAt = latestSubmittedAt + self.remote = remote + } +} diff --git a/Packages/CmuxSidebarLayout/Tests/CmuxSidebarLayoutTests/CustomSidebarDataContextBuilderTests.swift b/Packages/CmuxSidebarLayout/Tests/CmuxSidebarLayoutTests/CustomSidebarDataContextBuilderTests.swift new file mode 100644 index 000000000000..14f69a5d30cc --- /dev/null +++ b/Packages/CmuxSidebarLayout/Tests/CmuxSidebarLayoutTests/CustomSidebarDataContextBuilderTests.swift @@ -0,0 +1,311 @@ +import CmuxSwiftRender +import Foundation +import Testing + +@testable import CmuxSidebarLayout + +@Suite("CustomSidebarDataContextBuilder") +struct CustomSidebarDataContextBuilderTests { + private func fixedCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + private func minimalSurface(id: UUID = UUID()) -> CustomSidebarSurfaceSnapshot { + CustomSidebarSurfaceSnapshot( + panelId: id, + title: "shell", + isFocused: false, + isPinned: false, + directory: nil, + gitBranch: nil, + gitIsDirty: false, + listeningPorts: [] + ) + } + + private func minimalWorkspace( + id: UUID = UUID(), + index: Int = 0, + surfaces: [CustomSidebarSurfaceSnapshot] = [] + ) -> CustomSidebarWorkspaceSnapshot { + CustomSidebarWorkspaceSnapshot( + id: id, + title: "Workspace", + isSelected: false, + isPinned: false, + index: index, + directory: "/repo", + listeningPorts: [], + unreadCount: 0, + surfaces: surfaces, + surfaceCount: surfaces.count, + customDescription: nil, + customColor: nil, + gitBranch: nil, + gitIsDirty: false, + pullRequestValues: [], + progress: nil, + latestConversationMessage: nil, + latestSubmittedMessage: nil, + latestSubmittedAt: nil, + remote: nil + ) + } + + @Test("Always-present top-level keys are produced") + func topLevelKeys() { + let builder = CustomSidebarDataContextBuilder(calendar: fixedCalendar()) + let selectedId = UUID() + let snapshot = CustomSidebarContextSnapshot( + workspaces: [minimalWorkspace(id: selectedId)], + selectedWorkspaceId: selectedId, + selectedWorkspaceTitle: "Picked", + totalUnreadCount: 7, + now: Date(timeIntervalSince1970: 0) + ) + + let context = builder.dataContext(for: snapshot) + + #expect(context["workspaceCount"] == .int(1)) + #expect(context["selectedTitle"] == .string("Picked")) + #expect(context["selectedId"] == .string(selectedId.uuidString)) + #expect(context["unreadTotal"] == .int(7)) + #expect(context["workspaces"]?.iterationValues?.count == 1) + #expect(context["clock"]?.member("epoch") == .int(0)) + } + + @Test("Empty selection yields empty selectedId string") + func emptySelection() { + let builder = CustomSidebarDataContextBuilder(calendar: fixedCalendar()) + let snapshot = CustomSidebarContextSnapshot( + workspaces: [], + selectedWorkspaceId: nil, + selectedWorkspaceTitle: "", + totalUnreadCount: 0, + now: Date(timeIntervalSince1970: 0) + ) + + let context = builder.dataContext(for: snapshot) + + #expect(context["selectedId"] == .string("")) + #expect(context["workspaceCount"] == .int(0)) + } + + @Test("Clock components derive from the injected calendar") + func clockComponents() { + let builder = CustomSidebarDataContextBuilder(calendar: fixedCalendar()) + // 1970-01-01 01:02:03 UTC, a Thursday (gregorian weekday 5). + let instant = Date(timeIntervalSince1970: 3723) + let snapshot = CustomSidebarContextSnapshot( + workspaces: [], + selectedWorkspaceId: nil, + selectedWorkspaceTitle: "", + totalUnreadCount: 0, + now: instant + ) + + let clock = builder.dataContext(for: snapshot)["clock"] + + #expect(clock?.member("hour") == .int(1)) + #expect(clock?.member("minute") == .int(2)) + #expect(clock?.member("second") == .int(3)) + #expect(clock?.member("time") == .string("01:02:03")) + #expect(clock?.member("weekday") == .int(5)) + #expect(clock?.member("epoch") == .int(3723)) + } + + @Test("Workspace always-present fields map straight through") + func workspaceAlwaysPresentFields() { + let builder = CustomSidebarDataContextBuilder() + let id = UUID() + var workspace = minimalWorkspace(id: id, index: 3) + workspace = CustomSidebarWorkspaceSnapshot( + id: id, + title: "Title", + isSelected: true, + isPinned: true, + index: 3, + directory: "/work", + listeningPorts: [3000, 8080], + unreadCount: 2, + surfaces: [minimalSurface()], + surfaceCount: 1, + customDescription: nil, + customColor: nil, + gitBranch: nil, + gitIsDirty: false, + pullRequestValues: [], + progress: nil, + latestConversationMessage: nil, + latestSubmittedMessage: nil, + latestSubmittedAt: nil, + remote: nil + ) + + let value = builder.workspaceValue(workspace) + + #expect(value.member("id") == .string(id.uuidString)) + #expect(value.member("title") == .string("Title")) + #expect(value.member("selected") == .bool(true)) + #expect(value.member("pinned") == .bool(true)) + #expect(value.member("index") == .int(3)) + #expect(value.member("directory") == .string("/work")) + #expect(value.member("ports") == .array([.int(3000), .int(8080)])) + #expect(value.member("portCount") == .int(2)) + #expect(value.member("unread") == .int(2)) + #expect(value.member("tabCount") == .int(1)) + // Optional fields absent when their source is nil/empty. + #expect(value.member("description") == nil) + #expect(value.member("color") == nil) + #expect(value.member("branch") == nil) + #expect(value.member("pr") == nil) + #expect(value.member("progress") == nil) + #expect(value.member("remote") == nil) + } + + @Test("Empty optional strings are omitted like nil") + func emptyOptionalStringsOmitted() { + let builder = CustomSidebarDataContextBuilder() + let workspace = CustomSidebarWorkspaceSnapshot( + id: UUID(), + title: "W", + isSelected: false, + isPinned: false, + index: 0, + directory: "/", + listeningPorts: [], + unreadCount: 0, + surfaces: [], + surfaceCount: 0, + customDescription: "", + customColor: "", + gitBranch: nil, + gitIsDirty: false, + pullRequestValues: [], + progress: nil, + latestConversationMessage: "", + latestSubmittedMessage: "", + latestSubmittedAt: nil, + remote: nil + ) + + let value = builder.workspaceValue(workspace) + + #expect(value.member("description") == nil) + #expect(value.member("color") == nil) + #expect(value.member("latestMessage") == nil) + #expect(value.member("latestPrompt") == nil) + } + + @Test("Optional workspace fields appear when present") + func workspaceOptionalFields() { + let builder = CustomSidebarDataContextBuilder() + let prValue: SwiftValue = .object(["number": .int(42)]) + let workspace = CustomSidebarWorkspaceSnapshot( + id: UUID(), + title: "W", + isSelected: false, + isPinned: false, + index: 0, + directory: "/", + listeningPorts: [], + unreadCount: 0, + surfaces: [], + surfaceCount: 0, + customDescription: "desc", + customColor: "#fff", + gitBranch: "main", + gitIsDirty: true, + pullRequestValues: [prValue], + progress: .init(value: 0.5, label: "building"), + latestConversationMessage: "hi", + latestSubmittedMessage: "do it", + latestSubmittedAt: Date(timeIntervalSince1970: 100), + remote: .init(target: "host", stateRawValue: "connected", isConnected: true) + ) + + let value = builder.workspaceValue(workspace) + + #expect(value.member("description") == .string("desc")) + #expect(value.member("color") == .string("#fff")) + #expect(value.member("branch") == .string("main")) + #expect(value.member("dirty") == .bool(true)) + #expect(value.member("pr") == prValue) + #expect(value.member("prs") == .array([prValue])) + #expect(value.member("progress")?.member("value") == .double(0.5)) + #expect(value.member("progress")?.member("label") == .string("building")) + #expect(value.member("latestMessage") == .string("hi")) + #expect(value.member("latestPrompt") == .string("do it")) + #expect(value.member("latestAt") == .int(100)) + #expect(value.member("remote")?.member("target") == .string("host")) + #expect(value.member("remote")?.member("state") == .string("connected")) + #expect(value.member("remote")?.member("connected") == .bool(true)) + } + + @Test("Progress without a label omits the label key") + func progressWithoutLabel() { + let builder = CustomSidebarDataContextBuilder() + var workspace = minimalWorkspace() + workspace = CustomSidebarWorkspaceSnapshot( + id: workspace.id, + title: workspace.title, + isSelected: false, + isPinned: false, + index: 0, + directory: workspace.directory, + listeningPorts: [], + unreadCount: 0, + surfaces: [], + surfaceCount: 0, + customDescription: nil, + customColor: nil, + gitBranch: nil, + gitIsDirty: false, + pullRequestValues: [], + progress: .init(value: 0.25, label: nil), + latestConversationMessage: nil, + latestSubmittedMessage: nil, + latestSubmittedAt: nil, + remote: nil + ) + + let progress = builder.workspaceValue(workspace).member("progress") + + #expect(progress?.member("value") == .double(0.25)) + #expect(progress?.member("label") == nil) + } + + @Test("Surface enrichment fields appear only when present") + func surfaceFields() { + let builder = CustomSidebarDataContextBuilder() + let id = UUID() + let enriched = CustomSidebarSurfaceSnapshot( + panelId: id, + title: "editor", + isFocused: true, + isPinned: true, + directory: "/src", + gitBranch: "feat", + gitIsDirty: false, + listeningPorts: [5173] + ) + + let value = builder.surfaceValue(enriched) + + #expect(value.member("id") == .string(id.uuidString)) + #expect(value.member("title") == .string("editor")) + #expect(value.member("focused") == .bool(true)) + #expect(value.member("pinned") == .bool(true)) + #expect(value.member("directory") == .string("/src")) + #expect(value.member("branch") == .string("feat")) + #expect(value.member("dirty") == .bool(false)) + #expect(value.member("ports") == .array([.int(5173)])) + + let bare = builder.surfaceValue(minimalSurface(id: id)) + #expect(bare.member("directory") == nil) + #expect(bare.member("branch") == nil) + #expect(bare.member("ports") == nil) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1162f8fbae73..b94e08701622 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -19,6 +19,7 @@ import CmuxFileOpen import CmuxSettings import CmuxSettingsUI import CmuxSidebar +import CmuxSidebarLayout import CmuxSidebarRemoteRender import CmuxSwiftRender import CmuxSwiftRenderUI @@ -10308,106 +10309,85 @@ struct VerticalTabsSidebar: View { /// itself, so it respects the sidebar snapshot-boundary rule. private func customSidebarDataContext(now: Date) -> [String: SwiftValue] { let selectedId = tabManager.selectedTabId - let workspaces: [SwiftValue] = tabManager.tabs.enumerated().map { index, workspace in - customSidebarWorkspaceValue(workspace, index: index, selectedId: selectedId) + let workspaces = tabManager.tabs.enumerated().map { index, workspace in + customSidebarWorkspaceSnapshot(workspace, index: index, selectedId: selectedId) } let selectedWorkspace = tabManager.tabs.first { $0.id == selectedId } - let c = Calendar.current.dateComponents([.hour, .minute, .second, .weekday], from: now) - let hour = c.hour ?? 0, minute = c.minute ?? 0, second = c.second ?? 0 - let clock: SwiftValue = .object([ - "time": .string(String(format: "%02d:%02d:%02d", hour, minute, second)), - "hour": .int(hour), - "minute": .int(minute), - "second": .int(second), - "weekday": .int(c.weekday ?? 0), - "epoch": .int(Int(now.timeIntervalSince1970)), - ]) - return [ - "workspaces": .array(workspaces), - "workspaceCount": .int(tabManager.tabs.count), - "selectedTitle": .string(selectedWorkspace?.customTitle ?? selectedWorkspace?.title ?? ""), - "selectedId": .string(selectedId?.uuidString ?? ""), - "unreadTotal": .int(sidebarUnread.totalUnreadCount), - "clock": clock, - ] + let snapshot = CustomSidebarContextSnapshot( + workspaces: workspaces, + selectedWorkspaceId: selectedId, + selectedWorkspaceTitle: selectedWorkspace?.customTitle ?? selectedWorkspace?.title ?? "", + totalUnreadCount: sidebarUnread.totalUnreadCount, + now: now + ) + return CustomSidebarDataContextBuilder().dataContext(for: snapshot) } - /// Projects one workspace's live state into the interpreter value tree. - /// Optional fields are omitted when absent so interpreted `if let` / ternary - /// truthiness behaves; always-present fields default sensibly. Keep this in - /// sync with the data keys documented in `docs/custom-sidebars.md`. - private func customSidebarWorkspaceValue(_ workspace: Workspace, index: Int, selectedId: UUID?) -> SwiftValue { + /// Projects one workspace's live state into the interpreter input snapshot. + /// The SwiftValue assembly and optional-field omission rules live in + /// `CustomSidebarDataContextBuilder`; keep the projected fields in sync with + /// the data keys documented in `docs/custom-sidebars.md`. + private func customSidebarWorkspaceSnapshot(_ workspace: Workspace, index: Int, selectedId: UUID?) -> CustomSidebarWorkspaceSnapshot { let focusedPanelId = workspace.focusedPanelId - var fields: [String: SwiftValue] = [ - "id": .string(workspace.id.uuidString), - "title": .string(workspace.customTitle ?? workspace.title), - "selected": .bool(workspace.id == selectedId), - "pinned": .bool(workspace.isPinned), - "index": .int(index), - "directory": .string(workspace.currentDirectory), - "ports": .array(workspace.listeningPorts.map { .int($0) }), - "portCount": .int(workspace.listeningPorts.count), - "unread": .int(sidebarUnread.unreadCount(forWorkspaceId: workspace.id)), - "tabs": .array(customSidebarSurfaceValues(workspace, focusedPanelId: focusedPanelId)), - "tabCount": .int(workspace.bonsplitController.allPaneIds.reduce(0) { $0 + workspace.bonsplitController.tabs(inPane: $1).count }), - ] - if let description = workspace.customDescription, !description.isEmpty { fields["description"] = .string(description) } - if let color = workspace.customColor, !color.isEmpty { fields["color"] = .string(color) } - if let git = workspace.sidebarGitBranchesInDisplayOrder().first { - fields["branch"] = .string(git.branch) - fields["dirty"] = .bool(git.isDirty) - } - let pullRequestValues = workspace.customSidebarPullRequestValues() - if let firstPullRequest = pullRequestValues.first { - fields["pr"] = firstPullRequest - fields["prs"] = .array(pullRequestValues) - } - if let progress = workspace.progress { - var progressFields: [String: SwiftValue] = ["value": .double(progress.value)] - if let label = progress.label { progressFields["label"] = .string(label) } - fields["progress"] = .object(progressFields) - } - if let message = workspace.latestConversationMessage, !message.isEmpty { fields["latestMessage"] = .string(message) } - if let prompt = workspace.latestSubmittedMessage, !prompt.isEmpty { fields["latestPrompt"] = .string(prompt) } - if let at = workspace.latestSubmittedAt { fields["latestAt"] = .int(Int(at.timeIntervalSince1970)) } - if let target = workspace.remoteDisplayTarget { - fields["remote"] = .object([ - "target": .string(target), - "state": .string(workspace.remoteConnectionState.rawValue), - "connected": .bool(workspace.remoteConnectionState == .connected), - ]) + let firstBranch = workspace.sidebarGitBranchesInDisplayOrder().first + let progress = workspace.progress.map { + CustomSidebarWorkspaceSnapshot.Progress(value: $0.value, label: $0.label) + } + let remote = workspace.remoteDisplayTarget.map { target in + CustomSidebarWorkspaceSnapshot.Remote( + target: target, + stateRawValue: workspace.remoteConnectionState.rawValue, + isConnected: workspace.remoteConnectionState == .connected + ) } - return .object(fields) + return CustomSidebarWorkspaceSnapshot( + id: workspace.id, + title: workspace.customTitle ?? workspace.title, + isSelected: workspace.id == selectedId, + isPinned: workspace.isPinned, + index: index, + directory: workspace.currentDirectory, + listeningPorts: workspace.listeningPorts, + unreadCount: sidebarUnread.unreadCount(forWorkspaceId: workspace.id), + surfaces: customSidebarSurfaceSnapshots(workspace, focusedPanelId: focusedPanelId), + surfaceCount: workspace.bonsplitController.allPaneIds.reduce(0) { $0 + workspace.bonsplitController.tabs(inPane: $1).count }, + customDescription: workspace.customDescription, + customColor: workspace.customColor, + gitBranch: firstBranch?.branch, + gitIsDirty: firstBranch?.isDirty ?? false, + pullRequestValues: workspace.customSidebarPullRequestValues(), + progress: progress, + latestConversationMessage: workspace.latestConversationMessage, + latestSubmittedMessage: workspace.latestSubmittedMessage, + latestSubmittedAt: workspace.latestSubmittedAt, + remote: remote + ) } /// Projects a workspace's surfaces (terminal/browser/etc. tabs) into the - /// interpreter value tree, enriched with per-surface directory, pin, git, - /// and ports where available. - private func customSidebarSurfaceValues(_ workspace: Workspace, focusedPanelId: UUID?) -> [SwiftValue] { - var tabs: [SwiftValue] = [] + /// interpreter input snapshots, enriched with per-surface directory, pin, + /// git, and ports where available. + private func customSidebarSurfaceSnapshots(_ workspace: Workspace, focusedPanelId: UUID?) -> [CustomSidebarSurfaceSnapshot] { + var surfaces: [CustomSidebarSurfaceSnapshot] = [] for paneId in workspace.bonsplitController.allPaneIds { for tab in workspace.bonsplitController.tabs(inPane: paneId) { guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } - var surfaceFields: [String: SwiftValue] = [ - "id": .string(panelId.uuidString), - "title": .string(tab.title), - "focused": .bool(panelId == focusedPanelId), - "pinned": .bool(workspace.pinnedPanelIds.contains(panelId)), - ] - if let directory = workspace.panelDirectories[panelId], !directory.isEmpty { - surfaceFields["directory"] = .string(directory) - } - if let git = workspace.panelGitBranches[panelId] { - surfaceFields["branch"] = .string(git.branch) - surfaceFields["dirty"] = .bool(git.isDirty) - } - if let ports = workspace.surfaceListeningPorts[panelId], !ports.isEmpty { - surfaceFields["ports"] = .array(ports.map { .int($0) }) - } - tabs.append(.object(surfaceFields)) + let git = workspace.panelGitBranches[panelId] + surfaces.append( + CustomSidebarSurfaceSnapshot( + panelId: panelId, + title: tab.title, + isFocused: panelId == focusedPanelId, + isPinned: workspace.pinnedPanelIds.contains(panelId), + directory: workspace.panelDirectories[panelId], + gitBranch: git?.branch, + gitIsDirty: git?.isDirty ?? false, + listeningPorts: workspace.surfaceListeningPorts[panelId] ?? [] + ) + ) } } - return tabs + return surfaces } @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e6bcb1aa93c1..c5be8820cb9b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -265,6 +265,7 @@ C5B6A10000000000000000A3 /* CmuxSidebarGit in Frameworks */ = {isa = PBXBuildFile; productRef = C5B6A10000000000000000A2 /* CmuxSidebarGit */; }; C5A1FED200000000000000E1 /* CmuxSidebarInterpreterClient in Frameworks */ = {isa = PBXBuildFile; productRef = C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */; }; C5A1FED200000000000000E2 /* CmuxSidebarInterpreterClient in Frameworks */ = {isa = PBXBuildFile; productRef = C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */; }; + CA11A50000000000000000A3 /* CmuxSidebarLayout in Frameworks */ = {isa = PBXBuildFile; productRef = CA11A50000000000000000A2 /* CmuxSidebarLayout */; }; C0DE4A000000000000000001 /* CmuxSidebarProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE4A000000000000000002 /* CmuxSidebarProviderKit */; }; C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE4A000000000000000004 /* CmuxSidebarProviderKit */; }; C5A1FED200000000000000E5 /* CmuxSidebarRemoteRender in Frameworks */ = {isa = PBXBuildFile; productRef = C5A1FED200000000000000E6 /* CmuxSidebarRemoteRender */; }; @@ -1800,6 +1801,7 @@ E3B7A30000000000000000B3 /* CmuxSidebar in Frameworks */, C5B6A10000000000000000A3 /* CmuxSidebarGit in Frameworks */, C5A1FED200000000000000E1 /* CmuxSidebarInterpreterClient in Frameworks */, + CA11A50000000000000000A3 /* CmuxSidebarLayout in Frameworks */, C0DE4A000000000000000001 /* CmuxSidebarProviderKit in Frameworks */, C5A1FED200000000000000E5 /* CmuxSidebarRemoteRender in Frameworks */, A5354303A5354303A5354303 /* CmuxSocketControl in Frameworks */, @@ -2837,6 +2839,7 @@ C617000000000000000000A2 /* CmuxGit */, C5B6A10000000000000000A2 /* CmuxSidebarGit */, E3B7A30000000000000000B2 /* CmuxSidebar */, + CA11A50000000000000000A2 /* CmuxSidebarLayout */, E3B7A30000000000000000C2 /* CmuxBrowser */, E3B7A30000000000000000D2 /* CmuxNotifications */, E3B7A30000000000000000E2 /* CmuxWorkspaceNavigation */, @@ -3030,6 +3033,7 @@ C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, + CA11A50000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarLayout" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, E3B7A30000000000000000D1 /* XCLocalSwiftPackageReference "CmuxNotifications" */, E3B7A30000000000000000E1 /* XCLocalSwiftPackageReference "CmuxWorkspaceNavigation" */, @@ -4559,6 +4563,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxSidebar; }; + CA11A50000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarLayout" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/CmuxSidebarLayout; + }; E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/CmuxBrowser; @@ -4829,6 +4837,11 @@ package = E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */; productName = CmuxSidebar; }; + CA11A50000000000000000A2 /* CmuxSidebarLayout */ = { + isa = XCSwiftPackageProductDependency; + package = CA11A50000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarLayout" */; + productName = CmuxSidebarLayout; + }; E3B7A30000000000000000C2 /* CmuxBrowser */ = { isa = XCSwiftPackageProductDependency; package = E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */;