Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions App/Sources/Clients/Terminal/TerminalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,8 @@ struct TerminalClient {
case setRemoteAgentDetection(RemoteAgentDetectionConsent)
case setSelectedWorktreeID(Worktree.ID?)
case saveLayoutSnapshot
/// `allowsMissingWorktrees` skips the saved worktrees that `worktrees` does not hold
/// instead of resetting the whole layout; a failed repository load leaves them out.
case restoreLayoutSnapshot(worktrees: [Worktree], allowsMissingWorktrees: Bool = false)
/// Saved worktrees that `worktrees` does not hold are skipped.
case restoreLayoutSnapshot(worktrees: [Worktree])
case presentTabIconPicker(Worktree)
}

Expand Down
3 changes: 2 additions & 1 deletion App/Sources/Features/App/Reducer/AppFeature+Support.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ extension AppFeature {
{
effects.append(defaultViewSelectionEffect(for: targetID, repositories: state.repositories))
}
effects.append(.send(.repositories(.toggleCanvas)))
effects.append(.send(.repositories(.enterCanvasAtLaunch)))
}
return effects.isEmpty ? .none : .concatenate(effects)
}
Expand All @@ -291,6 +291,7 @@ extension AppFeature {
let candidateIDs =
[repositories.lastFocusedWorktreeID].compactMap(\.self)
+ repositories.orderedWorktreeRows().map(\.id)
+ repositories.orderedRepositoryIDs()
return candidateIDs.first { id in
repositories.worktree(for: id) != nil
|| repositories.repositories[id: id]?.kind == .plain
Expand Down
17 changes: 16 additions & 1 deletion App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,10 @@ extension AppFeature {

case .layoutRestoreFailed(let message):
appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)")
let fallbackSelection = layoutRestoreFallbackSelectionEffect(state: state)
return .merge(
.send(.repositories(.showToast(.warning(message)))),
applyDefaultViewMode(into: &state)
.concatenate(fallbackSelection, applyDefaultViewMode(into: &state))
)

default:
Expand Down Expand Up @@ -288,10 +289,24 @@ extension AppFeature {
} else {
effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID))))
}
} else {
effects.append(layoutRestoreFallbackSelectionEffect(state: state))
}
return .concatenate([.merge(effects), applyDefaultViewMode(into: &state)])
}

/// Restore clears the selection at launch, so a layout that names no worktree must select one
/// the same way a launch without restore does. The Shelf and Canvas default views select their
/// own anchor.
func layoutRestoreFallbackSelectionEffect(state: State) -> Effect<Action> {
@Shared(.settingsFile) var settingsFile
guard state.repositories.selection == nil,
settingsFile.global.defaultViewMode == .normal,
let targetID = defaultViewFallbackSelectionID(in: state.repositories)
else { return .none }
return defaultViewSelectionEffect(for: targetID, repositories: state.repositories)
}

func tabCreatedEffect(
worktreeID: Worktree.ID,
state: State
Expand Down
9 changes: 2 additions & 7 deletions App/Sources/Features/App/Reducer/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,6 @@ struct AppFeature {
ids.contains($0.key)
}
let restorableWorktrees = makeTerminalRestorableWorktrees(from: Array(repositories))
let allowsMissingWorktrees = !failedRepositoryIDs.isEmpty
appLogger.info("[LayoutRestore] restorableWorktrees count=\(restorableWorktrees.count)")
var allEffects: [Effect<Action>] = [
// Runs a previous app instance left behind are marked interrupted (dsl-spec §10 Restart).
Expand Down Expand Up @@ -377,9 +376,7 @@ struct AppFeature {
if shouldRestoreLayout {
effects.append(
.run { _ in
await terminalClient.send(
.restoreLayoutSnapshot(
worktrees: restorableWorktrees, allowsMissingWorktrees: allowsMissingWorktrees))
await terminalClient.send(.restoreLayoutSnapshot(worktrees: restorableWorktrees))
}
)
}
Expand All @@ -405,9 +402,7 @@ struct AppFeature {
if shouldRestoreLayout {
effects.append(
.run { _ in
await terminalClient.send(
.restoreLayoutSnapshot(
worktrees: restorableWorktrees, allowsMissingWorktrees: allowsMissingWorktrees))
await terminalClient.send(.restoreLayoutSnapshot(worktrees: restorableWorktrees))
}
)
}
Expand Down
12 changes: 11 additions & 1 deletion App/Sources/Features/Canvas/Models/CanvasFocusRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ struct CanvasFocusRequest: Equatable, Sendable {
enum Target: Equatable, Sendable {
case worktree(Worktree.ID)
case tab(TerminalTabID)
/// The tab that the worktree has selected, as Normal view would show it.
case selectedTabInWorktree(Worktree.ID)
}

/// How the viewport follows the focused card.
Expand Down Expand Up @@ -57,9 +59,17 @@ enum CanvasFocusResolver {
static func resolve(
request: CanvasFocusRequest,
candidates: [CanvasCardRef],
currentPrimaryTabID: TerminalTabID?
currentPrimaryTabID: TerminalTabID?,
selectedTabID: (Worktree.ID) -> TerminalTabID? = { _ in nil }
) -> TerminalTabID? {
switch request.target {
case .selectedTabInWorktree(let worktreeID):
let matchingTabIDs = candidates.filter { $0.worktreeID == worktreeID }.map(\.tabID)
if let selected = selectedTabID(worktreeID), matchingTabIDs.contains(selected) {
return selected
}
return matchingTabIDs.first

case .tab(let tabID):
return candidates.contains { $0.tabID == tabID } ? tabID : nil

Expand Down
5 changes: 4 additions & 1 deletion App/Sources/Features/Canvas/Views/CanvasView+Focus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ extension CanvasView {
let tabID = CanvasFocusResolver.resolve(
request: request,
candidates: canvasCards(in: states),
currentPrimaryTabID: selectionState.primaryTabID
currentPrimaryTabID: selectionState.primaryTabID,
selectedTabID: { worktreeID in
states.first { $0.worktreeID == worktreeID }?.tabManager.selectedTabId
}
)
guard let tabID else { return }
focusSingleCard(tabID, states: states)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,8 @@ extension RepositoriesFeature {
}
return .send(.selectWorktree(targetID, focusTerminal: true))
} else {
// Enter canvas if there are any open worktrees.
guard !state.orderedWorktreeRows().isEmpty else { return .none }
// Canvas has the same requirement as Shelf: a worktree or a folder.
guard state.canEnterShelf else { return .none }
return .send(.selectCanvas)
}

Expand Down Expand Up @@ -776,6 +776,16 @@ extension RepositoriesFeature {
guard let id = state.worktreeID(byOffset: -1) else { return .none }
return .send(.selectWorktree(id, focusTerminal: true))

// A restored terminal can hold first responder while no card is primary, so launching into
// Canvas names the anchor. `.reveal` keeps the board the user last saw instead of zooming in.
case .enterCanvasAtLaunch:
guard state.canEnterShelf else { return .none }
if let anchorID = state.selectedTerminalWorktree?.id {
requestCanvasFocus(
.selectedTabInWorktree(anchorID), viewport: .reveal, openedWorktreeID: anchorID, state: &state)
}
return .send(.selectCanvas)

case .consumeCanvasFocusRequest(let id):
if state.pendingCanvasFocusRequest?.id == id {
state.pendingCanvasFocusRequest = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ struct RepositoriesFeature {
case focusCanvasWorktree(Worktree.ID)
case selectNextWorktree
case selectPreviousWorktree
case enterCanvasAtLaunch
case consumeCanvasFocusRequest(Int)
case requestCanvasCommand(CanvasCommandRequest.Command)
case consumeCanvasCommandRequest(Int)
Expand Down
8 changes: 5 additions & 3 deletions App/Sources/Features/Settings/Models/GlobalSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
copyIgnoredOnWorktreeCreate: false,
copyUntrackedOnWorktreeCreate: false,
pullRequestMergeStrategy: .merge,
restoreTerminalLayoutOnLaunch: false,
restoreTerminalLayoutOnLaunch: true,
archivedAutoDeletePeriod: nil,
terminalFontSize: nil,
keybindingUserOverrides: .empty,
Expand Down Expand Up @@ -127,7 +127,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
copyIgnoredOnWorktreeCreate: Bool = false,
copyUntrackedOnWorktreeCreate: Bool = false,
pullRequestMergeStrategy: PullRequestMergeStrategy = .merge,
restoreTerminalLayoutOnLaunch: Bool = false,
restoreTerminalLayoutOnLaunch: Bool = true,
archivedAutoDeletePeriod: AutoDeletePeriod? = nil,
terminalFontSize: Float32? = nil,
keybindingUserOverrides: KeybindingUserOverrideStore = .empty,
Expand Down Expand Up @@ -281,7 +281,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable {
case copyIgnoredOnWorktreeCreate
case copyUntrackedOnWorktreeCreate
case pullRequestMergeStrategy
case restoreTerminalLayoutOnLaunch
// A new key turns this on once for every file that stored the old `restoreTerminalLayoutOnLaunch`,
// because that key stored `false` for anyone who kept the old default.
case restoreTerminalLayoutOnLaunch = "restoreTerminalLayout"
case archivedAutoDeletePeriod
case terminalFontSize
case keybindingUserOverrides
Expand Down
18 changes: 0 additions & 18 deletions App/Sources/Features/Settings/Views/AdvancedSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,6 @@ struct AdvancedSettingsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}

Section("Terminal Layout") {
VStack(alignment: .leading, spacing: 8) {
Toggle(
"Restore terminal layout on launch (experimental)",
isOn: $store.restoreTerminalLayoutOnLaunch
)
Text("When enabled, Prowl attempts to restore tabs and splits after restart.")
.foregroundStyle(.secondary)
.font(.callout)
Button("Clear saved terminal layout") {
store.send(.clearTerminalLayoutSnapshotButtonTapped)
}
.help("Remove the saved terminal tab and split layout from disk")
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.formStyle(.grouped)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Expand Down
11 changes: 11 additions & 0 deletions App/Sources/Features/Settings/Views/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ struct AppearanceSettingsView: View {
.foregroundStyle(.secondary)
.font(.callout)
}
Section("Terminal Layout") {
Toggle(isOn: $store.restoreTerminalLayoutOnLaunch) {
Text("Restore terminal layout on launch")
Text("Reopens your tabs and splits in their folders. Commands that were running do not start again.")
}
.help("Reopen the saved terminal tabs and splits when Prowl starts")
Button("Clear saved terminal layout") {
store.send(.clearTerminalLayoutSnapshotButtonTapped)
}
.help("Remove the saved terminal tab and split layout from disk")
}
Section("Default Editor") {
Toggle(
"Show in toolbar",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,11 +563,10 @@ final class WorktreeTerminalManager {
case .saveLayoutSnapshot:
terminalLogger.info("[LayoutRestore] received saveLayoutSnapshot command")
Task { await persistLayoutSnapshot() }
case .restoreLayoutSnapshot(let worktrees, let allowsMissingWorktrees):
case .restoreLayoutSnapshot(let worktrees):
terminalLogger.info(
"[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)"
+ " allowsMissing=\(allowsMissingWorktrees)")
Task { await restoreLayoutSnapshot(from: worktrees, allowsMissingWorktrees: allowsMissingWorktrees) }
"[LayoutRestore] received restoreLayoutSnapshot command, worktrees=\(worktrees.count)")
Task { await restoreLayoutSnapshot(from: worktrees) }
case .presentTabIconPicker(let worktree):
state(for: worktree).presentIconPickerForFocusedTab()
default:
Expand Down Expand Up @@ -1581,7 +1580,7 @@ final class WorktreeTerminalManager {
terminalLogger.info("[LayoutRestore] persistSync: save result=\(saved)")
}

func restoreLayoutSnapshot(from worktrees: [Worktree], allowsMissingWorktrees: Bool = false) async {
func restoreLayoutSnapshot(from worktrees: [Worktree]) async {
terminalLogger.info("[LayoutRestore] restore: loading snapshot from disk")
guard let payload = await layoutPersistence.loadSnapshot() else {
terminalLogger.info("[LayoutRestore] restore: no snapshot found on disk, skipping")
Expand All @@ -1604,8 +1603,7 @@ final class WorktreeTerminalManager {
}
let skippedWorktreeIDs = applyLayoutSnapshotPayload(
payload,
availableWorktrees: worktrees,
allowsMissingWorktrees: allowsMissingWorktrees
availableWorktrees: worktrees
)
terminalLogger.info("[LayoutRestore] restore: applyResult=\(skippedWorktreeIDs != nil)")
if let skippedWorktreeIDs {
Expand Down Expand Up @@ -1665,8 +1663,7 @@ final class WorktreeTerminalManager {
/// snapshot is invalid and nothing was restored.
private func applyLayoutSnapshotPayload(
_ payload: TerminalLayoutSnapshotPayload,
availableWorktrees: [Worktree],
allowsMissingWorktrees: Bool
availableWorktrees: [Worktree]
) -> Set<Worktree.ID>? {
let worktreeByID = Dictionary(
availableWorktrees.map { ($0.id, $0) },
Expand All @@ -1689,18 +1686,14 @@ final class WorktreeTerminalManager {
}
continue
}
// A saved worktree can be gone at launch: it was removed outside Prowl, or its repository
// failed to load. The rest of the layout is still valid.
guard let worktree = worktreeByID[snapshot.worktreeID] else {
terminalLogger.warning(
"[LayoutRestore] apply: worktreeID \(snapshot.worktreeID) not found in available worktrees"
)
if allowsMissingWorktrees {
skippedWorktreeIDs.insert(snapshot.worktreeID)
continue
}
for state in restoredStates {
state.closeAllSurfaces()
}
return nil
skippedWorktreeIDs.insert(snapshot.worktreeID)
continue
}
terminalLogger.info("[LayoutRestore] apply: restoring worktree \(worktree.id)")
let state = state(for: worktree)
Expand Down
38 changes: 23 additions & 15 deletions App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -959,24 +959,32 @@ final class GhosttySurfaceView: NSView, Identifiable {
static func moveFocus(
to view: GhosttySurfaceView,
from previous: GhosttySurfaceView? = nil,
delay: TimeInterval? = nil
clock: any Clock<Duration> = ContinuousClock(),
requests: FocusRequests = .shared
) {
let maxDelay: TimeInterval = 0.5
let currentDelay = delay ?? 0
guard currentDelay < maxDelay else { return }
let nextDelay: TimeInterval = if let delay { delay * 2 } else { 0.05 }
requests.latest &+= 1
let request = requests.latest
Task { @MainActor in
if let delay {
try? await ContinuousClock().sleep(for: .seconds(delay))
}
guard let window = view.window else {
moveFocus(to: view, from: previous, delay: nextDelay)
return
}
if let previous, previous !== view {
_ = previous.resignFirstResponder()
var delay: TimeInterval = 0.05
while request == requests.latest {
if let window = view.window {
if let previous, previous !== view {
_ = previous.resignFirstResponder()
}
window.makeFirstResponder(view)
return
}
guard delay < 0.5 else { return }
try? await clock.sleep(for: .seconds(delay))
delay *= 2
}
window.makeFirstResponder(view)
}
}

/// A request retries until its view has a window, so the newest request must cancel older ones:
/// a retry that lands later would move first responder away from the surface that shows focus.
final class FocusRequests {
static let shared = FocusRequests()
fileprivate var latest = 0
}
}
Loading
Loading