feat(sidebar): add markdown blocks, provider labels, and fine-grained toggles
This commit is contained in:
parent
f2ecb4877b
commit
513e9aa607
7 changed files with 599 additions and 71 deletions
|
|
@ -2480,6 +2480,7 @@ 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("sidebarShowPorts") private var sidebarShowPorts = true
|
||||
|
|
@ -2667,6 +2668,7 @@ private struct TabItemView: View {
|
|||
|
||||
if sidebarShowMetadata {
|
||||
let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
|
||||
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
|
||||
if !metadataEntries.isEmpty {
|
||||
SidebarMetadataRows(
|
||||
entries: metadataEntries,
|
||||
|
|
@ -2675,6 +2677,14 @@ private struct TabItemView: View {
|
|||
)
|
||||
.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
|
||||
|
|
@ -2717,54 +2727,56 @@ 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)
|
||||
}
|
||||
Text(dirRow)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(activeSecondaryColor(0.75))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2779,7 +2791,7 @@ private struct TabItemView: View {
|
|||
status: pullRequest.status,
|
||||
color: pullRequestForegroundColor
|
||||
)
|
||||
Text("PR #\(pullRequest.number)")
|
||||
Text("\(pullRequest.label) #\(pullRequest.number)")
|
||||
.underline()
|
||||
Text(pullRequestStatusLabel(pullRequest.status))
|
||||
if pullRequest.extraCount > 0 {
|
||||
|
|
@ -2792,7 +2804,7 @@ private struct TabItemView: View {
|
|||
.foregroundColor(pullRequestForegroundColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Open Pull Request #\(pullRequest.number)")
|
||||
.help("Open \(pullRequest.label) #\(pullRequest.number)")
|
||||
}
|
||||
|
||||
// Ports row
|
||||
|
|
@ -2806,6 +2818,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(
|
||||
|
|
@ -3300,6 +3313,7 @@ private struct TabItemView: View {
|
|||
|
||||
private struct PullRequestDisplay {
|
||||
let number: Int
|
||||
let label: String
|
||||
let url: URL
|
||||
let status: SidebarPullRequestStatus
|
||||
let extraCount: Int
|
||||
|
|
@ -3310,6 +3324,7 @@ private struct TabItemView: View {
|
|||
guard let first = pullRequests.first else { return nil }
|
||||
return PullRequestDisplay(
|
||||
number: first.number,
|
||||
label: first.label,
|
||||
url: first.url,
|
||||
status: first.status,
|
||||
extraCount: max(0, pullRequests.count - 1)
|
||||
|
|
@ -3675,6 +3690,89 @@ private struct SidebarMetadataEntryRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarDropEdge {
|
||||
case top
|
||||
case bottom
|
||||
|
|
|
|||
|
|
@ -180,6 +180,16 @@ class TerminalController {
|
|||
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(
|
||||
current: SidebarProgressState?,
|
||||
value: Double,
|
||||
|
|
@ -201,11 +211,12 @@ class TerminalController {
|
|||
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.url != url || current.status != status
|
||||
return current.number != number || current.label != label || current.url != url || current.status != status
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
|
||||
|
|
@ -728,18 +739,27 @@ class TerminalController {
|
|||
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)
|
||||
|
||||
|
|
@ -764,6 +784,9 @@ class TerminalController {
|
|||
case "report_pr":
|
||||
return reportPullRequest(args)
|
||||
|
||||
case "report_review":
|
||||
return reportPullRequest(args)
|
||||
|
||||
case "clear_pr":
|
||||
return clearPullRequest(args)
|
||||
|
||||
|
|
@ -7905,10 +7928,13 @@ class TerminalController {
|
|||
simulate_app_active - Trigger app active handler
|
||||
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
|
||||
|
|
@ -7916,7 +7942,8 @@ 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> [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request
|
||||
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
|
||||
|
|
@ -10847,6 +10874,33 @@ class TerminalController {
|
|||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}
|
||||
|
||||
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":
|
||||
|
|
@ -10901,12 +10955,13 @@ class TerminalController {
|
|||
parsedURL = nil
|
||||
}
|
||||
|
||||
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 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,
|
||||
|
|
@ -10930,7 +10985,7 @@ class TerminalController {
|
|||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
return result
|
||||
return "OK"
|
||||
}
|
||||
|
||||
private func clearSidebarMetadata(_ args: String, usage: String) -> String {
|
||||
|
|
@ -11009,6 +11064,115 @@ class TerminalController {
|
|||
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 trimmedMarkdown = markdown.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"
|
||||
}
|
||||
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: markdown,
|
||||
priority: priority
|
||||
) else {
|
||||
return
|
||||
}
|
||||
tab.metadataBlocks[key] = SidebarMetadataBlock(
|
||||
key: key,
|
||||
markdown: markdown,
|
||||
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
|
||||
}
|
||||
|
||||
private func appendLog(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
|
|
@ -11218,7 +11382,7 @@ class TerminalController {
|
|||
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> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
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)
|
||||
|
|
@ -11239,6 +11403,12 @@ class TerminalController {
|
|||
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 {
|
||||
|
|
@ -11252,7 +11422,7 @@ class TerminalController {
|
|||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
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 {
|
||||
|
|
@ -11276,13 +11446,20 @@ class TerminalController {
|
|||
guard Self.shouldReplacePullRequest(
|
||||
current: tab.panelPullRequests[surfaceId],
|
||||
number: number,
|
||||
label: label,
|
||||
url: url,
|
||||
status: status
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
tab.updatePanelPullRequest(panelId: surfaceId, number: number, url: url, status: status)
|
||||
tab.updatePanelPullRequest(
|
||||
panelId: surfaceId,
|
||||
number: number,
|
||||
label: label,
|
||||
url: url,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -11621,8 +11798,10 @@ class TerminalController {
|
|||
|
||||
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 {
|
||||
|
|
@ -11644,6 +11823,12 @@ class TerminalController {
|
|||
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)")
|
||||
for entry in tab.logEntries.suffix(5) {
|
||||
lines.append(" [\(entry.level.rawValue)] \(entry.message)")
|
||||
|
|
@ -11670,6 +11855,7 @@ class TerminalController {
|
|||
tab.panelPullRequests.removeAll()
|
||||
tab.surfaceListeningPorts.removeAll()
|
||||
tab.listeningPorts.removeAll()
|
||||
tab.metadataBlocks.removeAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ struct SidebarStatusEntry {
|
|||
let timestamp: Date
|
||||
}
|
||||
|
||||
struct SidebarMetadataBlock {
|
||||
let key: String
|
||||
let markdown: String
|
||||
let priority: Int
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum SidebarMetadataFormat: String {
|
||||
case plain
|
||||
case markdown
|
||||
|
|
@ -53,6 +60,7 @@ enum SidebarPullRequestStatus: String {
|
|||
|
||||
struct SidebarPullRequestState: Equatable {
|
||||
let number: Int
|
||||
let label: String
|
||||
let url: URL
|
||||
let status: SidebarPullRequestStatus
|
||||
}
|
||||
|
|
@ -150,28 +158,32 @@ enum SidebarBranchOrdering {
|
|||
}
|
||||
}
|
||||
|
||||
var orderedNumbers: [Int] = []
|
||||
var pullRequestsByNumber: [Int: SidebarPullRequestState] = [:]
|
||||
func reviewKey(for state: SidebarPullRequestState) -> String {
|
||||
"\(state.label.lowercased())#\(state.number)"
|
||||
}
|
||||
|
||||
var orderedKeys: [String] = []
|
||||
var pullRequestsByKey: [String: SidebarPullRequestState] = [:]
|
||||
|
||||
for panelId in orderedPanelIds {
|
||||
guard let state = panelPullRequests[panelId] else { continue }
|
||||
let number = state.number
|
||||
if pullRequestsByNumber[number] == nil {
|
||||
orderedNumbers.append(number)
|
||||
pullRequestsByNumber[number] = state
|
||||
let key = reviewKey(for: state)
|
||||
if pullRequestsByKey[key] == nil {
|
||||
orderedKeys.append(key)
|
||||
pullRequestsByKey[key] = state
|
||||
continue
|
||||
}
|
||||
guard let existing = pullRequestsByNumber[number] else { continue }
|
||||
guard let existing = pullRequestsByKey[key] else { continue }
|
||||
if statusPriority(state.status) > statusPriority(existing.status) {
|
||||
pullRequestsByNumber[number] = state
|
||||
pullRequestsByKey[key] = state
|
||||
}
|
||||
}
|
||||
|
||||
if orderedNumbers.isEmpty, let fallbackPullRequest {
|
||||
if orderedKeys.isEmpty, let fallbackPullRequest {
|
||||
return [fallbackPullRequest]
|
||||
}
|
||||
|
||||
return orderedNumbers.compactMap { pullRequestsByNumber[$0] }
|
||||
return orderedKeys.compactMap { pullRequestsByKey[$0] }
|
||||
}
|
||||
|
||||
static func orderedUniqueBranchDirectoryEntries(
|
||||
|
|
@ -353,6 +365,7 @@ 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?
|
||||
|
|
@ -862,8 +875,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updatePanelPullRequest(panelId: UUID, number: Int, url: URL, status: SidebarPullRequestStatus) {
|
||||
let state = SidebarPullRequestState(number: number, url: url, status: status)
|
||||
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
|
||||
|
|
@ -995,6 +1014,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// Create a new split with a terminal panel
|
||||
|
|
|
|||
|
|
@ -2568,7 +2568,11 @@ 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("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
|
||||
|
|
@ -2777,9 +2781,20 @@ struct SettingsView: View {
|
|||
|
||||
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 PR status, number, and a clickable link when available."
|
||||
subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowPullRequest)
|
||||
.labelsHidden()
|
||||
|
|
@ -2788,9 +2803,42 @@ struct SettingsView: View {
|
|||
|
||||
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 metadata rows from report_meta/set_status, including icons and optional links."
|
||||
subtitle: "Display custom metadata from report_meta/set_status and report_meta_block."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowMetadata)
|
||||
.labelsHidden()
|
||||
|
|
@ -3323,7 +3371,11 @@ struct SettingsView: View {
|
|||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
sidebarShowBranchDirectory = true
|
||||
sidebarShowPullRequest = true
|
||||
sidebarShowPorts = true
|
||||
sidebarShowLog = true
|
||||
sidebarShowProgress = true
|
||||
sidebarShowMetadata = true
|
||||
showOpenAccessConfirmation = false
|
||||
pendingOpenAccessMode = nil
|
||||
|
|
|
|||
|
|
@ -589,6 +589,37 @@ class cmux:
|
|||
raise cmuxError(response)
|
||||
return response
|
||||
|
||||
def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None:
|
||||
"""Report a freeform sidebar markdown metadata block."""
|
||||
cmd = f"report_meta_block {key}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(markdown)}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_meta_block(self, key: str, tab: str = None) -> None:
|
||||
"""Remove a sidebar markdown metadata block."""
|
||||
cmd = f"clear_meta_block {key}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def list_meta_blocks(self, tab: str = None) -> str:
|
||||
"""List sidebar markdown metadata blocks."""
|
||||
cmd = "list_meta_blocks"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if response.startswith("ERROR"):
|
||||
raise cmuxError(response)
|
||||
return response
|
||||
|
||||
def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None:
|
||||
"""Append a sidebar log entry."""
|
||||
# TerminalController.parseOptions treats any --* token as an option until
|
||||
|
|
@ -641,12 +672,38 @@ class cmux:
|
|||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
label: str = None,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report pull-request metadata for sidebar display."""
|
||||
cmd = f"report_pr {number} {url}"
|
||||
if label:
|
||||
cmd += f" --label={_quote_option_value(label)}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_review(
|
||||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
label: str = None,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.)."""
|
||||
cmd = f"report_review {number} {url}"
|
||||
if label:
|
||||
cmd += f" --label={_quote_option_value(label)}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
|
|
|
|||
100
tests/test_sidebar_meta_block.py
Normal file
100
tests/test_sidebar_meta_block.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for sidebar markdown metadata block commands.
|
||||
|
||||
Validates:
|
||||
1) report_meta_block stores markdown payload and priority
|
||||
2) metadata block list ordering follows priority
|
||||
3) clear_meta_block removes block metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for_state_field(
|
||||
client: cmux,
|
||||
key: str,
|
||||
expected: str,
|
||||
timeout: float = 8.0,
|
||||
interval: float = 0.1,
|
||||
) -> dict[str, str]:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
if state.get(key) == expected:
|
||||
return state
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
try:
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
tab_id = client.current_workspace()
|
||||
|
||||
summary_md = "### Agent\\n- status: in progress\\n- pr: #337"
|
||||
footer_md = "_last update: now_"
|
||||
|
||||
client.report_meta_block("summary", summary_md, priority=50, tab=tab_id)
|
||||
client.report_meta_block("footer", footer_md, priority=10, tab=tab_id)
|
||||
_wait_for_state_field(client, "meta_block_count", "2")
|
||||
|
||||
listed = client.list_meta_blocks(tab=tab_id).splitlines()
|
||||
if len(listed) != 2:
|
||||
raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}")
|
||||
if not listed[0].startswith("summary="):
|
||||
raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}")
|
||||
if "priority=50" not in listed[0]:
|
||||
raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}")
|
||||
|
||||
client.clear_meta_block("summary", tab=tab_id)
|
||||
_wait_for_state_field(client, "meta_block_count", "1")
|
||||
|
||||
listed = client.list_meta_blocks(tab=tab_id).splitlines()
|
||||
if any(line.startswith("summary=") for line in listed):
|
||||
raise AssertionError(f"Summary block should be cleared. Got: {listed}")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar markdown metadata block test passed.")
|
||||
return 0
|
||||
except (cmuxError, AssertionError) as e:
|
||||
print(f"Sidebar markdown metadata block test failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -5,7 +5,8 @@ End-to-end test for sidebar pull-request metadata.
|
|||
Validates:
|
||||
1) report_pr writes sidebar PR state
|
||||
2) state transition open -> merged is reflected
|
||||
3) clear_pr removes PR metadata
|
||||
3) provider labels can be set via report_review/report_pr --label
|
||||
4) clear_pr removes PR metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -71,12 +72,19 @@ def main() -> int:
|
|||
|
||||
client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
|
||||
_wait_for_state_field(client, "pr_label", "PR")
|
||||
|
||||
client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
|
||||
_wait_for_state_field(client, "pr_label", "MR")
|
||||
|
||||
client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}")
|
||||
_wait_for_state_field(client, "pr_label", "PR")
|
||||
|
||||
client.clear_pr(tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", "none")
|
||||
_wait_for_state_field(client, "pr_label", "none")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue