Merge pull request #337 from adinvadim/feature/sidebar-pr-metadata
feat: show linked pull request metadata in sidebar
This commit is contained in:
commit
7201dabdfd
15 changed files with 2231 additions and 135 deletions
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue