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
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions Packages/CmuxSidebarLayout/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions Packages/CmuxSidebarLayout/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading