Add sidebar metadata (git branch, ports, logs, progress), fix localhost URL resolution, simplify analytics
- Sidebar now shows git branch, listening ports, log entries, progress bars, and status pills with expand/collapse - Fix localhost/127.0.0.1 URL parsing by checking before generic URL(string:) which misinterprets the scheme - Remove custom Keychain distinct ID in favor of PostHog SDK's built-in anonymous ID - browser open now defaults to caller's workspace via CMUX_WORKSPACE_ID env var - Improve CLI help text for environment variables
This commit is contained in:
parent
b41331b43d
commit
3fa72a0b0b
5 changed files with 202 additions and 87 deletions
|
|
@ -1423,7 +1423,8 @@ struct CMUXCLI {
|
|||
if let sourceSurface = try normalizeSurfaceHandle(surfaceRaw, client: client) {
|
||||
params["surface_id"] = sourceSurface
|
||||
}
|
||||
if let workspaceRaw = workspaceOpt {
|
||||
let workspaceRaw = workspaceOpt ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
|
||||
if let workspaceRaw {
|
||||
if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) {
|
||||
params["workspace_id"] = workspace
|
||||
}
|
||||
|
|
@ -3128,7 +3129,7 @@ struct CMUXCLI {
|
|||
simulate-app-active
|
||||
|
||||
browser [--surface <id|ref|index> | <surface>] <subcommand> ...
|
||||
browser open [url] (create browser split; if surface supplied, behaves like navigate)
|
||||
browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
|
||||
browser open-split [url]
|
||||
browser goto|navigate <url> [--snapshot-after]
|
||||
browser back|forward|reload [--snapshot-after]
|
||||
|
|
@ -3171,7 +3172,10 @@ struct CMUXCLI {
|
|||
help
|
||||
|
||||
Environment:
|
||||
CMUX_WORKSPACE_ID, CMUX_SURFACE_ID, CMUX_SOCKET_PATH
|
||||
CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for
|
||||
browser open, new-surface, notify, and other commands.
|
||||
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
|
||||
CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock).
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -895,6 +895,12 @@ private struct TabItemView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
||||
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
|
||||
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
|
||||
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
||||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||||
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
||||
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
|
||||
|
||||
var isActive: Bool {
|
||||
tabManager.selectedTabId == tab.id
|
||||
|
|
@ -1008,14 +1014,75 @@ private struct TabItemView: View {
|
|||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
if let directories = directorySummary {
|
||||
Text(directories)
|
||||
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: isActive,
|
||||
onFocus: { updateSelection() }
|
||||
)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
// Latest log entry
|
||||
if sidebarShowLog, let latestLog = tab.logEntries.last {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: logLevelIcon(latestLog.level))
|
||||
.font(.system(size: 8))
|
||||
.foregroundColor(logLevelColor(latestLog.level, isActive: isActive))
|
||||
Text(latestLog.message)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
if sidebarShowProgress, let progress = tab.progress {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(isActive ? Color.white.opacity(0.8) : Color.accentColor)
|
||||
.frame(width: max(0, geo.size.width * CGFloat(progress.value)))
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
|
||||
if let label = progress.label {
|
||||
Text(label)
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
// Branch + directory + ports row
|
||||
if let dirRow = branchDirectoryRow {
|
||||
HStack(spacing: 3) {
|
||||
if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||||
}
|
||||
Text(dirRow)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
|
||||
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
|
|
@ -1306,7 +1373,31 @@ private struct TabItemView: View {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private var directorySummary: String? {
|
||||
private var branchDirectoryRow: String? {
|
||||
var parts: [String] = []
|
||||
|
||||
// Git branch (if enabled and available)
|
||||
if sidebarShowGitBranch, let git = tab.gitBranch {
|
||||
let dirty = git.isDirty ? "*" : ""
|
||||
parts.append("\(git.branch)\(dirty)")
|
||||
}
|
||||
|
||||
// Directory summary
|
||||
if let dirs = directorySummaryText {
|
||||
parts.append(dirs)
|
||||
}
|
||||
|
||||
// Ports (if enabled and available)
|
||||
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
|
||||
let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",")
|
||||
parts.append(portsStr)
|
||||
}
|
||||
|
||||
let result = parts.joined(separator: " · ")
|
||||
return result.isEmpty ? nil : result
|
||||
}
|
||||
|
||||
private var directorySummaryText: String? {
|
||||
guard !tab.panels.isEmpty else { return nil }
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
var seen: Set<String> = []
|
||||
|
|
@ -1322,6 +1413,35 @@ private struct TabItemView: View {
|
|||
return entries.isEmpty ? nil : entries.joined(separator: " | ")
|
||||
}
|
||||
|
||||
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
|
||||
switch level {
|
||||
case .info: return "circle.fill"
|
||||
case .progress: return "arrowtriangle.right.fill"
|
||||
case .success: return "checkmark.circle.fill"
|
||||
case .warning: return "exclamationmark.triangle.fill"
|
||||
case .error: return "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color {
|
||||
if isActive {
|
||||
switch level {
|
||||
case .info: return .white.opacity(0.5)
|
||||
case .progress: return .white.opacity(0.8)
|
||||
case .success: return .white.opacity(0.9)
|
||||
case .warning: return .white.opacity(0.9)
|
||||
case .error: return .white.opacity(0.9)
|
||||
}
|
||||
}
|
||||
switch level {
|
||||
case .info: return .secondary
|
||||
case .progress: return .blue
|
||||
case .success: return .green
|
||||
case .warning: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func shortenPath(_ path: String, home: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return path }
|
||||
|
|
@ -1356,6 +1476,62 @@ private struct TabItemView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct SidebarStatusPillsRow: View {
|
||||
let entries: [SidebarStatusEntry]
|
||||
let isActive: Bool
|
||||
let onFocus: () -> Void
|
||||
|
||||
@State private var isExpanded: Bool = false
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.help(statusText)
|
||||
}
|
||||
|
||||
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 shouldShowToggle: Bool {
|
||||
entries.count > 1 || statusText.count > 120
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarDropEdge {
|
||||
case top
|
||||
case bottom
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,13 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? {
|
|||
guard !trimmed.isEmpty else { return nil }
|
||||
guard !trimmed.contains(" ") else { return nil }
|
||||
|
||||
// Check localhost/loopback before generic URL parsing because
|
||||
// URL(string: "localhost:3777") treats "localhost" as a scheme.
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") {
|
||||
return URL(string: "http://\(trimmed)")
|
||||
}
|
||||
|
||||
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
||||
if scheme == "http" || scheme == "https" {
|
||||
return url
|
||||
|
|
@ -1226,11 +1233,6 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? {
|
|||
return nil
|
||||
}
|
||||
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") {
|
||||
return URL(string: "http://\(trimmed)")
|
||||
}
|
||||
|
||||
if trimmed.contains(":") || trimmed.contains("/") {
|
||||
return URL(string: "https://\(trimmed)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import PostHog
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class PostHogAnalytics {
|
||||
static let shared = PostHogAnalytics()
|
||||
|
||||
// The PostHog project API key is intentionally embedded in the app (it's a public key).
|
||||
// Replace with the real key for the cmux PostHog project.
|
||||
private let apiKey = "phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP"
|
||||
|
||||
// PostHog Cloud US default (matches other cmux properties).
|
||||
|
|
@ -16,9 +14,6 @@ final class PostHogAnalytics {
|
|||
|
||||
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
|
||||
|
||||
private let keychainService = "com.cmuxterm.posthog"
|
||||
private let keychainAccount = "distinct_id"
|
||||
|
||||
private var didStart = false
|
||||
private var activeCheckTimer: Timer?
|
||||
|
||||
|
|
@ -47,8 +42,7 @@ final class PostHogAnalytics {
|
|||
// Tag every event so PostHog can distinguish desktop from web.
|
||||
PostHogSDK.shared.register(["platform": "cmuxterm"])
|
||||
|
||||
// Keep a stable distinct id so DAU is "unique installs active" and doesn't churn.
|
||||
PostHogSDK.shared.identify(getOrCreateDistinctId())
|
||||
// The SDK automatically generates and persists an anonymous distinct ID.
|
||||
|
||||
didStart = true
|
||||
|
||||
|
|
@ -88,70 +82,6 @@ final class PostHogAnalytics {
|
|||
PostHogSDK.shared.flush()
|
||||
}
|
||||
|
||||
// MARK: - Distinct Id
|
||||
|
||||
private func getOrCreateDistinctId() -> String {
|
||||
if let existing = readKeychainString(service: keychainService, account: keychainAccount),
|
||||
!existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
|
||||
let fresh = UUID().uuidString
|
||||
if writeKeychainString(service: keychainService, account: keychainAccount, value: fresh) {
|
||||
return fresh
|
||||
}
|
||||
|
||||
// Keychain can fail in some environments; fall back to a per-install id in defaults.
|
||||
let defaultsKey = "posthog.distinctId.fallback"
|
||||
if let existing = UserDefaults.standard.string(forKey: defaultsKey), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
UserDefaults.standard.set(fresh, forKey: defaultsKey)
|
||||
return fresh
|
||||
}
|
||||
|
||||
private func readKeychainString(service: String, account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func writeKeychainString(service: String, account: String, value: String) -> Bool {
|
||||
guard let data = value.data(using: .utf8) else { return false }
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecSuccess {
|
||||
return true
|
||||
}
|
||||
|
||||
if status != errSecItemNotFound {
|
||||
return false
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData as String] = data
|
||||
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
private func utcDayString(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .iso8601)
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ This maps common `agent-browser` usage to `cmux browser` usage.
|
|||
### Navigation
|
||||
|
||||
```bash
|
||||
cmux browser open <url>
|
||||
cmux browser open <url> # opens in caller's workspace (uses CMUX_WORKSPACE_ID)
|
||||
cmux browser open <url> --workspace <id|ref> # opens in a specific workspace
|
||||
cmux browser <surface> goto <url>
|
||||
cmux browser <surface> back|forward|reload
|
||||
cmux browser <surface> get url|title
|
||||
```
|
||||
|
||||
> **Workspace context:** `browser open` targets the workspace of the terminal where the command is run (via `CMUX_WORKSPACE_ID`), even if a different workspace is currently focused. Use `--workspace` to override.
|
||||
|
||||
### Snapshot and Inspection
|
||||
|
||||
```bash
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue