Sidebar status as text + detect git HEAD changes instantly (#30)
* Sidebar status as text + detect git HEAD changes instantly - Replace sidebar status pills with plain text + show more/less toggle for a cleaner, more readable sidebar layout - Watch .git/HEAD mtime in zsh precmd to detect branch changes from aliases (gco), tools (gh pr checkout), etc. without waiting for the 3s polling interval - Fix NSImage shared instance mutation in DraggableFolderNSView by copying before resizing to prevent layout side-effects - Fix set_status --tab flag being swallowed by -- stop token via new parseOptionsNoStop parser - Update sidebar test to cover alias-based branch switching * Append status text to notification body automatically When creating notifications, include the tab's current status entries in the notification body so users see context (e.g. git branch, ports) alongside the notification message. * Add screenshot to README
This commit is contained in:
parent
119511f774
commit
7d6f33c143
7 changed files with 255 additions and 92 deletions
|
|
@ -7,6 +7,10 @@
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmuxterm screenshot" width="900" />
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **Native macOS app** — Built with Swift and AppKit, not Electron. Fast startup, low memory.
|
||||
|
|
|
|||
|
|
@ -30,12 +30,79 @@ typeset -g _CMUX_GIT_LAST_PWD=""
|
|||
typeset -g _CMUX_GIT_LAST_RUN=0
|
||||
typeset -g _CMUX_GIT_JOB_PID=""
|
||||
typeset -g _CMUX_GIT_FORCE=0
|
||||
typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_HEAD_PATH=""
|
||||
typeset -g _CMUX_GIT_HEAD_MTIME=0
|
||||
typeset -g _CMUX_HAVE_ZSTAT=0
|
||||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_PORTS_JOB_PID=""
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
typeset -g _CMUX_TTY_NAME=""
|
||||
|
||||
_cmux_ensure_zstat() {
|
||||
# zstat is substantially cheaper than spawning external `stat`.
|
||||
if (( _CMUX_HAVE_ZSTAT != 0 )); then
|
||||
return 0
|
||||
fi
|
||||
if zmodload -F zsh/stat b:zstat 2>/dev/null; then
|
||||
_CMUX_HAVE_ZSTAT=1
|
||||
return 0
|
||||
fi
|
||||
_CMUX_HAVE_ZSTAT=-1
|
||||
return 1
|
||||
}
|
||||
|
||||
_cmux_git_resolve_head_path() {
|
||||
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
|
||||
local dir="$PWD"
|
||||
while true; do
|
||||
if [[ -d "$dir/.git" ]]; then
|
||||
print -r -- "$dir/.git/HEAD"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$dir/.git" ]]; then
|
||||
local line gitdir
|
||||
line="$(<"$dir/.git")"
|
||||
if [[ "$line" == gitdir:* ]]; then
|
||||
gitdir="${line#gitdir:}"
|
||||
gitdir="${gitdir## }"
|
||||
gitdir="${gitdir%% }"
|
||||
[[ -n "$gitdir" ]] || return 1
|
||||
[[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir"
|
||||
print -r -- "$gitdir/HEAD"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
[[ "$dir" == "/" || -z "$dir" ]] && break
|
||||
dir="${dir:h}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_cmux_git_head_mtime() {
|
||||
local head_path="$1"
|
||||
[[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; }
|
||||
|
||||
if _cmux_ensure_zstat; then
|
||||
typeset -A st
|
||||
if zstat -H st +mtime -- "$head_path" 2>/dev/null; then
|
||||
print -r -- "${st[mtime]:-0}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback for environments where zsh/stat isn't available.
|
||||
if command -v stat >/dev/null 2>&1; then
|
||||
local mtime
|
||||
mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)"
|
||||
print -r -- "$mtime"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print -r -- 0
|
||||
}
|
||||
|
||||
_cmux_ports_scan() {
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
|
|
@ -173,6 +240,23 @@ _cmux_precmd() {
|
|||
|
||||
# Git branch/dirty: update immediately on directory change, otherwise every ~3s.
|
||||
local should_git=0
|
||||
|
||||
# Git branch can change without a `git ...`-prefixed command (aliases like `gco`,
|
||||
# tools like `gh pr checkout`, etc.). Detect HEAD changes and force a refresh.
|
||||
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
|
||||
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
|
||||
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
|
||||
_CMUX_GIT_HEAD_MTIME=0
|
||||
fi
|
||||
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
|
||||
local head_mtime
|
||||
head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)"
|
||||
if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then
|
||||
_CMUX_GIT_HEAD_MTIME="$head_mtime"
|
||||
should_git=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
|
||||
should_git=1
|
||||
elif (( _CMUX_GIT_FORCE )); then
|
||||
|
|
|
|||
|
|
@ -554,7 +554,8 @@ struct TabItemView: View {
|
|||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
||||
return lhs.key < rhs.key
|
||||
}),
|
||||
isActive: isActive
|
||||
isActive: isActive,
|
||||
onFocus: { updateSelection() }
|
||||
)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
|
@ -921,90 +922,65 @@ struct TabItemView: View {
|
|||
}
|
||||
|
||||
private struct SidebarStatusPillsRow: View {
|
||||
// Renamed/replaced: we now render status as normal text with an optional expand/collapse.
|
||||
// Kept as a separate view for minimal churn in call sites.
|
||||
let entries: [SidebarStatusEntry]
|
||||
let isActive: Bool
|
||||
let onFocus: () -> Void
|
||||
|
||||
private let maxVisiblePills = 3
|
||||
@State private var isExpanded: Bool = false
|
||||
|
||||
var body: some View {
|
||||
let visible = Array(entries.prefix(maxVisiblePills))
|
||||
let overflow = max(0, entries.count - visible.count)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(visible) { entry in
|
||||
SidebarStatusPill(entry: entry, isActive: isActive)
|
||||
}
|
||||
if overflow > 0 {
|
||||
SidebarStatusOverflowPill(count: overflow, isActive: isActive)
|
||||
if shouldShowToggle {
|
||||
Button(isExpanded ? "Show less" : "Show more") {
|
||||
onFocus()
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarStatusPill: View {
|
||||
let entry: SidebarStatusEntry
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
if let icon = entry.icon, !icon.isEmpty {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
Text("\(entry.key)=\(entry.value)")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 150, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(backgroundColor)
|
||||
)
|
||||
.help("\(entry.key)=\(entry.value)")
|
||||
.help(statusText)
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
isActive ? .white.opacity(0.15) : .secondary.opacity(0.14)
|
||||
private var statusText: String {
|
||||
entries
|
||||
.map { entry in
|
||||
// Render like notification text: show the status contents only.
|
||||
// If the value is empty, fall back to the key so the line isn't blank.
|
||||
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty { return value }
|
||||
return entry.key
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private var textColor: Color {
|
||||
isActive ? .white.opacity(0.9) : .secondary
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
guard !isActive else { return .white.opacity(0.85) }
|
||||
if let hex = entry.color, let nsColor = NSColor(hex: hex) {
|
||||
return Color(nsColor: nsColor)
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarStatusOverflowPill: View {
|
||||
let count: Int
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
Text("+\(count)")
|
||||
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(isActive ? .white.opacity(0.85) : .secondary)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(isActive ? .white.opacity(0.15) : .secondary.opacity(0.14))
|
||||
)
|
||||
.help("\(count) more status entries")
|
||||
private var shouldShowToggle: Bool {
|
||||
// We can't reliably measure truncation in SwiftUI without extra layout plumbing.
|
||||
// Heuristic: show toggle when there are multiple entries or the text is long enough
|
||||
// that it likely wraps past 3 lines in the sidebar.
|
||||
entries.count > 1 || statusText.count > 120
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1096,6 +1072,7 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable {
|
|||
private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
||||
var directory: String
|
||||
private var imageView: NSImageView!
|
||||
private static let iconSide: CGFloat = 16
|
||||
|
||||
init(directory: String) {
|
||||
self.directory = directory
|
||||
|
|
@ -1121,9 +1098,15 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
updateIcon()
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
NSSize(width: Self.iconSide, height: Self.iconSide)
|
||||
}
|
||||
|
||||
func updateIcon() {
|
||||
let icon = NSWorkspace.shared.icon(forFile: directory)
|
||||
icon.size = NSSize(width: 16, height: 16)
|
||||
// NSWorkspace may return cached/shared NSImage instances. Never mutate the shared image size,
|
||||
// since other callsites (e.g. dragging preview) may resize it and inadvertently affect layout.
|
||||
let icon = (NSWorkspace.shared.icon(forFile: directory).copy() as? NSImage) ?? NSImage()
|
||||
icon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
|
||||
imageView.image = icon
|
||||
}
|
||||
|
||||
|
|
@ -1135,7 +1118,7 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
let fileURL = URL(fileURLWithPath: directory)
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
|
||||
|
||||
let iconImage = NSWorkspace.shared.icon(forFile: directory)
|
||||
let iconImage = (NSWorkspace.shared.icon(forFile: directory).copy() as? NSImage) ?? NSImage()
|
||||
iconImage.size = NSSize(width: 32, height: 32)
|
||||
draggingItem.setDraggingFrame(bounds, contents: iconImage)
|
||||
|
||||
|
|
@ -1164,8 +1147,8 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
|
||||
// Add path components (current dir at top, root at bottom - matches native macOS)
|
||||
for pathURL in pathComponents {
|
||||
let icon = NSWorkspace.shared.icon(forFile: pathURL.path)
|
||||
icon.size = NSSize(width: 16, height: 16)
|
||||
let icon = (NSWorkspace.shared.icon(forFile: pathURL.path).copy() as? NSImage) ?? NSImage()
|
||||
icon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
|
||||
|
||||
let displayName: String
|
||||
if pathURL.path == "/" {
|
||||
|
|
@ -1188,8 +1171,8 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
|
||||
// Add computer name at the bottom (like native proxy icon)
|
||||
let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage()
|
||||
computerIcon.size = NSSize(width: 16, height: 16)
|
||||
let computerIcon = (NSImage(named: NSImage.computerName)?.copy() as? NSImage) ?? NSImage()
|
||||
computerIcon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
|
||||
|
||||
let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "")
|
||||
computerItem.target = self
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ 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] - Set a status entry
|
||||
set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
|
||||
clear_status <key> [--tab=X] - Remove a status entry
|
||||
list_status [--tab=X] - List all status entries
|
||||
log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry
|
||||
|
|
@ -490,14 +490,19 @@ class TerminalController {
|
|||
result = "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
||||
let (title, subtitle, body) = parseNotificationPayload(args)
|
||||
let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
body: body
|
||||
body: bodyWithStatus
|
||||
)
|
||||
}
|
||||
return result
|
||||
|
|
@ -524,12 +529,13 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
let (title, subtitle, body) = parseNotificationPayload(payload)
|
||||
let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
body: body
|
||||
body: bodyWithStatus
|
||||
)
|
||||
}
|
||||
return result
|
||||
|
|
@ -559,17 +565,43 @@ class TerminalController {
|
|||
return
|
||||
}
|
||||
let (title, subtitle, body) = parseNotificationPayload(payload)
|
||||
let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
tabId: tab.id,
|
||||
surfaceId: panelId,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
body: body
|
||||
body: bodyWithStatus
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func appendStatusTextIfPresent(body: String, tab: Tab) -> String {
|
||||
let statusText = statusTextForNotification(tab: tab)
|
||||
guard !statusText.isEmpty else { return body }
|
||||
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedBody.isEmpty {
|
||||
return statusText
|
||||
}
|
||||
return body + "\n\n" + statusText
|
||||
}
|
||||
|
||||
private func statusTextForNotification(tab: Tab) -> String {
|
||||
let entries = tab.statusEntries.values.sorted(by: { (lhs, rhs) in
|
||||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
||||
return lhs.key < rhs.key
|
||||
})
|
||||
|
||||
let lines = entries.compactMap { entry -> String? in
|
||||
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty { return value }
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return key.isEmpty ? nil : key
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func listNotifications() -> String {
|
||||
var result = ""
|
||||
DispatchQueue.main.sync {
|
||||
|
|
@ -1010,9 +1042,9 @@ class TerminalController {
|
|||
|
||||
// MARK: - Option Parsing
|
||||
|
||||
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
|
||||
private func tokenizeArgs(_ args: String) -> [String] {
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return ([], [:]) }
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
// Tokenize respecting quoted strings. Support basic backslash escapes inside quotes
|
||||
// (e.g. \" within "...") so shell integrations can safely escape embedded quotes.
|
||||
|
|
@ -1072,6 +1104,12 @@ class TerminalController {
|
|||
if !current.isEmpty {
|
||||
tokens.append(current)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
|
||||
let tokens = tokenizeArgs(args)
|
||||
guard !tokens.isEmpty else { return ([], [:]) }
|
||||
|
||||
var positional: [String] = []
|
||||
var options: [String: String] = [:]
|
||||
|
|
@ -1105,6 +1143,43 @@ class TerminalController {
|
|||
return (positional, options)
|
||||
}
|
||||
|
||||
private func parseOptionsNoStop(_ args: String) -> (positional: [String], options: [String: String]) {
|
||||
// Like parseOptions, but continues parsing `--key` options even after a `--` token.
|
||||
// Used for commands where we never want UI-facing content to accidentally include flags.
|
||||
let tokens = tokenizeArgs(args)
|
||||
guard !tokens.isEmpty else { return ([], [:]) }
|
||||
|
||||
var positional: [String] = []
|
||||
var options: [String: String] = [:]
|
||||
var i = 0
|
||||
while i < tokens.count {
|
||||
let token = tokens[i]
|
||||
if token == "--" {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if token.hasPrefix("--") {
|
||||
if let eqIndex = token.firstIndex(of: "=") {
|
||||
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
|
||||
let value = String(token[token.index(after: eqIndex)...])
|
||||
options[key] = value
|
||||
} else {
|
||||
let key = String(token.dropFirst(2))
|
||||
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
|
||||
options[key] = tokens[i + 1]
|
||||
i += 1
|
||||
} else {
|
||||
options[key] = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.append(token)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return (positional, options)
|
||||
}
|
||||
|
||||
// MARK: - Sidebar Commands
|
||||
|
||||
private func resolveTabForReport(_ args: String) -> Tab? {
|
||||
|
|
@ -1119,7 +1194,9 @@ class TerminalController {
|
|||
|
||||
private func setStatus(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
// Parse options even if the caller used `--` before/inside the value.
|
||||
// This avoids leaking flags like `--tab` into the stored (and rendered) status text.
|
||||
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]"
|
||||
}
|
||||
|
|
@ -1130,7 +1207,16 @@ class TerminalController {
|
|||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
guard let tabManager else { result = "ERROR: TabManager not available"; return }
|
||||
let tab: Tab?
|
||||
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
|
||||
tab = resolveTab(from: tabArg, tabManager: tabManager)
|
||||
} else if let selectedId = tabManager.selectedTabId {
|
||||
tab = tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
} else {
|
||||
tab = nil
|
||||
}
|
||||
guard let tab else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -962,7 +962,7 @@ struct SettingsView: View {
|
|||
Text("Sidebar")
|
||||
.font(.headline)
|
||||
|
||||
Toggle("Show status pills", isOn: $sidebarShowStatusPills)
|
||||
Toggle("Show status", isOn: $sidebarShowStatusPills)
|
||||
Toggle("Show git branch", isOn: $sidebarShowGitBranch)
|
||||
Toggle("Show branch icon", isOn: $sidebarShowGitBranchIcon)
|
||||
.disabled(!sidebarShowGitBranch)
|
||||
|
|
|
|||
BIN
docs/assets/screenshot.png
Normal file
BIN
docs/assets/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -139,9 +139,16 @@ def main() -> int:
|
|||
_wait_for_git_branch(client, "main")
|
||||
|
||||
# Branch change should update.
|
||||
client.send("git checkout -b feature/sidebar\n")
|
||||
# Cover alias/non-`git ...` command paths too (regression: branch could
|
||||
# stick for ~3s when switching via alias/tools like `gh pr checkout`).
|
||||
client.send("alias gco='git checkout'\n")
|
||||
time.sleep(0.2)
|
||||
client.send("gco -b feature/sidebar\n")
|
||||
_wait_for_git_branch(client, "feature/sidebar")
|
||||
|
||||
client.send("gco main\n")
|
||||
_wait_for_git_branch(client, "main")
|
||||
|
||||
# Leaving the repo should clear the branch.
|
||||
client.send(f"cd {other}\n")
|
||||
_wait_for_state_field(client, "cwd", str(other))
|
||||
|
|
@ -167,4 +174,3 @@ def main() -> int:
|
|||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue