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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2620"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES"
|
||||||
buildArchitectures = "Automatic">
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
BuildableName = "CommandNotch.app"
|
BuildableName = "CommandNotch.app"
|
||||||
BlueprintName = "CommandNotch"
|
BlueprintName = "CommandNotch"
|
||||||
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
@@ -28,7 +28,42 @@
|
|||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
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>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -44,12 +79,14 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
BuildableName = "CommandNotch.app"
|
BuildableName = "CommandNotch.app"
|
||||||
BlueprintName = "CommandNotch"
|
BlueprintName = "CommandNotch"
|
||||||
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -61,12 +98,14 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
BuildableName = "CommandNotch.app"
|
BuildableName = "CommandNotch.app"
|
||||||
BlueprintName = "CommandNotch"
|
BlueprintName = "CommandNotch"
|
||||||
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2620"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES"
|
||||||
buildArchitectures = "Automatic">
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
BuildableName = "CommandNotch.app"
|
BuildableName = "CommandNotch.app"
|
||||||
BlueprintName = "CommandNotch"
|
BlueprintName = "CommandNotch"
|
||||||
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
@@ -28,13 +28,48 @@
|
|||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
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>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = ""
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "1"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
@@ -44,12 +79,14 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
BuildableName = "CommandNotch.app"
|
BuildableName = "CommandNotch.app"
|
||||||
BlueprintName = "CommandNotch"
|
BlueprintName = "CommandNotch"
|
||||||
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -61,12 +98,14 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
|
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
|
||||||
BuildableName = "CommandNotch.app"
|
BuildableName = "CommandNotch.app"
|
||||||
BlueprintName = "CommandNotch"
|
BlueprintName = "CommandNotch"
|
||||||
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
ReferencedContainer = "container:CommandNotch.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@@ -76,9 +76,7 @@ struct HotkeyRecorderField: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
||||||
nsView.currentLabel = binding.displayString
|
nsView.update(currentLabel: binding.displayString, isRecording: isRecording)
|
||||||
nsView.showRecording = isRecording
|
|
||||||
nsView.needsDisplay = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +97,7 @@ struct OptionalHotkeyRecorderField: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
||||||
nsView.currentLabel = binding?.displayString ?? "Not set"
|
nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
|
||||||
nsView.showRecording = isRecording
|
|
||||||
nsView.needsDisplay = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +179,12 @@ class HotkeyNSView: NSView {
|
|||||||
updateLabelAppearance()
|
updateLabelAppearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func update(currentLabel: String, isRecording: Bool) {
|
||||||
|
self.currentLabel = currentLabel
|
||||||
|
showRecording = isRecording
|
||||||
|
updateLabelAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
private func updateLabelAppearance() {
|
private func updateLabelAppearance() {
|
||||||
label.stringValue = showRecording ? "Press keys..." : currentLabel
|
label.stringValue = showRecording ? "Press keys..." : currentLabel
|
||||||
label.textColor = showRecording ? .controlAccentColor : .labelColor
|
label.textColor = showRecording ? .controlAccentColor : .labelColor
|
||||||
|
|||||||
@@ -9,17 +9,6 @@ struct TabBar: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
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 {
|
Button {
|
||||||
workspace.newTab()
|
workspace.newTab()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -31,6 +20,15 @@ struct TabBar: View {
|
|||||||
.accessibilityIdentifier("notch.new-tab")
|
.accessibilityIdentifier("notch.new-tab")
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 8)
|
.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)
|
.frame(height: 28)
|
||||||
.background(.black)
|
.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.
|
/// Manages global and local hotkeys.
|
||||||
///
|
///
|
||||||
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
|
/// 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).
|
/// use a local `NSEvent` monitor (only fires when our app is active).
|
||||||
@MainActor
|
@MainActor
|
||||||
class HotkeyManager {
|
class HotkeyManager {
|
||||||
@@ -19,21 +19,29 @@ class HotkeyManager {
|
|||||||
var onCloseTab: (() -> Void)?
|
var onCloseTab: (() -> Void)?
|
||||||
var onNextTab: (() -> Void)?
|
var onNextTab: (() -> Void)?
|
||||||
var onPreviousTab: (() -> Void)?
|
var onPreviousTab: (() -> Void)?
|
||||||
|
var onNextWorkspace: (() -> Void)?
|
||||||
|
var onPreviousWorkspace: (() -> Void)?
|
||||||
var onDetachTab: (() -> Void)?
|
var onDetachTab: (() -> Void)?
|
||||||
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
|
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
|
||||||
var onSwitchToTab: ((Int) -> 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
|
var isNotchOpen: Bool = false
|
||||||
|
|
||||||
private var hotKeyRef: EventHotKeyRef?
|
private var hotKeyRef: EventHotKeyRef?
|
||||||
private var eventHandlerRef: EventHandlerRef?
|
private var eventHandlerRef: EventHandlerRef?
|
||||||
private var localMonitor: Any?
|
private var localMonitor: Any?
|
||||||
private let settingsProvider: TerminalSessionConfigurationProviding
|
private let settingsProvider: TerminalSessionConfigurationProviding
|
||||||
|
private let workspaceRegistry: WorkspaceRegistry
|
||||||
private var settingsCancellable: AnyCancellable?
|
private var settingsCancellable: AnyCancellable?
|
||||||
|
|
||||||
init(settingsProvider: TerminalSessionConfigurationProviding? = nil) {
|
init(
|
||||||
|
settingsProvider: TerminalSessionConfigurationProviding? = nil,
|
||||||
|
workspaceRegistry: WorkspaceRegistry? = nil
|
||||||
|
) {
|
||||||
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
|
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
|
||||||
|
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Resolved bindings from typed runtime settings
|
// MARK: - Resolved bindings from typed runtime settings
|
||||||
@@ -53,6 +61,12 @@ class HotkeyManager {
|
|||||||
private var prevTabBinding: HotkeyBinding {
|
private var prevTabBinding: HotkeyBinding {
|
||||||
settingsProvider.hotkeySettings.previousTab
|
settingsProvider.hotkeySettings.previousTab
|
||||||
}
|
}
|
||||||
|
private var nextWorkspaceBinding: HotkeyBinding {
|
||||||
|
settingsProvider.hotkeySettings.nextWorkspace
|
||||||
|
}
|
||||||
|
private var previousWorkspaceBinding: HotkeyBinding {
|
||||||
|
settingsProvider.hotkeySettings.previousWorkspace
|
||||||
|
}
|
||||||
private var detachBinding: HotkeyBinding {
|
private var detachBinding: HotkeyBinding {
|
||||||
settingsProvider.hotkeySettings.detachTab
|
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() {
|
private func installLocalMonitor() {
|
||||||
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
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 {
|
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 }
|
guard isNotchOpen else { return false }
|
||||||
|
|
||||||
if newTabBinding.matches(event) {
|
if newTabBinding.matches(event) {
|
||||||
@@ -210,10 +224,25 @@ class HotkeyManager {
|
|||||||
onPreviousTab?()
|
onPreviousTab?()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if nextWorkspaceBinding.matches(event) {
|
||||||
|
onNextWorkspace?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if previousWorkspaceBinding.matches(event) {
|
||||||
|
onPreviousWorkspace?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if detachBinding.matches(event) {
|
if detachBinding.matches(event) {
|
||||||
onDetachTab?()
|
onDetachTab?()
|
||||||
return true
|
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 {
|
for preset in sizePresets {
|
||||||
guard let binding = preset.hotkey else { continue }
|
guard let binding = preset.hotkey else { continue }
|
||||||
if binding.matches(event) {
|
if binding.matches(event) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ final class ScreenManager: ObservableObject {
|
|||||||
static let shared = ScreenManager()
|
static let shared = ScreenManager()
|
||||||
|
|
||||||
private let screenRegistry = ScreenRegistry.shared
|
private let screenRegistry = ScreenRegistry.shared
|
||||||
|
private let workspaceRegistry = WorkspaceRegistry.shared
|
||||||
private let windowCoordinator = WindowCoordinator()
|
private let windowCoordinator = WindowCoordinator()
|
||||||
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
|
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
|
||||||
|
|
||||||
@@ -55,6 +56,12 @@ final class ScreenManager: ObservableObject {
|
|||||||
hotkeyManager.onPreviousTab = { [weak self] in
|
hotkeyManager.onPreviousTab = { [weak self] in
|
||||||
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
|
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
|
hotkeyManager.onDetachTab = { [weak self] in
|
||||||
MainActor.assumeIsolated { self?.detachActiveTab() }
|
MainActor.assumeIsolated { self?.detachActiveTab() }
|
||||||
}
|
}
|
||||||
@@ -64,6 +71,9 @@ final class ScreenManager: ObservableObject {
|
|||||||
hotkeyManager.onSwitchToTab = { [weak self] index in
|
hotkeyManager.onSwitchToTab = { [weak self] index in
|
||||||
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
|
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
|
||||||
}
|
}
|
||||||
|
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
|
||||||
|
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
|
||||||
|
}
|
||||||
|
|
||||||
hotkeyManager.start()
|
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) {
|
func applySizePreset(_ preset: TerminalSizePreset) {
|
||||||
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
|
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
|
||||||
AppSettingsController.shared.update {
|
AppSettingsController.shared.update {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ struct AppSettings: Equatable, Codable {
|
|||||||
closeTab: .cmdW,
|
closeTab: .cmdW,
|
||||||
nextTab: .cmdShiftRB,
|
nextTab: .cmdShiftRB,
|
||||||
previousTab: .cmdShiftLB,
|
previousTab: .cmdShiftLB,
|
||||||
|
nextWorkspace: .cmdShiftDown,
|
||||||
|
previousWorkspace: .cmdShiftUp,
|
||||||
detachTab: .cmdD
|
detachTab: .cmdD
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -121,6 +123,8 @@ extension AppSettings {
|
|||||||
var closeTab: HotkeyBinding
|
var closeTab: HotkeyBinding
|
||||||
var nextTab: HotkeyBinding
|
var nextTab: HotkeyBinding
|
||||||
var previousTab: HotkeyBinding
|
var previousTab: HotkeyBinding
|
||||||
|
var nextWorkspace: HotkeyBinding
|
||||||
|
var previousWorkspace: HotkeyBinding
|
||||||
var detachTab: HotkeyBinding
|
var detachTab: HotkeyBinding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
|
|||||||
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
|
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
|
||||||
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
|
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
|
||||||
previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB),
|
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)
|
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.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab)
|
||||||
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
|
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
|
||||||
defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab)
|
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)
|
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 cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
|
||||||
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
|
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 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 let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
|
||||||
|
|
||||||
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
|
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ enum NotchSettings {
|
|||||||
static let hotkeyCloseTab = "hotkey_closeTab"
|
static let hotkeyCloseTab = "hotkey_closeTab"
|
||||||
static let hotkeyNextTab = "hotkey_nextTab"
|
static let hotkeyNextTab = "hotkey_nextTab"
|
||||||
static let hotkeyPreviousTab = "hotkey_previousTab"
|
static let hotkeyPreviousTab = "hotkey_previousTab"
|
||||||
|
static let hotkeyNextWorkspace = "hotkey_nextWorkspace"
|
||||||
|
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
|
||||||
static let hotkeyDetachTab = "hotkey_detachTab"
|
static let hotkeyDetachTab = "hotkey_detachTab"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +106,8 @@ enum NotchSettings {
|
|||||||
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
|
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
|
||||||
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
|
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
|
||||||
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.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()
|
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +155,8 @@ enum NotchSettings {
|
|||||||
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
|
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
|
||||||
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
|
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
|
||||||
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
|
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
|
||||||
|
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
|
||||||
|
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
|
||||||
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
|
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
|
|||||||
let id = UUID()
|
let id = UUID()
|
||||||
let terminalView: TerminalView
|
let terminalView: TerminalView
|
||||||
private var process: LocalProcess?
|
private var process: LocalProcess?
|
||||||
|
private var keyEventMonitor: Any?
|
||||||
private let backgroundColor = NSColor.black
|
private let backgroundColor = NSColor.black
|
||||||
private let configuredShellPath: String
|
private let configuredShellPath: String
|
||||||
|
|
||||||
@@ -26,10 +27,17 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
|
|||||||
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||||
terminalView.font = font
|
terminalView.font = font
|
||||||
applyTheme(theme)
|
applyTheme(theme)
|
||||||
|
installCommandArrowMonitor()
|
||||||
|
|
||||||
startShell()
|
startShell()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let keyEventMonitor {
|
||||||
|
NSEvent.removeMonitor(keyEventMonitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Shell management
|
// MARK: - Shell management
|
||||||
|
|
||||||
private func startShell() {
|
private func startShell() {
|
||||||
@@ -58,6 +66,26 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
|
|||||||
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
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) {
|
func updateFontSize(_ size: CGFloat) {
|
||||||
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ final class WorkspaceController: ObservableObject {
|
|||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
|
|
||||||
@Published private(set) var name: String
|
@Published private(set) var name: String
|
||||||
|
@Published private(set) var hotkey: HotkeyBinding?
|
||||||
@Published private(set) var tabs: [TerminalSession] = []
|
@Published private(set) var tabs: [TerminalSession] = []
|
||||||
@Published private(set) var activeTabIndex: Int = 0
|
@Published private(set) var activeTabIndex: Int = 0
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ final class WorkspaceController: ObservableObject {
|
|||||||
self.id = summary.id
|
self.id = summary.id
|
||||||
self.name = summary.name
|
self.name = summary.name
|
||||||
self.createdAt = summary.createdAt
|
self.createdAt = summary.createdAt
|
||||||
|
self.hotkey = summary.hotkey
|
||||||
self.sessionFactory = sessionFactory
|
self.sessionFactory = sessionFactory
|
||||||
self.settingsProvider = settingsProvider
|
self.settingsProvider = settingsProvider
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ final class WorkspaceController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var summary: WorkspaceSummary {
|
var summary: WorkspaceSummary {
|
||||||
WorkspaceSummary(id: id, name: name, createdAt: createdAt)
|
WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
var state: WorkspaceState {
|
var state: WorkspaceState {
|
||||||
@@ -78,6 +80,11 @@ final class WorkspaceController: ObservableObject {
|
|||||||
name = trimmed
|
name = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateHotkey(_ updatedHotkey: HotkeyBinding?) {
|
||||||
|
guard hotkey != updatedHotkey else { return }
|
||||||
|
hotkey = updatedHotkey
|
||||||
|
}
|
||||||
|
|
||||||
func newTab() {
|
func newTab() {
|
||||||
let config = settingsProvider.terminalSessionConfiguration
|
let config = settingsProvider.terminalSessionConfiguration
|
||||||
let session = sessionFactory.makeSession(
|
let session = sessionFactory.makeSession(
|
||||||
|
|||||||
@@ -104,6 +104,37 @@ final class WorkspaceRegistry: ObservableObject {
|
|||||||
persistWorkspaceSummaries()
|
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
|
@discardableResult
|
||||||
func deleteWorkspace(id: WorkspaceID) -> Bool {
|
func deleteWorkspace(id: WorkspaceID) -> Bool {
|
||||||
guard canDeleteWorkspace(id: id) else { return false }
|
guard canDeleteWorkspace(id: id) else { return false }
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ struct WorkspaceSummary: Identifiable, Equatable, Codable {
|
|||||||
var id: WorkspaceID
|
var id: WorkspaceID
|
||||||
var name: String
|
var name: String
|
||||||
var createdAt: Date
|
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.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
|
self.hotkey = hotkey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ struct HotkeySettingsView: View {
|
|||||||
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
|
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 {
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TerminalSettingsView: View {
|
struct TerminalSettingsView: View {
|
||||||
@ObservedObject private var settingsController = AppSettingsController.shared
|
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||||
|
@State private var sizePresets: [TerminalSizePreset] = []
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -55,7 +41,7 @@ struct TerminalSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Size Presets") {
|
Section("Size Presets") {
|
||||||
ForEach(sizePresetsBinding) { $preset in
|
ForEach($sizePresets) { $preset in
|
||||||
TerminalSizePresetEditor(
|
TerminalSizePresetEditor(
|
||||||
preset: $preset,
|
preset: $preset,
|
||||||
currentOpenWidth: settingsController.settings.display.openWidth,
|
currentOpenWidth: settingsController.settings.display.openWidth,
|
||||||
@@ -67,20 +53,18 @@ struct TerminalSettingsView: View {
|
|||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Add Preset") {
|
Button("Add Preset") {
|
||||||
var presets = sizePresetsBinding.wrappedValue
|
sizePresets.append(
|
||||||
presets.append(
|
|
||||||
TerminalSizePreset(
|
TerminalSizePreset(
|
||||||
name: "Preset \(presets.count + 1)",
|
name: "Preset \(sizePresets.count + 1)",
|
||||||
width: settingsController.settings.display.openWidth,
|
width: settingsController.settings.display.openWidth,
|
||||||
height: settingsController.settings.display.openHeight,
|
height: settingsController.settings.display.openHeight,
|
||||||
hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets)
|
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sizePresetsBinding.wrappedValue = presets
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Reset Presets") {
|
Button("Reset Presets") {
|
||||||
sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults()
|
sizePresets = TerminalSizePresetStore.loadDefaults()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +74,24 @@ struct TerminalSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.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) {
|
private func deletePreset(id: UUID) {
|
||||||
sizePresetsBinding.wrappedValue.removeAll { $0.id == id }
|
sizePresets.removeAll { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyPreset(_ preset: TerminalSizePreset) {
|
private func applyPreset(_ preset: TerminalSizePreset) {
|
||||||
@@ -103,6 +101,15 @@ struct TerminalSettingsView: View {
|
|||||||
}
|
}
|
||||||
ScreenManager.shared.applySizePreset(preset)
|
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 {
|
private struct TerminalSizePresetEditor: View {
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ struct WorkspacesSettingsView: View {
|
|||||||
renameSelectedWorkspace()
|
renameSelectedWorkspace()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionalHotkeyRecorderView(
|
||||||
|
label: "Jump Hotkey",
|
||||||
|
binding: workspaceHotkeyBinding(for: summary.id)
|
||||||
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Save Name") {
|
Button("Save Name") {
|
||||||
renameSelectedWorkspace()
|
renameSelectedWorkspace()
|
||||||
@@ -86,6 +91,10 @@ struct WorkspacesSettingsView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityIdentifier("settings.workspaces.new")
|
.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") {
|
Section("Usage") {
|
||||||
@@ -256,6 +265,17 @@ struct WorkspacesSettingsView: View {
|
|||||||
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
|
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() {
|
private func deleteSelectedWorkspace() {
|
||||||
guard let effectiveSelectedWorkspaceID,
|
guard let effectiveSelectedWorkspaceID,
|
||||||
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
|
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"])
|
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(
|
private func makeRegistry(
|
||||||
initialWorkspaces: [WorkspaceSummary]? = [],
|
initialWorkspaces: [WorkspaceSummary]? = [],
|
||||||
store: (any WorkspaceStoreType)? = nil
|
store: (any WorkspaceStoreType)? = nil
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ final class WorkspaceStoreTests: XCTestCase {
|
|||||||
|
|
||||||
let store = UserDefaultsWorkspaceStore(defaults: defaults)
|
let store = UserDefaultsWorkspaceStore(defaults: defaults)
|
||||||
let summaries = [
|
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")
|
WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,45 @@ packages:
|
|||||||
SwiftTerm:
|
SwiftTerm:
|
||||||
url: https://github.com/migueldeicaza/SwiftTerm.git
|
url: https://github.com/migueldeicaza/SwiftTerm.git
|
||||||
from: "1.2.0"
|
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:
|
targets:
|
||||||
CommandNotch:
|
CommandNotch:
|
||||||
type: application
|
type: application
|
||||||
@@ -29,9 +68,9 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleName: CommandNotch
|
CFBundleName: CommandNotch
|
||||||
CFBundleDisplayName: CommandNotch
|
CFBundleDisplayName: CommandNotch
|
||||||
CFBundleIdentifier: com.commandnotch.app
|
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
|
||||||
CFBundleVersion: "1"
|
CFBundleVersion: "1"
|
||||||
CFBundleShortVersionString: "0.2.0"
|
CFBundleShortVersionString: "0.0.3"
|
||||||
CFBundlePackageType: APPL
|
CFBundlePackageType: APPL
|
||||||
CFBundleExecutable: CommandNotch
|
CFBundleExecutable: CommandNotch
|
||||||
LSMinimumSystemVersion: "14.0"
|
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