Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ public struct AppCatalogSection: SettingCatalogSection {
userDefaultsKey: "paneFirstClickFocus.enabled"
)

/// Whether focus history includes pane and tab changes within a workspace.
/// Defaults to workspace-only navigation.
public let focusHistoryIncludesPanesAndTabs = DefaultsKey<Bool>(
id: "app.focusHistoryIncludesPanesAndTabs",
defaultValue: false,
userDefaultsKey: "focusHistoryIncludesPanesAndTabs"
)

public let preferredEditor = DefaultsKey<String>(
id: "app.preferredEditor",
defaultValue: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ struct SettingCatalogTests {
// `automation.socketPassword` must appear in `all`.
let ids = Set(SettingCatalog().all.map(\.id))
#expect(ids.contains("app.appearance"))
#expect(ids.contains("app.focusHistoryIncludesPanesAndTabs"))
#expect(ids.contains("paneBorderColor"))
#expect(ids.contains("activePaneBorderColor"))
#expect(ids.contains("mobile.iOSPairingHost.enabled"))
Expand All @@ -97,6 +98,10 @@ struct SettingCatalogTests {
#expect(ids.contains("automation.socketPassword"))
}

@Test func focusHistoryDefaultsToWorkspacesOnly() {
#expect(!SettingCatalog().app.focusHistoryIncludesPanesAndTabs.defaultValue)
}

@Test func keyIdsMatchTheirSectionPrefix() {
// Each key's dotted id must start with its section's prefix; this is
// the convention that lets the JSON store use `id` as the JSON path.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .app, id: "minimal-mode", title: "Minimal Mode", synonyms: "app.minimalMode presentation compact chrome layout simple titlebar controls"),
.init(section: .app, id: "keep-workspace-open", title: "Keep Workspace Open When Closing Last Surface", synonyms: "app.keepWorkspaceOpenWhenClosingLastSurface close last pane surface keep tab workspace"),
.init(section: .app, id: "focus-pane-first-click", title: "Focus Pane on First Click", synonyms: "app.focusPaneOnFirstClick click to focus focus follows mouse first click mouse activation"),
.init(
section: .app,
id: "focus-history-scope",
title: String(localized: "settings.app.focusHistoryIncludesPanesAndTabs", defaultValue: "Include Panes and Tabs in Focus History"),
synonyms: "app.focusHistoryIncludesPanesAndTabs focus history back forward panes tabs workspaces only navigation"
),
.init(section: .app, id: "file-drops", title: "File Drops", synonyms: "drag drop files finder path text terminal editor split preview shift"),
.init(section: .app, id: "preferred-editor", title: "Open Files With", synonyms: "app.preferredEditor editor open file code vscode visual studio zed sublime subl cursor"),
.init(section: .app, id: "supported-file-previews", title: "Open Supported Files in cmux", synonyms: "app.openSupportedFilesInCmux cmd click file preview pdf image video audio quicklook quick look editor external"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public struct AppSection: View {
@State private var minimalMode: DefaultsValueModel<WorkspacePresentationMode>
@State private var keepWorkspaceOpen: DefaultsValueModel<Bool>
@State private var firstClick: DefaultsValueModel<Bool>
@State private var focusHistoryIncludesPanesAndTabs: DefaultsValueModel<Bool>
@State private var fileDrop: DefaultsValueModel<FileDropDefaultBehavior>
@State private var preferredEditor: DefaultsValueModel<String>
@State private var openSupported: DefaultsValueModel<Bool>
Expand Down Expand Up @@ -83,6 +84,7 @@ public struct AppSection: View {
_minimalMode = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.presentationMode))
_keepWorkspaceOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.keepWorkspaceOpenWhenClosingLastSurface))
_firstClick = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.focusPaneOnFirstClick))
_focusHistoryIncludesPanesAndTabs = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.focusHistoryIncludesPanesAndTabs))
_fileDrop = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.fileDropDefaultBehavior))
_preferredEditor = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.preferredEditor))
_openSupported = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.openSupportedFilesInCmux))
Expand Down Expand Up @@ -136,7 +138,7 @@ public struct AppSection: View {
mainCard
}
.task {
startSettingsObservation([language, appearance, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundCommand, customSoundFile, telemetry, confirmQuit, warnCloseTab, warnCloseX, hideCloseButton, renameSelects, paletteAllSurfaces])
startSettingsObservation([language, appearance, appIcon, placement, inheritDir, minimalMode, keepWorkspaceOpen, firstClick, focusHistoryIncludesPanesAndTabs, fileDrop, preferredEditor, openSupported, openMarkdown, globalFontMagnification, markdownFontSize, markdownFontFamily, markdownMaxWidth, canvasPaneGap, canvasSnapping, fileEditorWordWrap, iMessage, reorder, dockBadge, menuBarOnly, showInMenuBar, paneRing, paneFlash, agentPermissionPrompt, agentTurnComplete, agentIdleReminder, soundName, soundCommand, customSoundFile, telemetry, confirmQuit, warnCloseTab, warnCloseX, hideCloseButton, renameSelects, paletteAllSurfaces])
if languageAtAppear == nil { languageAtAppear = language.current }; if telemetryAtAppear == nil { telemetryAtAppear = telemetry.current }
}
}
Expand Down Expand Up @@ -293,6 +295,24 @@ public struct AppSection: View {
}
SettingsCardDivider()

// Focus History Scope
SettingsCardRow(
configurationReview: .json("app.focusHistoryIncludesPanesAndTabs"),
String(localized: "settings.app.focusHistoryIncludesPanesAndTabs", defaultValue: "Include Panes and Tabs in Focus History"),
subtitle: focusHistoryIncludesPanesAndTabs.current
? String(localized: "settings.app.focusHistoryIncludesPanesAndTabs.subtitleOn", defaultValue: "Back and forward navigate focus changes between panes, tabs, and workspaces.")
: String(localized: "settings.app.focusHistoryIncludesPanesAndTabs.subtitleOff", defaultValue: "Back and forward navigate between workspaces only.")
) {
Toggle("", isOn: Binding(
get: { focusHistoryIncludesPanesAndTabs.current },
set: { focusHistoryIncludesPanesAndTabs.set($0) }
))
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("SettingsFocusHistoryIncludesPanesAndTabsToggle")
}
SettingsCardDivider()

// File Drops
SettingsCardRow(
configurationReview: .settingsOnly,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct SettingsRowAnchorResolutionTests {
"app.commandPaletteSearchesAllSurfaces",
"app.confirmQuit",
"app.focusPaneOnFirstClick",
"app.focusHistoryIncludesPanesAndTabs",
"app.globalFontMagnification",
"app.hideTabCloseButton",
"app.iMessageMode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,21 @@ public final class FocusHistoryModel: FocusHistoryNavigating {
private var focusHistoryRecordingSuppressionDepth = 0
private var focusHistorySuppressedSelectionSideEffectGenerations: Set<UInt64> = []
private let maxHistorySize: Int
private let navigationScope: @MainActor () -> FocusHistoryNavigationScope

/// Creates a detached model; call ``attach(host:)`` before use.
/// `maxHistorySize` is the legacy stack cap (50).
public init(maxHistorySize: Int = 50) {
///
/// - Parameters:
/// - maxHistorySize: The legacy stack cap (50).
/// - navigationScope: Supplies the current navigation scope. The model
/// reads it for each operation so a settings change applies without
/// rebuilding or clearing the recorded history.
public init(
maxHistorySize: Int = 50,
navigationScope: @escaping @MainActor () -> FocusHistoryNavigationScope = { .panesAndTabs }
) {
self.maxHistorySize = maxHistorySize
self.navigationScope = navigationScope
}

public func attach(host: any FocusHistoryHosting) {
Expand Down Expand Up @@ -78,6 +88,17 @@ public final class FocusHistoryModel: FocusHistoryNavigating {
let entry = FocusHistoryEntry(workspaceId: workspaceId, panelId: panelId)
guard focusHistoryEntryIsValid(entry) else { return }

if navigationScope() == .workspacesOnly,
historyIndex >= 0,
historyIndex < focusHistory.count,
focusHistory[historyIndex].entry.workspaceId == workspaceId {
if focusHistory[historyIndex].entry != entry {
focusHistory[historyIndex] = FocusHistoryRecord(entry: entry)
host?.focusHistoryRevisionDidChange()
}
return
}

if historyIndex >= 0,
historyIndex < focusHistory.count,
focusHistory[historyIndex].entry == entry {
Expand Down Expand Up @@ -245,10 +266,19 @@ public final class FocusHistoryModel: FocusHistoryNavigating {

private func focusHistoryEntryIsNavigable(_ entry: FocusHistoryEntry, currentEntry: FocusHistoryEntry?) -> Bool {
guard resolvedFocusHistoryEntry(for: entry) != nil else { return false }
if navigationScope() == .workspacesOnly,
entry.workspaceId == currentEntry?.workspaceId {
return false
}
if focusHistoryEntryResolvesToCurrent(entry, currentEntry: currentEntry) { return false }
return true
}

private func navigationEntry(for entry: FocusHistoryEntry) -> FocusHistoryEntry {
guard navigationScope() == .workspacesOnly else { return entry }
return FocusHistoryEntry(workspaceId: entry.workspaceId, panelId: nil)
}

// MARK: - Menu snapshots

public func focusHistoryMenuSnapshot(
Expand All @@ -269,24 +299,33 @@ public final class FocusHistoryModel: FocusHistoryNavigating {
: []
}

let scope = navigationScope()
var previousWorkspaceId: UUID?
let items = historyIndices.compactMap { index -> FocusHistoryMenuItem? in
let record = focusHistory[index]
let entry = record.entry
guard let resolvedEntry = resolvedFocusHistoryEntry(for: entry),
let scopedEntry = navigationEntry(for: entry)
guard let resolvedEntry = resolvedFocusHistoryEntry(for: scopedEntry),
let rawWorkspaceTitle = host?.workspaceTitle(resolvedEntry.workspaceId),
focusHistoryEntryIsNavigable(entry, currentEntry: currentEntry) else {
focusHistoryEntryIsNavigable(scopedEntry, currentEntry: currentEntry) else {
return nil
}
if scope == .workspacesOnly, previousWorkspaceId == entry.workspaceId {
return nil
}
previousWorkspaceId = entry.workspaceId

let workspaceTitle = rawWorkspaceTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let panelTitle = resolvedEntry.panelId
.flatMap { host?.panelTitle(workspaceId: resolvedEntry.workspaceId, panelId: $0) }?
.trimmingCharacters(in: .whitespacesAndNewlines)
let panelTitle = scope == .workspacesOnly
? nil
: resolvedEntry.panelId
.flatMap { host?.panelTitle(workspaceId: resolvedEntry.workspaceId, panelId: $0) }?
.trimmingCharacters(in: .whitespacesAndNewlines)
let position: FocusHistoryMenuPosition = direction == .back ? .older : .newer

return FocusHistoryMenuItem(
historyIndex: index,
entry: entry,
entry: scopedEntry,
workspaceTitle: workspaceTitle,
panelTitle: panelTitle?.isEmpty == true ? nil : panelTitle,
position: position,
Expand Down Expand Up @@ -353,6 +392,17 @@ public final class FocusHistoryModel: FocusHistoryNavigating {
public func navigateToFocusHistoryMenuItem(_ item: FocusHistoryMenuItem) -> Bool {
guard focusHistoryEntryIsNavigable(item.entry, currentEntry: currentFocusHistoryEntry) else { return false }
var targetIndex = item.historyIndex
if navigationScope() == .workspacesOnly {
guard focusHistory.indices.contains(targetIndex),
focusHistory[targetIndex].entry.workspaceId == item.entry.workspaceId else {
guard let fallbackIndex = focusHistory.lastIndex(where: {
$0.entry.workspaceId == item.entry.workspaceId
}) else { return false }
targetIndex = fallbackIndex
return navigateToFocusHistoryEntry(item.entry, targetIndex: targetIndex)
}
return navigateToFocusHistoryEntry(item.entry, targetIndex: targetIndex)
}
guard focusHistory.indices.contains(targetIndex), focusHistory[targetIndex].entry == item.entry else {
guard let fallbackIndex = focusHistory.lastIndex(where: { $0.entry == item.entry }) else { return false }
targetIndex = fallbackIndex
Expand All @@ -376,11 +426,11 @@ public final class FocusHistoryModel: FocusHistoryNavigating {
host?.focusHistoryRevisionDidChange()
continue
}
if focusHistoryEntryResolvesToCurrent(entry, currentEntry: currentEntry) {
if !focusHistoryEntryIsNavigable(entry, currentEntry: currentEntry) {
targetIndex -= 1
continue
}
if navigateToFocusHistoryEntry(entry, targetIndex: targetIndex) {
if navigateToFocusHistoryEntry(navigationEntry(for: entry), targetIndex: targetIndex) {
return true
}
focusHistory.remove(at: targetIndex)
Expand All @@ -404,11 +454,11 @@ public final class FocusHistoryModel: FocusHistoryNavigating {
host?.focusHistoryRevisionDidChange()
continue
}
if focusHistoryEntryResolvesToCurrent(entry, currentEntry: currentEntry) {
if !focusHistoryEntryIsNavigable(entry, currentEntry: currentEntry) {
targetIndex += 1
continue
}
if navigateToFocusHistoryEntry(entry, targetIndex: targetIndex) {
if navigateToFocusHistoryEntry(navigationEntry(for: entry), targetIndex: targetIndex) {
return true
}
focusHistory.remove(at: targetIndex)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Controls which recorded focus changes back/forward navigation can visit.
public enum FocusHistoryNavigationScope: Equatable, Sendable {
/// Navigate every recorded pane or tab focus change, including changes inside one workspace.
case panesAndTabs
/// Navigate only entries that move focus to a different workspace.
case workspacesOnly
}
Loading
Loading