macOS: save&restore quick terminal state
parent
72747a28af
commit
d680404fae
|
|
@ -95,6 +95,7 @@
|
||||||
Features/QuickTerminal/QuickTerminal.xib,
|
Features/QuickTerminal/QuickTerminal.xib,
|
||||||
Features/QuickTerminal/QuickTerminalController.swift,
|
Features/QuickTerminal/QuickTerminalController.swift,
|
||||||
Features/QuickTerminal/QuickTerminalPosition.swift,
|
Features/QuickTerminal/QuickTerminalPosition.swift,
|
||||||
|
Features/QuickTerminal/QuickTerminalRestorableState.swift,
|
||||||
Features/QuickTerminal/QuickTerminalScreen.swift,
|
Features/QuickTerminal/QuickTerminalScreen.swift,
|
||||||
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
|
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
|
||||||
Features/QuickTerminal/QuickTerminalSize.swift,
|
Features/QuickTerminal/QuickTerminalSize.swift,
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,35 @@ class AppDelegate: NSObject,
|
||||||
/// The global undo manager for app-level state such as window restoration.
|
/// The global undo manager for app-level state such as window restoration.
|
||||||
lazy var undoManager = ExpiringUndoManager()
|
lazy var undoManager = ExpiringUndoManager()
|
||||||
|
|
||||||
|
/// The current state of the quick terminal.
|
||||||
|
private var quickTerminalControllerState: QuickTerminalState = .uninitialized
|
||||||
|
|
||||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||||
private(set) lazy var quickController = QuickTerminalController(
|
var quickController: QuickTerminalController {
|
||||||
ghostty,
|
switch quickTerminalControllerState {
|
||||||
position: derivedConfig.quickTerminalPosition
|
case .initialized(let controller):
|
||||||
)
|
return controller
|
||||||
|
|
||||||
|
case .pendingRestore(let state):
|
||||||
|
let controller = QuickTerminalController(
|
||||||
|
ghostty,
|
||||||
|
position: derivedConfig.quickTerminalPosition,
|
||||||
|
baseConfig: state.baseConfig,
|
||||||
|
restorationState: state
|
||||||
|
)
|
||||||
|
quickTerminalControllerState = .initialized(controller)
|
||||||
|
return controller
|
||||||
|
|
||||||
|
case .uninitialized:
|
||||||
|
let controller = QuickTerminalController(
|
||||||
|
ghostty,
|
||||||
|
position: derivedConfig.quickTerminalPosition,
|
||||||
|
restorationState: nil
|
||||||
|
)
|
||||||
|
quickTerminalControllerState = .initialized(controller)
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages updates
|
/// Manages updates
|
||||||
let updateController = UpdateController()
|
let updateController = UpdateController()
|
||||||
|
|
@ -996,10 +1020,31 @@ class AppDelegate: NSObject,
|
||||||
|
|
||||||
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
|
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
|
||||||
Self.logger.debug("application will save window state")
|
Self.logger.debug("application will save window state")
|
||||||
|
|
||||||
|
guard ghostty.config.windowSaveState != "never" else { return }
|
||||||
|
|
||||||
|
// Encode our quick terminal state if we have it.
|
||||||
|
switch quickTerminalControllerState {
|
||||||
|
case .initialized(let controller) where controller.restorable:
|
||||||
|
let data = QuickTerminalRestorableState(from: controller)
|
||||||
|
data.encode(with: coder)
|
||||||
|
|
||||||
|
case .pendingRestore(let state):
|
||||||
|
state.encode(with: coder)
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
|
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
|
||||||
Self.logger.debug("application will restore window state")
|
Self.logger.debug("application will restore window state")
|
||||||
|
|
||||||
|
// Decode our quick terminal state.
|
||||||
|
if ghostty.config.windowSaveState != "never",
|
||||||
|
let state = QuickTerminalRestorableState(coder: coder) {
|
||||||
|
quickTerminalControllerState = .pendingRestore(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//MARK: - UNUserNotificationCenterDelegate
|
//MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
@ -1273,6 +1318,16 @@ extension AppDelegate: NSMenuItemValidation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents the state of the quick terminal controller.
|
||||||
|
private enum QuickTerminalState {
|
||||||
|
/// Controller has not been initialized and has no pending restoration state.
|
||||||
|
case uninitialized
|
||||||
|
/// Restoration state is pending; controller will use this when first accessed.
|
||||||
|
case pendingRestore(QuickTerminalRestorableState)
|
||||||
|
/// Controller has been initialized.
|
||||||
|
case initialized(QuickTerminalController)
|
||||||
|
}
|
||||||
|
|
||||||
@globalActor
|
@globalActor
|
||||||
fileprivate actor AppIconActor: GlobalActor {
|
fileprivate actor AppIconActor: GlobalActor {
|
||||||
static let shared = AppIconActor()
|
static let shared = AppIconActor()
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
private var previousActiveSpace: CGSSpace? = nil
|
private var previousActiveSpace: CGSSpace? = nil
|
||||||
|
|
||||||
/// Cache for per-screen window state.
|
/// Cache for per-screen window state.
|
||||||
private let screenStateCache = QuickTerminalScreenStateCache()
|
let screenStateCache: QuickTerminalScreenStateCache
|
||||||
|
|
||||||
/// Non-nil if we have hidden dock state.
|
/// Non-nil if we have hidden dock state.
|
||||||
private var hiddenDock: HiddenDock? = nil
|
private var hiddenDock: HiddenDock? = nil
|
||||||
|
|
@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
|
|
||||||
/// Tracks if we're currently handling a manual resize to prevent recursion
|
/// Tracks if we're currently handling a manual resize to prevent recursion
|
||||||
private var isHandlingResize: Bool = false
|
private var isHandlingResize: Bool = false
|
||||||
|
|
||||||
|
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||||
|
/// For example, terminals executing custom scripts are not restorable.
|
||||||
|
let restorable: Bool
|
||||||
|
private var restorationState: QuickTerminalRestorableState?
|
||||||
|
|
||||||
init(_ ghostty: Ghostty.App,
|
init(_ ghostty: Ghostty.App,
|
||||||
position: QuickTerminalPosition = .top,
|
position: QuickTerminalPosition = .top,
|
||||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||||
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
restorationState: QuickTerminalRestorableState? = nil,
|
||||||
) {
|
) {
|
||||||
self.position = position
|
self.position = position
|
||||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||||
|
// The window we manage is not restorable if we've specified a command
|
||||||
|
// to execute. We do this because the restored window is meaningless at the
|
||||||
|
// time of writing this: it'd just restore to a shell in the same directory
|
||||||
|
// as the script. We may want to revisit this behavior when we have scrollback
|
||||||
|
// restoration.
|
||||||
|
restorable = (base?.command ?? "") == ""
|
||||||
|
self.restorationState = restorationState
|
||||||
|
self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:])
|
||||||
// Important detail here: we initialize with an empty surface tree so
|
// Important detail here: we initialize with an empty surface tree so
|
||||||
// that we don't start a terminal process. This gets started when the
|
// that we don't start a terminal process. This gets started when the
|
||||||
// first terminal is shown in `animateIn`.
|
// first terminal is shown in `animateIn`.
|
||||||
|
|
@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
// window close so we can animate out.
|
// window close so we can animate out.
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
|
|
||||||
// The quick window is not restorable (yet!). "Yet" because in theory we can
|
// The quick window is restored by `screenStateCache`.
|
||||||
// make this restorable, but it isn't currently implemented.
|
// We disable this for better control
|
||||||
window.isRestorable = false
|
window.isRestorable = false
|
||||||
|
|
||||||
// Setup our configured appearance that we support.
|
// Setup our configured appearance that we support.
|
||||||
|
|
@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
// animate out.
|
// animate out.
|
||||||
if surfaceTree.isEmpty,
|
if surfaceTree.isEmpty,
|
||||||
let ghostty_app = ghostty.app {
|
let ghostty_app = ghostty.app {
|
||||||
var config = Ghostty.SurfaceConfiguration()
|
if let tree = restorationState?.surfaceTree, !tree.isEmpty {
|
||||||
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
surfaceTree = tree
|
||||||
|
let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first!
|
||||||
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
focusedSurface = view
|
||||||
surfaceTree = SplitTree(view: view)
|
// Add a short delay to check if the correct surface is focused.
|
||||||
focusedSurface = view
|
// Each SurfaceWrapper defaults its FocusedValue to itself; without this delay,
|
||||||
|
// the tree often focuses the first surface instead of the intended one.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
if !view.focused {
|
||||||
|
self.focusedSurface = view
|
||||||
|
self.makeWindowKey(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var config = Ghostty.SurfaceConfiguration()
|
||||||
|
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||||
|
|
||||||
|
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||||
|
surfaceTree = SplitTree(view: view)
|
||||||
|
focusedSurface = view
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate the window in
|
// Animate the window in
|
||||||
animateWindowIn(window: window, from: position)
|
animateWindowIn(window: window, from: position)
|
||||||
|
// Clear the restoration state after first use
|
||||||
|
restorationState = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func animateOut() {
|
func animateOut() {
|
||||||
|
|
@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
animateWindowOut(window: window, to: position)
|
animateWindowOut(window: window, to: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveScreenState(exitFullscreen: Bool) {
|
||||||
|
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
|
||||||
|
// we have th correct window.frame for the save state below.
|
||||||
|
if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen {
|
||||||
|
fullscreenStyle.exit()
|
||||||
|
}
|
||||||
|
guard let window else { return }
|
||||||
|
// Save the current window frame before animating out. This preserves
|
||||||
|
// the user's preferred window size and position for when the quick
|
||||||
|
// terminal is reactivated with a new surface. Without this, SwiftUI
|
||||||
|
// would reset the window to its minimum content size.
|
||||||
|
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
|
||||||
|
screenStateCache.save(frame: window.frame, for: screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
||||||
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
||||||
|
|
||||||
|
|
@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
|
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
|
||||||
// If we are in fullscreen, then we exit fullscreen. We do this immediately so
|
saveScreenState(exitFullscreen: true)
|
||||||
// we have th correct window.frame for the save state below.
|
|
||||||
if let fullscreenStyle, fullscreenStyle.isFullscreen {
|
|
||||||
fullscreenStyle.exit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the current window frame before animating out. This preserves
|
|
||||||
// the user's preferred window size and position for when the quick
|
|
||||||
// terminal is reactivated with a new surface. Without this, SwiftUI
|
|
||||||
// would reset the window to its minimum content size.
|
|
||||||
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
|
|
||||||
screenStateCache.save(frame: window.frame, for: screen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we hid the dock then we unhide it.
|
// If we hid the dock then we unhide it.
|
||||||
hiddenDock = nil
|
hiddenDock = nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
struct QuickTerminalRestorableState: TerminalRestorable {
|
||||||
|
static var version: Int { 1 }
|
||||||
|
|
||||||
|
let focusedSurface: String?
|
||||||
|
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||||
|
let screenStateEntries: QuickTerminalScreenStateCache.Entries
|
||||||
|
|
||||||
|
init(from controller: QuickTerminalController) {
|
||||||
|
controller.saveScreenState(exitFullscreen: true)
|
||||||
|
self.focusedSurface = controller.focusedSurface?.id.uuidString
|
||||||
|
self.surfaceTree = controller.surfaceTree
|
||||||
|
self.screenStateEntries = controller.screenStateCache.stateByDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
init(copy other: QuickTerminalRestorableState) {
|
||||||
|
self = other
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseConfig: Ghostty.SurfaceConfiguration? {
|
||||||
|
var config = Ghostty.SurfaceConfiguration()
|
||||||
|
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import Cocoa
|
||||||
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
|
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
|
||||||
/// to survive NSScreen garbage collection and automatically prunes stale entries.
|
/// to survive NSScreen garbage collection and automatically prunes stale entries.
|
||||||
class QuickTerminalScreenStateCache {
|
class QuickTerminalScreenStateCache {
|
||||||
|
typealias Entries = [UUID: DisplayEntry]
|
||||||
|
|
||||||
/// The maximum number of saved screen states we retain. This is to avoid some kind of
|
/// The maximum number of saved screen states we retain. This is to avoid some kind of
|
||||||
/// pathological memory growth in case we get our screen state serializing wrong. I don't
|
/// pathological memory growth in case we get our screen state serializing wrong. I don't
|
||||||
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
|
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
|
||||||
|
|
@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache {
|
||||||
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
|
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
|
||||||
|
|
||||||
/// Keyed by display UUID to survive NSScreen garbage collection.
|
/// Keyed by display UUID to survive NSScreen garbage collection.
|
||||||
private var stateByDisplay: [UUID: DisplayEntry] = [:]
|
private(set) var stateByDisplay: Entries = [:]
|
||||||
|
|
||||||
init() {
|
init(stateByDisplay: Entries = [:]) {
|
||||||
|
self.stateByDisplay = stateByDisplay
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(onScreensChanged(_:)),
|
selector: #selector(onScreensChanged(_:)),
|
||||||
|
|
@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DisplayEntry {
|
struct DisplayEntry: Codable {
|
||||||
var frame: NSRect
|
var frame: NSRect
|
||||||
var screenSize: CGSize
|
var screenSize: CGSize
|
||||||
var scale: CGFloat
|
var scale: CGFloat
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,47 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
|
|
||||||
|
protocol TerminalRestorable: Codable {
|
||||||
|
static var selfKey: String { get }
|
||||||
|
static var versionKey: String { get }
|
||||||
|
static var version: Int { get }
|
||||||
|
init(copy other: Self)
|
||||||
|
|
||||||
|
/// Returns a base configuration to use when restoring terminal surfaces.
|
||||||
|
/// Override this to provide custom environment variables or other configuration.
|
||||||
|
var baseConfig: Ghostty.SurfaceConfiguration? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TerminalRestorable {
|
||||||
|
static var selfKey: String { "state" }
|
||||||
|
static var versionKey: String { "version" }
|
||||||
|
|
||||||
|
/// Default implementation returns nil (no custom base config).
|
||||||
|
var baseConfig: Ghostty.SurfaceConfiguration? { nil }
|
||||||
|
|
||||||
|
init?(coder aDecoder: NSCoder) {
|
||||||
|
// If the version doesn't match then we can't decode. In the future we can perform
|
||||||
|
// version upgrading or something but for now we only have one version so we
|
||||||
|
// don't bother.
|
||||||
|
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(copy: v.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(with coder: NSCoder) {
|
||||||
|
coder.encode(Self.version, forKey: Self.versionKey)
|
||||||
|
coder.encode(CodableBridge(self), forKey: Self.selfKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The state stored for terminal window restoration.
|
/// The state stored for terminal window restoration.
|
||||||
class TerminalRestorableState: Codable {
|
class TerminalRestorableState: TerminalRestorable {
|
||||||
static let selfKey = "state"
|
class var version: Int { 7 }
|
||||||
static let versionKey = "version"
|
|
||||||
static let version: Int = 7
|
|
||||||
|
|
||||||
let focusedSurface: String?
|
let focusedSurface: String?
|
||||||
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
let surfaceTree: SplitTree<Ghostty.SurfaceView>
|
||||||
|
|
@ -20,28 +57,12 @@ class TerminalRestorableState: Codable {
|
||||||
self.titleOverride = controller.titleOverride
|
self.titleOverride = controller.titleOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(coder aDecoder: NSCoder) {
|
required init(copy other: TerminalRestorableState) {
|
||||||
// If the version doesn't match then we can't decode. In the future we can perform
|
self.surfaceTree = other.surfaceTree
|
||||||
// version upgrading or something but for now we only have one version so we
|
self.focusedSurface = other.focusedSurface
|
||||||
// don't bother.
|
self.effectiveFullscreenMode = other.effectiveFullscreenMode
|
||||||
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
|
self.tabColor = other.tabColor
|
||||||
return nil
|
self.titleOverride = other.titleOverride
|
||||||
}
|
|
||||||
|
|
||||||
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.surfaceTree = v.value.surfaceTree
|
|
||||||
self.focusedSurface = v.value.focusedSurface
|
|
||||||
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
|
|
||||||
self.tabColor = v.value.tabColor
|
|
||||||
self.titleOverride = v.value.titleOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(with coder: NSCoder) {
|
|
||||||
coder.encode(Self.version, forKey: Self.versionKey)
|
|
||||||
coder.encode(CodableBridge(self), forKey: Self.selfKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue