Skip to content
Open
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
26 changes: 23 additions & 3 deletions Macterm/App/AppCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +46,7 @@ enum AppCommand: String, CaseIterable, Identifiable {
case previousProject
// Window
case toggleSidebar
case focusSidebar
case closeWindow
case toggleCommandPalette
case reloadGhosttyConfig
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -98,7 +108,11 @@ enum AppCommand: String, CaseIterable, Identifiable {
.renameTab,
.nextTab,
.previousTab,
.recentTab: .tabs
.recentTab,
.previousTabInProject,
.nextTabInProject,
.moveTabUp,
.moveTabDown: .tabs
case .splitRight,
.splitDown,
.splitAuto,
Expand All @@ -124,6 +138,7 @@ enum AppCommand: String, CaseIterable, Identifiable {
.nextProject,
.previousProject: .projects
case .toggleSidebar,
.focusSidebar,
.closeWindow,
.toggleCommandPalette: .window
case .reloadGhosttyConfig,
Expand All @@ -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
Expand Down Expand Up @@ -169,8 +191,6 @@ enum AppCommand: String, CaseIterable, Identifiable {
.unloadProject,
.removeProject,
.replaceProjectPathWithCurrentDir,
.applyLayout,
.saveLayout,
.checkForUpdate: nil
}
}
Expand Down
14 changes: 14 additions & 0 deletions Macterm/App/AppCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions Macterm/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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<UUID> = []

// 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
Expand Down Expand Up @@ -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) {
Expand All @@ -816,6 +857,14 @@ 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 cycleRecentTab(projectID: UUID) {
Expand Down Expand Up @@ -1272,6 +1321,97 @@ 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]
}

/// 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]) {
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() {
Expand Down
17 changes: 16 additions & 1 deletion Macterm/App/Hotkeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand 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"
Expand Down Expand Up @@ -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
}
}

Expand Down
Loading
Loading