diff --git a/README.md b/README.md index c804e44b..071bd186 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ cmuxterm screenshot +

+ ## Features - **Native macOS app** — Built with Swift and AppKit, not Electron. Fast startup, low memory. diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index a70c9ed8..1f50aa21 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a1001b1b..2710515c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(visible) { entry in - SidebarStatusPill(entry: entry, isActive: isActive) - } - if overflow > 0 { - SidebarStatusOverflowPill(count: overflow, isActive: isActive) - } - } - .padding(.vertical, 1) - } - .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) + VStack(alignment: .leading, spacing: 2) { + Text(statusText) + .font(.system(size: 10)) + .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .lineLimit(isExpanded ? nil : 3) .truncationMode(.tail) - .frame(maxWidth: 150, alignment: .leading) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + onFocus() + guard shouldShowToggle else { return } + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + + if shouldShowToggle { + Button(isExpanded ? "Show less" : "Show more") { + 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) + } } - .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 diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3e812ec5..1bd0e44f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -298,7 +298,7 @@ class TerminalController { clear_notifications - Clear all notifications set_app_focus - Override app focus state simulate_app_active - Trigger app active handler - set_status [--icon=X] [--color=#hex] - Set a status entry + set_status [--icon=X] [--color=#hex] [--tab=X] - Set a status entry clear_status [--tab=X] - Remove a status entry list_status [--tab=X] - List all status entries log [--level=X] [--source=X] [--tab=X] -- - 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).. 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 [--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 } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 1c826fef..8252f2d7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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) diff --git a/docs/assets/screenshot.png b/docs/assets/screenshot.png new file mode 100644 index 00000000..bc8399e4 Binary files /dev/null and b/docs/assets/screenshot.png differ diff --git a/tests/test_sidebar_cwd_git.py b/tests/test_sidebar_cwd_git.py index b9d5d42a..401a80a0 100644 --- a/tests/test_sidebar_cwd_git.py +++ b/tests/test_sidebar_cwd_git.py @@ -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()) -