Reduce typing lag from sidebar re-evaluation and hitTest overhead (#1204)
* Add typing hot path timing diagnostics * Add stress workspace debug menu item * Restore stress workspace preload debug path * Reduce typing lag from sidebar re-evaluation and hitTest overhead hitTest: gate divider/sidebar/drag routing to pointer events only, avoiding two full view-tree walks per non-pointer event. forceRefresh: replace per-keystroke ISO8601DateFormatter + FileHandle I/O with dlog() in DEBUG builds. TabItemView: replace @EnvironmentObject subscriptions with plain refs and precomputed parameters, add Equatable conformance to skip body re-evaluation when parent rebuilds with unchanged values. @self changed re-evaluations dropped from 668 to 1 during rapid typing. * Add typing-latency guardrail comments and CLAUDE.md pitfalls Strategic comments on hitTest, TabItemView, and forceRefresh to prevent future regressions. Adds typing-latency-sensitive paths to CLAUDE.md pitfalls section so agents know the constraints before editing. * Add workspace palette actions and fix release autosave typing guard Add Move Up/Down/Top, Close Other/Above/Below, Mark Read/Unread to Cmd+Shift+P command palette and a Workspace submenu in the menu bar. Fix recordTypingActivity() being gated behind #if DEBUG, which prevented release builds from honoring the typing quiet period in autosave.
This commit is contained in:
parent
18bdbef882
commit
6849b83f8d
11 changed files with 1863 additions and 151 deletions
|
|
@ -126,6 +126,10 @@ This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the
|
|||
|
||||
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
|
||||
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
|
||||
- **Typing-latency-sensitive paths** (read carefully before touching these areas):
|
||||
- `WindowTerminalHostView.hitTest()` in `TerminalWindowPortal.swift`: called on every event including keyboard. All divider/sidebar/drag routing is gated to pointer events only. Do not add work outside the `isPointerEvent` guard.
|
||||
- `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead.
|
||||
- `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here.
|
||||
- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
|
||||
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
|
||||
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.
|
||||
|
|
|
|||
|
|
@ -788,6 +788,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"debug.menu.openStressWorkspacesWithLoadedSurfaces": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open Stress Workspaces and Load All Terminals"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "負荷テスト用ワークスペースを開いてすべてのターミナルを読み込む"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"debug.devBuildBanner.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1527,6 +1527,11 @@ struct ContentView: View {
|
|||
static let workspaceShouldPin = "workspace.shouldPin"
|
||||
static let workspaceHasPullRequests = "workspace.hasPullRequests"
|
||||
static let workspaceHasSplits = "workspace.hasSplits"
|
||||
static let workspaceHasPeers = "workspace.hasPeers"
|
||||
static let workspaceHasAbove = "workspace.hasAbove"
|
||||
static let workspaceHasBelow = "workspace.hasBelow"
|
||||
static let workspaceHasUnread = "workspace.hasUnread"
|
||||
static let workspaceHasRead = "workspace.hasRead"
|
||||
|
||||
static let hasFocusedPanel = "panel.hasFocus"
|
||||
static let panelName = "panel.name"
|
||||
|
|
@ -2319,6 +2324,10 @@ struct ContentView: View {
|
|||
reconcileMountedWorkspaceIds()
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(tabManager.$debugPinnedWorkspaceLoadIds) { _ in
|
||||
reconcileMountedWorkspaceIds()
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
|
||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||
tabId == tabManager.selectedTabId else { return }
|
||||
|
|
@ -2699,7 +2708,9 @@ struct ContentView: View {
|
|||
let orderedTabIds = currentTabs.map { $0.id }
|
||||
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
|
||||
let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
|
||||
let pinnedIds = handoffPinnedIds.union(tabManager.pendingBackgroundWorkspaceLoadIds)
|
||||
let pinnedIds = handoffPinnedIds
|
||||
.union(tabManager.pendingBackgroundWorkspaceLoadIds)
|
||||
.union(tabManager.debugPinnedWorkspaceLoadIds)
|
||||
let isCycleHot = tabManager.isWorkspaceCycleHot
|
||||
let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty
|
||||
let baseMaxMounted = shouldKeepHandoffPair
|
||||
|
|
@ -4027,6 +4038,21 @@ struct ContentView: View {
|
|||
CommandPaletteContextKeys.workspaceHasSplits,
|
||||
workspace.bonsplitController.allPaneIds.count > 1
|
||||
)
|
||||
let workspaceIndex = tabManager.tabs.firstIndex { $0.id == workspace.id }
|
||||
snapshot.setBool(CommandPaletteContextKeys.workspaceHasPeers, tabManager.tabs.count > 1)
|
||||
snapshot.setBool(CommandPaletteContextKeys.workspaceHasAbove, (workspaceIndex ?? 0) > 0)
|
||||
snapshot.setBool(
|
||||
CommandPaletteContextKeys.workspaceHasBelow,
|
||||
(workspaceIndex ?? tabManager.tabs.count - 1) < tabManager.tabs.count - 1
|
||||
)
|
||||
snapshot.setBool(
|
||||
CommandPaletteContextKeys.workspaceHasUnread,
|
||||
notificationStore.notifications.contains { $0.tabId == workspace.id && !$0.isRead }
|
||||
)
|
||||
snapshot.setBool(
|
||||
CommandPaletteContextKeys.workspaceHasRead,
|
||||
notificationStore.notifications.contains { $0.tabId == workspace.id && $0.isRead }
|
||||
)
|
||||
}
|
||||
|
||||
if let panelContext = focusedPanelContext {
|
||||
|
|
@ -4320,6 +4346,86 @@ struct ContentView: View {
|
|||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.moveWorkspaceUp",
|
||||
title: constant(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["workspace", "move", "up", "reorder"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.moveWorkspaceDown",
|
||||
title: constant(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["workspace", "move", "down", "reorder"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.moveWorkspaceToTop",
|
||||
title: constant(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["workspace", "move", "top", "reorder"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.closeOtherWorkspaces",
|
||||
title: constant(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["close", "other", "workspaces", "reset", "workspace"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasPeers) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.closeWorkspacesBelow",
|
||||
title: constant(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["close", "below", "workspaces", "workspace"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.closeWorkspacesAbove",
|
||||
title: constant(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["close", "above", "workspaces", "workspace"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.markWorkspaceRead",
|
||||
title: constant(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["workspace", "read", "notification", "inbox"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasUnread) }
|
||||
)
|
||||
)
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.markWorkspaceUnread",
|
||||
title: constant(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")),
|
||||
subtitle: workspaceSubtitle,
|
||||
keywords: ["workspace", "unread", "notification", "inbox"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) },
|
||||
enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasRead) }
|
||||
)
|
||||
)
|
||||
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
|
|
@ -4796,6 +4902,43 @@ struct ContentView: View {
|
|||
registry.register(commandId: "palette.previousWorkspace") {
|
||||
tabManager.selectPreviousTab()
|
||||
}
|
||||
registry.register(commandId: "palette.moveWorkspaceUp") {
|
||||
moveSelectedWorkspace(by: -1)
|
||||
}
|
||||
registry.register(commandId: "palette.moveWorkspaceDown") {
|
||||
moveSelectedWorkspace(by: 1)
|
||||
}
|
||||
registry.register(commandId: "palette.moveWorkspaceToTop") {
|
||||
guard let workspace = tabManager.selectedWorkspace else {
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
tabManager.moveTabsToTop([workspace.id])
|
||||
tabManager.selectWorkspace(workspace)
|
||||
}
|
||||
registry.register(commandId: "palette.closeOtherWorkspaces") {
|
||||
closeOtherSelectedWorkspaces()
|
||||
}
|
||||
registry.register(commandId: "palette.closeWorkspacesBelow") {
|
||||
closeSelectedWorkspacesBelow()
|
||||
}
|
||||
registry.register(commandId: "palette.closeWorkspacesAbove") {
|
||||
closeSelectedWorkspacesAbove()
|
||||
}
|
||||
registry.register(commandId: "palette.markWorkspaceRead") {
|
||||
guard let workspaceId = tabManager.selectedWorkspace?.id else {
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
notificationStore.markRead(forTabId: workspaceId)
|
||||
}
|
||||
registry.register(commandId: "palette.markWorkspaceUnread") {
|
||||
guard let workspaceId = tabManager.selectedWorkspace?.id else {
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
notificationStore.markUnread(forTabId: workspaceId)
|
||||
}
|
||||
|
||||
registry.register(commandId: "palette.renameTab") {
|
||||
beginRenameTabFlow()
|
||||
|
|
@ -5796,6 +5939,48 @@ struct ContentView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private func selectedWorkspaceIndex() -> Int? {
|
||||
guard let workspace = tabManager.selectedWorkspace else { return nil }
|
||||
return tabManager.tabs.firstIndex { $0.id == workspace.id }
|
||||
}
|
||||
|
||||
private func moveSelectedWorkspace(by delta: Int) {
|
||||
guard let workspace = tabManager.selectedWorkspace,
|
||||
let currentIndex = selectedWorkspaceIndex() else { return }
|
||||
let targetIndex = currentIndex + delta
|
||||
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
|
||||
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex)
|
||||
tabManager.selectWorkspace(workspace)
|
||||
}
|
||||
|
||||
private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) {
|
||||
for workspaceId in workspaceIds {
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue }
|
||||
guard allowPinned || !workspace.isPinned else { continue }
|
||||
tabManager.closeWorkspaceWithConfirmation(workspace)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeOtherSelectedWorkspaces() {
|
||||
guard let workspace = tabManager.selectedWorkspace else { return }
|
||||
let workspaceIds = tabManager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id }
|
||||
closeWorkspaceIds(workspaceIds, allowPinned: false)
|
||||
}
|
||||
|
||||
private func closeSelectedWorkspacesBelow() {
|
||||
guard let workspace = tabManager.selectedWorkspace,
|
||||
let anchorIndex = selectedWorkspaceIndex() else { return }
|
||||
let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id)
|
||||
closeWorkspaceIds(workspaceIds, allowPinned: false)
|
||||
}
|
||||
|
||||
private func closeSelectedWorkspacesAbove() {
|
||||
guard let workspace = tabManager.selectedWorkspace,
|
||||
let anchorIndex = selectedWorkspaceIndex() else { return }
|
||||
let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id)
|
||||
closeWorkspaceIds(workspaceIds, allowPinned: false)
|
||||
}
|
||||
|
||||
private func beginRenameWorkspaceFlow() {
|
||||
guard let workspace = tabManager.selectedWorkspace else {
|
||||
NSSound.beep()
|
||||
|
|
@ -6960,6 +7145,7 @@ struct VerticalTabsSidebar: View {
|
|||
@ObservedObject var updateViewModel: UpdateViewModel
|
||||
let onSendFeedback: () -> Void
|
||||
@EnvironmentObject var tabManager: TabManager
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
@Binding var selection: SidebarSelection
|
||||
@Binding var selectedTabIds: Set<UUID>
|
||||
@Binding var lastSidebarSelectionIndex: Int?
|
||||
|
|
@ -6985,10 +7171,21 @@ struct VerticalTabsSidebar: View {
|
|||
LazyVStack(spacing: tabRowSpacing) {
|
||||
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
TabItemView(
|
||||
tabManager: tabManager,
|
||||
notificationStore: notificationStore,
|
||||
tab: tab,
|
||||
index: index,
|
||||
isActive: tabManager.selectedTabId == tab.id,
|
||||
tabCount: tabManager.tabs.count,
|
||||
unreadCount: notificationStore.unreadCount(forTabId: tab.id),
|
||||
latestNotificationText: {
|
||||
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
|
||||
let text = notification.body.isEmpty ? notification.title : notification.body
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}(),
|
||||
rowSpacing: tabRowSpacing,
|
||||
selection: $selection,
|
||||
setSelectionToTabs: { selection = .tabs },
|
||||
selectedTabIds: $selectedTabIds,
|
||||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||||
showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed,
|
||||
|
|
@ -6996,6 +7193,7 @@ struct VerticalTabsSidebar: View {
|
|||
draggedTabId: $draggedTabId,
|
||||
dropIndicator: $dropIndicator
|
||||
)
|
||||
.equatable()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
|
|
@ -9231,14 +9429,40 @@ enum SidebarWorkspaceShortcutHintMetrics {
|
|||
#endif
|
||||
}
|
||||
|
||||
private struct TabItemView: View {
|
||||
@EnvironmentObject var tabManager: TabManager
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when
|
||||
// the parent rebuilds with unchanged values. Without this, every TabManager
|
||||
// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of
|
||||
// main thread during typing). If you add new properties, update == below.
|
||||
// Do NOT add @EnvironmentObject or new @Binding without updating ==.
|
||||
// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar.
|
||||
private struct TabItemView: View, Equatable {
|
||||
// Closures, Bindings, and object references are excluded from ==
|
||||
// because they're recreated every parent eval but don't affect rendering.
|
||||
nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool {
|
||||
lhs.tab === rhs.tab &&
|
||||
lhs.index == rhs.index &&
|
||||
lhs.isActive == rhs.isActive &&
|
||||
lhs.tabCount == rhs.tabCount &&
|
||||
lhs.unreadCount == rhs.unreadCount &&
|
||||
lhs.latestNotificationText == rhs.latestNotificationText &&
|
||||
lhs.rowSpacing == rhs.rowSpacing &&
|
||||
lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints
|
||||
}
|
||||
|
||||
// Use plain references instead of @EnvironmentObject to avoid subscribing
|
||||
// to ALL changes on these objects. Body reads use precomputed parameters;
|
||||
// action handlers use the plain references without triggering re-evaluation.
|
||||
let tabManager: TabManager
|
||||
let notificationStore: TerminalNotificationStore
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject var tab: Tab
|
||||
let index: Int
|
||||
let isActive: Bool
|
||||
let tabCount: Int
|
||||
let unreadCount: Int
|
||||
let latestNotificationText: String?
|
||||
let rowSpacing: CGFloat
|
||||
@Binding var selection: SidebarSelection
|
||||
let setSelectionToTabs: () -> Void
|
||||
@Binding var selectedTabIds: Set<UUID>
|
||||
@Binding var lastSidebarSelectionIndex: Int?
|
||||
let showsModifierShortcutHints: Bool
|
||||
|
|
@ -9264,10 +9488,6 @@ private struct TabItemView: View {
|
|||
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
|
||||
private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
|
||||
var isActive: Bool {
|
||||
tabManager.selectedTabId == tab.id
|
||||
}
|
||||
|
||||
var isMultiSelected: Bool {
|
||||
selectedTabIds.contains(tab.id)
|
||||
}
|
||||
|
|
@ -9340,11 +9560,11 @@ private struct TabItemView: View {
|
|||
}
|
||||
|
||||
private var workspaceShortcutDigit: Int? {
|
||||
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count)
|
||||
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount)
|
||||
}
|
||||
|
||||
private var showCloseButton: Bool {
|
||||
isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
}
|
||||
|
||||
private var workspaceShortcutLabel: String? {
|
||||
|
|
@ -9409,7 +9629,6 @@ private struct TabItemView: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
let unreadCount = notificationStore.unreadCount(forTabId: tab.id)
|
||||
if unreadCount > 0 {
|
||||
ZStack {
|
||||
Circle()
|
||||
|
|
@ -9969,7 +10188,7 @@ private struct TabItemView: View {
|
|||
}
|
||||
|
||||
private var accessibilityTitle: String {
|
||||
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)")
|
||||
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)")
|
||||
}
|
||||
|
||||
private func moveBy(_ delta: Int) {
|
||||
|
|
@ -9979,7 +10198,7 @@ private struct TabItemView: View {
|
|||
selectedTabIds = [tab.id]
|
||||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id }
|
||||
tabManager.selectTab(tab)
|
||||
selection = .tabs
|
||||
setSelectionToTabs()
|
||||
}
|
||||
|
||||
private func updateSelection() {
|
||||
|
|
@ -10024,7 +10243,7 @@ private struct TabItemView: View {
|
|||
surfaceId: tabManager.focusedSurfaceId(for: tab.id)
|
||||
)
|
||||
}
|
||||
selection = .tabs
|
||||
setSelectionToTabs()
|
||||
}
|
||||
|
||||
private func contextTargetIds() -> [UUID] {
|
||||
|
|
@ -10134,12 +10353,8 @@ private struct TabItemView: View {
|
|||
syncSelectionAfterMutation()
|
||||
}
|
||||
|
||||
private var latestNotificationText: String? {
|
||||
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
|
||||
let text = notification.body.isEmpty ? notification.title : notification.body
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
// latestNotificationText is now passed as a parameter from the parent view
|
||||
// to avoid subscribing to notificationStore changes in every TabItemView.
|
||||
|
||||
private func branchDirectoryRow(
|
||||
gitSummary: String?,
|
||||
|
|
|
|||
|
|
@ -3031,16 +3031,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)"
|
||||
}
|
||||
#if DEBUG
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n"
|
||||
let logPath = "/tmp/cmux-refresh-debug.log"
|
||||
if let handle = FileHandle(forWritingAtPath: logPath) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(line.data(using: .utf8)!)
|
||||
handle.closeFile()
|
||||
} else {
|
||||
FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8))
|
||||
}
|
||||
dlog("forceRefresh: \(id) reason=\(reason) \(viewState)")
|
||||
#endif
|
||||
guard let view = attachedView,
|
||||
view.window != nil,
|
||||
|
|
@ -4302,10 +4293,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
#if DEBUG
|
||||
private func recordKeyLatency(path: String, event: NSEvent) {
|
||||
guard Self.keyLatencyProbeEnabled else { return }
|
||||
guard event.timestamp > 0 else { return }
|
||||
let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000)
|
||||
let delayText = String(format: "%.2f", delayMs)
|
||||
dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
|
||||
CmuxTypingTiming.logEventDelay(path: path, event: event)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
@ -4322,6 +4310,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.performKeyEquivalent",
|
||||
startedAt: typingTimingStart,
|
||||
event: event
|
||||
)
|
||||
}
|
||||
#endif
|
||||
guard event.type == .keyDown else { return false }
|
||||
guard let fr = window?.firstResponder as? NSView,
|
||||
fr === self || fr.isDescendant(of: self) else { return false }
|
||||
|
|
@ -4443,15 +4441,59 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
let phaseTotalStart = ProcessInfo.processInfo.systemUptime
|
||||
var ensureSurfaceMs: Double = 0
|
||||
var dismissNotificationMs: Double = 0
|
||||
var keyboardCopyModeMs: Double = 0
|
||||
var interpretMs: Double = 0
|
||||
var syncPreeditMs: Double = 0
|
||||
var ghosttySendMs: Double = 0
|
||||
var refreshMs: Double = 0
|
||||
defer {
|
||||
let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0
|
||||
CmuxTypingTiming.logBreakdown(
|
||||
path: "terminal.keyDown.phase",
|
||||
totalMs: totalMs,
|
||||
event: event,
|
||||
thresholdMs: 1.0,
|
||||
parts: [
|
||||
("ensureSurfaceMs", ensureSurfaceMs),
|
||||
("dismissNotificationMs", dismissNotificationMs),
|
||||
("keyboardCopyModeMs", keyboardCopyModeMs),
|
||||
("interpretMs", interpretMs),
|
||||
("syncPreeditMs", syncPreeditMs),
|
||||
("ghosttySendMs", ghosttySendMs),
|
||||
("refreshMs", refreshMs),
|
||||
],
|
||||
extra: "marked=\(hasMarkedText() ? 1 : 0)"
|
||||
)
|
||||
CmuxTypingTiming.logDuration(path: "terminal.keyDown", startedAt: typingTimingStart, event: event)
|
||||
}
|
||||
let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
guard let surface = ensureSurfaceReadyForInput() else {
|
||||
#if DEBUG
|
||||
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
|
||||
#endif
|
||||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
|
||||
#endif
|
||||
if let terminalSurface {
|
||||
#if DEBUG
|
||||
let dismissNotificationStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
tabId: terminalSurface.tabId,
|
||||
surfaceId: terminalSurface.id
|
||||
)
|
||||
#if DEBUG
|
||||
dismissNotificationMs = (ProcessInfo.processInfo.systemUptime - dismissNotificationStart) * 1000.0
|
||||
#endif
|
||||
}
|
||||
if event.keyCode != 53 {
|
||||
endFindEscapeSuppression()
|
||||
|
|
@ -4459,10 +4501,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
if shouldConsumeSuppressedFindEscape(event) {
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
let keyboardCopyModeStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
if handleKeyboardCopyModeIfNeeded(event, surface: surface) {
|
||||
#if DEBUG
|
||||
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
||||
#endif
|
||||
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
||||
#endif
|
||||
#if DEBUG
|
||||
recordKeyLatency(path: "keyDown", event: event)
|
||||
#endif
|
||||
|
|
@ -4500,12 +4551,36 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
let handled: Bool
|
||||
if text.isEmpty {
|
||||
keyEvent.text = nil
|
||||
#if DEBUG
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
handled = sendTimedGhosttyKey(
|
||||
surface,
|
||||
keyEvent,
|
||||
path: "terminal.keyDown.ctrlGhosttySend",
|
||||
event: event
|
||||
)
|
||||
ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
#else
|
||||
handled = ghostty_surface_key(surface, keyEvent)
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
let sendTimingStart = CmuxTypingTiming.start()
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
handled = text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
return ghostty_surface_key(surface, keyEvent)
|
||||
}
|
||||
#if DEBUG
|
||||
ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.keyDown.ctrlGhosttySend",
|
||||
startedAt: sendTimingStart,
|
||||
event: event,
|
||||
extra: "handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
|
|
@ -4582,18 +4657,42 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
|
||||
// Let the input system handle the event (for IME, dead keys, etc.)
|
||||
#if DEBUG
|
||||
let interpretTimingStart = CmuxTypingTiming.start()
|
||||
let interpretPhaseStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
interpretKeyEvents([translationEvent])
|
||||
#if DEBUG
|
||||
interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.keyDown.interpretKeyEvents",
|
||||
startedAt: interpretTimingStart,
|
||||
event: event
|
||||
)
|
||||
#endif
|
||||
|
||||
// If the keyboard layout changed, an input method grabbed the event.
|
||||
// Sync preedit and return without sending the key to Ghostty.
|
||||
if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id {
|
||||
#if DEBUG
|
||||
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
syncPreedit(clearIfNeeded: markedTextBefore)
|
||||
#if DEBUG
|
||||
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
// Sync the preedit state with Ghostty so it can render the IME
|
||||
// composition overlay (e.g. for Korean, Japanese, Chinese input).
|
||||
#if DEBUG
|
||||
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
syncPreedit(clearIfNeeded: markedTextBefore)
|
||||
#if DEBUG
|
||||
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
|
||||
#endif
|
||||
|
||||
// Build the key event
|
||||
var keyEvent = ghostty_input_key_s()
|
||||
|
|
@ -4623,13 +4722,37 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
for text in accumulatedText {
|
||||
if shouldSendText(text) {
|
||||
shouldRefreshAfterTextInput = true
|
||||
#if DEBUG
|
||||
let sendTimingStart = CmuxTypingTiming.start()
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
}
|
||||
#if DEBUG
|
||||
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.keyDown.accumulatedGhosttySend",
|
||||
startedAt: sendTimingStart,
|
||||
event: event,
|
||||
extra: "textBytes=\(text.utf8.count)"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
keyEvent.text = nil
|
||||
#if DEBUG
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
_ = sendTimedGhosttyKey(
|
||||
surface,
|
||||
keyEvent,
|
||||
path: "terminal.keyDown.accumulatedGhosttySend",
|
||||
event: event
|
||||
)
|
||||
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
#else
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -4644,22 +4767,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
if let text = textForKeyEvent(translationEvent) {
|
||||
if shouldSendText(text), !suppressShiftSpaceFallbackText {
|
||||
shouldRefreshAfterTextInput = true
|
||||
#if DEBUG
|
||||
let sendTimingStart = CmuxTypingTiming.start()
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
}
|
||||
#if DEBUG
|
||||
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.keyDown.ghosttySend",
|
||||
startedAt: sendTimingStart,
|
||||
event: event,
|
||||
extra: "textBytes=\(text.utf8.count)"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
keyEvent.text = nil
|
||||
#if DEBUG
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
_ = sendTimedGhosttyKey(
|
||||
surface,
|
||||
keyEvent,
|
||||
path: "terminal.keyDown.ghosttySend",
|
||||
event: event
|
||||
)
|
||||
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
#else
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
keyEvent.text = nil
|
||||
#if DEBUG
|
||||
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
||||
_ = sendTimedGhosttyKey(
|
||||
surface,
|
||||
keyEvent,
|
||||
path: "terminal.keyDown.ghosttySend",
|
||||
event: event
|
||||
)
|
||||
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
||||
#else
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if shouldRefreshAfterTextInput {
|
||||
#if DEBUG
|
||||
let refreshStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
|
||||
#if DEBUG
|
||||
refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0
|
||||
#endif
|
||||
}
|
||||
|
||||
// Rendering is driven by Ghostty's wakeups/renderer.
|
||||
|
|
@ -4673,6 +4837,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return ghostty_surface_key(surface, keyEvent)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@discardableResult
|
||||
private func sendTimedGhosttyKey(
|
||||
_ surface: ghostty_surface_t,
|
||||
_ keyEvent: ghostty_input_key_s,
|
||||
path: String,
|
||||
event: NSEvent? = nil,
|
||||
extra: String? = nil
|
||||
) -> Bool {
|
||||
let timingStart = CmuxTypingTiming.start()
|
||||
let handled = sendGhosttyKey(surface, keyEvent)
|
||||
let baseExtra = "handled=\(handled ? 1 : 0)"
|
||||
let mergedExtra: String
|
||||
if let extra, !extra.isEmpty {
|
||||
mergedExtra = "\(baseExtra) \(extra)"
|
||||
} else {
|
||||
mergedExtra = baseExtra
|
||||
}
|
||||
CmuxTypingTiming.logDuration(path: path, startedAt: timingStart, event: event, extra: mergedExtra)
|
||||
return handled
|
||||
}
|
||||
#endif
|
||||
|
||||
override func keyUp(with event: NSEvent) {
|
||||
guard let surface = ensureSurfaceReadyForInput() else {
|
||||
super.keyUp(with: event)
|
||||
|
|
@ -7453,6 +7640,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
extension GhosttyNSView: NSTextInputClient {
|
||||
fileprivate func sendTextToSurface(_ chars: String) {
|
||||
guard let surface = surface else { return }
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
#endif
|
||||
#if DEBUG
|
||||
cmuxWriteChildExitProbe(
|
||||
[
|
||||
|
|
@ -7472,6 +7662,13 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
keyEvent.composing = false
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
}
|
||||
#if DEBUG
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.sendTextToSurface",
|
||||
startedAt: typingTimingStart,
|
||||
extra: "textBytes=\(chars.utf8.count)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func hasMarkedText() -> Bool {
|
||||
|
|
@ -7488,6 +7685,16 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
}
|
||||
|
||||
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.setMarkedText",
|
||||
startedAt: typingTimingStart,
|
||||
extra: "markedLength=\(markedText.length)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
switch string {
|
||||
case let v as NSAttributedString:
|
||||
markedText = NSMutableAttributedString(attributedString: v)
|
||||
|
|
@ -7506,6 +7713,17 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
}
|
||||
|
||||
func unmarkText() {
|
||||
#if DEBUG
|
||||
let hadMarkedText = markedText.length > 0
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.unmarkText",
|
||||
startedAt: typingTimingStart,
|
||||
extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
if markedText.length > 0 {
|
||||
markedText.mutableString.setString("")
|
||||
syncPreedit()
|
||||
|
|
@ -7516,6 +7734,16 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
/// This tells Ghostty about IME composition text so it can render the
|
||||
/// preedit overlay (e.g. for Korean, Japanese, Chinese input).
|
||||
private func syncPreedit(clearIfNeeded: Bool = true) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.syncPreedit",
|
||||
startedAt: typingTimingStart,
|
||||
extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
guard let surface = surface else { return }
|
||||
|
||||
if markedText.length > 0 {
|
||||
|
|
@ -7583,6 +7811,17 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
}
|
||||
|
||||
func insertText(_ string: Any, replacementRange: NSRange) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "terminal.insertText",
|
||||
startedAt: typingTimingStart,
|
||||
event: NSApp.currentEvent,
|
||||
extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
// Get the string value
|
||||
var chars = ""
|
||||
switch string {
|
||||
|
|
|
|||
|
|
@ -1288,6 +1288,17 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private func refreshSuggestions() {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
let trimmedQuery = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.omnibar.refreshSuggestions",
|
||||
startedAt: typingTimingStart,
|
||||
extra: "focused=\(addressBarFocused ? 1 : 0) queryLen=\(trimmedQuery.utf8.count) suggestionCount=\(omnibarState.suggestions.count)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
suggestionTask?.cancel()
|
||||
suggestionTask = nil
|
||||
isLoadingRemoteSuggestions = false
|
||||
|
|
@ -2702,6 +2713,18 @@ private final class OmnibarNativeTextField: NSTextField {
|
|||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
var route = "super"
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.omnibar.keyDown",
|
||||
startedAt: typingTimingStart,
|
||||
event: event,
|
||||
extra: "route=\(route)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
// Reset shift-click anchor on any keyboard input so that a subsequent
|
||||
// Shift+click uses the post-keyboard selection as its anchor, not a
|
||||
// stale value from a prior mouse interaction.
|
||||
|
|
@ -2711,20 +2734,46 @@ private final class OmnibarNativeTextField: NSTextField {
|
|||
return
|
||||
}
|
||||
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
|
||||
#if DEBUG
|
||||
route = "custom"
|
||||
#endif
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
var handled = false
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.omnibar.performKeyEquivalent",
|
||||
startedAt: typingTimingStart,
|
||||
event: event,
|
||||
extra: "handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
shiftClickAnchor = nil
|
||||
if (currentEditor() as? NSTextView)?.hasMarkedText() == true {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
let result = super.performKeyEquivalent(with: event)
|
||||
#if DEBUG
|
||||
handled = result
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
let result = super.performKeyEquivalent(with: event)
|
||||
#if DEBUG
|
||||
handled = result
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2947,6 +2996,17 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func controlTextDidChange(_ obj: Notification) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.omnibar.controlTextDidChange",
|
||||
startedAt: typingTimingStart,
|
||||
event: NSApp.currentEvent,
|
||||
extra: "programmatic=\(isProgrammaticMutation ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
guard !isProgrammaticMutation else { return }
|
||||
guard let field = obj.object as? NSTextField else { return }
|
||||
let editor = field.currentEditor() as? NSTextView
|
||||
|
|
@ -2960,36 +3020,69 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
var handled = false
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.omnibar.doCommandBy",
|
||||
startedAt: typingTimingStart,
|
||||
event: NSApp.currentEvent,
|
||||
extra: "handled=\(handled ? 1 : 0) selector=\(NSStringFromSelector(commandSelector))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
switch commandSelector {
|
||||
case #selector(NSResponder.moveDown(_:)):
|
||||
parent.onMoveSelection(+1)
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case #selector(NSResponder.moveUp(_:)):
|
||||
parent.onMoveSelection(-1)
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
let currentFlags = NSApp.currentEvent?.modifierFlags ?? []
|
||||
guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false }
|
||||
parent.onSubmit()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
parent.onEscape()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case #selector(NSResponder.moveRight(_:)), #selector(NSResponder.moveToEndOfLine(_:)):
|
||||
if parent.inlineCompletion != nil {
|
||||
parent.onAcceptInlineCompletion()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case #selector(NSResponder.insertTab(_:)):
|
||||
if parent.inlineCompletion != nil {
|
||||
parent.onAcceptInlineCompletion()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case #selector(NSResponder.deleteBackward(_:)):
|
||||
if suffixSelectionMatchesInline(textView, inline: parent.inlineCompletion) {
|
||||
parent.onDeleteBackwardWithInlineSelection()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
|
@ -3057,6 +3150,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
var handled = false
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.omnibar.handleKeyEvent",
|
||||
startedAt: typingTimingStart,
|
||||
event: event,
|
||||
extra: "handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
let keyCode = event.keyCode
|
||||
let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function])
|
||||
let lowered = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||
|
|
@ -3065,16 +3170,25 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
// Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held.
|
||||
if hasCommandOrControl, lowered == "n" {
|
||||
parent.onMoveSelection(+1)
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
if hasCommandOrControl, lowered == "p" {
|
||||
parent.onMoveSelection(-1)
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
// Shift+Delete removes the selected history suggestion when possible.
|
||||
if modifiers.contains(.shift), (keyCode == 51 || keyCode == 117) {
|
||||
parent.onDeleteSelectedSuggestion()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -3082,30 +3196,51 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
|
|||
case 36, 76: // Return / keypad Enter
|
||||
guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false }
|
||||
parent.onSubmit()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case 53: // Escape
|
||||
parent.onEscape()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case 125: // Down
|
||||
parent.onMoveSelection(+1)
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case 126: // Up
|
||||
parent.onMoveSelection(-1)
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
case 124, 119: // Right arrow / End
|
||||
if parent.inlineCompletion != nil {
|
||||
parent.onAcceptInlineCompletion()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
case 48: // Tab
|
||||
if parent.inlineCompletion != nil {
|
||||
parent.onAcceptInlineCompletion()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
case 51: // Backspace
|
||||
if let inline = parent.inlineCompletion,
|
||||
(suffixSelectionMatchesInline(editor, inline: inline) || selectionIsTypedPrefixBoundary(editor, inline: inline)) {
|
||||
parent.onDeleteBackwardWithInlineSelection()
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -114,6 +114,18 @@ final class CmuxWebView: WKWebView {
|
|||
}
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
var handled = false
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.web.performKeyEquivalent",
|
||||
startedAt: typingTimingStart,
|
||||
event: event,
|
||||
extra: "handled=\(handled ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
if event.keyCode == 36 || event.keyCode == 76 {
|
||||
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
|
||||
// receives the keyDown path used by form submission handlers.
|
||||
|
|
@ -124,28 +136,57 @@ final class CmuxWebView: WKWebView {
|
|||
// Menu/app shortcut routing is only needed for Command equivalents
|
||||
// (New Tab, Close Tab, tab switching, split commands, etc).
|
||||
guard flags.contains(.command) else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
let result = super.performKeyEquivalent(with: event)
|
||||
#if DEBUG
|
||||
handled = result
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle app-level shortcuts that are not menu-backed (for example split commands).
|
||||
// Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them.
|
||||
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
||||
#if DEBUG
|
||||
handled = true
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
return super.performKeyEquivalent(with: event)
|
||||
let result = super.performKeyEquivalent(with: event)
|
||||
#if DEBUG
|
||||
handled = result
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
var route = "super"
|
||||
defer {
|
||||
CmuxTypingTiming.logDuration(
|
||||
path: "browser.web.keyDown",
|
||||
startedAt: typingTimingStart,
|
||||
event: event,
|
||||
extra: "route=\(route)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
// Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent.
|
||||
// Route them through the same app-level shortcut handler as a fallback.
|
||||
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
|
||||
AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
|
||||
#if DEBUG
|
||||
route = "appShortcut"
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ class TabManager: ObservableObject {
|
|||
@Published var tabs: [Workspace] = []
|
||||
@Published private(set) var isWorkspaceCycleHot: Bool = false
|
||||
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
|
||||
@Published private(set) var debugPinnedWorkspaceLoadIds: Set<UUID> = []
|
||||
|
||||
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
|
||||
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
|
||||
|
|
@ -1059,10 +1060,25 @@ class TabManager: ObservableObject {
|
|||
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
|
||||
}
|
||||
|
||||
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
||||
guard !workspaceIds.isEmpty else { return }
|
||||
debugPinnedWorkspaceLoadIds.formUnion(workspaceIds)
|
||||
}
|
||||
|
||||
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
||||
guard !workspaceIds.isEmpty else { return }
|
||||
debugPinnedWorkspaceLoadIds.subtract(workspaceIds)
|
||||
}
|
||||
|
||||
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
||||
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
|
||||
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
|
||||
pendingBackgroundWorkspaceLoadIds = pruned
|
||||
if pruned != pendingBackgroundWorkspaceLoadIds {
|
||||
pendingBackgroundWorkspaceLoadIds = pruned
|
||||
}
|
||||
let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds)
|
||||
if retained != debugPinnedWorkspaceLoadIds {
|
||||
debugPinnedWorkspaceLoadIds = retained
|
||||
}
|
||||
}
|
||||
|
||||
// Keep addTab as convenience alias
|
||||
|
|
|
|||
|
|
@ -129,44 +129,69 @@ final class WindowTerminalHostView: NSView {
|
|||
clearActiveDividerCursor(restoreArrow: true)
|
||||
}
|
||||
|
||||
// PERF: hitTest is called on EVERY event including keyboard. Keep non-pointer
|
||||
// path minimal. Do not add work outside the isPointerEvent guard.
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
updateDividerCursor(at: point)
|
||||
|
||||
if shouldPassThroughToSidebarResizer(at: point) {
|
||||
return nil
|
||||
let currentEvent = NSApp.currentEvent
|
||||
let isPointerEvent: Bool
|
||||
switch currentEvent?.type {
|
||||
case .mouseMoved, .mouseEntered, .mouseExited,
|
||||
.leftMouseDown, .leftMouseUp, .leftMouseDragged,
|
||||
.rightMouseDown, .rightMouseUp, .rightMouseDragged,
|
||||
.otherMouseDown, .otherMouseUp, .otherMouseDragged,
|
||||
.scrollWheel, .cursorUpdate:
|
||||
isPointerEvent = true
|
||||
default:
|
||||
isPointerEvent = false
|
||||
}
|
||||
|
||||
if shouldPassThroughToSplitDivider(at: point) {
|
||||
return nil
|
||||
}
|
||||
if isPointerEvent {
|
||||
if shouldPassThroughToSidebarResizer(at: point) {
|
||||
clearActiveDividerCursor(restoreArrow: false)
|
||||
return nil
|
||||
}
|
||||
|
||||
let dragPasteboardTypes = NSPasteboard(name: .drag).types
|
||||
let eventType = NSApp.currentEvent?.type
|
||||
let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
|
||||
pasteboardTypes: dragPasteboardTypes,
|
||||
eventType: eventType
|
||||
)
|
||||
if shouldPassThrough {
|
||||
// Compute divider hit once and reuse for both cursor update and pass-through.
|
||||
if let kind = splitDividerCursorKind(at: point) {
|
||||
activeDividerCursorKind = kind
|
||||
kind.cursor.set()
|
||||
return nil
|
||||
}
|
||||
|
||||
clearActiveDividerCursor(restoreArrow: true)
|
||||
|
||||
let dragPasteboardTypes = NSPasteboard(name: .drag).types
|
||||
let eventType = currentEvent?.type
|
||||
let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
|
||||
pasteboardTypes: dragPasteboardTypes,
|
||||
eventType: eventType
|
||||
)
|
||||
if shouldPassThrough {
|
||||
#if DEBUG
|
||||
logDragRouteDecision(
|
||||
passThrough: true,
|
||||
eventType: eventType,
|
||||
pasteboardTypes: dragPasteboardTypes,
|
||||
hitView: nil
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
let hitView = super.hitTest(point)
|
||||
#if DEBUG
|
||||
logDragRouteDecision(
|
||||
passThrough: true,
|
||||
eventType: eventType,
|
||||
passThrough: false,
|
||||
eventType: currentEvent?.type,
|
||||
pasteboardTypes: dragPasteboardTypes,
|
||||
hitView: nil
|
||||
hitView: hitView
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
return hitView === self ? nil : hitView
|
||||
}
|
||||
|
||||
// Non-pointer event: skip divider/drag routing, just do standard hit testing.
|
||||
let hitView = super.hitTest(point)
|
||||
#if DEBUG
|
||||
logDragRouteDecision(
|
||||
passThrough: false,
|
||||
eventType: eventType,
|
||||
pasteboardTypes: dragPasteboardTypes,
|
||||
hitView: hitView
|
||||
)
|
||||
#endif
|
||||
return hitView === self ? nil : hitView
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -933,6 +933,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
|
||||
private var isProgrammaticSplit = false
|
||||
private var debugStressPreloadSelectionDepth = 0
|
||||
|
||||
/// Last terminal panel used as an inheritance source (typically last focused terminal).
|
||||
private var lastTerminalConfigInheritancePanelId: UUID?
|
||||
|
|
@ -968,6 +969,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return panel
|
||||
}
|
||||
|
||||
func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? {
|
||||
bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) }
|
||||
}
|
||||
|
||||
enum FocusPanelTrigger {
|
||||
case standard
|
||||
case terminalFirstResponder
|
||||
|
|
@ -1493,6 +1498,36 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func preloadTerminalPanelForDebugStress(
|
||||
tabId: TabID,
|
||||
inPane paneId: PaneID
|
||||
) -> TerminalPanel? {
|
||||
guard let panelId = panelIdFromSurfaceId(tabId),
|
||||
let terminalPanel = panels[panelId] as? TerminalPanel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
debugStressPreloadSelectionDepth += 1
|
||||
defer { debugStressPreloadSelectionDepth -= 1 }
|
||||
let isVisibleSelection =
|
||||
bonsplitController.focusedPaneId == paneId &&
|
||||
bonsplitController.selectedTab(inPane: paneId)?.id == tabId &&
|
||||
terminalPanel.hostedView.window != nil &&
|
||||
terminalPanel.hostedView.superview != nil
|
||||
|
||||
if isVisibleSelection {
|
||||
terminalPanel.requestViewReattach()
|
||||
scheduleTerminalGeometryReconcile()
|
||||
}
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
func scheduleDebugStressTerminalGeometryReconcile() {
|
||||
scheduleTerminalGeometryReconcile()
|
||||
}
|
||||
|
||||
func hasLoadedTerminalSurface() -> Bool {
|
||||
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
|
||||
guard !terminalPanels.isEmpty else { return true }
|
||||
|
|
@ -4132,23 +4167,37 @@ extension Workspace: BonsplitDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
// Focus the selected panel
|
||||
guard let panelId = panelIdFromSurfaceId(selectedTabId),
|
||||
let panel = panels[panelId] else {
|
||||
// Focus the selected panel, but keep the previously focused terminal active while a
|
||||
// newly created split terminal is still unattached.
|
||||
guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else {
|
||||
return
|
||||
}
|
||||
let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId
|
||||
guard let panel = panels[effectiveFocusedPanelId] else {
|
||||
return
|
||||
}
|
||||
|
||||
if debugStressPreloadSelectionDepth > 0 {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
terminalPanel.requestViewReattach()
|
||||
scheduleTerminalGeometryReconcile()
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if shouldTreatCurrentEventAsExplicitFocusIntent() {
|
||||
markExplicitFocusIntent(on: panelId)
|
||||
markExplicitFocusIntent(on: effectiveFocusedPanelId)
|
||||
}
|
||||
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
|
||||
panel.prepareFocusIntentForActivation(activationIntent)
|
||||
let panelId = effectiveFocusedPanelId
|
||||
|
||||
syncPinnedStateForTab(selectedTabId, panelId: panelId)
|
||||
syncUnreadBadgeStateForPanel(panelId)
|
||||
syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId)
|
||||
syncUnreadBadgeStateForPanel(selectedPanelId)
|
||||
|
||||
// Unfocus all other panels
|
||||
for (id, p) in panels where id != panelId {
|
||||
for (id, p) in panels where id != effectiveFocusedPanelId {
|
||||
p.unfocus()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,15 @@ struct cmuxApp: App {
|
|||
appDelegate.openDebugColorComparisonWorkspaces(nil)
|
||||
}
|
||||
|
||||
Button(
|
||||
String(
|
||||
localized: "debug.menu.openStressWorkspacesWithLoadedSurfaces",
|
||||
defaultValue: "Open Stress Workspaces and Load All Terminals"
|
||||
)
|
||||
) {
|
||||
appDelegate.openDebugStressWorkspacesWithLoadedSurfaces(nil)
|
||||
}
|
||||
|
||||
Divider()
|
||||
Menu("Debug Windows") {
|
||||
Button("Debug Window Controls…") {
|
||||
|
|
@ -462,6 +471,10 @@ struct cmuxApp: App {
|
|||
closeTabOrWindow()
|
||||
}
|
||||
|
||||
Menu(String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace")) {
|
||||
workspaceCommandMenuContent(manager: activeTabManager)
|
||||
}
|
||||
|
||||
Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) {
|
||||
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
|
||||
}
|
||||
|
|
@ -819,6 +832,199 @@ struct cmuxApp: App {
|
|||
_ = tabManager.createBrowserSplit(direction: direction)
|
||||
}
|
||||
|
||||
private func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? {
|
||||
manager.tabs.firstIndex { $0.id == workspaceId }
|
||||
}
|
||||
|
||||
private func selectedWorkspaceWindowMoveTargets(in manager: TabManager) -> [AppDelegate.WindowMoveTarget] {
|
||||
let referenceWindowId = AppDelegate.shared?.windowId(for: manager)
|
||||
return AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? []
|
||||
}
|
||||
|
||||
private func toggleSelectedWorkspacePinned(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace else { return }
|
||||
manager.setPinned(workspace, pinned: !workspace.isPinned)
|
||||
}
|
||||
|
||||
private func clearSelectedWorkspaceCustomName(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace else { return }
|
||||
manager.clearCustomTitle(tabId: workspace.id)
|
||||
}
|
||||
|
||||
private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) {
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
|
||||
let targetIndex = currentIndex + delta
|
||||
guard targetIndex >= 0, targetIndex < manager.tabs.count else { return }
|
||||
_ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex)
|
||||
manager.selectWorkspace(workspace)
|
||||
}
|
||||
|
||||
private func moveSelectedWorkspaceToTop(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace else { return }
|
||||
manager.moveTabsToTop([workspace.id])
|
||||
manager.selectWorkspace(workspace)
|
||||
}
|
||||
|
||||
private func moveSelectedWorkspace(in manager: TabManager, toWindow windowId: UUID) {
|
||||
guard let workspace = manager.selectedWorkspace else { return }
|
||||
_ = AppDelegate.shared?.moveWorkspaceToWindow(workspaceId: workspace.id, windowId: windowId, focus: true)
|
||||
}
|
||||
|
||||
private func moveSelectedWorkspaceToNewWindow(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace else { return }
|
||||
_ = AppDelegate.shared?.moveWorkspaceToNewWindow(workspaceId: workspace.id, focus: true)
|
||||
}
|
||||
|
||||
private func closeWorkspaceIds(
|
||||
_ workspaceIds: [UUID],
|
||||
in manager: TabManager,
|
||||
allowPinned: Bool
|
||||
) {
|
||||
for workspaceId in workspaceIds {
|
||||
guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue }
|
||||
guard allowPinned || !workspace.isPinned else { continue }
|
||||
manager.closeWorkspaceWithConfirmation(workspace)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeOtherSelectedWorkspacePeers(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace else { return }
|
||||
let workspaceIds = manager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id }
|
||||
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false)
|
||||
}
|
||||
|
||||
private func closeSelectedWorkspacesBelow(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
|
||||
let workspaceIds = manager.tabs.suffix(from: anchorIndex + 1).map(\.id)
|
||||
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false)
|
||||
}
|
||||
|
||||
private func closeSelectedWorkspacesAbove(in manager: TabManager) {
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return }
|
||||
let workspaceIds = manager.tabs.prefix(upTo: anchorIndex).map(\.id)
|
||||
closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false)
|
||||
}
|
||||
|
||||
private func selectedWorkspaceHasUnreadNotifications(in manager: TabManager) -> Bool {
|
||||
guard let workspaceId = manager.selectedWorkspace?.id else { return false }
|
||||
return notificationStore.notifications.contains { $0.tabId == workspaceId && !$0.isRead }
|
||||
}
|
||||
|
||||
private func selectedWorkspaceHasReadNotifications(in manager: TabManager) -> Bool {
|
||||
guard let workspaceId = manager.selectedWorkspace?.id else { return false }
|
||||
return notificationStore.notifications.contains { $0.tabId == workspaceId && $0.isRead }
|
||||
}
|
||||
|
||||
private func markSelectedWorkspaceRead(in manager: TabManager) {
|
||||
guard let workspaceId = manager.selectedWorkspace?.id else { return }
|
||||
notificationStore.markRead(forTabId: workspaceId)
|
||||
}
|
||||
|
||||
private func markSelectedWorkspaceUnread(in manager: TabManager) {
|
||||
guard let workspaceId = manager.selectedWorkspace?.id else { return }
|
||||
notificationStore.markUnread(forTabId: workspaceId)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func workspaceCommandMenuContent(manager: TabManager) -> some View {
|
||||
let workspace = manager.selectedWorkspace
|
||||
let workspaceIndex = workspace.flatMap { selectedWorkspaceIndex(in: manager, workspaceId: $0.id) }
|
||||
let windowMoveTargets = selectedWorkspaceWindowMoveTargets(in: manager)
|
||||
|
||||
Button(
|
||||
workspace?.isPinned == true
|
||||
? String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace")
|
||||
: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace")
|
||||
) {
|
||||
toggleSelectedWorkspacePinned(in: manager)
|
||||
}
|
||||
.disabled(workspace == nil)
|
||||
|
||||
Button(String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…")) {
|
||||
_ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette()
|
||||
}
|
||||
.disabled(workspace == nil)
|
||||
|
||||
if workspace?.hasCustomTitle == true {
|
||||
Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) {
|
||||
clearSelectedWorkspaceCustomName(in: manager)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) {
|
||||
moveSelectedWorkspace(in: manager, by: -1)
|
||||
}
|
||||
.disabled(workspaceIndex == nil || workspaceIndex == 0)
|
||||
|
||||
Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) {
|
||||
moveSelectedWorkspace(in: manager, by: 1)
|
||||
}
|
||||
.disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1)
|
||||
|
||||
Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) {
|
||||
moveSelectedWorkspaceToTop(in: manager)
|
||||
}
|
||||
.disabled(workspace == nil || workspaceIndex == 0)
|
||||
|
||||
Menu(String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window")) {
|
||||
Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) {
|
||||
moveSelectedWorkspaceToNewWindow(in: manager)
|
||||
}
|
||||
.disabled(workspace == nil)
|
||||
|
||||
if !windowMoveTargets.isEmpty {
|
||||
Divider()
|
||||
}
|
||||
|
||||
ForEach(windowMoveTargets) { target in
|
||||
Button(target.label) {
|
||||
moveSelectedWorkspace(in: manager, toWindow: target.windowId)
|
||||
}
|
||||
.disabled(target.isCurrentWindow || workspace == nil)
|
||||
}
|
||||
}
|
||||
.disabled(workspace == nil)
|
||||
|
||||
Divider()
|
||||
|
||||
Button(String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace")) {
|
||||
manager.closeCurrentWorkspaceWithConfirmation()
|
||||
}
|
||||
.disabled(workspace == nil)
|
||||
|
||||
Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) {
|
||||
closeOtherSelectedWorkspacePeers(in: manager)
|
||||
}
|
||||
.disabled(workspace == nil || manager.tabs.count <= 1)
|
||||
|
||||
Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) {
|
||||
closeSelectedWorkspacesBelow(in: manager)
|
||||
}
|
||||
.disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1)
|
||||
|
||||
Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) {
|
||||
closeSelectedWorkspacesAbove(in: manager)
|
||||
}
|
||||
.disabled(workspaceIndex == nil || workspaceIndex == 0)
|
||||
|
||||
Divider()
|
||||
|
||||
Button(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")) {
|
||||
markSelectedWorkspaceRead(in: manager)
|
||||
}
|
||||
.disabled(!selectedWorkspaceHasUnreadNotifications(in: manager))
|
||||
|
||||
Button(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")) {
|
||||
markSelectedWorkspaceUnread(in: manager)
|
||||
}
|
||||
.disabled(!selectedWorkspaceHasReadNotifications(in: manager))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
|
||||
if let key = shortcut.keyEquivalent {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue