Merge pull request #337 from adinvadim/feature/sidebar-pr-metadata

feat: show linked pull request metadata in sidebar
This commit is contained in:
Lawrence Chen 2026-02-24 21:44:27 -08:00 committed by GitHub
commit 7201dabdfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2231 additions and 135 deletions

View file

@ -1226,6 +1226,8 @@ struct ContentView: View {
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@FocusState private var isCommandPaletteSearchFocused: Bool
@FocusState private var isCommandPaletteRenameFocused: Bool
@ -1368,6 +1370,7 @@ struct ContentView: View {
static let workspaceName = "workspace.name"
static let workspaceHasCustomName = "workspace.hasCustomName"
static let workspaceShouldPin = "workspace.shouldPin"
static let workspaceHasPullRequests = "workspace.hasPullRequests"
static let hasFocusedPanel = "panel.hasFocus"
static let panelName = "panel.name"
@ -3339,6 +3342,10 @@ struct ContentView: View {
snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace))
snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil)
snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned)
snapshot.setBool(
CommandPaletteContextKeys.workspaceHasPullRequests,
!workspace.sidebarPullRequestsInDisplayOrder().isEmpty
)
}
if let panelContext = focusedPanelContext {
@ -3654,6 +3661,18 @@ struct ContentView: View {
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.openWorkspacePullRequests",
title: constant("Open All Workspace PR Links"),
subtitle: workspaceSubtitle,
keywords: ["pull", "request", "review", "merge", "pr", "mr", "open", "links", "workspace"],
when: {
$0.bool(CommandPaletteContextKeys.hasWorkspace) &&
$0.bool(CommandPaletteContextKeys.workspaceHasPullRequests)
}
)
)
contributions.append(
CommandPaletteCommandContribution(
commandId: "palette.browserBack",
@ -4019,6 +4038,13 @@ struct ContentView: View {
registry.register(commandId: "palette.previousTabInPane") {
tabManager.selectPreviousSurface()
}
registry.register(commandId: "palette.openWorkspacePullRequests") {
DispatchQueue.main.async {
if !openWorkspacePullRequestsInConfiguredBrowser() {
NSSound.beep()
}
}
}
registry.register(commandId: "palette.browserBack") {
tabManager.focusedBrowserPanel?.goBack()
@ -4664,6 +4690,31 @@ struct ContentView: View {
return NSWorkspace.shared.open(url)
}
private func openWorkspacePullRequestsInConfiguredBrowser() -> Bool {
guard let workspace = tabManager.selectedWorkspace else { return false }
let pullRequests = workspace.sidebarPullRequestsInDisplayOrder()
guard !pullRequests.isEmpty else { return false }
var openedCount = 0
if openSidebarPullRequestLinksInCmuxBrowser {
for pullRequest in pullRequests {
if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil {
openedCount += 1
} else if NSWorkspace.shared.open(pullRequest.url) {
openedCount += 1
}
}
return openedCount > 0
}
for pullRequest in pullRequests {
if NSWorkspace.shared.open(pullRequest.url) {
openedCount += 1
}
}
return openedCount > 0
}
private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool {
guard let directoryURL = focusedTerminalDirectoryURL() else { return false }
return openFocusedDirectory(directoryURL, in: target)
@ -6037,11 +6088,15 @@ private struct TabItemView: View {
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@ -6225,16 +6280,25 @@ private struct TabItemView: View {
.multilineTextAlignment(.leading)
}
if sidebarShowStatusPills, !tab.statusEntries.isEmpty {
SidebarStatusPillsRow(
entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}),
isActive: usesInvertedActiveForeground,
onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
if sidebarShowMetadata {
let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
if !metadataEntries.isEmpty {
SidebarMetadataRows(
entries: metadataEntries,
isActive: usesInvertedActiveForeground,
onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
}
if !metadataBlocks.isEmpty {
SidebarMetadataMarkdownBlocks(
blocks: metadataBlocks,
isActive: usesInvertedActiveForeground,
onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// Latest log entry
@ -6277,54 +6341,85 @@ private struct TabItemView: View {
}
// Branch + directory row
if sidebarBranchVerticalLayout {
if !verticalBranchDirectoryLines.isEmpty {
HStack(alignment: .top, spacing: 3) {
if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9))
.foregroundColor(activeSecondaryColor(0.6))
}
VStack(alignment: .leading, spacing: 1) {
ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in
HStack(spacing: 3) {
if let branch = line.branch {
Text(branch)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
.truncationMode(.tail)
}
if line.branch != nil, line.directory != nil {
Image(systemName: "circle.fill")
.font(.system(size: 3))
.foregroundColor(activeSecondaryColor(0.6))
.padding(.horizontal, 1)
}
if let directory = line.directory {
Text(directory)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
.truncationMode(.tail)
if sidebarShowBranchDirectory {
if sidebarBranchVerticalLayout {
if !verticalBranchDirectoryLines.isEmpty {
HStack(alignment: .top, spacing: 3) {
if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9))
.foregroundColor(activeSecondaryColor(0.6))
}
VStack(alignment: .leading, spacing: 1) {
ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in
HStack(spacing: 3) {
if let branch = line.branch {
Text(branch)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
.truncationMode(.tail)
}
if line.branch != nil, line.directory != nil {
Image(systemName: "circle.fill")
.font(.system(size: 3))
.foregroundColor(activeSecondaryColor(0.6))
.padding(.horizontal, 1)
}
if let directory = line.directory {
Text(directory)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
.truncationMode(.tail)
}
}
}
}
}
}
}
} else if let dirRow = branchDirectoryRow {
HStack(spacing: 3) {
if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9))
.foregroundColor(activeSecondaryColor(0.6))
} else if let dirRow = branchDirectoryRow {
HStack(spacing: 3) {
if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9))
.foregroundColor(activeSecondaryColor(0.6))
}
Text(dirRow)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
.truncationMode(.tail)
}
}
}
// Pull request rows
if sidebarShowPullRequest, !pullRequestDisplays.isEmpty {
VStack(alignment: .leading, spacing: 1) {
ForEach(pullRequestDisplays) { pullRequest in
Button(action: {
openPullRequestLink(pullRequest.url)
}) {
HStack(spacing: 4) {
PullRequestStatusIcon(
status: pullRequest.status,
color: pullRequestForegroundColor
)
Text("\(pullRequest.label) #\(pullRequest.number)")
.underline()
.lineLimit(1)
.truncationMode(.tail)
Text(pullRequestStatusLabel(pullRequest.status))
.lineLimit(1)
Spacer(minLength: 0)
}
.font(.system(size: 10, weight: .semibold))
.foregroundColor(pullRequestForegroundColor)
}
.buttonStyle(.plain)
.help("Open \(pullRequest.label) #\(pullRequest.number)")
}
Text(dirRow)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1)
.truncationMode(.tail)
}
}
@ -6339,6 +6434,7 @@ private struct TabItemView: View {
}
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
.animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
@ -6895,6 +6991,54 @@ private struct TabItemView: View {
return entries.isEmpty ? nil : entries.joined(separator: " | ")
}
private struct PullRequestDisplay: Identifiable {
let id: String
let number: Int
let label: String
let url: URL
let status: SidebarPullRequestStatus
}
private var pullRequestDisplays: [PullRequestDisplay] {
tab.sidebarPullRequestsInDisplayOrder().map { pullRequest in
PullRequestDisplay(
id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)",
number: pullRequest.number,
label: pullRequest.label,
url: pullRequest.url,
status: pullRequest.status
)
}
}
private var pullRequestForegroundColor: Color {
isActive ? .white.opacity(0.75) : .secondary
}
private func openPullRequestLink(_ url: URL) {
updateSelection()
if openSidebarPullRequestLinksInCmuxBrowser {
if tabManager.openBrowser(
inWorkspace: tab.id,
url: url,
preferSplitRight: true,
insertAtEnd: true
) == nil {
NSWorkspace.shared.open(url)
}
return
}
NSWorkspace.shared.open(url)
}
private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String {
switch status {
case .open: return "open"
case .merged: return "merged"
case .closed: return "closed"
}
}
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
switch level {
case .info: return "circle.fill"
@ -6941,6 +7085,101 @@ private struct TabItemView: View {
return trimmed
}
private struct PullRequestStatusIcon: View {
let status: SidebarPullRequestStatus
let color: Color
private static let frameSize: CGFloat = 12
var body: some View {
switch status {
case .open:
PullRequestOpenIcon(color: color)
case .merged:
PullRequestMergedIcon(color: color)
case .closed:
Image(systemName: "xmark.circle")
.font(.system(size: 7, weight: .regular))
.foregroundColor(color)
.frame(width: Self.frameSize, height: Self.frameSize)
}
}
}
private struct PullRequestOpenIcon: View {
let color: Color
private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round)
private static let nodeDiameter: CGFloat = 3.0
private static let frameSize: CGFloat = 13
var body: some View {
ZStack {
Path { path in
path.move(to: CGPoint(x: 3.0, y: 4.8))
path.addLine(to: CGPoint(x: 3.0, y: 9.2))
path.move(to: CGPoint(x: 4.8, y: 3.0))
path.addLine(to: CGPoint(x: 9.4, y: 3.0))
path.addLine(to: CGPoint(x: 11.0, y: 4.6))
path.addLine(to: CGPoint(x: 11.0, y: 9.2))
}
.stroke(color, style: Self.stroke)
Circle()
.stroke(color, lineWidth: Self.stroke.lineWidth)
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
.position(x: 3.0, y: 3.0)
Circle()
.stroke(color, lineWidth: Self.stroke.lineWidth)
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
.position(x: 3.0, y: 11.0)
Circle()
.stroke(color, lineWidth: Self.stroke.lineWidth)
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
.position(x: 11.0, y: 11.0)
}
.frame(width: Self.frameSize, height: Self.frameSize)
}
}
private struct PullRequestMergedIcon: View {
let color: Color
private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round)
private static let nodeDiameter: CGFloat = 3.0
private static let frameSize: CGFloat = 13
var body: some View {
ZStack {
Path { path in
path.move(to: CGPoint(x: 4.6, y: 4.6))
path.addLine(to: CGPoint(x: 7.1, y: 7.0))
path.addLine(to: CGPoint(x: 9.2, y: 7.0))
path.move(to: CGPoint(x: 4.6, y: 9.4))
path.addLine(to: CGPoint(x: 7.1, y: 7.0))
}
.stroke(color, style: Self.stroke)
Circle()
.stroke(color, lineWidth: Self.stroke.lineWidth)
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
.position(x: 3.0, y: 3.0)
Circle()
.stroke(color, lineWidth: Self.stroke.lineWidth)
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
.position(x: 3.0, y: 11.0)
Circle()
.stroke(color, lineWidth: Self.stroke.lineWidth)
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
.position(x: 11.0, y: 7.0)
}
.frame(width: Self.frameSize, height: Self.frameSize)
}
}
private func applyTabColor(_ hex: String?, targetIds: [UUID]) {
for targetId in targetIds {
tabManager.setTabColor(tabId: targetId, color: hex)
@ -7012,30 +7251,19 @@ private struct TabItemView: View {
}
}
private struct SidebarStatusPillsRow: View {
private struct SidebarMetadataRows: View {
let entries: [SidebarStatusEntry]
let isActive: Bool
let onFocus: () -> Void
@State private var isExpanded: Bool = false
private let collapsedEntryLimit = 3
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(statusText)
.font(.system(size: 10))
.foregroundColor(isActive ? activePrimaryTextColor : .secondary)
.lineLimit(isExpanded ? nil : 3)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
onFocus()
guard shouldShowToggle else { return }
withAnimation(.easeInOut(duration: 0.15)) {
isExpanded.toggle()
}
}
ForEach(visibleEntries, id: \.key) { entry in
SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus)
}
if shouldShowToggle {
Button(isExpanded ? "Show less" : "Show more") {
@ -7050,29 +7278,203 @@ private struct SidebarStatusPillsRow: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.help(statusText)
}
private var activePrimaryTextColor: Color {
Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8))
.help(helpText)
}
private var activeSecondaryTextColor: Color {
Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65))
}
private var statusText: String {
entries
.map { entry in
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty { return value }
return entry.key
}
.joined(separator: "\n")
private var visibleEntries: [SidebarStatusEntry] {
guard !isExpanded, entries.count > collapsedEntryLimit else { return entries }
return Array(entries.prefix(collapsedEntryLimit))
}
private var helpText: String {
entries.map { entry in
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? entry.key : trimmed
}
.joined(separator: "\n")
}
private var shouldShowToggle: Bool {
entries.count > 1 || statusText.count > 120
entries.count > collapsedEntryLimit
}
}
private struct SidebarMetadataEntryRow: View {
let entry: SidebarStatusEntry
let isActive: Bool
let onFocus: () -> Void
var body: some View {
Group {
if let url = entry.url {
Button {
onFocus()
NSWorkspace.shared.open(url)
} label: {
rowContent(underlined: true)
}
.buttonStyle(.plain)
.help(url.absoluteString)
} else {
rowContent(underlined: false)
.contentShape(Rectangle())
.onTapGesture { onFocus() }
}
}
}
@ViewBuilder
private func rowContent(underlined: Bool) -> some View {
HStack(spacing: 4) {
if let icon = iconView {
icon
.foregroundColor(foregroundColor.opacity(0.95))
}
metadataText(underlined: underlined)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.font(.system(size: 10))
.frame(maxWidth: .infinity, alignment: .leading)
}
private var foregroundColor: Color {
if let raw = entry.color, let explicit = Color(hex: raw) {
return explicit
}
return isActive ? .white.opacity(0.8) : .secondary
}
private var iconView: AnyView? {
guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines),
!iconRaw.isEmpty else {
return nil
}
if iconRaw.hasPrefix("emoji:") {
let value = String(iconRaw.dropFirst("emoji:".count))
guard !value.isEmpty else { return nil }
return AnyView(Text(value).font(.system(size: 9)))
}
if iconRaw.hasPrefix("text:") {
let value = String(iconRaw.dropFirst("text:".count))
guard !value.isEmpty else { return nil }
return AnyView(Text(value).font(.system(size: 8, weight: .semibold)))
}
let symbolName: String
if iconRaw.hasPrefix("sf:") {
symbolName = String(iconRaw.dropFirst("sf:".count))
} else {
symbolName = iconRaw
}
guard !symbolName.isEmpty else { return nil }
return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium)))
}
@ViewBuilder
private func metadataText(underlined: Bool) -> some View {
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
let display = trimmed.isEmpty ? entry.key : trimmed
if entry.format == .markdown,
let attributed = try? AttributedString(
markdown: display,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
Text(attributed)
.underline(underlined)
.foregroundColor(foregroundColor)
} else {
Text(display)
.underline(underlined)
.foregroundColor(foregroundColor)
}
}
}
private struct SidebarMetadataMarkdownBlocks: View {
let blocks: [SidebarMetadataBlock]
let isActive: Bool
let onFocus: () -> Void
@State private var isExpanded: Bool = false
private let collapsedBlockLimit = 1
var body: some View {
VStack(alignment: .leading, spacing: 3) {
ForEach(visibleBlocks, id: \.key) { block in
SidebarMetadataMarkdownBlockRow(
block: block,
isActive: isActive,
onFocus: onFocus
)
}
if shouldShowToggle {
Button(isExpanded ? "Show less details" : "Show more details") {
onFocus()
withAnimation(.easeInOut(duration: 0.15)) {
isExpanded.toggle()
}
}
.buttonStyle(.plain)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private var visibleBlocks: [SidebarMetadataBlock] {
guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks }
return Array(blocks.prefix(collapsedBlockLimit))
}
private var shouldShowToggle: Bool {
blocks.count > collapsedBlockLimit
}
}
private struct SidebarMetadataMarkdownBlockRow: View {
let block: SidebarMetadataBlock
let isActive: Bool
let onFocus: () -> Void
@State private var renderedMarkdown: AttributedString?
var body: some View {
Group {
if let renderedMarkdown {
Text(renderedMarkdown)
.foregroundColor(foregroundColor)
} else {
Text(block.markdown)
.foregroundColor(foregroundColor)
}
}
.font(.system(size: 10))
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.contentShape(Rectangle())
.onTapGesture { onFocus() }
.onAppear(perform: renderMarkdown)
.onChange(of: block.markdown) { _ in
renderMarkdown()
}
}
private var foregroundColor: Color {
isActive ? .white.opacity(0.8) : .secondary
}
private func renderMarkdown() {
renderedMarkdown = try? AttributedString(
markdown: block.markdown,
options: .init(interpretedSyntax: .full)
)
}
}

View file

@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings {
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser"
static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
@ -140,6 +143,13 @@ enum BrowserLinkOpenSettings {
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
}
static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil {
return defaultOpenSidebarPullRequestLinksInCmuxBrowser
}
return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey)
}
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)

View file

@ -156,13 +156,10 @@ private struct OmnibarAddressButtonStyleBody: View {
}
private extension View {
@ViewBuilder
func cmuxFlatSymbolColorRendering() -> some View {
if #available(macOS 26.0, *) {
self.symbolColorRenderingMode(.flat)
} else {
self
}
// `symbolColorRenderingMode(.flat)` is not available in the current SDK
// used by CI/local builds. Keep this modifier as a compatibility no-op.
self
}
}

View file

@ -1940,19 +1940,81 @@ class TabManager: ObservableObject {
return tab.browserPanel(for: panelId)
}
/// Open a browser in a specific workspace, optionally preferring a split-right layout.
@discardableResult
func openBrowser(
inWorkspace tabId: UUID,
url: URL? = nil,
preferSplitRight: Bool = false,
insertAtEnd: Bool = false
) -> UUID? {
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
if selectedTabId != tabId {
selectedTabId = tabId
}
if preferSplitRight {
if let targetPaneId = workspace.topRightBrowserReusePane(),
let browserPanel = workspace.newBrowserSurface(
inPane: targetPaneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
}
let splitSourcePanelId: UUID? = {
if let focusedPanelId = workspace.focusedPanelId,
workspace.panels[focusedPanelId] != nil {
return focusedPanelId
}
if let rememberedPanelId = lastFocusedPanelByTab[tabId],
workspace.panels[rememberedPanelId] != nil {
return rememberedPanelId
}
if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) {
return orderedPanelId
}
return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first
}()
if let splitSourcePanelId,
let browserPanel = workspace.newBrowserSplit(
from: splitSourcePanelId,
orientation: .horizontal,
url: url,
focus: true
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
}
}
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first,
let browserPanel = workspace.newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
) else {
return nil
}
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
}
/// Open a browser in the currently focused pane (as a new surface)
@discardableResult
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
guard let tabId = selectedTabId,
let tab = tabs.first(where: { $0.id == tabId }),
let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil }
let panel = tab.newBrowserSurface(
inPane: focusedPaneId,
guard let tabId = selectedTabId else { return nil }
return openBrowser(
inWorkspace: tabId,
url: url,
focus: true,
preferSplitRight: false,
insertAtEnd: insertAtEnd
)
return panel?.id
}
/// Reopen the most recently closed browser panel (Cmd+Shift+T).

View file

@ -166,10 +166,29 @@ class TerminalController {
key: String,
value: String,
icon: String?,
color: String?
color: String?,
url: URL?,
priority: Int,
format: SidebarMetadataFormat
) -> Bool {
guard let current else { return true }
return current.key != key || current.value != value || current.icon != icon || current.color != color
return current.key != key ||
current.value != value ||
current.icon != icon ||
current.color != color ||
current.url != url ||
current.priority != priority ||
current.format != format
}
nonisolated static func shouldReplaceMetadataBlock(
current: SidebarMetadataBlock?,
key: String,
markdown: String,
priority: Int
) -> Bool {
guard let current else { return true }
return current.key != key || current.markdown != markdown || current.priority != priority
}
nonisolated static func shouldReplaceProgress(
@ -190,6 +209,17 @@ class TerminalController {
return current.branch != branch || current.isDirty != isDirty
}
nonisolated static func shouldReplacePullRequest(
current: SidebarPullRequestState?,
number: Int,
label: String,
url: URL,
status: SidebarPullRequestStatus
) -> Bool {
guard let current else { return true }
return current.number != number || current.label != label || current.url != url || current.status != status
}
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
let currentSorted = Array(Set(current ?? [])).sorted()
let nextSorted = Array(Set(next)).sorted()
@ -707,12 +737,30 @@ class TerminalController {
case "set_status":
return setStatus(args)
case "report_meta":
return reportMeta(args)
case "report_meta_block":
return reportMetaBlock(args)
case "clear_status":
return clearStatus(args)
case "clear_meta":
return clearMeta(args)
case "clear_meta_block":
return clearMetaBlock(args)
case "list_status":
return listStatus(args)
case "list_meta":
return listMeta(args)
case "list_meta_blocks":
return listMetaBlocks(args)
case "log":
return appendLog(args)
@ -734,6 +782,15 @@ class TerminalController {
case "clear_git_branch":
return clearGitBranch(args)
case "report_pr":
return reportPullRequest(args)
case "report_review":
return reportPullRequest(args)
case "clear_pr":
return clearPullRequest(args)
case "report_ports":
return reportPorts(args)
@ -8339,9 +8396,15 @@ class TerminalController {
clear_notifications - Clear all notifications
set_app_focus <active|inactive|clear> - Override app focus state
simulate_app_active - Trigger app active handler
set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry
report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry
report_meta_block <key> [--priority=N] [--tab=X] -- <markdown> - Set freeform sidebar markdown block
clear_status <key> [--tab=X] - Remove a status entry
clear_meta <key> [--tab=X] - Remove sidebar metadata entry
clear_meta_block <key> [--tab=X] - Remove sidebar markdown block
list_status [--tab=X] - List all status entries
list_meta [--tab=X] - List sidebar metadata entries
list_meta_blocks [--tab=X] - List sidebar markdown blocks
log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry
clear_log [--tab=X] - Clear log entries
list_log [--limit=N] [--tab=X] - List log entries
@ -8349,6 +8412,9 @@ class TerminalController {
clear_progress [--tab=X] - Clear progress bar
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item
report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item
clear_pr [--tab=X] [--panel=Y] - Clear pull request
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
@ -11325,29 +11391,103 @@ class TerminalController {
return tabManager.tabs.first(where: { $0.id == selectedId })
}
private func setStatus(_ args: String) -> String {
private func resolveTabIdForSidebarMutation(
reportArgs: String,
options: [String: String]
) -> (tabId: UUID?, error: String?) {
var tabId: UUID?
DispatchQueue.main.sync {
if let tab = resolveTabForReport(reportArgs) {
tabId = tab.id
}
}
if let tabId {
return (tabId, nil)
}
let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return (nil, error)
}
private func tabForSidebarMutation(id: UUID) -> Tab? {
if let tab = tabManager?.tabs.first(where: { $0.id == id }) {
return tab
}
if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) {
return otherManager.tabs.first(where: { $0.id == id })
}
return nil
}
private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? {
switch raw.lowercased() {
case "plain":
return .plain
case "markdown", "md":
return .markdown
default:
return nil
}
}
private func normalizedOptionValue(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func upsertSidebarMetadata(_ args: String, missingError: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptionsNoStop(args)
guard parsed.positional.count >= 2 else {
return "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X]"
}
guard parsed.positional.count >= 2 else { return missingError }
let key = parsed.positional[0]
let value = parsed.positional[1...].joined(separator: " ")
let icon = parsed.options["icon"]
let color = parsed.options["color"]
let icon = normalizedOptionValue(parsed.options["icon"])
let color = normalizedOptionValue(parsed.options["color"])
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue
guard let format = parseSidebarMetadataFormat(formatRaw) else {
return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown"
}
let priority: Int
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
guard let parsedPriority = Int(rawPriority) else {
return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer"
}
priority = max(-9999, min(9999, parsedPriority))
} else {
priority = 0
}
let parsedURL: URL?
if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) {
guard let candidate = URL(string: rawURL),
let scheme = candidate.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL"
}
parsedURL = candidate
} else {
parsedURL = nil
}
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
guard Self.shouldReplaceStatusEntry(
current: tab.statusEntries[key],
key: key,
value: value,
icon: icon,
color: color
color: color,
url: parsedURL,
priority: priority,
format: format
) else {
return
}
@ -11356,16 +11496,19 @@ class TerminalController {
value: value,
icon: icon,
color: color,
url: parsedURL,
priority: priority,
format: format,
timestamp: Date()
)
}
return result
return "OK"
}
private func clearStatus(_ args: String) -> String {
private func clearSidebarMetadata(_ args: String, usage: String) -> String {
let parsed = parseOptions(args)
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
return "ERROR: Missing status key — usage: clear_status <key> [--tab=X]"
return "ERROR: Missing metadata key — usage: \(usage)"
}
var result = "OK"
@ -11381,24 +11524,173 @@ class TerminalController {
return result
}
private func listStatus(_ args: String) -> String {
private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
var line = "\(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
if let color = entry.color { line += " color=\(color)" }
if let url = entry.url { line += " url=\(url.absoluteString)" }
if entry.priority != 0 { line += " priority=\(entry.priority)" }
if entry.format != .plain { line += " format=\(entry.format.rawValue)" }
return line
}
private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String {
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
if tab.statusEntries.isEmpty {
result = "No status entries"
let entries = tab.sidebarStatusEntriesInDisplayOrder()
if entries.isEmpty {
result = emptyMessage
return
}
let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in
var line = "\(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
if let color = entry.color { line += " color=\(color)" }
return line
result = entries.map(sidebarMetadataLine).joined(separator: "\n")
}
return result
}
private func setStatus(_ args: String) -> String {
upsertSidebarMetadata(
args,
missingError: "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
)
}
private func reportMeta(_ args: String) -> String {
upsertSidebarMetadata(
args,
missingError: "ERROR: Missing metadata key or value — usage: report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
)
}
private func clearStatus(_ args: String) -> String {
clearSidebarMetadata(args, usage: "clear_status <key> [--tab=X]")
}
private func clearMeta(_ args: String) -> String {
clearSidebarMetadata(args, usage: "clear_meta <key> [--tab=X]")
}
private func listStatus(_ args: String) -> String {
listSidebarMetadata(args, emptyMessage: "No status entries")
}
private func listMeta(_ args: String) -> String {
listSidebarMetadata(args, emptyMessage: "No metadata entries")
}
private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) {
guard let separatorRange = args.range(of: " -- ") else {
return (args, nil)
}
let optionsPart = String(args[..<separatorRange.lowerBound])
let markdownPart = String(args[separatorRange.upperBound...])
return (optionsPart, markdownPart)
}
private func sidebarMetadataBlockLine(_ block: SidebarMetadataBlock) -> String {
var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))"
if block.priority != 0 { line += " priority=\(block.priority)" }
return line
}
private func reportMetaBlock(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parts = splitMetadataBlockArgs(args)
let parsed = parseOptionsNoStop(parts.optionsPart)
guard let key = parsed.positional.first, !key.isEmpty else {
return "ERROR: Missing metadata block key — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
let markdown: String
if let raw = parts.markdownPart {
markdown = raw
} else if parsed.positional.count >= 2 {
markdown = parsed.positional.dropFirst().joined(separator: " ")
} else {
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
let normalizedMarkdown = markdown
.replacingOccurrences(of: "\\r\\n", with: "\n")
.replacingOccurrences(of: "\\n", with: "\n")
.replacingOccurrences(of: "\\t", with: "\t")
let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedMarkdown.isEmpty else {
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
let priority: Int
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
guard let parsedPriority = Int(rawPriority) else {
return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer"
}
result = lines.joined(separator: "\n")
priority = max(-9999, min(9999, parsedPriority))
} else {
priority = 0
}
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
guard Self.shouldReplaceMetadataBlock(
current: tab.metadataBlocks[key],
key: key,
markdown: normalizedMarkdown,
priority: priority
) else {
return
}
tab.metadataBlocks[key] = SidebarMetadataBlock(
key: key,
markdown: normalizedMarkdown,
priority: priority,
timestamp: Date()
)
}
return "OK"
}
private func clearMetaBlock(_ args: String) -> String {
let parsed = parseOptions(args)
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
return "ERROR: Missing metadata block key — usage: clear_meta_block <key> [--tab=X]"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
if tab.metadataBlocks.removeValue(forKey: key) == nil {
result = "OK (key not found)"
}
}
return result
}
private func listMetaBlocks(_ args: String) -> String {
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
let blocks = tab.sidebarMetadataBlocksInDisplayOrder()
if blocks.isEmpty {
result = "No metadata blocks"
return
}
result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n")
}
return result
}
@ -11609,6 +11901,132 @@ class TerminalController {
return result
}
private func reportPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
guard parsed.positional.count >= 2 else {
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
}
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber
guard let number = Int(numberToken), number > 0 else {
return "ERROR: Invalid pull request number '\(rawNumber)'"
}
let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: rawURL),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return "ERROR: Invalid pull request URL '\(rawURL)'"
}
let statusRaw = (parsed.options["state"] ?? "open").lowercased()
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
}
let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR"
guard !labelRaw.isEmpty else {
return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
}
let label = String(labelRaw.prefix(16))
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
guard Self.shouldReplacePullRequest(
current: tab.panelPullRequests[surfaceId],
number: number,
label: label,
url: url,
status: status
) else {
return
}
tab.updatePanelPullRequest(
panelId: surfaceId,
number: number,
label: label,
url: url,
status: status
)
}
return result
}
private func clearPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.clearPanelPullRequest(panelId: surfaceId)
}
return result
}
private func reportPorts(_ args: String) -> String {
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
@ -11900,6 +12318,14 @@ class TerminalController {
lines.append("git_branch=none")
}
if let pr = tab.pullRequest {
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
lines.append("pr_label=\(pr.label)")
} else {
lines.append("pr=none")
lines.append("pr_label=none")
}
if tab.listeningPorts.isEmpty {
lines.append("ports=none")
} else {
@ -11913,12 +12339,16 @@ class TerminalController {
lines.append("progress=none")
}
lines.append("status_count=\(tab.statusEntries.count)")
for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) {
var line = " \(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
if let color = entry.color { line += " color=\(color)" }
lines.append(line)
let statusEntries = tab.sidebarStatusEntriesInDisplayOrder()
lines.append("status_count=\(statusEntries.count)")
for entry in statusEntries {
lines.append(" \(sidebarMetadataLine(entry))")
}
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
lines.append("meta_block_count=\(metadataBlocks.count)")
for block in metadataBlocks {
lines.append(" \(sidebarMetadataBlockLine(block))")
}
lines.append("log_count=\(tab.logEntries.count)")
@ -11943,8 +12373,11 @@ class TerminalController {
tab.progress = nil
tab.gitBranch = nil
tab.panelGitBranches.removeAll()
tab.pullRequest = nil
tab.panelPullRequests.removeAll()
tab.surfaceListeningPorts.removeAll()
tab.listeningPorts.removeAll()
tab.metadataBlocks.removeAll()
}
return result
}

View file

@ -61,7 +61,42 @@ struct SidebarStatusEntry {
let value: String
let icon: String?
let color: String?
let url: URL?
let priority: Int
let format: SidebarMetadataFormat
let timestamp: Date
init(
key: String,
value: String,
icon: String? = nil,
color: String? = nil,
url: URL? = nil,
priority: Int = 0,
format: SidebarMetadataFormat = .plain,
timestamp: Date = Date()
) {
self.key = key
self.value = value
self.icon = icon
self.color = color
self.url = url
self.priority = priority
self.format = format
self.timestamp = timestamp
}
}
struct SidebarMetadataBlock {
let key: String
let markdown: String
let priority: Int
let timestamp: Date
}
enum SidebarMetadataFormat: String {
case plain
case markdown
}
private struct SessionPaneRestoreEntry {
@ -581,6 +616,19 @@ struct SidebarGitBranchState {
let isDirty: Bool
}
enum SidebarPullRequestStatus: String {
case open
case merged
case closed
}
struct SidebarPullRequestState: Equatable {
let number: Int
let label: String
let url: URL
let status: SidebarPullRequestStatus
}
enum SidebarBranchOrdering {
struct BranchEntry: Equatable {
let name: String
@ -661,6 +709,65 @@ enum SidebarBranchOrdering {
}
}
static func orderedUniquePullRequests(
orderedPanelIds: [UUID],
panelPullRequests: [UUID: SidebarPullRequestState],
fallbackPullRequest: SidebarPullRequestState?
) -> [SidebarPullRequestState] {
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
switch status {
case .merged: return 3
case .open: return 2
case .closed: return 1
}
}
func normalizedReviewURLKey(for url: URL) -> String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
}
// Treat URL variants that differ only by query/fragment as the same review item.
components.query = nil
components.fragment = nil
let scheme = components.scheme?.lowercased() ?? ""
let host = components.host?.lowercased() ?? ""
let port = components.port.map { ":\($0)" } ?? ""
var path = components.path
if path.hasSuffix("/"), path.count > 1 {
path.removeLast()
}
return "\(scheme)://\(host)\(port)\(path)"
}
func reviewKey(for state: SidebarPullRequestState) -> String {
"\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))"
}
var orderedKeys: [String] = []
var pullRequestsByKey: [String: SidebarPullRequestState] = [:]
for panelId in orderedPanelIds {
guard let state = panelPullRequests[panelId] else { continue }
let key = reviewKey(for: state)
if pullRequestsByKey[key] == nil {
orderedKeys.append(key)
pullRequestsByKey[key] = state
continue
}
guard let existing = pullRequestsByKey[key] else { continue }
if statusPriority(state.status) > statusPriority(existing.status) {
pullRequestsByKey[key] = state
}
}
if orderedKeys.isEmpty, let fallbackPullRequest {
return [fallbackPullRequest]
}
return orderedKeys.compactMap { pullRequestsByKey[$0] }
}
static func orderedUniqueBranchDirectoryEntries(
orderedPanelIds: [UUID],
panelBranches: [UUID: SidebarGitBranchState],
@ -854,10 +961,13 @@ final class Workspace: Identifiable, ObservableObject {
nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var metadataBlocks: [String: SidebarMetadataBlock] = [:]
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: SidebarProgressState?
@Published var gitBranch: SidebarGitBranchState?
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
@Published var pullRequest: SidebarPullRequestState?
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:]
@ -1408,6 +1518,30 @@ final class Workspace: Identifiable, ObservableObject {
}
}
func updatePanelPullRequest(
panelId: UUID,
number: Int,
label: String,
url: URL,
status: SidebarPullRequestStatus
) {
let state = SidebarPullRequestState(number: number, label: label, url: url, status: status)
let existing = panelPullRequests[panelId]
if existing != state {
panelPullRequests[panelId] = state
}
if panelId == focusedPanelId {
pullRequest = state
}
}
func clearPanelPullRequest(panelId: UUID) {
panelPullRequests.removeValue(forKey: panelId)
if panelId == focusedPanelId {
pullRequest = nil
}
}
@discardableResult
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
@ -1456,6 +1590,7 @@ final class Workspace: Identifiable, ObservableObject {
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
}
@ -1506,6 +1641,30 @@ final class Workspace: Identifiable, ObservableObject {
)
}
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: sidebarOrderedPanelIds(),
panelPullRequests: panelPullRequests,
fallbackPullRequest: pullRequest
)
}
func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] {
statusEntries.values.sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}
}
func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] {
metadataBlocks.values.sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}
}
// MARK: - Panel Operations
private func seedTerminalInheritanceFontPoints(
@ -2025,6 +2184,49 @@ final class Workspace: Identifiable, ObservableObject {
return nil
}
/// Returns the top-right pane in the current split tree.
/// When a workspace is already split, sidebar PR opens should reuse an existing pane
/// instead of creating additional right splits.
func topRightBrowserReusePane() -> PaneID? {
let paneIds = bonsplitController.allPaneIds
guard paneIds.count > 1 else { return nil }
let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) })
var paneBounds: [String: CGRect] = [:]
browserCollectNormalizedPaneBounds(
node: bonsplitController.treeSnapshot(),
availableRect: CGRect(x: 0, y: 0, width: 1, height: 1),
into: &paneBounds
)
guard !paneBounds.isEmpty else {
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
}
let epsilon = 0.000_1
let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0
let sortedCandidates = paneBounds
.filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon }
.sorted { lhs, rhs in
if abs(lhs.value.minY - rhs.value.minY) > epsilon {
return lhs.value.minY < rhs.value.minY
}
if abs(lhs.value.minX - rhs.value.minX) > epsilon {
return lhs.value.minX > rhs.value.minX
}
return lhs.key < rhs.key
}
for candidate in sortedCandidates {
if let pane = paneById[candidate.key] {
return pane
}
}
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
}
private enum BrowserPaneBranch {
case first
case second
@ -2062,6 +2264,54 @@ final class Workspace: Identifiable, ObservableObject {
}
}
private func browserCollectNormalizedPaneBounds(
node: ExternalTreeNode,
availableRect: CGRect,
into output: inout [String: CGRect]
) {
switch node {
case .pane(let paneNode):
output[paneNode.id] = availableRect
case .split(let splitNode):
let divider = min(max(splitNode.dividerPosition, 0), 1)
let firstRect: CGRect
let secondRect: CGRect
if splitNode.orientation.lowercased() == "vertical" {
// Stacked split: first = top, second = bottom
firstRect = CGRect(
x: availableRect.minX,
y: availableRect.minY,
width: availableRect.width,
height: availableRect.height * divider
)
secondRect = CGRect(
x: availableRect.minX,
y: availableRect.minY + (availableRect.height * divider),
width: availableRect.width,
height: availableRect.height * (1 - divider)
)
} else {
// Side-by-side split: first = left, second = right
firstRect = CGRect(
x: availableRect.minX,
y: availableRect.minY,
width: availableRect.width * divider,
height: availableRect.height
)
secondRect = CGRect(
x: availableRect.minX + (availableRect.width * divider),
y: availableRect.minY,
width: availableRect.width * (1 - divider),
height: availableRect.height
)
}
browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output)
browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output)
}
}
private struct BrowserCloseFallbackPlan {
let orientation: SplitOrientation
let insertFirst: Bool
@ -2763,6 +3013,7 @@ final class Workspace: Identifiable, ObservableObject {
currentDirectory = dir
}
gitBranch = panelGitBranches[targetPanelId]
pullRequest = panelPullRequests[targetPanelId]
}
/// Reconcile focus/first-responder convergence.
@ -3223,6 +3474,7 @@ extension Workspace: BonsplitDelegate {
currentDirectory = dir
}
gitBranch = panelGitBranches[panelId]
pullRequest = panelPullRequests[panelId]
// Post notification
NotificationCenter.default.post(
@ -3416,6 +3668,7 @@ extension Workspace: BonsplitDelegate {
surfaceIdToPanelId.removeValue(forKey: tabId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
@ -3560,6 +3813,7 @@ extension Workspace: BonsplitDelegate {
panels.removeValue(forKey: panelId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)

View file

@ -2631,6 +2631,14 @@ struct SettingsView: View {
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@State private var topBlurBaselineOffset: CGFloat?
@ -2849,6 +2857,84 @@ struct SettingsView: View {
.pickerStyle(.menu)
}
SettingsCardDivider()
SettingsCardRow(
"Show Branch + Directory in Sidebar",
subtitle: "Display the built-in git branch and working-directory row."
) {
Toggle("", isOn: $sidebarShowBranchDirectory)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Pull Requests in Sidebar",
subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link."
) {
Toggle("", isOn: $sidebarShowPullRequest)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Open Sidebar PR Links in cmux Browser",
subtitle: openSidebarPullRequestLinksInCmuxBrowser
? "Clicks open inside cmux browser."
: "Clicks open in your default browser."
) {
Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Listening Ports in Sidebar",
subtitle: "Display detected listening ports for the active workspace."
) {
Toggle("", isOn: $sidebarShowPorts)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Latest Log in Sidebar",
subtitle: "Display the latest imperative log/status message."
) {
Toggle("", isOn: $sidebarShowLog)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Progress in Sidebar",
subtitle: "Display the built-in progress bar from set_progress."
) {
Toggle("", isOn: $sidebarShowProgress)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Show Custom Metadata in Sidebar",
subtitle: "Display custom metadata from report_meta/set_status and report_meta_block."
) {
Toggle("", isOn: $sidebarShowMetadata)
.labelsHidden()
.controlSize(.small)
}
}
SettingsSectionHeader(title: "Workspace Colors")
@ -3389,6 +3475,13 @@ struct SettingsView: View {
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
sidebarShowBranchDirectory = true
sidebarShowPullRequest = true
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true
sidebarShowMetadata = true
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""