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)
)
}
}