Compare commits
2 Commits
fe6c7d8c12
...
8ecb7d4382
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ecb7d4382
|
|||
|
1e30e9bf9e
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
|
||||
Text("⌘1–9 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
351
docs/workspace-split-pane-plan.md
Normal file
351
docs/workspace-split-pane-plan.md
Normal 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.
|
||||
Reference in New Issue
Block a user