Implement horizontal tab context menu actions
This commit is contained in:
parent
51a0b3222c
commit
dc2b3e506b
3 changed files with 367 additions and 15 deletions
|
|
@ -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<UUID> = []
|
||||
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
|
||||
@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<TabID> = []
|
||||
|
|
@ -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<UUID>) {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<UUID> = Set(
|
||||
let unreadFromNotifications: Set<UUID> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 43aedcf760b5e9c7e57a3ac1d0fcd5f8c877415f
|
||||
Subproject commit af9461efdb4c84f901f27fac9e4838b2d297d842
|
||||
Loading…
Add table
Add a link
Reference in a new issue