Compare commits

..

2 Commits

25 changed files with 1276 additions and 532 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
@@ -28,7 +28,42 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -44,12 +79,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -61,12 +98,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
@@ -28,13 +28,48 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "1"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
@@ -44,12 +79,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -61,12 +98,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -76,9 +76,7 @@ struct HotkeyRecorderField: NSViewRepresentable {
}
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding.displayString
nsView.showRecording = isRecording
nsView.needsDisplay = true
nsView.update(currentLabel: binding.displayString, isRecording: isRecording)
}
}
@@ -99,9 +97,7 @@ struct OptionalHotkeyRecorderField: NSViewRepresentable {
}
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding?.displayString ?? "Not set"
nsView.showRecording = isRecording
nsView.needsDisplay = true
nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
}
}
@@ -183,6 +179,12 @@ class HotkeyNSView: NSView {
updateLabelAppearance()
}
func update(currentLabel: String, isRecording: Bool) {
self.currentLabel = currentLabel
showRecording = isRecording
updateLabelAppearance()
}
private func updateLabelAppearance() {
label.stringValue = showRecording ? "Press keys..." : currentLabel
label.textColor = showRecording ? .controlAccentColor : .labelColor

View File

@@ -9,17 +9,6 @@ struct TabBar: View {
var body: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
Spacer()
Button {
workspace.newTab()
} label: {
@@ -31,6 +20,15 @@ struct TabBar: View {
.accessibilityIdentifier("notch.new-tab")
.buttonStyle(.plain)
.padding(.horizontal, 8)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
}
.frame(height: 28)
.background(.black)

View File

@@ -0,0 +1,31 @@
import AppKit
import Carbon.HIToolbox
import SwiftTerm
enum TerminalCommandArrowBehavior {
private static let relevantModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
private static let lineKill: [UInt8] = [0x15]
private static let clearScreen: [UInt8] = [0x0c]
static func sequence(
for modifierFlags: NSEvent.ModifierFlags,
keyCode: UInt16,
applicationCursor: Bool
) -> [UInt8]? {
let flags = modifierFlags.intersection(relevantModifiers)
guard flags == [.command] else { return nil }
switch Int(keyCode) {
case kVK_LeftArrow:
return applicationCursor ? EscapeSequences.moveHomeApp : EscapeSequences.moveHomeNormal
case kVK_RightArrow:
return applicationCursor ? EscapeSequences.moveEndApp : EscapeSequences.moveEndNormal
case kVK_Delete:
return lineKill
case kVK_ANSI_L:
return clearScreen
default:
return nil
}
}
}

View File

@@ -5,7 +5,7 @@ import Combine
/// Manages global and local hotkeys.
///
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
/// system-wide without Accessibility permission. Tab-level hotkeys
/// system-wide without Accessibility permission. Notch-scoped hotkeys
/// use a local `NSEvent` monitor (only fires when our app is active).
@MainActor
class HotkeyManager {
@@ -19,21 +19,29 @@ class HotkeyManager {
var onCloseTab: (() -> Void)?
var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)?
var onNextWorkspace: (() -> Void)?
var onPreviousWorkspace: (() -> Void)?
var onDetachTab: (() -> Void)?
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
var onSwitchToTab: ((Int) -> Void)?
var onSwitchToWorkspace: ((WorkspaceID) -> Void)?
/// Tab-level hotkeys only fire when the notch is open.
/// Notch-scoped hotkeys only fire when the notch is open.
var isNotchOpen: Bool = false
private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef?
private var localMonitor: Any?
private let settingsProvider: TerminalSessionConfigurationProviding
private let workspaceRegistry: WorkspaceRegistry
private var settingsCancellable: AnyCancellable?
init(settingsProvider: TerminalSessionConfigurationProviding? = nil) {
init(
settingsProvider: TerminalSessionConfigurationProviding? = nil,
workspaceRegistry: WorkspaceRegistry? = nil
) {
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
}
// MARK: - Resolved bindings from typed runtime settings
@@ -53,6 +61,12 @@ class HotkeyManager {
private var prevTabBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousTab
}
private var nextWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.nextWorkspace
}
private var previousWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousWorkspace
}
private var detachBinding: HotkeyBinding {
settingsProvider.hotkeySettings.detachTab
}
@@ -173,7 +187,7 @@ class HotkeyManager {
}
}
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
// MARK: - Local monitor (notch-level hotkeys, only when our app is active)
private func installLocalMonitor() {
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
@@ -189,9 +203,9 @@ class HotkeyManager {
}
}
/// Handles tab-level hotkeys. Returns true if the event was consumed.
/// Handles notch-scoped hotkeys. Returns true if the event was consumed.
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
// Tab hotkeys only when the notch is open and focused
// Local shortcuts only fire when the notch is open and focused.
guard isNotchOpen else { return false }
if newTabBinding.matches(event) {
@@ -210,10 +224,25 @@ class HotkeyManager {
onPreviousTab?()
return true
}
if nextWorkspaceBinding.matches(event) {
onNextWorkspace?()
return true
}
if previousWorkspaceBinding.matches(event) {
onPreviousWorkspace?()
return true
}
if detachBinding.matches(event) {
onDetachTab?()
return true
}
for summary in workspaceRegistry.workspaceSummaries {
guard let binding = summary.hotkey else { continue }
if binding.matches(event) {
onSwitchToWorkspace?(summary.id)
return true
}
}
for preset in sizePresets {
guard let binding = preset.hotkey else { continue }
if binding.matches(event) {

View File

@@ -9,6 +9,7 @@ final class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let screenRegistry = ScreenRegistry.shared
private let workspaceRegistry = WorkspaceRegistry.shared
private let windowCoordinator = WindowCoordinator()
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
@@ -55,6 +56,12 @@ final class ScreenManager: ObservableObject {
hotkeyManager.onPreviousTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
}
hotkeyManager.onNextWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) }
}
hotkeyManager.onPreviousWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) }
}
hotkeyManager.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
@@ -64,6 +71,9 @@ final class ScreenManager: ObservableObject {
hotkeyManager.onSwitchToTab = { [weak self] index in
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
}
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
}
hotkeyManager.start()
}
@@ -92,6 +102,33 @@ final class ScreenManager: ObservableObject {
}
}
private func switchWorkspace(offset: Int) {
guard let screenID = screenRegistry.activeScreenID() else { return }
let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
let nextWorkspaceID = offset >= 0
? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID)
: workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID)
guard let nextWorkspaceID else { return }
switchScreen(screenID, to: nextWorkspaceID)
}
private func switchActiveScreen(to workspaceID: WorkspaceID) {
guard let screenID = screenRegistry.activeScreenID() else { return }
switchScreen(screenID, to: workspaceID)
}
private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screenID)
guard let context = screenRegistry.screenContext(for: screenID),
context.notchState == .open else {
return
}
orchestrator.open(screenID: screenID)
}
func applySizePreset(_ preset: TerminalSizePreset) {
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
AppSettingsController.shared.update {

View File

@@ -56,6 +56,8 @@ struct AppSettings: Equatable, Codable {
closeTab: .cmdW,
nextTab: .cmdShiftRB,
previousTab: .cmdShiftLB,
nextWorkspace: .cmdShiftDown,
previousWorkspace: .cmdShiftUp,
detachTab: .cmdD
)
)
@@ -121,6 +123,8 @@ extension AppSettings {
var closeTab: HotkeyBinding
var nextTab: HotkeyBinding
var previousTab: HotkeyBinding
var nextWorkspace: HotkeyBinding
var previousWorkspace: HotkeyBinding
var detachTab: HotkeyBinding
}
}

View File

@@ -60,6 +60,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB),
nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown),
previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp),
detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD)
)
)
@@ -106,6 +108,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab)
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab)
defaults.set(settings.hotkeys.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace)
defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace)
defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab)
}

View File

@@ -88,6 +88,8 @@ struct HotkeyBinding: Codable, Equatable, Hashable {
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {

View File

@@ -57,6 +57,8 @@ enum NotchSettings {
static let hotkeyCloseTab = "hotkey_closeTab"
static let hotkeyNextTab = "hotkey_nextTab"
static let hotkeyPreviousTab = "hotkey_previousTab"
static let hotkeyNextWorkspace = "hotkey_nextWorkspace"
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
static let hotkeyDetachTab = "hotkey_detachTab"
}
@@ -104,6 +106,8 @@ enum NotchSettings {
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON()
static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON()
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
}
@@ -151,6 +155,8 @@ enum NotchSettings {
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
])
}

View File

@@ -9,6 +9,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
private var keyEventMonitor: Any?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
@@ -26,10 +27,17 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
applyTheme(theme)
installCommandArrowMonitor()
startShell()
}
deinit {
if let keyEventMonitor {
NSEvent.removeMonitor(keyEventMonitor)
}
}
// MARK: - Shell management
private func startShell() {
@@ -58,6 +66,26 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
private func installCommandArrowMonitor() {
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
guard let window = self.terminalView.window else { return event }
guard event.window === window else { return event }
guard window.firstResponder === self.terminalView else { return event }
guard let sequence = TerminalCommandArrowBehavior.sequence(
for: event.modifierFlags,
keyCode: event.keyCode,
applicationCursor: self.terminalView.getTerminal().applicationCursor
) else {
return event
}
self.terminalView.send(data: sequence[...])
return nil
}
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}

View File

@@ -18,6 +18,7 @@ final class WorkspaceController: ObservableObject {
let createdAt: Date
@Published private(set) var name: String
@Published private(set) var hotkey: HotkeyBinding?
@Published private(set) var tabs: [TerminalSession] = []
@Published private(set) var activeTabIndex: Int = 0
@@ -34,6 +35,7 @@ final class WorkspaceController: ObservableObject {
self.id = summary.id
self.name = summary.name
self.createdAt = summary.createdAt
self.hotkey = summary.hotkey
self.sessionFactory = sessionFactory
self.settingsProvider = settingsProvider
@@ -51,7 +53,7 @@ final class WorkspaceController: ObservableObject {
}
var summary: WorkspaceSummary {
WorkspaceSummary(id: id, name: name, createdAt: createdAt)
WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey)
}
var state: WorkspaceState {
@@ -78,6 +80,11 @@ final class WorkspaceController: ObservableObject {
name = trimmed
}
func updateHotkey(_ updatedHotkey: HotkeyBinding?) {
guard hotkey != updatedHotkey else { return }
hotkey = updatedHotkey
}
func newTab() {
let config = settingsProvider.terminalSessionConfiguration
let session = sessionFactory.makeSession(

View File

@@ -104,6 +104,37 @@ final class WorkspaceRegistry: ObservableObject {
persistWorkspaceSummaries()
}
func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) {
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
guard workspaceSummaries[index].hotkey != hotkey else { return }
workspaceSummaries[index].hotkey = hotkey
controllers[id]?.updateHotkey(hotkey)
persistWorkspaceSummaries()
}
func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.first?.id
}
let nextIndex = workspaceSummaries.index(after: index)
return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id
}
func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.last?.id
}
let previousIndex = index == workspaceSummaries.startIndex
? workspaceSummaries.index(before: workspaceSummaries.endIndex)
: workspaceSummaries.index(before: index)
return workspaceSummaries[previousIndex].id
}
@discardableResult
func deleteWorkspace(id: WorkspaceID) -> Bool {
guard canDeleteWorkspace(id: id) else { return false }

View File

@@ -6,11 +6,13 @@ struct WorkspaceSummary: Identifiable, Equatable, Codable {
var id: WorkspaceID
var name: String
var createdAt: Date
var hotkey: HotkeyBinding?
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date()) {
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) {
self.id = id
self.name = name
self.createdAt = createdAt
self.hotkey = hotkey
}
}

View File

@@ -17,8 +17,13 @@ struct HotkeySettingsView: View {
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
}
Section("Workspaces (active when notch is open)") {
HotkeyRecorderView(label: "Next workspace", binding: settingsController.binding(\.hotkeys.nextWorkspace))
HotkeyRecorderView(label: "Previous workspace", binding: settingsController.binding(\.hotkeys.previousWorkspace))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets. Per-workspace jump hotkeys are configured in Workspaces.")
.font(.caption)
.foregroundStyle(.secondary)
}

View File

@@ -2,21 +2,7 @@ import SwiftUI
struct TerminalSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var sizePresetsBinding: Binding<[TerminalSizePreset]> {
Binding(
get: {
TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
},
set: { newValue in
settingsController.update {
$0.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(newValue)
}
}
)
}
@State private var sizePresets: [TerminalSizePreset] = []
var body: some View {
Form {
@@ -55,7 +41,7 @@ struct TerminalSettingsView: View {
}
Section("Size Presets") {
ForEach(sizePresetsBinding) { $preset in
ForEach($sizePresets) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: settingsController.settings.display.openWidth,
@@ -67,20 +53,18 @@ struct TerminalSettingsView: View {
HStack {
Button("Add Preset") {
var presets = sizePresetsBinding.wrappedValue
presets.append(
sizePresets.append(
TerminalSizePreset(
name: "Preset \(presets.count + 1)",
name: "Preset \(sizePresets.count + 1)",
width: settingsController.settings.display.openWidth,
height: settingsController.settings.display.openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets)
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
)
)
sizePresetsBinding.wrappedValue = presets
}
Button("Reset Presets") {
sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults()
sizePresets = TerminalSizePresetStore.loadDefaults()
}
}
@@ -90,10 +74,24 @@ struct TerminalSettingsView: View {
}
}
.formStyle(.grouped)
.onAppear {
synchronizePresetsFromSettings()
}
.onChange(of: settingsController.settings.terminal.sizePresetsJSON) { _, _ in
synchronizePresetsFromSettings()
}
.onChange(of: sizePresets) { _, newValue in
let encoded = TerminalSizePresetStore.encodePresets(newValue)
guard encoded != settingsController.settings.terminal.sizePresetsJSON else { return }
settingsController.update {
$0.terminal.sizePresetsJSON = encoded
}
}
}
private func deletePreset(id: UUID) {
sizePresetsBinding.wrappedValue.removeAll { $0.id == id }
sizePresets.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
@@ -103,6 +101,15 @@ struct TerminalSettingsView: View {
}
ScreenManager.shared.applySizePreset(preset)
}
private func synchronizePresetsFromSettings() {
let decoded = TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
guard decoded != sizePresets else { return }
sizePresets = decoded
}
}
private struct TerminalSizePresetEditor: View {

View File

@@ -75,6 +75,11 @@ struct WorkspacesSettingsView: View {
renameSelectedWorkspace()
}
OptionalHotkeyRecorderView(
label: "Jump Hotkey",
binding: workspaceHotkeyBinding(for: summary.id)
)
HStack {
Button("Save Name") {
renameSelectedWorkspace()
@@ -86,6 +91,10 @@ struct WorkspacesSettingsView: View {
}
.accessibilityIdentifier("settings.workspaces.new")
}
Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Usage") {
@@ -256,6 +265,17 @@ struct WorkspacesSettingsView: View {
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
}
private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding<HotkeyBinding?> {
Binding(
get: {
workspaceRegistry.summary(for: workspaceID)?.hotkey
},
set: { newValue in
workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue)
}
)
}
private func deleteSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID,
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(

View File

@@ -0,0 +1,57 @@
import AppKit
import Carbon.HIToolbox
import XCTest
import SwiftTerm
@testable import CommandNotch
final class TerminalCommandArrowBehaviorTests: XCTestCase {
func testCommandLeftUsesHomeSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertEqual(sequence, EscapeSequences.moveHomeNormal)
}
func testCommandRightUsesApplicationEndSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_RightArrow),
applicationCursor: true
)
XCTAssertEqual(sequence, EscapeSequences.moveEndApp)
}
func testOptionLeftKeepsSwiftTermWordNavigationPath() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.option],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertNil(sequence)
}
func testCommandDeleteUsesLineKillSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_Delete),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x15])
}
func testCommandLUsesClearScreenSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_ANSI_L),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x0c])
}
}

View File

@@ -74,6 +74,30 @@ final class WorkspaceRegistryTests: XCTestCase {
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main"])
}
func testUpdateWorkspaceHotkeyPersistsAndUpdatesSummary() {
let store = InMemoryWorkspaceStore()
let registry = makeRegistry(store: store)
let docsID = registry.createWorkspace(named: "Docs")
let hotkey = HotkeyBinding.cmdShiftDigit(4)
registry.updateWorkspaceHotkey(id: docsID, to: hotkey)
XCTAssertEqual(registry.summary(for: docsID)?.hotkey, hotkey)
XCTAssertEqual(store.savedSummaries.last?.hotkey, hotkey)
}
func testNextAndPreviousWorkspaceWrapAroundRegistryOrder() {
let registry = makeRegistry()
let mainID = registry.defaultWorkspaceID
let docsID = registry.createWorkspace(named: "Docs")
let reviewID = registry.createWorkspace(named: "Review")
XCTAssertEqual(registry.nextWorkspaceID(after: mainID), docsID)
XCTAssertEqual(registry.nextWorkspaceID(after: reviewID), mainID)
XCTAssertEqual(registry.previousWorkspaceID(before: mainID), reviewID)
XCTAssertEqual(registry.previousWorkspaceID(before: docsID), mainID)
}
private func makeRegistry(
initialWorkspaces: [WorkspaceSummary]? = [],
store: (any WorkspaceStoreType)? = nil

View File

@@ -9,7 +9,11 @@ final class WorkspaceStoreTests: XCTestCase {
let store = UserDefaultsWorkspaceStore(defaults: defaults)
let summaries = [
WorkspaceSummary(id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, name: "Main"),
WorkspaceSummary(
id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
name: "Main",
hotkey: HotkeyBinding.cmdShiftDigit(4)
),
WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs")
]

View File

@@ -14,6 +14,45 @@ packages:
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm.git
from: "1.2.0"
schemes:
CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
Release-CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Release
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
targets:
CommandNotch:
type: application
@@ -29,9 +68,9 @@ targets:
properties:
CFBundleName: CommandNotch
CFBundleDisplayName: CommandNotch
CFBundleIdentifier: com.commandnotch.app
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
CFBundleVersion: "1"
CFBundleShortVersionString: "0.2.0"
CFBundleShortVersionString: "0.0.3"
CFBundlePackageType: APPL
CFBundleExecutable: CommandNotch
LSMinimumSystemVersion: "14.0"

View File

@@ -0,0 +1,351 @@
# Workspace Split Pane Plan
## Purpose
Capture the agreed design for workspace-owned split terminals so the feature can be implemented later without redoing scope and architecture decisions.
## Agreed Scope
- Split layouts belong to the workspace, not the screen.
- A workspace may only be actively presented on one screen at a time.
- Split layouts should be visible the same way when that workspace is opened on another monitor.
- Nested splits are supported.
- Any two tabs may be joined into a split.
- Closing a pane causes its sibling to expand.
- Split layout must survive relaunch.
## Non-Goals
- Simultaneous live rendering of the same workspace on multiple screens.
- Full multi-user or collaborative state sync.
- Arbitrary freeform pane layout. The model is tree-based binary splits.
- Solving all interaction polish in the first pass.
## Current Constraint
The current workspace model is flat:
- `WorkspaceController` owns `[TerminalSession]`
- one tab is active at a time
- `ContentView` renders exactly one active `SwiftTermView`
This means split panes are not an additive UI feature. They require a workspace model refactor.
## Recommended Model
Do not model splits as "adjacent tabs with special behavior" internally.
Instead:
1. A workspace owns ordered top-level tabs.
2. Each top-level tab owns a pane tree.
3. Pane tree leaves own `TerminalSession` instances.
4. Pane tree internal nodes represent binary splits.
5. The tab bar can visually present grouped/joined tabs based on the active tab's pane composition.
This keeps the domain model clean while still allowing the desired visual metaphor.
## Proposed Domain Types
```swift
typealias WorkspaceTabID = UUID
typealias PaneID = UUID
struct WorkspaceTab: Identifiable, Equatable, Codable {
var id: WorkspaceTabID
var rootPane: PaneNode
var selectedPaneID: PaneID?
var titleMode: WorkspaceTabTitleMode
}
enum PaneNode: Equatable, Codable, Identifiable {
case leaf(PaneLeaf)
case split(PaneSplit)
var id: PaneID { ... }
}
struct PaneLeaf: Equatable, Codable, Identifiable {
var id: PaneID
var sessionID: UUID
}
struct PaneSplit: Equatable, Codable, Identifiable {
var id: PaneID
var axis: SplitAxis
var ratio: Double
var first: PaneNode
var second: PaneNode
}
enum SplitAxis: String, Codable {
case horizontal
case vertical
}
```
Notes:
- `ratio` should be persisted so user-adjusted divider positions survive relaunch.
- `selectedPaneID` allows focus-based commands such as split active pane, close active pane, and move focus.
- The persisted model should store session identity separately from runtime AppKit/SwiftTerm objects.
## Runtime Ownership
Global:
- workspace summaries
- workspace ordering
- workspace-to-screen assignment
- hotkeys
Workspace:
- ordered top-level tabs
- active top-level tab
- per-tab pane tree
- active/selected pane
- terminal sessions for pane leaves
Screen:
- which workspace is assigned
- notch open/close state
- geometry and transitions
This remains aligned with the existing workspace architecture.
## Persistence Strategy
Split layout must survive relaunch, so workspace persistence needs to grow beyond summary metadata.
Recommended approach:
1. Keep `WorkspaceSummary` for list metadata only.
2. Introduce a persisted `WorkspaceDocument` or `WorkspaceSnapshot` per workspace.
3. Persist:
- top-level tabs
- pane tree
- active tab ID
- selected pane ID
- divider ratios
4. Do not attempt to persist shell process contents.
5. On relaunch, recreate sessions for pane leaves and restore layout structure only.
Important distinction:
- layout persistence is required
- terminal process continuity is not
## UI Model
### Main Content
The active top-level tab renders as a recursive split tree.
- leaf node -> one `SwiftTermView`
- split node -> `HSplitView`/`VSplitView` equivalent SwiftUI container with draggable divider
### Tab Bar
Top-level tabs remain the primary navigation unit.
Visual behavior:
- a normal unsplit tab looks like today
- a joined/split tab should look grouped
- grouped tabs should expose child pane titles visually inside the tab item
Recommended first-pass appearance:
- one outer tab pill per top-level tab
- inside that pill, show compact child title chips for each leaf pane
- highlight the selected pane's chip
This gives the "tabs are joined" feel without making pane leaves first-class top-level tabs in the data model.
## Command Model
New actions likely needed:
- split active pane horizontally
- split active pane vertically
- focus next pane
- focus previous pane
- close active pane
- resize focused split divider
- join tab A into tab B
- detach pane into new top-level tab
Semantics:
- joining any two tabs merges one tab's root pane into the other's tree
- the source top-level tab is removed
- the destination top-level tab remains
- sibling expansion on close is standard tree collapse
## Join Semantics
Joining any two tabs should work as:
1. Choose destination tab.
2. Choose source tab.
3. Replace destination root with a new split node:
- first child = old destination root
- second child = source root
4. Remove source tab from workspace order.
5. Select a predictable pane, preferably the source pane that was just added.
An explicit split axis should be required for the join action.
## Close Semantics
When closing a pane:
- if the pane has a sibling, the sibling expands into the parent's position
- if the pane was the only leaf in a top-level tab:
- close the whole top-level tab if more than one tab exists
- otherwise create a replacement shell pane, matching current single-tab safety behavior
## Focus Semantics
Pane focus must become explicit.
Recommended rules:
- mouse click focuses that pane
- splitting focuses the new pane
- joining focuses the moved-in pane
- closing a pane focuses the surviving sibling
- top-level tab switch restores the previously selected pane in that tab
## Migration Path
Implement in stages to reduce risk.
### Stage 1: Domain Refactor
- Replace flat workspace tabs with top-level tab objects.
- Introduce pane tree types.
- Keep only one leaf per tab initially so behavior is unchanged.
### Stage 2: Runtime Layout Rendering
- Render pane trees recursively in `ContentView`.
- Add active pane selection.
- Keep persistence off until runtime behavior is stable.
### Stage 3: Split Actions
- Split active pane horizontally/vertically.
- Close pane with sibling expansion.
- Basic focus movement.
### Stage 4: Joined Tab UI
- Update tab bar to show grouped child pane chips.
- Surface active pane clearly.
### Stage 5: Join / Unjoin Flows
- Join any two tabs with explicit axis choice.
- Support promoting a pane back to its own top-level tab if needed.
### Stage 6: Persistence
- Persist pane trees and top-level tab state.
- Recreate sessions on launch.
### Stage 7: Hotkeys and Polish
- Add pane-focused shortcuts.
- Add divider dragging polish.
- Improve visual grouped-tab affordances.
## Main Risks
### 1. Model complexity
The current flat `[TerminalSession]` model is simple. Tree-based layout introduces more state, more edge cases, and more focus semantics.
Mitigation:
- refactor the data model before touching complex UI
- keep top-level tabs and pane leaves distinct
### 2. SwiftTerm view ownership
`TerminalView` cannot be mounted in multiple places safely.
Mitigation:
- preserve current rule: only one active presenting screen per workspace
- keep one runtime `TerminalSession` per pane leaf
### 3. Persistence mismatch
Persisting layout is easy compared with persisting process state.
Mitigation:
- persist layout and selection only
- recreate shell sessions on launch
### 4. Joined-tab UX ambiguity
If grouped tabs are also used as pane chips, the interaction model can get confusing.
Mitigation:
- preserve top-level tabs as the real navigation unit
- use internal chips only as secondary indicators/actions
## Recommended First Implementation Boundary
The first deliverable should include:
- nested binary split trees
- active pane focus
- split and close pane actions
- sibling expansion
- grouped tab appearance
- join any two tabs
- persisted layout across relaunch
- same workspace layout when moved to another screen
The first deliverable should not include:
- simultaneous same-workspace rendering on multiple screens
- drag-and-drop tree editing
- restoring running shell process contents
## Files Likely Impacted
- `Downterm/CommandNotch/Models/WorkspaceController.swift`
- `Downterm/CommandNotch/Models/WorkspaceRegistry.swift`
- `Downterm/CommandNotch/Models/WorkspaceStore.swift`
- `Downterm/CommandNotch/ContentView.swift`
- `Downterm/CommandNotch/Components/TabBar.swift`
- `Downterm/CommandNotch/Models/TerminalSession.swift`
- workspace-related tests
Likely new files:
- pane tree domain types
- persisted workspace document types
- split-pane rendering view(s)
- pane-focused command helpers
## Decision Summary
The feature is feasible.
The correct architecture is:
- workspace-owned split tree
- top-level tabs remain the primary unit
- grouped tab visuals are a UI layer over the pane tree
- one presenting screen per workspace
- persisted layout, not persisted process contents
That is the implementation direction to use when this work is resumed.