macOS: save&restore quick terminal state

pull/9588/head
Lukas 2025-11-14 14:33:06 +01:00 committed by Mitchell Hashimoto
parent 72747a28af
commit d680404fae
6 changed files with 200 additions and 59 deletions

View File

@ -95,6 +95,7 @@
Features/QuickTerminal/QuickTerminal.xib,
Features/QuickTerminal/QuickTerminalController.swift,
Features/QuickTerminal/QuickTerminalPosition.swift,
Features/QuickTerminal/QuickTerminalRestorableState.swift,
Features/QuickTerminal/QuickTerminalScreen.swift,
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
Features/QuickTerminal/QuickTerminalSize.swift,

View File

@ -99,11 +99,35 @@ class AppDelegate: NSObject,
/// The global undo manager for app-level state such as window restoration.
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.
private(set) lazy var quickController = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition
)
var quickController: QuickTerminalController {
switch quickTerminalControllerState {
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
let updateController = UpdateController()
@ -996,10 +1020,31 @@ class AppDelegate: NSObject,
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
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) {
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
@ -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
fileprivate actor AppIconActor: GlobalActor {
static let shared = AppIconActor()

View File

@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController {
private var previousActiveSpace: CGSSpace? = nil
/// Cache for per-screen window state.
private let screenStateCache = QuickTerminalScreenStateCache()
let screenStateCache: QuickTerminalScreenStateCache
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController {
/// Tracks if we're currently handling a manual resize to prevent recursion
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,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
restorationState: QuickTerminalRestorableState? = nil,
) {
self.position = position
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
// that we don't start a terminal process. This gets started when the
// first terminal is shown in `animateIn`.
@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController {
// window close so we can animate out.
window.delegate = self
// The quick window is not restorable (yet!). "Yet" because in theory we can
// make this restorable, but it isn't currently implemented.
// The quick window is restored by `screenStateCache`.
// We disable this for better control
window.isRestorable = false
// Setup our configured appearance that we support.
@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController {
// animate out.
if surfaceTree.isEmpty,
let ghostty_app = ghostty.app {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
surfaceTree = SplitTree(view: view)
focusedSurface = view
if let tree = restorationState?.surfaceTree, !tree.isEmpty {
surfaceTree = tree
let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first!
focusedSurface = view
// Add a short delay to check if the correct surface is focused.
// 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
animateWindowIn(window: window, from: position)
// Clear the restoration state after first use
restorationState = nil
}
func animateOut() {
@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController {
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) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
// 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 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)
}
saveScreenState(exitFullscreen: true)
// If we hid the dock then we unhide it.
hiddenDock = nil

View File

@ -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
}
}

View File

@ -7,6 +7,8 @@ import Cocoa
/// 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.
class QuickTerminalScreenStateCache {
typealias Entries = [UUID: DisplayEntry]
/// 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
/// 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
/// Keyed by display UUID to survive NSScreen garbage collection.
private var stateByDisplay: [UUID: DisplayEntry] = [:]
init() {
private(set) var stateByDisplay: Entries = [:]
init(stateByDisplay: Entries = [:]) {
self.stateByDisplay = stateByDisplay
NotificationCenter.default.addObserver(
self,
selector: #selector(onScreensChanged(_:)),
@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache {
}
}
private struct DisplayEntry {
struct DisplayEntry: Codable {
var frame: NSRect
var screenSize: CGSize
var scale: CGFloat

View File

@ -1,10 +1,47 @@
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.
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 7
class TerminalRestorableState: TerminalRestorable {
class var version: Int { 7 }
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
@ -20,28 +57,12 @@ class TerminalRestorableState: Codable {
self.titleOverride = controller.titleOverride
}
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.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)
required init(copy other: TerminalRestorableState) {
self.surfaceTree = other.surfaceTree
self.focusedSurface = other.focusedSurface
self.effectiveFullscreenMode = other.effectiveFullscreenMode
self.tabColor = other.tabColor
self.titleOverride = other.titleOverride
}
}
@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
}
}
}