From dc2b3e506b71dfed5498387d3c08c7d2d4414d4f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 19:05:56 -0800 Subject: [PATCH] Implement horizontal tab context menu actions --- Sources/Workspace.swift | 357 ++++++++++++++++++++++++++++- Sources/WorkspaceContentView.swift | 23 +- vendor/bonsplit | 2 +- 3 files changed, 367 insertions(+), 15 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e8c4953d..791d2c57 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -87,6 +87,9 @@ final class Workspace: Identifiable, ObservableObject { /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] + @Published private(set) var panelCustomTitles: [UUID: String] = [:] + @Published private(set) var pinnedPanelIds: Set = [] + @Published private(set) var manualUnreadPanelIds: Set = [] @Published var statusEntries: [String: SidebarStatusEntry] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @@ -103,6 +106,11 @@ final class Workspace: Identifiable, ObservableObject { private var processTitle: String + private enum SurfaceKind { + static let terminal = "terminal" + static let browser = "browser" + } + // MARK: - Initialization private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { @@ -178,13 +186,16 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel + panelTitles[terminalPanel.id] = terminalPanel.displayTitle // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? if let tabId = bonsplitController.createTab( title: title, icon: "terminal.fill", - isDirty: false + kind: SurfaceKind.terminal, + isDirty: false, + isPinned: false ) { surfaceIdToPanelId[tabId] = terminalPanel.id initialTabId = tabId @@ -246,6 +257,7 @@ final class Workspace: Identifiable, ObservableObject { private var isReconcilingFocusState = false private var focusReconcileScheduled = false private var geometryReconcileScheduled = false + private var isNormalizingPinnedTabOrder = false struct DetachedSurfaceTransfer { let panelId: UUID @@ -253,9 +265,13 @@ final class Workspace: Identifiable, ObservableObject { let title: String let icon: String? let iconImageData: Data? + let kind: String? let isLoading: Bool + let isPinned: Bool let directory: String? let cachedTitle: String? + let customTitle: String? + let manuallyUnread: Bool } private var detachingTabIds: Set = [] @@ -284,7 +300,11 @@ final class Workspace: Identifiable, ObservableObject { guard let existing = self.bonsplitController.tab(tabId) else { return } let nextTitle = browserPanel.displayTitle - let titleUpdate: String? = existing.title == nextTitle ? nil : nextTitle + if self.panelTitles[browserPanel.id] != nextTitle { + self.panelTitles[browserPanel.id] = nextTitle + } + let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) + let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading @@ -313,6 +333,141 @@ final class Workspace: Identifiable, ObservableObject { panels[panelId] as? BrowserPanel } + private func surfaceKind(for panel: any Panel) -> String { + switch panel.panelType { + case .terminal: + return SurfaceKind.terminal + case .browser: + return SurfaceKind.browser + } + } + + private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback + if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), + !custom.isEmpty { + return custom + } + return fallbackTitle + } + + private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { + let isPinned = pinnedPanelIds.contains(panelId) + if let panel = panels[panelId] { + bonsplitController.updateTab( + tabId, + kind: .some(surfaceKind(for: panel)), + isPinned: isPinned + ) + } else { + bonsplitController.updateTab(tabId, isPinned: isPinned) + } + } + + private func hasUnreadNotification(panelId: UUID) -> Bool { + AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false + } + + private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { + guard let tabId = surfaceIdFromPanelId(panelId) else { return } + let shouldShowUnread = manualUnreadPanelIds.contains(panelId) || hasUnreadNotification(panelId: panelId) + if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread { + return + } + bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) + } + + private func normalizePinnedTabs(in paneId: PaneID) { + guard !isNormalizingPinnedTabOrder else { return } + isNormalizingPinnedTabOrder = true + defer { isNormalizingPinnedTabOrder = false } + + let tabs = bonsplitController.tabs(inPane: paneId) + let pinnedTabs = tabs.filter { tab in + guard let panelId = panelIdFromSurfaceId(tab.id) else { return false } + return pinnedPanelIds.contains(panelId) + } + let unpinnedTabs = tabs.filter { tab in + guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } + return !pinnedPanelIds.contains(panelId) + } + let desiredOrder = pinnedTabs + unpinnedTabs + + for (index, desiredTab) in desiredOrder.enumerated() { + let currentTabs = bonsplitController.tabs(inPane: paneId) + guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue } + if currentIndex != index { + _ = bonsplitController.reorderTab(desiredTab.id, toIndex: index) + } + } + } + + private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } + let pinnedCount = tabs.reduce(into: 0) { count, tab in + if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { + count += 1 + } + } + let rawTarget = min(anchorIndex + 1, tabs.count) + return max(rawTarget, pinnedCount) + } + + func setPanelCustomTitle(panelId: UUID, title: String?) { + guard panels[panelId] != nil else { return } + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let previous = panelCustomTitles[panelId] + if trimmed.isEmpty { + guard previous != nil else { return } + panelCustomTitles.removeValue(forKey: panelId) + } else { + guard previous != trimmed else { return } + panelCustomTitles[panelId] = trimmed + } + + guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } + let baseTitle = panelTitles[panelId] ?? panel.displayTitle + bonsplitController.updateTab(tabId, title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle)) + } + + func isPanelPinned(_ panelId: UUID) -> Bool { + pinnedPanelIds.contains(panelId) + } + + func panelKind(panelId: UUID) -> String? { + guard let panel = panels[panelId] else { return nil } + return surfaceKind(for: panel) + } + + func setPanelPinned(panelId: UUID, pinned: Bool) { + guard panels[panelId] != nil else { return } + let wasPinned = pinnedPanelIds.contains(panelId) + guard wasPinned != pinned else { return } + if pinned { + pinnedPanelIds.insert(panelId) + } else { + pinnedPanelIds.remove(panelId) + } + + guard let tabId = surfaceIdFromPanelId(panelId), + let paneId = paneId(forPanelId: panelId) else { return } + bonsplitController.updateTab(tabId, isPinned: pinned) + normalizePinnedTabs(in: paneId) + } + + func markPanelUnread(_ panelId: UUID) { + guard panels[panelId] != nil else { return } + guard manualUnreadPanelIds.insert(panelId).inserted else { return } + syncUnreadBadgeStateForPanel(panelId) + } + + func clearManualUnread(panelId: UUID) { + guard manualUnreadPanelIds.remove(panelId) != nil else { return } + syncUnreadBadgeStateForPanel(panelId) + } + // MARK: - Title Management var hasCustomTitle: Bool { @@ -359,8 +514,11 @@ final class Workspace: Identifiable, ObservableObject { } // Update bonsplit tab title - if let tabId = surfaceIdFromPanelId(panelId) { - bonsplitController.updateTab(tabId, title: trimmed) + if let tabId = surfaceIdFromPanelId(panelId), + let panel = panels[panelId] { + let baseTitle = panelTitles[panelId] ?? panel.displayTitle + let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle) + bonsplitController.updateTab(tabId, title: resolvedTitle) } // If this is the only panel and no custom title, update workspace title @@ -373,6 +531,9 @@ final class Workspace: Identifiable, ObservableObject { func pruneSurfaceMetadata(validSurfaceIds: Set) { panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } + panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } + pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } + manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() @@ -429,13 +590,16 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). let newTab = Bonsplit.Tab( title: newPanel.displayTitle, icon: newPanel.displayIcon, - isDirty: newPanel.isDirty + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id @@ -448,6 +612,7 @@ final class Workspace: Identifiable, ObservableObject { defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) return nil } @@ -495,15 +660,19 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, isDirty: newPanel.isDirty, + isPinned: false, inPane: paneId ) else { panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) return nil } @@ -545,13 +714,16 @@ final class Workspace: Identifiable, ObservableObject { // Create browser panel let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) panels[browserPanel.id] = browserPanel + panelTitles[browserPanel.id] = browserPanel.displayTitle // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: browserPanel.displayTitle, icon: browserPanel.displayIcon, + kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, - isLoading: browserPanel.isLoading + isLoading: browserPanel.isLoading, + isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id @@ -562,6 +734,7 @@ final class Workspace: Identifiable, ObservableObject { guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { surfaceIdToPanelId.removeValue(forKey: newTab.id) panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) return nil } @@ -598,15 +771,19 @@ final class Workspace: Identifiable, ObservableObject { bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel + panelTitles[browserPanel.id] = browserPanel.displayTitle guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, icon: browserPanel.displayIcon, + kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, + isPinned: false, inPane: paneId ) else { panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) return nil } @@ -824,18 +1001,36 @@ final class Workspace: Identifiable, ObservableObject { if let cachedTitle = detached.cachedTitle { panelTitles[detached.panelId] = cachedTitle } + if let customTitle = detached.customTitle { + panelCustomTitles[detached.panelId] = customTitle + } + if detached.isPinned { + pinnedPanelIds.insert(detached.panelId) + } else { + pinnedPanelIds.remove(detached.panelId) + } + if detached.manuallyUnread { + manualUnreadPanelIds.insert(detached.panelId) + } else { + manualUnreadPanelIds.remove(detached.panelId) + } guard let newTabId = bonsplitController.createTab( title: detached.title, icon: detached.icon, iconImageData: detached.iconImageData, + kind: detached.kind, isDirty: detached.panel.isDirty, isLoading: detached.isLoading, + isPinned: detached.isPinned, inPane: paneId ) else { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) panelTitles.removeValue(forKey: detached.panelId) + panelCustomTitles.removeValue(forKey: detached.panelId) + pinnedPanelIds.remove(detached.panelId) + manualUnreadPanelIds.remove(detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) return nil } @@ -844,6 +1039,9 @@ final class Workspace: Identifiable, ObservableObject { if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } + syncPinnedStateForTab(newTabId, panelId: detached.panelId) + syncUnreadBadgeStateForPanel(detached.panelId) + normalizePinnedTabs(in: paneId) if focus { bonsplitController.focusPane(paneId) @@ -1041,12 +1239,15 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle // Create tab in bonsplit if let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, icon: newPanel.displayIcon, - isDirty: newPanel.isDirty + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false ) { surfaceIdToPanelId[newTabId] = newPanel.id } @@ -1143,6 +1344,79 @@ final class Workspace: Identifiable, ObservableObject { } } + private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { + for tabId in tabIds { + if skipPinned, + let panelId = panelIdFromSurfaceId(tabId), + pinnedPanelIds.contains(panelId) { + continue + } + _ = bonsplitController.closeTab(tabId) + } + } + + private func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] } + return Array(tabs.prefix(index).map(\.id)) + } + + private func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }), + index + 1 < tabs.count else { return [] } + return Array(tabs.suffix(from: index + 1).map(\.id)) + } + + private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { + bonsplitController.tabs(inPane: paneId) + .map(\.id) + .filter { $0 != anchorTabId } + } + + private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } + + private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { + let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return } + _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + } + + private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { + guard let panelId = panelIdFromSurfaceId(anchorTabId), + let browser = browserPanel(for: panelId) else { return } + createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL) + } + + private func promptRenamePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let panel = panels[panelId] else { return } + + let alert = NSAlert() + alert.messageText = "Rename Tab" + alert.informativeText = "Enter a custom name for this tab." + let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle + let input = NSTextField(string: currentTitle) + input.placeholderString = "Tab name" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + setPanelCustomTitle(panelId: panelId, title: input.stringValue) + } + } // MARK: - BonsplitDelegate @@ -1220,6 +1494,8 @@ extension Workspace: BonsplitDelegate { let panel = panels[panelId] else { return } + syncPinnedStateForTab(selectedTabId, panelId: panelId) + syncUnreadBadgeStateForPanel(panelId) // Unfocus all other panels for (id, p) in panels where id != panelId { @@ -1227,6 +1503,7 @@ extension Workspace: BonsplitDelegate { } panel.focus() + clearManualUnread(panelId: panelId) // Converge AppKit first responder with bonsplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. @@ -1276,6 +1553,12 @@ extension Workspace: BonsplitDelegate { return true } + if let panelId = panelIdFromSurfaceId(tab.id), + pinnedPanelIds.contains(panelId) { + NSSound.beep() + return false + } + // Check if the panel needs close confirmation guard let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId) else { @@ -1342,12 +1625,16 @@ extension Workspace: BonsplitDelegate { pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: panel.displayTitle, + title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, + kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, + isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - cachedTitle: panelTitles[panelId] + cachedTitle: panelTitles[panelId], + customTitle: panelCustomTitles[panelId], + manuallyUnread: manualUnreadPanelIds.contains(panelId) ) } else { panel?.close() @@ -1357,6 +1644,9 @@ extension Workspace: BonsplitDelegate { surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.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) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) @@ -1386,6 +1676,9 @@ extension Workspace: BonsplitDelegate { applyTabSelection(tabId: selectTabId, inPane: pane) } + if bonsplitController.allPaneIds.contains(pane) { + normalizePinnedTabs(in: pane) + } scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -1404,6 +1697,8 @@ extension Workspace: BonsplitDelegate { ) #endif applyTabSelection(tabId: tab.id, inPane: destination) + normalizePinnedTabs(in: source) + normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() scheduleFocusReconcile() } @@ -1474,6 +1769,8 @@ extension Workspace: BonsplitDelegate { // Only auto-create a terminal if the split came from bonsplit UI. // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. guard !isProgrammaticSplit else { + normalizePinnedTabs(in: originalPane) + normalizePinnedTabs(in: newPane) scheduleTerminalGeometryReconcile() return } @@ -1526,6 +1823,7 @@ extension Workspace: BonsplitDelegate { portOrdinal: portOrdinal ) panels[replacementPanel.id] = replacementPanel + panelTitles[replacementPanel.id] = replacementPanel.displayTitle surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -1533,9 +1831,11 @@ extension Workspace: BonsplitDelegate { title: replacementPanel.displayTitle, icon: .some(replacementPanel.displayIcon), iconImageData: .some(nil), + kind: .some(SurfaceKind.terminal), isDirty: replacementPanel.isDirty, showsNotificationBadge: false, - isLoading: false + isLoading: false, + isPinned: false ) for extraPlaceholder in placeholderTabs.dropFirst() { @@ -1556,6 +1856,8 @@ extension Workspace: BonsplitDelegate { } } } + normalizePinnedTabs(in: originalPane) + normalizePinnedTabs(in: newPane) scheduleTerminalGeometryReconcile() return } @@ -1585,18 +1887,23 @@ extension Workspace: BonsplitDelegate { portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, isDirty: newPanel.isDirty, + isPinned: false, inPane: newPane ) else { panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) return } surfaceIdToPanelId[newTabId] = newPanel.id + normalizePinnedTabs(in: newPane) #if DEBUG dlog( "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + @@ -1627,6 +1934,36 @@ extension Workspace: BonsplitDelegate { } } + func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { + switch action { + case .rename: + promptRenamePanel(tabId: tab.id) + case .closeToLeft: + closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) + case .closeToRight: + closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) + case .closeOthers: + closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .newTerminalToRight: + createTerminalToRight(of: tab.id, inPane: pane) + case .newBrowserToRight: + createBrowserToRight(of: tab.id, inPane: pane) + case .reload: + guard let panelId = panelIdFromSurfaceId(tab.id), + let browser = browserPanel(for: panelId) else { return } + browser.reload() + case .duplicate: + duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) + case .togglePin: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + let shouldPin = !pinnedPanelIds.contains(panelId) + setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsUnread: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + markPanelUnread(panelId) + } + } + func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 8c8cfbab..ec34dd1b 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -89,6 +89,9 @@ struct WorkspaceContentView: View { .onChange(of: notificationStore.notifications) { _, _ in syncBonsplitNotificationBadges() } + .onChange(of: workspace.manualUnreadPanelIds) { _, _ in + syncBonsplitNotificationBadges() + } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in refreshGhosttyAppearanceConfig() } @@ -106,18 +109,30 @@ struct WorkspaceContentView: View { } private func syncBonsplitNotificationBadges() { - let unreadPanelIds: Set = Set( + let unreadFromNotifications: Set = Set( notificationStore.notifications .filter { $0.tabId == workspace.id && !$0.isRead } .compactMap { $0.surfaceId } ) + let manualUnread = workspace.manualUnreadPanelIds for paneId in workspace.bonsplitController.allPaneIds { for tab in workspace.bonsplitController.tabs(inPane: paneId) { let panelId = workspace.panelIdFromSurfaceId(tab.id) - let shouldShow = panelId.map { unreadPanelIds.contains($0) } ?? false - if tab.showsNotificationBadge != shouldShow { - workspace.bonsplitController.updateTab(tab.id, showsNotificationBadge: shouldShow) + let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) } + let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false + let shouldShow = panelId.map { unreadFromNotifications.contains($0) || manualUnread.contains($0) } ?? false + let kindUpdate: String?? = expectedKind.map { .some($0) } + + if tab.showsNotificationBadge != shouldShow || + tab.isPinned != expectedPinned || + (expectedKind != nil && tab.kind != expectedKind) { + workspace.bonsplitController.updateTab( + tab.id, + kind: kindUpdate, + showsNotificationBadge: shouldShow, + isPinned: expectedPinned + ) } } } diff --git a/vendor/bonsplit b/vendor/bonsplit index 43aedcf7..af9461ef 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 43aedcf760b5e9c7e57a3ac1d0fcd5f8c877415f +Subproject commit af9461efdb4c84f901f27fac9e4838b2d297d842