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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue