Merge origin/main and resolve reopen-focus conflicts

This commit is contained in:
Lawrence Chen 2026-02-21 03:08:14 -08:00
commit e9f25ef67f
23 changed files with 2152 additions and 278 deletions

View file

@ -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)?
@ -468,6 +629,12 @@ final class Workspace: Identifiable, ObservableObject {
return surfaceKind(for: panel)
}
func panelTitle(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
let fallback = panelTitles[panelId] ?? panel.displayTitle
return resolvedPanelTitle(panelId: panelId, fallback: fallback)
}
func setPanelPinned(panelId: UUID, pinned: Bool) {
guard panels[panelId] != nil else { return }
let wasPinned = pinnedPanelIds.contains(panelId)
@ -559,11 +726,29 @@ final class Workspace: Identifiable, ObservableObject {
panelDirectories[panelId] = trimmed
}
// Update current directory if this is the focused panel
if panelId == focusedPanelId {
if panelId == focusedPanelId, currentDirectory != trimmed {
currentDirectory = trimmed
}
}
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 +793,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) }
@ -616,7 +802,49 @@ final class Workspace: Identifiable, ObservableObject {
func recomputeListeningPorts() {
let unique = Set(surfaceListeningPorts.values.flatMap { $0 })
listeningPorts = unique.sorted()
let next = unique.sorted()
if listeningPorts != next {
listeningPorts = next
}
}
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
@ -626,7 +854,8 @@ final class Workspace: Identifiable, ObservableObject {
func newTerminalSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false
insertFirst: Bool = false,
focus: Bool = true
) -> TerminalPanel? {
// Get inherited config from the source terminal when possible.
// If the split is initiated from a non-terminal panel (for example browser),
@ -699,10 +928,14 @@ final class Workspace: Identifiable, ObservableObject {
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
// stealing focus from the new panel and creating model/surface divergence.
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
}
return newPanel
@ -771,7 +1004,8 @@ final class Workspace: Identifiable, ObservableObject {
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil
url: URL? = nil,
focus: Bool = true
) -> BrowserPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
@ -815,10 +1049,14 @@ final class Workspace: Identifiable, ObservableObject {
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
}
installBrowserPanelSubscription(browserPanel)
@ -1501,6 +1739,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.
@ -1721,6 +1963,7 @@ extension Workspace: BonsplitDelegate {
if let dir = panelDirectories[panelId] {
currentDirectory = dir
}
gitBranch = panelGitBranches[panelId]
// Post notification
NotificationCenter.default.post(
@ -1858,6 +2101,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)
@ -1890,6 +2134,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) {
@ -1937,7 +2186,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()
}
@ -1950,9 +2228,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
}
@ -2178,12 +2458,12 @@ 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)
case .markAsRead:
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
markPanelRead(panelId)
}
}