From f0d7a5ba7fd1d1791d6826bfc6931457a69755c7 Mon Sep 17 00:00:00 2001 From: Aryeh Date: Mon, 6 Jul 2026 16:41:12 -0400 Subject: [PATCH 1/3] Add project tab-navigation + Focus Sidebar key bindings and off-screen render pause --- Macterm/App/AppCommand.swift | 26 ++- Macterm/App/AppCommandActions.swift | 14 ++ Macterm/App/AppState.swift | 134 +++++++++++++++ Macterm/App/Hotkeys.swift | 17 +- Macterm/App/Responders.swift | 72 +++++++- Macterm/Model/Workspace.swift | 17 ++ Macterm/Views/MainWindow.swift | 22 ++- Macterm/Views/Sidebar.swift | 216 ++++++++++++++++-------- Macterm/Views/TerminalPane.swift | 14 +- MactermTests/App/AppStateTests.swift | 86 ++++++++++ MactermTests/App/HotkeysTests.swift | 44 +++++ MactermTests/Model/WorkspaceTests.swift | 43 +++++ 12 files changed, 617 insertions(+), 88 deletions(-) diff --git a/Macterm/App/AppCommand.swift b/Macterm/App/AppCommand.swift index c6011e4..785418d 100644 --- a/Macterm/App/AppCommand.swift +++ b/Macterm/App/AppCommand.swift @@ -14,6 +14,10 @@ enum AppCommand: String, CaseIterable, Identifiable { case nextTab case previousTab case recentTab + case previousTabInProject + case nextTabInProject + case moveTabUp + case moveTabDown // Panes case splitRight case splitDown @@ -42,6 +46,7 @@ enum AppCommand: String, CaseIterable, Identifiable { case previousProject // Window case toggleSidebar + case focusSidebar case closeWindow case toggleCommandPalette case reloadGhosttyConfig @@ -58,6 +63,10 @@ enum AppCommand: String, CaseIterable, Identifiable { case .nextTab: "Next Tab" case .previousTab: "Previous Tab" case .recentTab: "Recent Tab" + case .previousTabInProject: "Previous Tab in Project" + case .nextTabInProject: "Next Tab in Project" + case .moveTabUp: "Move Tab Up" + case .moveTabDown: "Move Tab Down" case .splitRight: "Split Right" case .splitDown: "Split Down" case .splitAuto: "Split Automatically" @@ -83,6 +92,7 @@ enum AppCommand: String, CaseIterable, Identifiable { case .nextProject: "Next Project" case .previousProject: "Previous Project" case .toggleSidebar: "Toggle Sidebar" + case .focusSidebar: "Focus Sidebar" case .closeWindow: "Close Window" case .toggleCommandPalette: "Command Palette" case .reloadGhosttyConfig: "Reload Ghostty Config" @@ -98,7 +108,11 @@ enum AppCommand: String, CaseIterable, Identifiable { .renameTab, .nextTab, .previousTab, - .recentTab: .tabs + .recentTab, + .previousTabInProject, + .nextTabInProject, + .moveTabUp, + .moveTabDown: .tabs case .splitRight, .splitDown, .splitAuto, @@ -124,6 +138,7 @@ enum AppCommand: String, CaseIterable, Identifiable { .nextProject, .previousProject: .projects case .toggleSidebar, + .focusSidebar, .closeWindow, .toggleCommandPalette: .window case .reloadGhosttyConfig, @@ -141,6 +156,13 @@ enum AppCommand: String, CaseIterable, Identifiable { case .nextTab: .nextGlobalTab case .previousTab: .previousGlobalTab case .recentTab: .recentTab + case .previousTabInProject: .previousTabInProject + case .nextTabInProject: .nextTabInProject + case .moveTabUp: .moveTabUp + case .moveTabDown: .moveTabDown + case .focusSidebar: .focusSidebar + case .applyLayout: .applyLayout + case .saveLayout: .saveLayout case .splitRight: .splitRight case .splitDown: .splitDown case .splitAuto: .splitAuto @@ -169,8 +191,6 @@ enum AppCommand: String, CaseIterable, Identifiable { .unloadProject, .removeProject, .replaceProjectPathWithCurrentDir, - .applyLayout, - .saveLayout, .checkForUpdate: nil } } diff --git a/Macterm/App/AppCommandActions.swift b/Macterm/App/AppCommandActions.swift index 47761c8..faf8d75 100644 --- a/Macterm/App/AppCommandActions.swift +++ b/Macterm/App/AppCommandActions.swift @@ -37,6 +37,20 @@ extension AppCommand { case .recentTab: guard let projectID else { return nil } return { ctx.appState.cycleRecentTab(projectID: projectID) } + case .previousTabInProject: + guard let projectID else { return nil } + return { ctx.appState.selectPreviousTab(projectID: projectID) } + case .nextTabInProject: + guard let projectID else { return nil } + return { ctx.appState.selectNextTab(projectID: projectID) } + case .moveTabUp: + guard let projectID else { return nil } + return { ctx.appState.moveActiveTab(by: -1, projectID: projectID) } + case .moveTabDown: + guard let projectID else { return nil } + return { ctx.appState.moveActiveTab(by: 1, projectID: projectID) } + case .focusSidebar: + return { ctx.appState.enterSidebarFocus() } case .renameTab: guard let projectID, let tab = ctx.appState.workspaces[projectID]?.activeTab diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 7bf6a09..5f1b9cd 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -4,6 +4,21 @@ import os private let logger = Logger(subsystem: appBundleID, category: "AppState") +/// A row in the sidebar — a project header or one of its tabs. Shared by the +/// sidebar view (selection + focus ring) and `AppState` (ring navigation). +enum SidebarFocusItem: Hashable { + case project(UUID) + case tab(projectID: UUID, tabID: UUID) + + /// The list row identity to scroll to (matches the `ForEach` element ids). + var scrollID: UUID { + switch self { + case let .project(id): id + case let .tab(_, tabID): tabID + } + } +} + @MainActor @Observable final class AppState { var activeProjectID: UUID? { @@ -12,6 +27,30 @@ final class AppState { var workspaces: [UUID: Workspace] = [:] var sidebarVisible = true + /// Which project disclosure groups are expanded in the sidebar. Lives here + /// (not in the view) so ring navigation can compute the visible-row order. + var expandedProjects: Set = [] + + // Sidebar: hotkey Focus Sidebar mode (tentative ring; terminal keeps focus). + /// True while the Focus Sidebar hotkey mode is active: ↑/↓ move a tentative + /// ring (`sidebarFocusItem`) without switching the active tab; Enter/click + /// choose, ESC cancels. The terminal keeps first responder throughout. + var sidebarFocusMode = false + /// The tentative ring's row while `sidebarFocusMode` is active. + var sidebarFocusItem: SidebarFocusItem? + /// The sidebar's open/closed state captured when Focus Sidebar mode began, so + /// exiting restores it (a sidebar opened only for the interaction re-closes). + private var sidebarFocusPriorVisible = true + + // Sidebar: click-into-list navigation (live switch, keep list focus). + /// True while the sidebar List genuinely holds keyboard focus (the user + /// clicked into it). While true, an active-tab change must not steal first + /// responder into the terminal (see `TerminalSurface`). + var sidebarListFocused = false + /// Set for one selection change when ↑/↓ drove it (vs a mouse click), so the + /// sidebar can switch the tab live without yanking focus into its pane. + var sidebarArrowSwitch = false + var pendingClosePane: PendingClosePane? /// A computed layout-apply plan awaiting user confirmation because applying /// it would terminate one or more live panes/tabs. nil when no apply is @@ -807,6 +846,8 @@ final class AppState { if ws.activeTabID != before || didAcknowledgeCompletion { saveWorkspaces() } + // A keyboard tab-switch should land the cursor in the new tab's pane. + restoreFocusToActivePane() } func selectPreviousTab(projectID: UUID) { @@ -816,6 +857,21 @@ final class AppState { if ws.activeTabID != before || didAcknowledgeCompletion { saveWorkspaces() } + restoreFocusToActivePane() + } + + func moveActiveTab(by offset: Int, projectID: UUID) { + guard let ws = workspaces[projectID] else { return } + if ws.moveActiveTab(by: offset) { + saveWorkspaces() + } + } + + func moveActiveTab(by offset: Int, projectID: UUID) { + guard let ws = workspaces[projectID] else { return } + if ws.moveActiveTab(by: offset) { + saveWorkspaces() + } } func cycleRecentTab(projectID: UUID) { @@ -1272,6 +1328,84 @@ final class AppState { selectProject(project) } + // MARK: - Sidebar: hotkey focus mode (tentative ring) + + /// Enter the Focus Sidebar hotkey mode. The terminal keeps first responder; + /// the ring is pure state and arrow/Enter/ESC come from the global key + /// monitor, so there's no fragile programmatic focus grab. Remembers the + /// sidebar's open/closed state, forces it open, and seeds the ring on the + /// active tab. + func enterSidebarFocus() { + guard !sidebarFocusMode else { return } + sidebarFocusPriorVisible = sidebarVisible + sidebarVisible = true + sidebarFocusItem = seededSidebarRingItem() + sidebarFocusMode = true + } + + /// Move the tentative ring `offset` rows through the visible sidebar rows, + /// wrapping at both ends. Does not switch the active tab. + func moveSidebarFocus(by offset: Int, projects: [Project]) { + guard sidebarFocusMode else { return } + let rows = visibleSidebarRows(projects: projects) + guard !rows.isEmpty else { return } + let current = sidebarFocusItem.flatMap { rows.firstIndex(of: $0) } ?? 0 + let next = ((current + offset) % rows.count + rows.count) % rows.count + sidebarFocusItem = rows[next] + } + + /// Choose the ring's row (select its project, and tab if any), then leave + /// the mode — restoring the sidebar's prior state and focusing the pane. + func commitSidebarFocus(projects: [Project]) { + defer { exitSidebarFocus() } + guard let item = sidebarFocusItem else { return } + switch item { + case let .project(projectID): + if let project = projects.first(where: { $0.id == projectID }) { + selectProject(project) + } + case let .tab(projectID, tabID): + if let project = projects.first(where: { $0.id == projectID }) { + selectProject(project) + selectTab(tabID, projectID: projectID) + } + } + } + + /// Leave the hotkey mode without choosing: restore the sidebar's prior + /// open/closed state and return first responder to the active pane. + func exitSidebarFocus() { + guard sidebarFocusMode else { return } + sidebarFocusMode = false + sidebarFocusItem = nil + sidebarVisible = sidebarFocusPriorVisible + restoreFocusToActivePane() + } + + /// The sidebar's rows in on-screen order: each project, followed by its tabs + /// when the project is expanded. Drives ring navigation ("all sidebar rows"). + func visibleSidebarRows(projects: [Project]) -> [SidebarFocusItem] { + var rows: [SidebarFocusItem] = [] + for project in projects { + rows.append(.project(project.id)) + guard expandedProjects.contains(project.id), + let ws = workspaces[project.id] + else { continue } + for tab in ws.tabs { + rows.append(.tab(projectID: project.id, tabID: tab.id)) + } + } + return rows + } + + private func seededSidebarRingItem() -> SidebarFocusItem? { + guard let projectID = activeProjectID else { return nil } + if let tabID = workspaces[projectID]?.activeTabID { + return .tab(projectID: projectID, tabID: tabID) + } + return .project(projectID) + } + // MARK: - Focus func restoreFocusToActivePane() { diff --git a/Macterm/App/Hotkeys.swift b/Macterm/App/Hotkeys.swift index c1a74ac..2c0af46 100644 --- a/Macterm/App/Hotkeys.swift +++ b/Macterm/App/Hotkeys.swift @@ -19,6 +19,13 @@ enum HotkeyAction: String, CaseIterable, Identifiable { case previousProject = "previous_project" case nextGlobalTab = "next_global_tab" case previousGlobalTab = "previous_global_tab" + case previousTabInProject = "previous_tab_in_project" + case nextTabInProject = "next_tab_in_project" + case moveTabUp = "move_tab_up" + case moveTabDown = "move_tab_down" + case focusSidebar = "focus_sidebar" + case saveLayout = "save_layout" + case applyLayout = "apply_layout" case focusPaneLeft = "focus_pane_left" case focusPaneDown = "focus_pane_down" case focusPaneUp = "focus_pane_up" @@ -59,6 +66,13 @@ enum HotkeyAction: String, CaseIterable, Identifiable { case .previousProject: "cmd+[" case .nextGlobalTab: "ctrl+]" case .previousGlobalTab: "ctrl+[" + case .previousTabInProject: "cmd+shift+left" + case .nextTabInProject: "cmd+shift+right" + case .moveTabUp: "cmd+shift+up" + case .moveTabDown: "cmd+shift+down" + case .focusSidebar: "cmd+shift+b" + case .saveLayout: "none" + case .applyLayout: "none" case .focusPaneLeft: "cmd+ctrl+h" case .focusPaneDown: "cmd+ctrl+j" case .focusPaneUp: "cmd+ctrl+k" @@ -92,7 +106,8 @@ struct HotkeyShortcut: Identifiable { guard let token = HotkeyRegistry.eventToken(event), token == keyToken else { return false } - return event.modifierFlags.intersection(HotkeyRegistry.comparableModifierMask) == modifiers + let relevant: NSEvent.ModifierFlags = [.command, .control, .option, .shift] + return event.modifierFlags.intersection(relevant) == modifiers } } diff --git a/Macterm/App/Responders.swift b/Macterm/App/Responders.swift index ff8c92a..a152c15 100644 --- a/Macterm/App/Responders.swift +++ b/Macterm/App/Responders.swift @@ -137,6 +137,56 @@ final class MainAppResponder: KeyResponder { // into the palette would fire Cmd+T's New Tab action. if appState.isCommandPaletteVisible { return .passThrough } + // Sidebar keyboard navigation. Both modes are fed by this global monitor + // so they work regardless of which view holds first responder. Stand down + // while a sidebar rename field is open so its TextField owns Return/ESC. + let renaming = appState.renamingTabID != nil || appState.renamingProjectID != nil + // Any keystroke that matches a real app shortcut keeps working while the + // sidebar is focused — fall through to its handler below instead of + // treating it as ring/list navigation. + let isAppShortcut = HotkeyAction.allCases.contains { HotkeyRegistry.matches(event, action: $0) } + if !renaming, !isAppShortcut, appState.sidebarFocusMode { + // Hotkey Focus Sidebar: a tentative ring; the terminal keeps first + // responder. Bare ↑/↓ move the ring, Enter/ESC choose/cancel. + switch HotkeyRegistry.eventToken(event) ?? "" { + case "up": + appState.moveSidebarFocus(by: -1, projects: projectStore.projects) + return .handled + case "down": + appState.moveSidebarFocus(by: 1, projects: projectStore.projects) + return .handled + case "return", + "enter": + appState.commitSidebarFocus(projects: projectStore.projects) + return .handled + case "escape": + appState.exitSidebarFocus() + return .handled + default: + // Swallow other bare keys so they don't leak into the terminal. + return .handled + } + } + if !renaming, !isAppShortcut, appState.sidebarListFocused { + // Click-into-sidebar: the native List has keyboard focus and handles + // bare ↑/↓ itself (live tab switch). We only flag arrow-driven + // switches so focus stays in the list, and route Enter/ESC into the + // terminal. + switch HotkeyRegistry.eventToken(event) ?? "" { + case "up", + "down": + appState.sidebarArrowSwitch = true + return .passThrough + case "return", + "enter", + "escape": + appState.restoreFocusToActivePane() + return .handled + default: + return .passThrough + } + } + // Everything below acts on the main terminal window's workspace. // When a different window is key — Settings, an alert sheet — none of // it may fire: Cmd+W would close a terminal tab behind the window the @@ -281,11 +331,23 @@ final class MainAppResponder: KeyResponder { return .handled } - // Rename routes through AppCommand.action(in:) — the single source of - // truth shared with the palette and menu bar — so the three paths can't - // drift. The action defers begin-editing a tick (see AppCommandActions) - // so the sidebar row's TextField exists before it takes first responder. - for action in [HotkeyAction.renameTab, .renameProject] { + // These actions route through AppCommand.action(in:) — the single source + // of truth shared with the palette and menu bar — so the paths can't + // drift. (Rename defers begin-editing a tick, see AppCommandActions, so + // the sidebar row's TextField exists before it takes first responder; + // Save/Apply Layout default to unbound and only match once the user binds + // them.) + for action in [ + HotkeyAction.previousTabInProject, + .nextTabInProject, + .moveTabUp, + .moveTabDown, + .focusSidebar, + .saveLayout, + .applyLayout, + .renameTab, + .renameProject, + ] { guard HotkeyRegistry.matches(event, action: action), let command = AppCommand.allCases.first(where: { $0.hotkeyAction == action }) else { continue } diff --git a/Macterm/Model/Workspace.swift b/Macterm/Model/Workspace.swift index b8cb23e..042d7d2 100644 --- a/Macterm/Model/Workspace.swift +++ b/Macterm/Model/Workspace.swift @@ -368,4 +368,21 @@ final class Workspace: Identifiable { func reorderTabs(fromOffsets source: IndexSet, toOffset destination: Int) { tabs.move(fromOffsets: source, toOffset: destination) } + + /// Move the active tab `offset` positions within the project's tab order, + /// wrapping at the ends (moving the first tab up sends it to the end, and + /// vice versa). The same tab stays active — only its position changes. + /// Returns true if a move happened. + @discardableResult + func moveActiveTab(by offset: Int) -> Bool { + let n = tabs.count + guard n > 1, offset != 0, let activeTabID, + let i = tabs.firstIndex(where: { $0.id == activeTabID }) + else { return false } + let j = ((i + offset) % n + n) % n + guard j != i else { return false } + let tab = tabs.remove(at: i) + tabs.insert(tab, at: j) + return true + } } diff --git a/Macterm/Views/MainWindow.swift b/Macterm/Views/MainWindow.swift index 39810a3..6012c84 100644 --- a/Macterm/Views/MainWindow.swift +++ b/Macterm/Views/MainWindow.swift @@ -7,12 +7,23 @@ struct MainWindow: View { @Environment(ProjectStore.self) private var projectStore @State - private var columnVisibility: NavigationSplitViewVisibility = .automatic - @State private var detailWidth: CGFloat = .infinity + /// Drive the split view's column visibility directly off `sidebarVisible`, + /// the single source of truth. A two-way binding (rather than a one-way + /// mirror) keeps them in sync when the sidebar is collapsed by the native + /// split-view control — otherwise `sidebarVisible` would drift to `true` + /// while the column is hidden, and `enterSidebarFocus()` (which sets it + /// `true`) couldn't re-open it. + private var columnVisibility: Binding { + Binding( + get: { appState.sidebarVisible ? .all : .detailOnly }, + set: { appState.sidebarVisible = ($0 != .detailOnly) } + ) + } + var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { + NavigationSplitView(columnVisibility: columnVisibility) { SidebarContent() .navigationSplitViewColumnWidth(min: 140, ideal: 180, max: 280) } detail: { @@ -66,11 +77,6 @@ struct MainWindow: View { guard !appState.hasRestoredSelection else { return } appState.restoreSelection(projects: projectStore.projects) } - .onChange(of: appState.sidebarVisible) { _, visible in - withAnimation { - columnVisibility = visible ? .automatic : .detailOnly - } - } .onChange(of: appState.isCommandPaletteVisible) { _, visible in guard !visible else { return } // Run a post-dismiss action if one was registered, otherwise return diff --git a/Macterm/Views/Sidebar.swift b/Macterm/Views/Sidebar.swift index 59ca835..ff0a91d 100644 --- a/Macterm/Views/Sidebar.swift +++ b/Macterm/Views/Sidebar.swift @@ -1,10 +1,5 @@ import SwiftUI -private enum SidebarItem: Hashable { - case project(UUID) - case tab(projectID: UUID, tabID: UUID) -} - struct SidebarContent: View { @Environment(AppState.self) private var appState @@ -13,66 +8,100 @@ struct SidebarContent: View { @AppStorage(Preferences.Keys.showNewProjectButton) private var showNewProjectButton = true @State - private var expandedProjects: Set = [] + private var selection: SidebarFocusItem? + /// Tracks whether the sidebar List actually holds keyboard focus (the user + /// clicked into it). Mirrored to `appState.sidebarListFocused` so the + /// terminal won't steal first responder during live arrow navigation. + @FocusState + private var listFocused: Bool + /// Set right before `syncSelection()` assigns `selection`, so the resulting + /// `onChange` is recognized as programmatic (no commit, no focus change). @State - private var selection: SidebarItem? + private var programmaticSelection = false var body: some View { - List(selection: $selection) { - ForEach(Array(projectStore.projects.enumerated()), id: \.element.id) { projectIndex, project in - let ws = appState.workspaces[project.id] - let tabs = ws?.tabs ?? [] + ScrollViewReader { proxy in + List(selection: $selection) { + ForEach(Array(projectStore.projects.enumerated()), id: \.element.id) { projectIndex, project in + let ws = appState.workspaces[project.id] + let tabs = ws?.tabs ?? [] - DisclosureGroup(isExpanded: Binding( - get: { expandedProjects.contains(project.id) }, - set: { if $0 { expandedProjects.insert(project.id) } else { expandedProjects.remove(project.id) } } - )) { - ForEach(Array(tabs.enumerated()), id: \.element.id) { tabIndex, tab in - SidebarTabRow( - tab: tab, - index: tabIndex + 1, - isActive: ws?.activeTabID == tab.id && appState.activeProjectID == project.id, - moveTargets: projectStore.projects.filter { $0.id != project.id }, - onClose: { appState.requestCloseTab(tab.id, projectID: project.id) }, - onRename: { newName in - tab.customTitle = newName.isEmpty ? nil : newName - appState.saveWorkspaces() - }, - onMoveToProject: { destination in - appState.moveTab(tab.id, from: project.id, to: destination.id, destPath: destination.path) - expandedProjects.insert(destination.id) + DisclosureGroup(isExpanded: Binding( + get: { appState.expandedProjects.contains(project.id) }, + set: { isExpanded in + if isExpanded { + appState.expandedProjects.insert(project.id) + } else { + appState.expandedProjects.remove(project.id) } - ) - .tag(SidebarItem.tab(projectID: project.id, tabID: tab.id)) - } - .onMove { source, destination in - appState.workspaces[project.id]?.reorderTabs(fromOffsets: source, toOffset: destination) - appState.saveWorkspaces() - } - } label: { - SidebarProjectRow(project: project, index: projectIndex + 1) { - appState.selectProject(project) - appState.createTab(projectID: project.id, projectPath: project.path) - expandedProjects.insert(project.id) - } onRename: { - projectStore.rename(id: project.id, to: $0) - } onUnload: { - appState.requestUnloadProject(project.id) - } onRemove: { - appState.requestRemoveProject(project.id) { - expandedProjects.remove(project.id) - appState.removeProject(project.id) - projectStore.remove(id: project.id) } + )) { + ForEach(Array(tabs.enumerated()), id: \.element.id) { tabIndex, tab in + SidebarTabRow( + tab: tab, + index: tabIndex + 1, + isActive: ws?.activeTabID == tab.id && appState.activeProjectID == project.id, + moveTargets: projectStore.projects.filter { $0.id != project.id }, + onClose: { appState.requestCloseTab(tab.id, projectID: project.id) }, + onRename: { newName in + tab.customTitle = newName.isEmpty ? nil : newName + appState.saveWorkspaces() + }, + onMoveToProject: { destination in + appState.moveTab(tab.id, from: project.id, to: destination.id, destPath: destination.path) + appState.expandedProjects.insert(destination.id) + } + ) + .tag(SidebarFocusItem.tab(projectID: project.id, tabID: tab.id)) + } + .onMove { source, destination in + appState.workspaces[project.id]?.reorderTabs(fromOffsets: source, toOffset: destination) + appState.saveWorkspaces() + } + } label: { + SidebarProjectRow( + project: project, + index: projectIndex + 1 + ) { + appState.selectProject(project) + appState.createTab(projectID: project.id, projectPath: project.path) + appState.expandedProjects.insert(project.id) + } onRename: { + projectStore.rename(id: project.id, to: $0) + } onUnload: { + appState.requestUnloadProject(project.id) + } onRemove: { + appState.requestRemoveProject(project.id) { + appState.expandedProjects.remove(project.id) + appState.removeProject(project.id) + projectStore.remove(id: project.id) + } + } + .tag(SidebarFocusItem.project(project.id)) } - .tag(SidebarItem.project(project.id)) + } + .onMove { source, destination in + projectStore.reorder(fromOffsets: source, toOffset: destination) } } - .onMove { source, destination in - projectStore.reorder(fromOffsets: source, toOffset: destination) + .onChange(of: appState.sidebarFocusItem) { _, item in + if let item { + // Show the tentative ring as the native full-row selection + // highlight (without committing), and scroll it into view. + if selection != item { + programmaticSelection = true + selection = item + } + withAnimation { proxy.scrollTo(item.scrollID) } + } else { + // Left Focus Sidebar mode — restore the highlight to the + // active tab. + syncSelection() + } } } .listStyle(.sidebar) + .focused($listFocused) .scrollContentBackground(.hidden) // No background here: the window's NSWindow.backgroundColor (set by // WindowAppearance) provides the translucent fill uniformly. Adding @@ -103,28 +132,72 @@ struct SidebarContent: View { } } .onChange(of: selection) { _, item in + // Ignore programmatic syncs that just keep `selection` on the active + // tab — only a genuine user click commits. + if programmaticSelection { + programmaticSelection = false + return + } guard let item else { return } - switch item { - case let .project(projectID): - guard let project = projectStore.projects.first(where: { $0.id == projectID }) else { return } - appState.selectProject(project) - case let .tab(projectID, tabID): - if let project = projectStore.projects.first(where: { $0.id == projectID }) { - appState.selectProject(project) - appState.selectTab(tabID, projectID: projectID) - } + commitSelection(item) + // A click on a tab moves focus into its pane; an arrow-driven live + // switch (flagged by the key responder) keeps focus in the list so + // the user can keep arrowing. Clicking a project keeps list focus too. + let wasArrow = appState.sidebarArrowSwitch + appState.sidebarArrowSwitch = false + if !wasArrow, case .tab = item { + appState.restoreFocusToActivePane() } } + .onChange(of: listFocused) { _, focused in + appState.sidebarListFocused = focused + } .onChange(of: appState.activeProjectID) { _, newID in - if let newID { expandedProjects.insert(newID) } + if let newID { appState.expandedProjects.insert(newID) } syncSelection() } .onChange(of: activeTabID) { syncSelection() } + .onChange(of: appState.sidebarFocusMode) { _, on in + // Grab first-responder when entering the mode (already open case); + // release it when leaving (AppState restores terminal focus). + listFocused = on + } + .onKeyPress(.return) { + guard appState.sidebarFocusMode else { return .ignored } + if let item = selection { commitSelection(item) } + appState.exitSidebarFocus() + return .handled + } + .onExitCommand { + guard appState.sidebarFocusMode else { return } + // Cancel: drop the tentative ring back onto the still-active tab, + // then restore the sidebar's prior open/closed state and terminal focus. + syncSelection() + appState.exitSidebarFocus() + } .onAppear { - if let id = appState.activeProjectID { expandedProjects.insert(id) } + if let id = appState.activeProjectID { appState.expandedProjects.insert(id) } syncSelection() + // The sidebar may have been collapsed when Focus Sidebar was invoked; + // it mounts only once forced open, so claim focus here too. + if appState.sidebarFocusMode { listFocused = true } + } + } + + /// Commit a sidebar selection to the workspace (select the project, and the + /// tab if one was chosen). Shared by click-selection and live arrow nav. + private func commitSelection(_ item: SidebarFocusItem) { + switch item { + case let .project(projectID): + guard let project = projectStore.projects.first(where: { $0.id == projectID }) else { return } + appState.selectProject(project) + case let .tab(projectID, tabID): + if let project = projectStore.projects.first(where: { $0.id == projectID }) { + appState.selectProject(project) + appState.selectTab(tabID, projectID: projectID) + } } } @@ -138,16 +211,23 @@ struct SidebarContent: View { let ws = appState.workspaces[pid], let tabID = ws.activeTabID else { - selection = appState.activeProjectID.map { .project($0) } + let desired = appState.activeProjectID.map { SidebarFocusItem.project($0) } + if selection != desired { + programmaticSelection = true + selection = desired + } return } - let desired = SidebarItem.tab(projectID: pid, tabID: tabID) - if selection != desired { selection = desired } + let desired = SidebarFocusItem.tab(projectID: pid, tabID: tabID) + if selection != desired { + programmaticSelection = true + selection = desired + } } private func openProject() { if let project = appState.openProject(store: projectStore) { - expandedProjects.insert(project.id) + appState.expandedProjects.insert(project.id) } } } diff --git a/Macterm/Views/TerminalPane.swift b/Macterm/Views/TerminalPane.swift index 96b05a9..c18c734 100644 --- a/Macterm/Views/TerminalPane.swift +++ b/Macterm/Views/TerminalPane.swift @@ -51,6 +51,8 @@ struct TerminalPane: View { /// stored instance so SwiftUI lifecycle events (tab switches, split reshapes) /// don't destroy the underlying ghostty surface. private struct TerminalSurface: NSViewRepresentable { + @Environment(AppState.self) + private var appState let pane: Pane let focused: Bool let isZoomed: Bool @@ -81,11 +83,13 @@ private struct TerminalSurface: NSViewRepresentable { configure(view) // Defer surface creation until the view is actually in a window — the // Metal layer needs a non-zero size to initialize. - DispatchQueue.main.async { [pane] in + DispatchQueue.main.async { [pane, appState] in if view.surface == nil, view.window != nil { view.createSurface() } - if focused { + // Don't grab first responder while the user is navigating the + // sidebar list with the keyboard — that would yank focus out of it. + if focused, !appState.sidebarListFocused { FocusRestoration.restoreFocus(to: pane.id, finder: { pane }, in: view.window) } } @@ -108,7 +112,11 @@ private struct TerminalSurface: NSViewRepresentable { view.isFocused = focused if focused, !wasFocused { view.notifySurfaceFocused() - FocusRestoration.restoreFocus(to: pane.id, finder: { [pane] in pane }, in: view.window) + // While the sidebar list owns keyboard focus (live arrow nav), a tab + // switch must not steal first responder back into the terminal. + if !appState.sidebarListFocused { + FocusRestoration.restoreFocus(to: pane.id, finder: { [pane] in pane }, in: view.window) + } } else if !focused, wasFocused { view.notifySurfaceUnfocused() } diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index 444c893..0b22afb 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -921,6 +921,92 @@ struct AppStateTests { #expect(AppState.panesToWarm(in: ws).isEmpty) } + // MARK: - Sidebar focus (hotkey ring) + + @Test + func visibleSidebarRows_expanded_lists_project_then_its_tabs() throws { + let state = makeAppState() + let p = seedProject(state) + let ws = try #require(state.workspaces[p.id]) + let t1 = ws.activeTabID + let t2 = ws.createTab(projectPath: "/tmp").id + state.expandedProjects = [p.id] + + let rows = state.visibleSidebarRows(projects: [p]) + #expect(try rows == [ + .project(p.id), + .tab(projectID: p.id, tabID: #require(t1)), + .tab(projectID: p.id, tabID: t2), + ]) + } + + @Test + func visibleSidebarRows_collapsed_lists_only_project() { + let state = makeAppState() + let p = seedProject(state) + state.expandedProjects = [] // collapsed + #expect(state.visibleSidebarRows(projects: [p]) == [.project(p.id)]) + } + + @Test + func enterSidebarFocus_opens_sidebar_and_seeds_ring_on_active_tab() throws { + let state = makeAppState() + let p = seedProject(state) + let activeTab = try #require(state.workspaces[p.id]?.activeTabID) + state.sidebarVisible = false + + state.enterSidebarFocus() + #expect(state.sidebarFocusMode) + #expect(state.sidebarVisible) + #expect(state.sidebarFocusItem == .tab(projectID: p.id, tabID: activeTab)) + } + + @Test + func moveSidebarFocus_wraps_through_visible_rows() throws { + let state = makeAppState() + let p = seedProject(state) + let ws = try #require(state.workspaces[p.id]) + let t1 = try #require(ws.activeTabID) + let t2 = ws.createTab(projectPath: "/tmp").id + ws.selectTab(t1) + state.expandedProjects = [p.id] + state.enterSidebarFocus() // ring seeded on t1 (rows: [project, t1, t2]) + state.moveSidebarFocus(by: -1, projects: [p]) // t1 → project header + #expect(state.sidebarFocusItem == .project(p.id)) + state.moveSidebarFocus(by: -1, projects: [p]) // project → wrap to last (t2) + #expect(state.sidebarFocusItem == .tab(projectID: p.id, tabID: t2)) + } + + @Test + func exitSidebarFocus_restores_prior_visibility() { + let state = makeAppState() + let p = seedProject(state) + state.sidebarVisible = false + state.enterSidebarFocus() + #expect(state.sidebarVisible) + state.exitSidebarFocus() + #expect(!state.sidebarFocusMode) + #expect(!state.sidebarVisible) // back to closed + #expect(state.sidebarFocusItem == nil) + _ = p + } + + @Test + func commitSidebarFocus_selects_ringed_tab_and_exits() throws { + let state = makeAppState() + let p = seedProject(state) + let ws = try #require(state.workspaces[p.id]) + let t1 = try #require(ws.activeTabID) + let t2 = ws.createTab(projectPath: "/tmp").id + ws.selectTab(t1) + state.expandedProjects = [p.id] + state.enterSidebarFocus() // ring on t1 + state.moveSidebarFocus(by: 1, projects: [p]) // ring t1 → t2 + state.commitSidebarFocus(projects: [p]) + #expect(ws.activeTabID == t2) + #expect(!state.sidebarFocusMode) + } + // MARK: - Occlusion-aware quiet-settle // These drive `AppState.settleIfVisible` directly with an injected diff --git a/MactermTests/App/HotkeysTests.swift b/MactermTests/App/HotkeysTests.swift index 770e96b..e8c2937 100644 --- a/MactermTests/App/HotkeysTests.swift +++ b/MactermTests/App/HotkeysTests.swift @@ -317,6 +317,29 @@ struct HotkeysTests { #expect(ids.count == Set(ids).count) } + @Test + func every_action_is_linked_to_a_command() { + // HotkeyAction.appCommand falls back to .newTab when no AppCommand links + // the action — which would mislabel it in Settings → Keymaps. Ensure + // every action has a real owning command. + for action in HotkeyAction.allCases { + let owner = AppCommand.allCases.first { $0.hotkeyAction == action } + #expect(owner != nil, "no AppCommand links hotkey action \(action.rawValue)") + } + } + + @Test + func new_tab_and_sidebar_actions_have_expected_defaults() { + #expect(HotkeyAction.previousTabInProject.defaultShortcut == "cmd+shift+left") + #expect(HotkeyAction.nextTabInProject.defaultShortcut == "cmd+shift+right") + #expect(HotkeyAction.moveTabUp.defaultShortcut == "cmd+shift+up") + #expect(HotkeyAction.moveTabDown.defaultShortcut == "cmd+shift+down") + #expect(HotkeyAction.focusSidebar.defaultShortcut == "cmd+shift+b") + // Layout actions ship unbound — opt-in via Settings → Keymaps. + #expect(HotkeyAction.saveLayout.defaultShortcut == "none") + #expect(HotkeyAction.applyLayout.defaultShortcut == "none") + } + @Test func all_action_defaultsKeys_are_unique() { let keys = HotkeyAction.allCases.map(\.defaultsKey) @@ -344,6 +367,27 @@ struct HotkeysTests { #expect(up.keyCode == 126) } + /// Real arrow-key events carry `.function` and `.numericPad` in their + /// modifier flags. The matcher must ignore those, or no arrow shortcut + /// (e.g. cmd+shift+left) would ever match. + @Test + func matches_arrow_with_function_and_numericPad_flags() throws { + let shortcut = try #require(HotkeyRegistry.parseShortcut("cmd+shift+left")) + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .shift, .function, .numericPad], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{F702}", // NSLeftArrowFunctionKey + charactersIgnoringModifiers: "\u{F702}", + isARepeat: false, + keyCode: 123 // left arrow + )) + #expect(shortcut.matches(event)) + } + // Arrow: characters = "", matched by keyCode fallback. @Test func matches_arrow_keys_by_position() throws { diff --git a/MactermTests/Model/WorkspaceTests.swift b/MactermTests/Model/WorkspaceTests.swift index 9401a4e..b262a64 100644 --- a/MactermTests/Model/WorkspaceTests.swift +++ b/MactermTests/Model/WorkspaceTests.swift @@ -197,4 +197,47 @@ struct WorkspaceTests { ws.reorderTabs(fromOffsets: IndexSet(integer: 0), toOffset: 2) #expect(ws.tabs.map(\.id) == [t2, t1]) } + + @Test + func moveActiveTab_down_shifts_active_later() { + let ws = makeWorkspace() + let t1 = ws.tabs[0].id + let t2 = ws.createTab(projectPath: "/tmp").id + let t3 = ws.createTab(projectPath: "/tmp").id + ws.selectTab(t1) + #expect(ws.moveActiveTab(by: 1)) + #expect(ws.tabs.map(\.id) == [t2, t1, t3]) + #expect(ws.activeTabID == t1) // same tab stays active + } + + @Test + func moveActiveTab_up_from_first_wraps_to_end() { + let ws = makeWorkspace() + let t1 = ws.tabs[0].id + let t2 = ws.createTab(projectPath: "/tmp").id + let t3 = ws.createTab(projectPath: "/tmp").id + ws.selectTab(t1) + #expect(ws.moveActiveTab(by: -1)) + #expect(ws.tabs.map(\.id) == [t2, t3, t1]) + #expect(ws.activeTabID == t1) + } + + @Test + func moveActiveTab_down_from_last_wraps_to_front() { + let ws = makeWorkspace() + let t1 = ws.tabs[0].id + let t2 = ws.createTab(projectPath: "/tmp").id + let t3 = ws.createTab(projectPath: "/tmp").id + ws.selectTab(t3) + #expect(ws.moveActiveTab(by: 1)) + #expect(ws.tabs.map(\.id) == [t3, t1, t2]) + #expect(ws.activeTabID == t3) + } + + @Test + func moveActiveTab_single_tab_is_noop() { + let ws = makeWorkspace() + #expect(!ws.moveActiveTab(by: 1)) + #expect(ws.tabs.count == 1) + } } From 5f284bcd81cbe02f03ea38d7b5a3ad9283fbdfcd Mon Sep 17 00:00:00 2001 From: Aryeh Date: Mon, 6 Jul 2026 17:51:27 -0400 Subject: [PATCH 2/3] rename redeclared moveActiveTab for use within a project --- Macterm/App/AppState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 5f1b9cd..5b861f6 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -860,7 +860,7 @@ final class AppState { restoreFocusToActivePane() } - func moveActiveTab(by offset: Int, projectID: UUID) { + func moveActiveProjectTab(by offset: Int, projectID: UUID) { guard let ws = workspaces[projectID] else { return } if ws.moveActiveTab(by: offset) { saveWorkspaces() From acc98bedc5cadb05e3fb2275b7a94cd67709738d Mon Sep 17 00:00:00 2001 From: Aryeh Date: Mon, 6 Jul 2026 19:11:42 -0400 Subject: [PATCH 3/3] redo broken code from merge --- Macterm/App/AppState.swift | 20 +++++++++++++------- Macterm/App/Responders.swift | 23 +++++++++++++++++++---- Macterm/Model/Workspace.swift | 13 +++++++++++-- Macterm/Views/MainWindow.swift | 2 +- Macterm/Views/Sidebar.swift | 6 ++++++ 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 5b861f6..b219863 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -860,13 +860,6 @@ final class AppState { restoreFocusToActivePane() } - func moveActiveProjectTab(by offset: Int, projectID: UUID) { - guard let ws = workspaces[projectID] else { return } - if ws.moveActiveTab(by: offset) { - saveWorkspaces() - } - } - func moveActiveTab(by offset: Int, projectID: UUID) { guard let ws = workspaces[projectID] else { return } if ws.moveActiveTab(by: offset) { @@ -1354,6 +1347,19 @@ final class AppState { sidebarFocusItem = rows[next] } + /// Reorder the ringed tab `offset` positions within its project (shift+↑/↓ in + /// Focus Sidebar mode). The ring stays on the moved tab. No-op when the ring + /// is on a project header rather than a tab. + func moveSidebarFocusTab(by offset: Int) { + guard sidebarFocusMode, let item = sidebarFocusItem, + case let .tab(projectID, tabID) = item, + let ws = workspaces[projectID] + else { return } + if ws.moveTab(id: tabID, by: offset) { + saveWorkspaces() + } + } + /// Choose the ring's row (select its project, and tab if any), then leave /// the mode — restoring the sidebar's prior state and focusing the pane. func commitSidebarFocus(projects: [Project]) { diff --git a/Macterm/App/Responders.swift b/Macterm/App/Responders.swift index a152c15..395a798 100644 --- a/Macterm/App/Responders.swift +++ b/Macterm/App/Responders.swift @@ -147,13 +147,23 @@ final class MainAppResponder: KeyResponder { let isAppShortcut = HotkeyAction.allCases.contains { HotkeyRegistry.matches(event, action: $0) } if !renaming, !isAppShortcut, appState.sidebarFocusMode { // Hotkey Focus Sidebar: a tentative ring; the terminal keeps first - // responder. Bare ↑/↓ move the ring, Enter/ESC choose/cancel. + // responder. Bare ↑/↓ move the ring; shift+↑/↓ reorder the ringed + // tab within its project; Enter/ESC choose/cancel. + let shift = event.modifierFlags.contains(.shift) switch HotkeyRegistry.eventToken(event) ?? "" { case "up": - appState.moveSidebarFocus(by: -1, projects: projectStore.projects) + if shift { + appState.moveSidebarFocusTab(by: -1) + } else { + appState.moveSidebarFocus(by: -1, projects: projectStore.projects) + } return .handled case "down": - appState.moveSidebarFocus(by: 1, projects: projectStore.projects) + if shift { + appState.moveSidebarFocusTab(by: 1) + } else { + appState.moveSidebarFocus(by: 1, projects: projectStore.projects) + } return .handled case "return", "enter": @@ -198,7 +208,12 @@ final class MainAppResponder: KeyResponder { // quick-terminal panel is exempt: QuickTerminalResponder has already // claimed its slice, and the app-wide keys that fall through (project // nav, new tab, …) intentionally keep working while the panel is up. - if let keyWindow = NSApp.keyWindow, keyWindow !== mainWindow, + // Only retarget when we actually know the main window. If `mainWindow` + // is nil (not yet wired), we can't tell "a different window is key" from + // "the main window is key", and returning here would swallow every + // routed-loop hotkey (Focus Sidebar, tab-nav, rename) — which have no + // menu-bar fallback — before they're ever reached. + if let mainWindow, let keyWindow = NSApp.keyWindow, keyWindow !== mainWindow, !(keyWindow is QuickTerminalPanel) { if HotkeyRegistry.matches(event, action: .closePane) diff --git a/Macterm/Model/Workspace.swift b/Macterm/Model/Workspace.swift index 042d7d2..f757eea 100644 --- a/Macterm/Model/Workspace.swift +++ b/Macterm/Model/Workspace.swift @@ -375,9 +375,18 @@ final class Workspace: Identifiable { /// Returns true if a move happened. @discardableResult func moveActiveTab(by offset: Int) -> Bool { + guard let activeTabID else { return false } + return moveTab(id: activeTabID, by: offset) + } + + /// Move the tab with `tabID` by `offset` positions, wrapping at the ends. + /// The tab keeps its identity (and stays active if it was); only its + /// position changes. Returns true if a move happened. + @discardableResult + func moveTab(id tabID: UUID, by offset: Int) -> Bool { let n = tabs.count - guard n > 1, offset != 0, let activeTabID, - let i = tabs.firstIndex(where: { $0.id == activeTabID }) + guard n > 1, offset != 0, + let i = tabs.firstIndex(where: { $0.id == tabID }) else { return false } let j = ((i + offset) % n + n) % n guard j != i else { return false } diff --git a/Macterm/Views/MainWindow.swift b/Macterm/Views/MainWindow.swift index 6012c84..6f3894b 100644 --- a/Macterm/Views/MainWindow.swift +++ b/Macterm/Views/MainWindow.swift @@ -25,7 +25,7 @@ struct MainWindow: View { var body: some View { NavigationSplitView(columnVisibility: columnVisibility) { SidebarContent() - .navigationSplitViewColumnWidth(min: 140, ideal: 180, max: 280) + .navigationSplitViewColumnWidth(min: 140, ideal: 180, max: .infinity) } detail: { ZStack { // The window's NSWindow.backgroundColor (set by WindowAppearance) diff --git a/Macterm/Views/Sidebar.swift b/Macterm/Views/Sidebar.swift index ff0a91d..f861a1d 100644 --- a/Macterm/Views/Sidebar.swift +++ b/Macterm/Views/Sidebar.swift @@ -151,6 +151,12 @@ struct SidebarContent: View { } .onChange(of: listFocused) { _, focused in appState.sidebarListFocused = focused + // Leaving the sidebar while in Focus Sidebar mode (e.g. clicking + // away) exits the mode, which returns keyboard focus to the active + // tab's pane. ESC does the same via the key responder. + if !focused, appState.sidebarFocusMode { + appState.exitSidebarFocus() + } } .onChange(of: appState.activeProjectID) { _, newID in if let newID { appState.expandedProjects.insert(newID) }