import SwiftUI import TermindDomain import struct TermindDomain.Host /// The menu bar. /// /// Every command routes through `@FocusedValue(\.window)` rather than a global, /// so ⌘T opens a Workspace in the window you are actually looking at. When no window is /// frontmost the value is nil and the items disable themselves, which is the /// correct behaviour and comes for free. /// /// `AppModel` is passed in rather than read from the environment: `Commands` is /// not a `View`, so `@Environment` on a custom `@Observable` does not resolve /// here. It is app-wide and there is exactly one, so injection costs nothing. struct TermindCommands: Commands { let app: AppModel @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.openWindow) private var openWindow @FocusedValue(\.window) private var window private var closeTitle: LocalizedStringKey { guard let workspace = window?.selectedWorkspace else { return "Close Window" } return workspace.isSplit ? "Close Pane" : "Close Workspace" } private var newWindowConnects: Bool { app.mainWindows.isFullScreen(window) } private var newWindowTitle: LocalizedStringKey { newWindowConnects ? "Connect" : "New Window" } var body: some Commands { let _ = app.mainWindows.registerWindowCreation { [openWindow] id in openWindow(id: "main", value: id) } CommandGroup(replacing: .appSettings) { Button("Settings", systemImage: MenuSymbol.settings) { window?.openSettings() } .termindShortcut(",") .disabled(window == nil) } CommandGroup(replacing: .newItem) { Button( newWindowTitle, systemImage: newWindowConnects ? MenuSymbol.ssh : MenuSymbol.newWindow ) { app.mainWindows.performNewWindowCommand(from: window) } .termindShortcut("n") Divider() // Keep Quick Open inside the replacement group. Placing it in a // separate group after `.newItem` makes SwiftUI discard it when // this replacement is merged into the final File menu, leaving // no menu item (and therefore no working Command-P shortcut). Button("Quick Open", systemImage: MenuSymbol.quickOpen) { app.onboarding.recordUse(of: .quickOpen) window?.isQuickOpenPresented = true } .termindShortcut("p") .disabled(window == nil) Divider() Button("Connect", systemImage: MenuSymbol.ssh) { guard let window else { return } app.connect(in: window) } .termindShortcut("t") Button("Batch Startup", systemImage: MenuSymbol.batchStartup) { guard let window else { return } app.batchStartup(in: window) } .termindShortcut("b", modifiers: [.command, .shift]) .disabled(window == nil) Button("New Chat", systemImage: MenuSymbol.newChat) { window?.openNewAssistantChat() } .termindShortcut("t", modifiers: [.command, .option]) Button("New Host", systemImage: MenuSymbol.newHost) { window?.openHostEditor(host: nil) } .termindShortcut("n", modifiers: [.command, .shift]) Divider() // A multi-pane selection is already a Workspace, so Duplicate // copies that whole arrangement into one new Workspace. A single pane can // instead choose whether its fresh copy belongs beside it or apart. if window?.selectedWorkspace?.isSplit == true { Button("Duplicate", systemImage: MenuSymbol.duplicate) { window?.duplicateSelectedInNewWorkspace() } .termindShortcut("d") .disabled(!(window?.canDuplicateSelected ?? false)) } else { Menu("Duplicate", systemImage: MenuSymbol.duplicate) { Button("in Current Workspace", systemImage: MenuSymbol.currentWorkspace) { window?.duplicateSelected(axis: .horizontal) } .termindShortcut("d") Button("in New Workspace", systemImage: MenuSymbol.newWorkspace) { window?.duplicatePaneInNewWorkspace() } .termindShortcut("d", modifiers: [.command, .shift]) } .disabled(!(window?.canDuplicateSelected ?? false)) } } CommandGroup(after: .pasteboard) { Button("Generate Command", systemImage: MenuSymbol.generateCommand) { app.onboarding.recordUse(of: .commandGenerator) window?.generateTerminalCommand(using: app.commandGenerator) } .termindShortcut("i") .disabled(!(window?.canGenerateTerminalCommand ?? false)) Divider() // One Find for the whole window: this focuses the field in the // titlebar, whatever that field is currently pointed at. See // `WindowModel.findKind`. // // Find Next and Find Previous below carry no icon of their own: they // are the same verb as the item above them, and a menu's icon column // reads as a group. Button("Find", systemImage: MenuSymbol.find) { window?.focusToolbarFind() } .termindShortcut("f") .disabled(!(window?.canFindInMainContent ?? false)) // `.previous` walks toward older output, which is where the matches // are when a search starts at the bottom of the buffer — so it is // what "next" means to a reader. Return in the Find field agrees. Button("Find Next in Terminal") { window?.navigateTerminalFind(.previous) } .termindShortcut("g") .disabled(!(window?.canFindInTerminal ?? false)) Button("Find Previous in Terminal") { window?.navigateTerminalFind(.next) } .termindShortcut("g", modifiers: [.command, .shift]) .disabled(!(window?.canFindInTerminal ?? false)) } CommandGroup(replacing: .saveItem) { Button( window?.selectedWorkspace?.sourceTemplateID == nil ? "Save Workspace Template" : "Update Workspace Template", systemImage: "square.and.arrow.down" ) { window?.requestSaveWorkspaceTemplate() } .termindShortcut("s") .disabled(!(window?.canSaveSelectedWorkspaceTemplate ?? false)) Divider() // Close Other Workspaces is the same verb one item down, so it goes // without an icon of its own. Button(closeTitle, systemImage: MenuSymbol.closeWorkspace) { guard let window else { return } app.mainWindows.closeSelectionOrWindow(in: window) } .termindShortcut("w") .disabled(window == nil) Button("Close Window") { guard let window else { return } app.mainWindows.closeWindow(in: window) } .termindShortcut("w", modifiers: [.command, .shift]) .disabled(window == nil) Button("Close Other Workspaces") { guard let window, let id = window.selectedWorkspaceID else { return } window.closeOthers(than: id) } .termindShortcut("w", modifiers: [.command, .option]) .disabled(window?.selectedWorkspace == nil) Button("Reopen Closed Workspace", systemImage: MenuSymbol.reopenWorkspace) { window?.reopenLastClosed() } .termindShortcut("t", modifiers: [.command, .shift]) .disabled(!(window?.canReopen ?? false)) } // No Session menu. What was in it lives in File (Duplicate), // in View (the two Splits), on the pane header (Broadcast Input, which // shows its own state right next to the panes it reaches and so does not // need a second copy in the menu bar — ⌘B is registered by // `WorkspaceContentView`), or nowhere yet: Clear Scrollback, Ask AI About // Selection and Suggest a Command were disabled placeholders holding // shortcuts for P2/P4, and P2 can put them wherever the real commands // end up. CommandGroup(after: .sidebar) { Button("Show Assistant in Sidebar", systemImage: MenuSymbol.showAssistant) { guard let window else { return } withAnimation(Motion.panel(reduceMotion: reduceMotion)) { window.showAssistant() } } .termindShortcut("i", modifiers: [.command, .option]) .disabled(window?.selectedWorkspace == nil) Divider() // ⌥ still marks the pair that only rearranges the window, against // Duplicate's ⌘D, which opens a connection. The keys are the // directions themselves rather than ⌥⌘D: macOS owns that one for // Turn Dock Hiding On/Off, so pressing it here only hid the Dock. Button("Split Right", systemImage: MenuSymbol.splitRight) { window?.splitSelected(axis: .horizontal) } .termindShortcut(.rightArrow, modifiers: [.command, .option]) .disabled(!(window?.canSplitSelected ?? false)) Button("Split Down", systemImage: MenuSymbol.splitDown) { window?.splitSelected(axis: .vertical) } .termindShortcut(.downArrow, modifiers: [.command, .option]) .disabled(!(window?.canSplitSelected ?? false)) } CommandGroup(before: .windowList) { Button("Move Workspace to New Window", systemImage: MenuSymbol.moveToNewWindow) { guard let window, let id = window.selectedWorkspaceID else { return } app.mainWindows.moveWorkspace(id, from: window) } .disabled(window?.selectedWorkspace == nil) Divider() // One pair, not two. There used to be a ⌃⇥ copy of these beside // them, which AppKit's automatic window tabbing already claims for // Show Next Tab — so the app's copy was both shadowed and a second // name for what ⇧⌘] does. ⇧⌘] is what Safari, Terminal and Finder // use, so it is the pair that stays. Button("Next Workspace", systemImage: MenuSymbol.nextWorkspace) { window?.selectNext() } .termindShortcut("]", modifiers: [.command, .shift]) .disabled(window?.workspaces.isEmpty != false) Button("Previous Workspace", systemImage: MenuSymbol.previousWorkspace) { window?.selectPrevious() } .termindShortcut("[", modifiers: [.command, .shift]) .disabled(window?.workspaces.isEmpty != false) Divider() // ⌘1…⌘8 by sidebar position, ⌘9 to the last Workspace — the convention every // browser and terminal shares. // // One glyph for the whole run, the way Safari lists its tabs, and the // same one the Navigator draws beside a merged Workspace. Giving each // entry the symbol of what it holds would be readable one line at a // time and noise read down the column. ForEach(1...8, id: \.self) { index in Button("Workspace \(index)", systemImage: MenuSymbol.workspace) { window?.select(index: index - 1) } .termindShortcut(AppShortcutKey.character(Character("\(index)"))) .disabled((window?.workspaces.count ?? 0) < index) } Button("Last Workspace", systemImage: MenuSymbol.workspace) { guard let window else { return } window.select(index: window.workspaces.count - 1) } .termindShortcut("9") .disabled(window?.workspaces.isEmpty != false) Divider() } CommandGroup(replacing: .help) { Link(destination: URL(string: "https://termind.akinokaede.com/support/")!) { Label("Support", systemImage: MenuSymbol.support) } } } }