Merge remote-tracking branch 'origin/main' into issue-230-cli-unix-socket-lag
# Conflicts: # Sources/TerminalController.swift
This commit is contained in:
commit
e74f67d369
12 changed files with 715 additions and 61 deletions
|
|
@ -195,7 +195,7 @@ cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the
|
|||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.com/invite/QRxkhZgY)
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ _cmux_prompt_command() {
|
|||
local first
|
||||
first=$(git status --porcelain -uno 2>/dev/null | head -1)
|
||||
[[ -n "$first" ]] && dirty_opt="--status=dirty"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
|
|
|
|||
|
|
@ -240,9 +240,9 @@ _cmux_precmd() {
|
|||
local first
|
||||
first=$(git status --porcelain -uno 2>/dev/null | head -1)
|
||||
[[ -n "$first" ]] && dirty_opt="--status=dirty"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
|
|
|
|||
|
|
@ -2458,6 +2458,7 @@ private struct TabItemView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
|
||||
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
||||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||||
|
|
@ -2633,9 +2634,45 @@ private struct TabItemView: View {
|
|||
}
|
||||
|
||||
// Branch + directory row
|
||||
if let dirRow = branchDirectoryRow {
|
||||
if sidebarBranchVerticalLayout {
|
||||
if !verticalBranchDirectoryLines.isEmpty {
|
||||
HStack(alignment: .top, spacing: 3) {
|
||||
if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||||
}
|
||||
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(isActive ? .white.opacity(0.75) : .secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
if line.branch != nil, line.directory != nil {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 3))
|
||||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
if let directory = line.directory {
|
||||
Text(directory)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let dirRow = branchDirectoryRow {
|
||||
HStack(spacing: 3) {
|
||||
if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon {
|
||||
if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||||
|
|
@ -2969,9 +3006,8 @@ private struct TabItemView: View {
|
|||
var parts: [String] = []
|
||||
|
||||
// Git branch (if enabled and available)
|
||||
if sidebarShowGitBranch, let git = tab.gitBranch {
|
||||
let dirty = git.isDirty ? "*" : ""
|
||||
parts.append("\(git.branch)\(dirty)")
|
||||
if sidebarShowGitBranch, let gitSummary = gitBranchSummaryText {
|
||||
parts.append(gitSummary)
|
||||
}
|
||||
|
||||
// Directory summary
|
||||
|
|
@ -2983,12 +3019,64 @@ private struct TabItemView: View {
|
|||
return result.isEmpty ? nil : result
|
||||
}
|
||||
|
||||
private var gitBranchSummaryText: String? {
|
||||
let lines = gitBranchSummaryLines
|
||||
guard !lines.isEmpty else { return nil }
|
||||
return lines.joined(separator: " | ")
|
||||
}
|
||||
|
||||
private var gitBranchSummaryLines: [String] {
|
||||
tab.sidebarGitBranchesInDisplayOrder().map { branch in
|
||||
"\(branch.branch)\(branch.isDirty ? "*" : "")"
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalBranchDirectoryEntries: [SidebarBranchOrdering.BranchDirectoryEntry] {
|
||||
tab.sidebarBranchDirectoryEntriesInDisplayOrder()
|
||||
}
|
||||
|
||||
private var verticalRowsContainBranch: Bool {
|
||||
sidebarShowGitBranch && verticalBranchDirectoryLines.contains { $0.branch != nil }
|
||||
}
|
||||
|
||||
private struct VerticalBranchDirectoryLine {
|
||||
let branch: String?
|
||||
let directory: String?
|
||||
}
|
||||
|
||||
private var verticalBranchDirectoryLines: [VerticalBranchDirectoryLine] {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
return verticalBranchDirectoryEntries.compactMap { entry in
|
||||
let branchText: String? = {
|
||||
guard sidebarShowGitBranch, let branch = entry.branch else { return nil }
|
||||
return "\(branch)\(entry.isDirty ? "*" : "")"
|
||||
}()
|
||||
|
||||
let directoryText: String? = {
|
||||
guard let directory = entry.directory else { return nil }
|
||||
let shortened = shortenPath(directory, home: home)
|
||||
return shortened.isEmpty ? nil : shortened
|
||||
}()
|
||||
|
||||
switch (branchText, directoryText) {
|
||||
case let (branch?, directory?):
|
||||
return VerticalBranchDirectoryLine(branch: branch, directory: directory)
|
||||
case let (branch?, nil):
|
||||
return VerticalBranchDirectoryLine(branch: branch, directory: nil)
|
||||
case let (nil, directory?):
|
||||
return VerticalBranchDirectoryLine(branch: nil, directory: directory)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var directorySummaryText: String? {
|
||||
guard !tab.panels.isEmpty else { return nil }
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
var seen: Set<String> = []
|
||||
var entries: [String] = []
|
||||
for panelId in tab.panels.keys {
|
||||
for panelId in tab.sidebarOrderedPanelIds() {
|
||||
let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory
|
||||
let shortened = shortenPath(directory, home: home)
|
||||
guard !shortened.isEmpty else { continue }
|
||||
|
|
|
|||
|
|
@ -51,6 +51,18 @@ enum WorkspaceAutoReorderSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum SidebarBranchLayoutSettings {
|
||||
static let key = "sidebarBranchVerticalLayout"
|
||||
static let defaultVerticalLayout = true
|
||||
|
||||
static func usesVerticalLayout(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: key) == nil {
|
||||
return defaultVerticalLayout
|
||||
}
|
||||
return defaults.bool(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkspacePlacementSettings {
|
||||
static let placementKey = "newWorkspacePlacement"
|
||||
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
|
||||
|
|
|
|||
|
|
@ -7648,8 +7648,8 @@ class TerminalController {
|
|||
list_log [--limit=N] [--tab=X] - List log entries
|
||||
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
|
||||
clear_progress [--tab=X] - Clear progress bar
|
||||
report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch
|
||||
clear_git_branch [--tab=X] - Clear git branch
|
||||
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
|
||||
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
|
||||
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
|
||||
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
||||
|
|
@ -10696,7 +10696,7 @@ class TerminalController {
|
|||
private func reportGitBranch(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let branch = parsed.positional.first else {
|
||||
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]"
|
||||
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]"
|
||||
}
|
||||
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
|
||||
|
||||
|
|
@ -10706,24 +10706,76 @@ class TerminalController {
|
|||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
guard Self.shouldReplaceGitBranch(current: tab.gitBranch, branch: branch, isDirty: isDirty) else {
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty)
|
||||
|
||||
tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearGitBranch(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = "ERROR: Tab not found"
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
if tab.gitBranch != nil {
|
||||
tab.gitBranch = nil
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tab.clearPanelGitBranch(panelId: surfaceId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -11061,6 +11113,7 @@ class TerminalController {
|
|||
tab.logEntries.removeAll()
|
||||
tab.progress = nil
|
||||
tab.gitBranch = nil
|
||||
tab.panelGitBranches.removeAll()
|
||||
tab.surfaceListeningPorts.removeAll()
|
||||
tab.listeningPorts.removeAll()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,163 @@ struct SidebarGitBranchState {
|
|||
let isDirty: Bool
|
||||
}
|
||||
|
||||
enum SidebarBranchOrdering {
|
||||
struct BranchEntry: Equatable {
|
||||
let name: String
|
||||
let isDirty: Bool
|
||||
}
|
||||
|
||||
struct BranchDirectoryEntry: Equatable {
|
||||
let branch: String?
|
||||
let isDirty: Bool
|
||||
let directory: String?
|
||||
}
|
||||
|
||||
static func orderedPaneIds(tree: ExternalTreeNode) -> [String] {
|
||||
switch tree {
|
||||
case .pane(let pane):
|
||||
return [pane.id]
|
||||
case .split(let split):
|
||||
// Bonsplit split order matches visual order for both horizontal and vertical splits.
|
||||
return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second)
|
||||
}
|
||||
}
|
||||
|
||||
static func orderedPanelIds(
|
||||
tree: ExternalTreeNode,
|
||||
paneTabs: [String: [UUID]],
|
||||
fallbackPanelIds: [UUID]
|
||||
) -> [UUID] {
|
||||
var ordered: [UUID] = []
|
||||
var seen: Set<UUID> = []
|
||||
|
||||
for paneId in orderedPaneIds(tree: tree) {
|
||||
for panelId in paneTabs[paneId] ?? [] {
|
||||
if seen.insert(panelId).inserted {
|
||||
ordered.append(panelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for panelId in fallbackPanelIds {
|
||||
if seen.insert(panelId).inserted {
|
||||
ordered.append(panelId)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
static func orderedUniqueBranches(
|
||||
orderedPanelIds: [UUID],
|
||||
panelBranches: [UUID: SidebarGitBranchState],
|
||||
fallbackBranch: SidebarGitBranchState?
|
||||
) -> [BranchEntry] {
|
||||
var orderedNames: [String] = []
|
||||
var branchDirty: [String: Bool] = [:]
|
||||
|
||||
for panelId in orderedPanelIds {
|
||||
guard let state = panelBranches[panelId] else { continue }
|
||||
let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { continue }
|
||||
|
||||
if branchDirty[name] == nil {
|
||||
orderedNames.append(name)
|
||||
branchDirty[name] = state.isDirty
|
||||
} else if state.isDirty {
|
||||
branchDirty[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
if orderedNames.isEmpty, let fallbackBranch {
|
||||
let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !name.isEmpty {
|
||||
return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)]
|
||||
}
|
||||
}
|
||||
|
||||
return orderedNames.map { name in
|
||||
BranchEntry(name: name, isDirty: branchDirty[name] ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
static func orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: [UUID],
|
||||
panelBranches: [UUID: SidebarGitBranchState],
|
||||
panelDirectories: [UUID: String],
|
||||
defaultDirectory: String?,
|
||||
fallbackBranch: SidebarGitBranchState?
|
||||
) -> [BranchDirectoryEntry] {
|
||||
struct EntryKey: Hashable {
|
||||
let branch: String?
|
||||
let directory: String?
|
||||
}
|
||||
|
||||
struct MutableEntry {
|
||||
var branch: String?
|
||||
var isDirty: Bool
|
||||
var directory: String?
|
||||
}
|
||||
|
||||
func normalized(_ text: String?) -> String? {
|
||||
guard let text else { return nil }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
let normalizedFallbackBranch = normalized(fallbackBranch?.branch)
|
||||
let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains {
|
||||
normalized(panelBranches[$0]?.branch) != nil
|
||||
}
|
||||
let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil
|
||||
let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false
|
||||
|
||||
var order: [EntryKey] = []
|
||||
var entries: [EntryKey: MutableEntry] = [:]
|
||||
|
||||
for panelId in orderedPanelIds {
|
||||
let panelBranch = normalized(panelBranches[panelId]?.branch)
|
||||
let branch = panelBranch ?? defaultBranchForPanels
|
||||
let directory = normalized(panelDirectories[panelId] ?? defaultDirectory)
|
||||
guard branch != nil || directory != nil else { continue }
|
||||
|
||||
let panelDirty = panelBranch != nil
|
||||
? (panelBranches[panelId]?.isDirty ?? false)
|
||||
: defaultBranchDirty
|
||||
|
||||
let key = EntryKey(branch: branch, directory: directory)
|
||||
if entries[key] == nil {
|
||||
order.append(key)
|
||||
entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory)
|
||||
} else if panelDirty {
|
||||
entries[key]?.isDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if order.isEmpty {
|
||||
let fallbackDirectory = normalized(defaultDirectory)
|
||||
if normalizedFallbackBranch != nil || fallbackDirectory != nil {
|
||||
return [
|
||||
BranchDirectoryEntry(
|
||||
branch: normalizedFallbackBranch,
|
||||
isDirty: fallbackBranch?.isDirty ?? false,
|
||||
directory: fallbackDirectory
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return order.compactMap { key in
|
||||
guard let entry = entries[key] else { return nil }
|
||||
return BranchDirectoryEntry(
|
||||
branch: entry.branch,
|
||||
isDirty: entry.isDirty,
|
||||
directory: entry.directory
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClosedBrowserPanelRestoreSnapshot {
|
||||
let workspaceId: UUID
|
||||
let url: URL?
|
||||
|
|
@ -110,6 +267,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published var logEntries: [SidebarLogEntry] = []
|
||||
@Published var progress: SidebarProgressState?
|
||||
@Published var gitBranch: SidebarGitBranchState?
|
||||
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
|
||||
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
||||
@Published var listeningPorts: [Int] = []
|
||||
var surfaceTTYNames: [UUID: String] = [:]
|
||||
|
|
@ -270,6 +428,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
/// Deterministic tab selection to apply after a tab closes.
|
||||
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
|
||||
private var postCloseSelectTabId: [TabID: TabID] = [:]
|
||||
/// Panel IDs that were in a pane when a pane-close operation was approved.
|
||||
/// Bonsplit pane-close does not emit per-tab didClose callbacks.
|
||||
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
|
||||
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
|
||||
private var isApplyingTabSelection = false
|
||||
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
|
||||
|
|
@ -564,6 +725,24 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
|
||||
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
|
||||
let existing = panelGitBranches[panelId]
|
||||
if existing?.branch != branch || existing?.isDirty != isDirty {
|
||||
panelGitBranches[panelId] = state
|
||||
}
|
||||
if panelId == focusedPanelId {
|
||||
gitBranch = state
|
||||
}
|
||||
}
|
||||
|
||||
func clearPanelGitBranch(panelId: UUID) {
|
||||
panelGitBranches.removeValue(forKey: panelId)
|
||||
if panelId == focusedPanelId {
|
||||
gitBranch = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -608,6 +787,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) }
|
||||
pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) }
|
||||
manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) }
|
||||
panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) }
|
||||
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
|
|
@ -622,6 +802,45 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func sidebarOrderedPanelIds() -> [UUID] {
|
||||
let paneTabs: [String: [UUID]] = Dictionary(
|
||||
uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in
|
||||
let panelIds = bonsplitController
|
||||
.tabs(inPane: paneId)
|
||||
.compactMap { panelIdFromSurfaceId($0.id) }
|
||||
return (paneId.id.uuidString, panelIds)
|
||||
}
|
||||
)
|
||||
|
||||
let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString }
|
||||
let tree = bonsplitController.treeSnapshot()
|
||||
return SidebarBranchOrdering.orderedPanelIds(
|
||||
tree: tree,
|
||||
paneTabs: paneTabs,
|
||||
fallbackPanelIds: fallbackPanelIds
|
||||
)
|
||||
}
|
||||
|
||||
func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] {
|
||||
SidebarBranchOrdering
|
||||
.orderedUniqueBranches(
|
||||
orderedPanelIds: sidebarOrderedPanelIds(),
|
||||
panelBranches: panelGitBranches,
|
||||
fallbackBranch: gitBranch
|
||||
)
|
||||
.map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) }
|
||||
}
|
||||
|
||||
func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] {
|
||||
SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: sidebarOrderedPanelIds(),
|
||||
panelBranches: panelGitBranches,
|
||||
panelDirectories: panelDirectories,
|
||||
defaultDirectory: currentDirectory,
|
||||
fallbackBranch: gitBranch
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Panel Operations
|
||||
|
||||
/// Create a new split with a terminal panel
|
||||
|
|
@ -1504,6 +1723,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
if let terminalPanel = targetPanel as? TerminalPanel {
|
||||
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId)
|
||||
}
|
||||
if let dir = panelDirectories[targetPanelId] {
|
||||
currentDirectory = dir
|
||||
}
|
||||
gitBranch = panelGitBranches[targetPanelId]
|
||||
}
|
||||
|
||||
/// Reconcile focus/first-responder convergence.
|
||||
|
|
@ -1724,6 +1947,7 @@ extension Workspace: BonsplitDelegate {
|
|||
if let dir = panelDirectories[panelId] {
|
||||
currentDirectory = dir
|
||||
}
|
||||
gitBranch = panelGitBranches[panelId]
|
||||
|
||||
// Post notification
|
||||
NotificationCenter.default.post(
|
||||
|
|
@ -1861,6 +2085,7 @@ extension Workspace: BonsplitDelegate {
|
|||
panels.removeValue(forKey: panelId)
|
||||
surfaceIdToPanelId.removeValue(forKey: tabId)
|
||||
panelDirectories.removeValue(forKey: panelId)
|
||||
panelGitBranches.removeValue(forKey: panelId)
|
||||
panelTitles.removeValue(forKey: panelId)
|
||||
panelCustomTitles.removeValue(forKey: panelId)
|
||||
pinnedPanelIds.remove(panelId)
|
||||
|
|
@ -1893,6 +2118,11 @@ extension Workspace: BonsplitDelegate {
|
|||
// frame where the pane has no selected content.
|
||||
bonsplitController.selectTab(selectTabId)
|
||||
applyTabSelection(tabId: selectTabId, inPane: pane)
|
||||
} else if let focusedPane = bonsplitController.focusedPaneId,
|
||||
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {
|
||||
// When closing the last tab in a pane, Bonsplit may focus a different pane and skip
|
||||
// emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync.
|
||||
applyTabSelection(tabId: focusedTabId, inPane: focusedPane)
|
||||
}
|
||||
|
||||
if bonsplitController.allPaneIds.contains(pane) {
|
||||
|
|
@ -1940,7 +2170,36 @@ extension Workspace: BonsplitDelegate {
|
|||
}
|
||||
|
||||
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
|
||||
_ = paneId
|
||||
let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? []
|
||||
|
||||
if !closedPanelIds.isEmpty {
|
||||
for panelId in closedPanelIds {
|
||||
panels[panelId]?.close()
|
||||
panels.removeValue(forKey: panelId)
|
||||
panelDirectories.removeValue(forKey: panelId)
|
||||
panelGitBranches.removeValue(forKey: panelId)
|
||||
panelTitles.removeValue(forKey: panelId)
|
||||
panelCustomTitles.removeValue(forKey: panelId)
|
||||
pinnedPanelIds.remove(panelId)
|
||||
manualUnreadPanelIds.remove(panelId)
|
||||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
surfaceListeningPorts.removeValue(forKey: panelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||
}
|
||||
|
||||
let closedSet = Set(closedPanelIds)
|
||||
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
|
||||
recomputeListeningPorts()
|
||||
|
||||
if let focusedPane = bonsplitController.focusedPaneId,
|
||||
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {
|
||||
applyTabSelection(tabId: focusedTabId, inPane: focusedPane)
|
||||
} else {
|
||||
scheduleFocusReconcile()
|
||||
}
|
||||
}
|
||||
|
||||
scheduleTerminalGeometryReconcile()
|
||||
scheduleFocusReconcile()
|
||||
}
|
||||
|
|
@ -1953,9 +2212,11 @@ extension Workspace: BonsplitDelegate {
|
|||
if let panelId = panelIdFromSurfaceId(tab.id),
|
||||
let terminalPanel = terminalPanel(for: panelId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
|
||||
return false
|
||||
}
|
||||
}
|
||||
pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) }
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -2181,9 +2442,6 @@ extension Workspace: BonsplitDelegate {
|
|||
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
||||
let shouldPin = !pinnedPanelIds.contains(panelId)
|
||||
setPanelPinned(panelId: panelId, pinned: shouldPin)
|
||||
case .markAsRead:
|
||||
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
||||
markPanelRead(panelId)
|
||||
case .markAsUnread:
|
||||
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
||||
markPanelUnread(panelId)
|
||||
|
|
|
|||
|
|
@ -1183,6 +1183,7 @@ private enum DebugWindowConfigSnapshot {
|
|||
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
|
||||
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
|
||||
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
|
||||
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
|
||||
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
|
||||
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
|
||||
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
|
||||
|
|
@ -1749,6 +1750,7 @@ private struct SidebarDebugView: View {
|
|||
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
|
||||
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
|
||||
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
||||
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
||||
|
|
@ -1857,6 +1859,16 @@ private struct SidebarDebugView: View {
|
|||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
GroupBox("Workspace Metadata") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout)
|
||||
Text("When enabled, each branch appears on its own line in the sidebar.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Reset Tint") {
|
||||
sidebarTintOpacity = 0.62
|
||||
|
|
@ -1940,6 +1952,7 @@ private struct SidebarDebugView: View {
|
|||
sidebarTintHex=\(sidebarTintHex)
|
||||
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
|
||||
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
|
||||
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
|
||||
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
|
||||
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
|
||||
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
|
||||
|
|
@ -2433,6 +2446,7 @@ struct SettingsView: View {
|
|||
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
@State private var shortcutResetToken = UUID()
|
||||
@State private var topBlurOpacity: Double = 0
|
||||
@State private var topBlurBaselineOffset: CGFloat?
|
||||
|
|
@ -2523,6 +2537,22 @@ struct SettingsView: View {
|
|||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Sidebar Branch Layout",
|
||||
subtitle: sidebarBranchVerticalLayout
|
||||
? "Vertical: each branch appears on its own line."
|
||||
: "Inline: all branches share one line."
|
||||
) {
|
||||
Picker("", selection: $sidebarBranchVerticalLayout) {
|
||||
Text("Vertical").tag(true)
|
||||
Text("Inline").tag(false)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: "Automation")
|
||||
|
|
@ -2874,6 +2904,7 @@ struct SettingsView: View {
|
|||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
KeyboardShortcutSettings.resetAll()
|
||||
shortcutResetToken = UUID()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -765,6 +765,34 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class SidebarBranchLayoutSettingsTests: XCTestCase {
|
||||
func testDefaultUsesVerticalLayout() {
|
||||
let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
|
||||
}
|
||||
|
||||
func testStoredPreferenceOverridesDefault() {
|
||||
let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(false, forKey: SidebarBranchLayoutSettings.key)
|
||||
XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
|
||||
|
||||
defaults.set(true, forKey: SidebarBranchLayoutSettings.key)
|
||||
XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
final class AppearanceSettingsTests: XCTestCase {
|
||||
func testResolvedModeDefaultsToSystemWhenUnset() {
|
||||
let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"
|
||||
|
|
@ -783,53 +811,26 @@ final class AppearanceSettingsTests: XCTestCase {
|
|||
}
|
||||
|
||||
final class UpdateChannelSettingsTests: XCTestCase {
|
||||
func testDefaultNightlyPreferenceIsDisabled() {
|
||||
XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds)
|
||||
}
|
||||
|
||||
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
|
||||
let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults)
|
||||
XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL)
|
||||
func testResolvedFeedFallsBackWhenInfoFeedMissing() {
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
|
||||
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
|
||||
XCTAssertFalse(resolved.isNightly)
|
||||
XCTAssertTrue(resolved.usedFallback)
|
||||
}
|
||||
|
||||
func testResolvedFeedUsesInfoFeedForStableChannel() {
|
||||
let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let infoFeed = "https://example.com/custom/appcast.xml"
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults)
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
|
||||
XCTAssertEqual(resolved.url, infoFeed)
|
||||
XCTAssertFalse(resolved.isNightly)
|
||||
XCTAssertFalse(resolved.usedFallback)
|
||||
}
|
||||
|
||||
func testResolvedFeedUsesNightlyWhenPreferenceEnabled() {
|
||||
let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey)
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(
|
||||
infoFeedURL: "https://example.com/custom/appcast.xml",
|
||||
defaults: defaults
|
||||
func testResolvedFeedDetectsNightlyFromInfoFeedURL() {
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(
|
||||
infoFeedURL: "https://example.com/nightly/appcast.xml"
|
||||
)
|
||||
XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL)
|
||||
XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml")
|
||||
XCTAssertTrue(resolved.isNightly)
|
||||
XCTAssertFalse(resolved.usedFallback)
|
||||
}
|
||||
|
|
@ -953,6 +954,217 @@ final class TabManagerSurfaceCreationTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspacePanelGitBranchTests: XCTestCase {
|
||||
func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
|
||||
let workspace = Workspace()
|
||||
guard let firstPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected initial focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
|
||||
guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true)
|
||||
XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused")
|
||||
XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix")
|
||||
XCTAssertEqual(workspace.gitBranch?.isDirty, true)
|
||||
|
||||
XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed")
|
||||
XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused")
|
||||
XCTAssertEqual(workspace.gitBranch?.branch, "main")
|
||||
XCTAssertEqual(workspace.gitBranch?.isDirty, false)
|
||||
}
|
||||
|
||||
func testSidebarGitBranchesFollowLeftToRightSplitOrder() {
|
||||
let workspace = Workspace()
|
||||
guard let leftPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected initial focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false)
|
||||
guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split panel to be created")
|
||||
return
|
||||
}
|
||||
workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true)
|
||||
|
||||
let ordered = workspace.sidebarGitBranchesInDisplayOrder()
|
||||
XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"])
|
||||
XCTAssertEqual(ordered.map(\.isDirty), [false, true])
|
||||
}
|
||||
|
||||
func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() {
|
||||
let workspace = Workspace()
|
||||
guard let leftFirstPanelId = workspace.focusedPanelId,
|
||||
let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
|
||||
let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
|
||||
let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
|
||||
let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
|
||||
let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
|
||||
XCTFail("Expected panes and panels for ordering test")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0))
|
||||
XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1))
|
||||
XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0))
|
||||
XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1))
|
||||
|
||||
workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
|
||||
workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false)
|
||||
workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true)
|
||||
workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false)
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.sidebarOrderedPanelIds(),
|
||||
[leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id]
|
||||
)
|
||||
|
||||
let branches = workspace.sidebarGitBranchesInDisplayOrder()
|
||||
XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"])
|
||||
XCTAssertEqual(branches.map(\.isDirty), [true, false, false])
|
||||
}
|
||||
|
||||
func testClosingPaneDropsBranchesFromClosedSide() {
|
||||
let workspace = Workspace()
|
||||
guard let leftPanelId = workspace.focusedPanelId,
|
||||
let leftPaneId = workspace.paneId(forPanelId: leftPanelId),
|
||||
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected left/right split panes")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false)
|
||||
workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false)
|
||||
|
||||
XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"])
|
||||
XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId))
|
||||
XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"])
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarBranchOrderingTests: XCTestCase {
|
||||
|
||||
func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() {
|
||||
let first = UUID()
|
||||
let second = UUID()
|
||||
let third = UUID()
|
||||
|
||||
let branches = SidebarBranchOrdering.orderedUniqueBranches(
|
||||
orderedPanelIds: [first, second, third],
|
||||
panelBranches: [
|
||||
first: SidebarGitBranchState(branch: "main", isDirty: false),
|
||||
second: SidebarGitBranchState(branch: "feature", isDirty: false),
|
||||
third: SidebarGitBranchState(branch: "main", isDirty: true)
|
||||
],
|
||||
fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
branches,
|
||||
[
|
||||
SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true),
|
||||
SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() {
|
||||
let branches = SidebarBranchOrdering.orderedUniqueBranches(
|
||||
orderedPanelIds: [],
|
||||
panelBranches: [:],
|
||||
fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
branches,
|
||||
[SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() {
|
||||
let first = UUID()
|
||||
let second = UUID()
|
||||
let third = UUID()
|
||||
let fourth = UUID()
|
||||
let fifth = UUID()
|
||||
|
||||
let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: [first, second, third, fourth, fifth],
|
||||
panelBranches: [
|
||||
first: SidebarGitBranchState(branch: "main", isDirty: false),
|
||||
second: SidebarGitBranchState(branch: "feature", isDirty: false),
|
||||
third: SidebarGitBranchState(branch: "main", isDirty: true),
|
||||
fourth: SidebarGitBranchState(branch: "main", isDirty: false)
|
||||
],
|
||||
panelDirectories: [
|
||||
first: "/repo/a",
|
||||
second: "/repo/b",
|
||||
third: "/repo/a",
|
||||
fourth: "/repo/d",
|
||||
fifth: "/repo/e"
|
||||
],
|
||||
defaultDirectory: "/repo/default",
|
||||
fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
rows,
|
||||
[
|
||||
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"),
|
||||
SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"),
|
||||
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"),
|
||||
SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() {
|
||||
let first = UUID()
|
||||
let second = UUID()
|
||||
|
||||
let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: [first, second],
|
||||
panelBranches: [:],
|
||||
panelDirectories: [
|
||||
first: "/repo/one",
|
||||
second: "/repo/two"
|
||||
],
|
||||
defaultDirectory: "/repo/default",
|
||||
fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
rows,
|
||||
[
|
||||
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"),
|
||||
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() {
|
||||
let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: [],
|
||||
panelBranches: [:],
|
||||
panelDirectories: [:],
|
||||
defaultDirectory: "/repo/default",
|
||||
fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
rows,
|
||||
[SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserPanelAddressBarFocusRequestTests: XCTestCase {
|
||||
func testRequestPersistsUntilAcknowledged() {
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 4ceff31931f8b873e450c81f60148be3f8ce9cdb
|
||||
Subproject commit dd20247b5536b4bd5b9b15cdf940e847daa1a18d
|
||||
|
|
@ -54,7 +54,7 @@ export default function CommunityPage() {
|
|||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<CommunityLink
|
||||
href="https://discord.com/invite/QRxkhZgY"
|
||||
href="https://discord.gg/xsgFEVrWCZ"
|
||||
name="Discord"
|
||||
action="Join our Discord"
|
||||
description="Chat with the community, get help, and share feedback"
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function SiteFooter() {
|
|||
GitHub
|
||||
</a>
|
||||
<a href="https://twitter.com/manaflowai" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Twitter</a>
|
||||
<a href="https://discord.com/invite/QRxkhZgY" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a>
|
||||
<Link href="/privacy-policy" className="hover:text-foreground transition-colors">Privacy</Link>
|
||||
<Link href="/terms-of-service" className="hover:text-foreground transition-colors">Terms</Link>
|
||||
<Link href="/eula" className="hover:text-foreground transition-colors">EULA</Link>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue