feat(sidebar): add generic metadata rows and commands
This commit is contained in:
parent
a33e231c79
commit
f2ecb4877b
6 changed files with 473 additions and 66 deletions
|
|
@ -2485,7 +2485,7 @@ private struct TabItemView: View {
|
|||
@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
|
||||
|
||||
|
|
@ -2665,16 +2665,16 @@ 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()
|
||||
if !metadataEntries.isEmpty {
|
||||
SidebarMetadataRows(
|
||||
entries: metadataEntries,
|
||||
isActive: usesInvertedActiveForeground,
|
||||
onFocus: { updateSelection() }
|
||||
)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
|
||||
// Latest log entry
|
||||
|
|
@ -3535,30 +3535,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 ? .white.opacity(0.8) : .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") {
|
||||
|
|
@ -3573,21 +3562,116 @@ private struct SidebarStatusPillsRow: View {
|
|||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.help(statusText)
|
||||
.help(helpText)
|
||||
}
|
||||
|
||||
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: 10)))
|
||||
}
|
||||
if iconRaw.hasPrefix("text:") {
|
||||
let value = String(iconRaw.dropFirst("text:".count))
|
||||
guard !value.isEmpty else { return nil }
|
||||
return AnyView(Text(value).font(.system(size: 9, 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: 9, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,10 +165,19 @@ 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 shouldReplaceProgress(
|
||||
|
|
@ -716,12 +725,21 @@ class TerminalController {
|
|||
case "set_status":
|
||||
return setStatus(args)
|
||||
|
||||
case "report_meta":
|
||||
return reportMeta(args)
|
||||
|
||||
case "clear_status":
|
||||
return clearStatus(args)
|
||||
|
||||
case "clear_meta":
|
||||
return clearMeta(args)
|
||||
|
||||
case "list_status":
|
||||
return listStatus(args)
|
||||
|
||||
case "list_meta":
|
||||
return listMeta(args)
|
||||
|
||||
case "log":
|
||||
return appendLog(args)
|
||||
|
||||
|
|
@ -7885,9 +7903,12 @@ 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
|
||||
clear_status <key> [--tab=X] - Remove a status entry
|
||||
clear_meta <key> [--tab=X] - Remove sidebar metadata entry
|
||||
list_status [--tab=X] - List all status entries
|
||||
list_meta [--tab=X] - List sidebar metadata entries
|
||||
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
|
||||
|
|
@ -10826,16 +10847,59 @@ class TerminalController {
|
|||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}
|
||||
|
||||
private func setStatus(_ args: String) -> String {
|
||||
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"])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
|
|
@ -10848,7 +10912,10 @@ class TerminalController {
|
|||
key: key,
|
||||
value: value,
|
||||
icon: icon,
|
||||
color: color
|
||||
color: color,
|
||||
url: parsedURL,
|
||||
priority: priority,
|
||||
format: format
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
|
@ -10857,16 +10924,19 @@ class TerminalController {
|
|||
value: value,
|
||||
icon: icon,
|
||||
color: color,
|
||||
url: parsedURL,
|
||||
priority: priority,
|
||||
format: format,
|
||||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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"
|
||||
|
|
@ -10882,28 +10952,63 @@ 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 = lines.joined(separator: "\n")
|
||||
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 appendLog(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
|
|
@ -11533,12 +11638,10 @@ 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))")
|
||||
}
|
||||
|
||||
lines.append("log_count=\(tab.logEntries.count)")
|
||||
|
|
|
|||
|
|
@ -9,9 +9,17 @@ struct SidebarStatusEntry {
|
|||
let value: String
|
||||
let icon: String?
|
||||
let color: String?
|
||||
let url: URL?
|
||||
let priority: Int
|
||||
let format: SidebarMetadataFormat
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum SidebarMetadataFormat: String {
|
||||
case plain
|
||||
case markdown
|
||||
}
|
||||
|
||||
enum SidebarLogLevel: String {
|
||||
case info
|
||||
case progress
|
||||
|
|
@ -979,6 +987,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel Operations
|
||||
|
||||
/// Create a new split with a terminal panel
|
||||
|
|
|
|||
|
|
@ -2569,6 +2569,7 @@ struct SettingsView: View {
|
|||
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
|
||||
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
|
||||
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
|
||||
@State private var shortcutResetToken = UUID()
|
||||
@State private var topBlurOpacity: Double = 0
|
||||
@State private var topBlurBaselineOffset: CGFloat?
|
||||
|
|
@ -2784,6 +2785,17 @@ struct SettingsView: View {
|
|||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Custom Metadata in Sidebar",
|
||||
subtitle: "Display metadata rows from report_meta/set_status, including icons and optional links."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowMetadata)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: "Workspace Colors")
|
||||
|
|
@ -3312,6 +3324,7 @@ struct SettingsView: View {
|
|||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
sidebarShowPullRequest = true
|
||||
sidebarShowMetadata = true
|
||||
showOpenAccessConfirmation = false
|
||||
pendingOpenAccessMode = nil
|
||||
socketPasswordDraft = ""
|
||||
|
|
|
|||
|
|
@ -500,7 +500,17 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None:
|
||||
def set_status(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
icon: str = None,
|
||||
color: str = None,
|
||||
url: str = None,
|
||||
priority: int = None,
|
||||
format: str = None,
|
||||
tab: str = None,
|
||||
) -> None:
|
||||
"""Set a sidebar status entry."""
|
||||
# Put options before `--` so value can contain arbitrary tokens like `--tab`.
|
||||
cmd = f"set_status {key}"
|
||||
|
|
@ -508,6 +518,12 @@ class cmux:
|
|||
cmd += f" --icon={icon}"
|
||||
if color:
|
||||
cmd += f" --color={color}"
|
||||
if url:
|
||||
cmd += f" --url={_quote_option_value(url)}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if format:
|
||||
cmd += f" --format={format}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(value)}"
|
||||
|
|
@ -524,6 +540,55 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_meta(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
icon: str = None,
|
||||
color: str = None,
|
||||
url: str = None,
|
||||
priority: int = None,
|
||||
format: str = None,
|
||||
tab: str = None,
|
||||
) -> None:
|
||||
"""Report a sidebar metadata entry."""
|
||||
cmd = f"report_meta {key}"
|
||||
if icon:
|
||||
cmd += f" --icon={icon}"
|
||||
if color:
|
||||
cmd += f" --color={color}"
|
||||
if url:
|
||||
cmd += f" --url={_quote_option_value(url)}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if format:
|
||||
cmd += f" --format={format}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(value)}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_meta(self, key: str, tab: str = None) -> None:
|
||||
"""Remove a sidebar metadata entry."""
|
||||
cmd = f"clear_meta {key}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def list_meta(self, tab: str = None) -> str:
|
||||
"""List sidebar metadata entries."""
|
||||
cmd = "list_meta"
|
||||
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
|
||||
|
|
|
|||
126
tests/test_sidebar_meta.py
Normal file
126
tests/test_sidebar_meta.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for generic sidebar metadata commands.
|
||||
|
||||
Validates:
|
||||
1) report_meta stores icon/url/priority/format metadata
|
||||
2) metadata list ordering follows priority
|
||||
3) set_status remains compatible as an alias-style metadata writer
|
||||
4) clear_meta removes metadata entries
|
||||
"""
|
||||
|
||||
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.")
|
||||
|
||||
pr_url = "https://github.com/manaflow-ai/cmux/pull/337"
|
||||
|
||||
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()
|
||||
|
||||
client.report_meta(
|
||||
"task",
|
||||
"**Review** PR 337",
|
||||
icon="sf:doc.text.magnifyingglass",
|
||||
url=pr_url,
|
||||
priority=50,
|
||||
format="markdown",
|
||||
tab=tab_id,
|
||||
)
|
||||
client.report_meta(
|
||||
"context",
|
||||
"issue-336-sidebar-pr-metadata",
|
||||
icon="text:CTX",
|
||||
priority=10,
|
||||
tab=tab_id,
|
||||
)
|
||||
_wait_for_state_field(client, "status_count", "2")
|
||||
|
||||
listed = client.list_meta(tab=tab_id).splitlines()
|
||||
if len(listed) != 2:
|
||||
raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}")
|
||||
|
||||
if not listed[0].startswith("task="):
|
||||
raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}")
|
||||
if "priority=50" not in listed[0]:
|
||||
raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}")
|
||||
if "format=markdown" not in listed[0]:
|
||||
raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}")
|
||||
if f"url={pr_url}" not in listed[0]:
|
||||
raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}")
|
||||
|
||||
client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id)
|
||||
_wait_for_state_field(client, "status_count", "3")
|
||||
|
||||
listed = client.list_meta(tab=tab_id).splitlines()
|
||||
if not listed[0].startswith("agent="):
|
||||
raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}")
|
||||
|
||||
client.clear_meta("task", tab=tab_id)
|
||||
_wait_for_state_field(client, "status_count", "2")
|
||||
|
||||
listed = client.list_meta(tab=tab_id).splitlines()
|
||||
if any(line.startswith("task=") for line in listed):
|
||||
raise AssertionError(f"Task metadata should be cleared. Got: {listed}")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar metadata test passed.")
|
||||
return 0
|
||||
except (cmuxError, AssertionError) as e:
|
||||
print(f"Sidebar metadata test failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue