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
2 changes: 1 addition & 1 deletion .claude/rules/connection-window.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ paths:
- **A cancel updates the UI at once and is fenced by the workspace's `attemptToken`**, because the driver may finish late. Closing a window cancels the attempt of every workspace it hosts.
- **`transition(to:for:)` is the only phase writer and ends in `syncPanes(of:)`** for the workspace it names, selected or not, so a background connection repaints. Panes rebuild only when their `WorkspacePaneRenderKey` changes, so a repaint request is cheap.
- **An installed driver is a handle, not a live connection.** Health comes from `ConnectionSession.liveness`, set only through `markSessionUnreachable(_:startedWith:info:)` and `markSessionLive`, and every UI reads `reportedStatus`, never `status` or `hasDriver`. Never nil the driver to signal an outage: metadata reads, queries and the reconnect still go through it.
- **A failed connect keeps its place in "Reopen Last Session"; a canceled one leaves it** (`retainsRestoreIntent`).
- **"Reopen Last Session" brings back every connection on the strip, and only a connected or failed one dials again** (`retainsRestoreIntent`). A canceled or disconnected one comes back Not Connected.
- **Window titles go through `WindowTitleResolver`** and editor tab labels through `EditorTabLabelResolver`. Never write `window.title`; change `tab.title` and call `QueryTabManager.markTabRenamed(_:)`. A title must be right and non-blank from creation, because AppKit draws background tabs that never activate.
- **Only the user emptying a connection may clear its saved tabs**: `closeTabsByUser`, or `takeTabForMove` taking its last tab. A lost session empties the tab manager without the user closing anything, so teardown saves (`saveAggregatedSync`) skip an empty list, and a clear before the connection's saved tabs were read (`hasObservedTabs`) is refused.
- **A restore merges, never replaces.** A tab can reach a coordinator before its `.task` restore (a reopened or linked tab), so `RestoredTabMerge` keeps it.
Expand Down
1 change: 1 addition & 0 deletions .claude/rules/data-sync-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ This path holds user data. A silent wrong answer here looks exactly like an empt
| History drawer state | UserDefaults, device-local | `HistoryPanelPreferencesStorage` |
| Trusted external links (loopback only) | UserDefaults | `ExternalConnectionTrustStore` |
| Table load timings (no identifying data, never uploaded) | JSON lines | `TableLoadHistoryStore` |
| Reopen Last Session list (`LastSession.json`, plus `LastOpenConnections.json` holding only the ids that reconnect, which older builds read) | JSON | `LastOpenConnectionsStorage` |
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,6 @@ internal final class ConnectionWorkspace {
}
}

internal var retainsRestoreIntent: Bool {
ConnectionWindowPhaseMachine.retainsRestoreIntent(phase: phase)
}

/// The panes go down with the rest of it. They retain the SwiftUI tree, which retains the
/// coordinator this tears down, and a coordinator only leaves the app-wide registry on deinit.
internal func teardown() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,22 +168,27 @@ internal final class LiveLaunchEnvironment: LaunchEnvironment {
ConnectionWindowIdentity.isConnectionWindow($0.identifier?.rawValue)
}) else { return }

let connectionIds = LastOpenConnectionsStorage.shared.load()
guard !connectionIds.isEmpty else { return }
let session = LastOpenConnectionsStorage.shared.load()
guard !session.connections.isEmpty else { return }

let knownIds = Set(ConnectionStorage.shared.loadConnections().map(\.id))
var frontWindow: NSWindow?
for connectionId in connectionIds where knownIds.contains(connectionId) {
for connection in session.connections where knownIds.contains(connection.id) {
WindowManager.shared.openTab(
payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault),
payload: EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault),
activate: false,
autoConnect: true
autoConnect: connection.reconnects
)
if frontWindow == nil {
frontWindow = WindowManager.shared.window(for: connectionId)
frontWindow = WindowManager.shared.window(for: connection.id)
}
}

// Each adoption selects the newcomer, which could leave the window on a Not Connected entry.
if let shown = session.shownConnectionId, knownIds.contains(shown) {
WindowManager.shared.show(shown, inWindowHosting: shown)
}

guard let frontWindow else { return }
WindowOpener.shared.closeWelcome()
frontWindow.makeKeyAndOrderFront(nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,10 @@ internal extension MainSplitViewController {
Logger(subsystem: "com.TablePro", category: "ConnectionWindow")
}

/// Every path out of here settles the workspace, because a window that declines to dial and
/// says nothing is a window the resolver can only answer for with a timeout, and a state
/// reached by a timeout is a state nothing transitions into. That used to be the pane
/// resolver's job: it reported nothing for half a second and then fell back to
/// `.notConnected`, which is also what put a blank window on screen for the first half second
/// of every connect.
func startActivationConnectIfNeeded() {
guard autoConnect else { return }
// Every path settles the phase: a workspace that declines to dial silently is one the pane resolver
// could only time out on. `requested` is a caller asking a workspace not opened to dial on its own.
func startActivationConnectIfNeeded(requested: Bool = false) {
guard autoConnect || requested else { return }
guard ConnectionWindowPhaseMachine.allowsActivationConnect(phase: phase) else { return }
/// A workspace whose record has gone still names a connection through its session, so the
/// pane has something to draw and nothing to dial with.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ internal final class MainSplitViewController: NSSplitViewController {
/// `NSSplitViewItem` remembers, rather than depending on a second restore.
workspaces.onSelectionChange = { [weak self] _ in
self?.applySelectedWorkspace()
SessionRecoveryTracker.sync()
}

/// A connection joining or leaving the window changes what every rail in the app lists,
Expand All @@ -374,6 +375,8 @@ internal final class MainSplitViewController: NSSplitViewController {
workspaces.onMembershipChange = { [weak self] in
self?.recentTabSwitcher.cancel()
AppEvents.shared.connectionWindowsChanged.send()
// A Not Connected entry joins or leaves without a phase transition to record it.
SessionRecoveryTracker.sync()
}

splitView.autosaveName = splitAutosaveName
Expand Down Expand Up @@ -811,14 +814,6 @@ internal final class MainSplitViewController: NSSplitViewController {
applyPhase()
}

internal var retainsRestoreIntent: Bool {
workspaces.workspaces.contains { $0.retainsRestoreIntent }
}

internal var connectionIdsRetainingRestoreIntent: [UUID] {
workspaces.workspaces.filter(\.retainsRestoreIntent).map(\.connectionId)
}

// MARK: - Pane Construction

/// Rebuilds one connection's panes into its own hosting controllers, whether or not it is the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate {
if let splitVC = window.contentViewController as? MainSplitViewController {
splitVC.startActivationConnectIfNeeded()
splitVC.syncFrontmostTabManager()
// The recovery list records the connection the front window shows.
SessionRecoveryTracker.sync()
}

guard let coordinator = MainContentCoordinator.coordinator(forWindow: window) else { return }
Expand Down
22 changes: 14 additions & 8 deletions apple/TablePro/Core/Services/Infrastructure/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ internal final class WindowManager {
if let existing = host.workspaces.workspace(for: payload.connectionId) {
host.workspaces.select(payload.connectionId)
existing.open(payload)
// Dials only where a new window would: never one the user canceled or disconnected.
if autoConnect {
host.startActivationConnectIfNeeded(requested: true)
}
} else {
host.adoptWorkspace(payload: payload, autoConnect: autoConnect)
}
Expand Down Expand Up @@ -156,6 +160,14 @@ internal final class WindowManager {
.first
}

// Front to back and visible or not: a hidden app or a minimized window still has a selection to restore.
internal func shownConnectionId() -> UUID? {
let ordered = ([NSApp.keyWindow].compactMap { $0 } + NSApp.orderedWindows)
.lazy
.compactMap { $0.contentViewController as? MainSplitViewController }
return (ordered.first ?? hosts().first)?.workspaces.selectedConnectionId
}

/// The window already hosting this connection wins, so opening a table for it never lands in
/// a different window that merely happened to be in front. The choice itself is
/// `WindowHostSelection`, which is pure and tested.
Expand Down Expand Up @@ -489,6 +501,8 @@ internal final class WindowManager {
self?.release(windowKey: key)
}
}
// The window's first workspace joined before its registry reported membership changes.
SessionRecoveryTracker.sync()
}

private func release(windowKey: ObjectIdentifier) {
Expand Down Expand Up @@ -634,14 +648,6 @@ internal final class WindowManager {
}
}

internal func connectionIdsRetainingRestoreIntent() -> [UUID] {
var seen = Set<UUID>()
return controllers.values
.compactMap { $0.window?.contentViewController as? MainSplitViewController }
.flatMap(\.connectionIdsRetainingRestoreIntent)
.filter { seen.insert($0).inserted }
}

/// Closing a connection removes its workspace. The window itself only closes once it has no
/// connection left to show, because it is no longer the connection's window.
/// A miniaturized window still hosts its connections, so `isVisible` is not the test: closing a
Expand Down
76 changes: 62 additions & 14 deletions apple/TablePro/Core/Storage/LastOpenConnectionsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ final class LastOpenConnectionsStorage {

nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "LastOpenConnections")

private let fileURL: URL
// The legacy file keeps the [UUID] format older builds read, holding only connections that
// reconnect, so an older build never dials one the user canceled.
private let legacyURL: URL
private let sessionURL: URL
private var isSealed = false

private convenience init() {
let appSupport = AppStorageEnvironment.shared.applicationSupportRoot
Expand All @@ -25,35 +29,79 @@ final class LastOpenConnectionsStorage {

init(directory: URL) {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("LastOpenConnections.json")
legacyURL = directory.appendingPathComponent("LastOpenConnections.json")
sessionURL = directory.appendingPathComponent("LastSession.json")
}

func save(connectionIds: [UUID]) {
guard !connectionIds.isEmpty else {
// A missing legacy list means an older build removed it, so this never leaves the session alone:
// a first save writes the list before the session, and every later save writes the session first
// so one cut short leaves the session the newer file. The list is written even when empty.
func save(_ session: LastSession) {
guard !isSealed else { return }
guard !session.connections.isEmpty else {
clear()
return
}
let reconnecting = session.reconnectingIds
let hasLegacy = FileManager.default.fileExists(atPath: legacyURL.path)
if !hasLegacy {
write(reconnecting, to: legacyURL)
}
write(session, to: sessionURL)
if hasLegacy {
write(reconnecting, to: legacyURL)
}
}

// An older build rewrites only the legacy file, so when the two disagree the newer one is current.
func load() -> LastSession {
guard let legacy = read([UUID].self, from: legacyURL) else { return .empty }
let legacySession = LastSession(
connections: legacy.map { LastSession.Connection(id: $0, reconnects: true) },
shownConnectionId: nil
)
guard let session = read(LastSession.self, from: sessionURL) else { return legacySession }
guard session.reconnectingIds != legacy else { return session }
return modificationDate(of: legacyURL) > modificationDate(of: sessionURL) ? legacySession : session
}

func clear() {
remove(legacyURL)
remove(sessionURL)
}

// The quit save is the last word: AppKit closes the windows after it, and no coordinator may
// exist to have flagged termination, so those closes would otherwise save an empty session.
func seal() {
isSealed = true
}

private func write(_ value: some Encodable, to url: URL) {
do {
let data = try JSONEncoder().encode(connectionIds)
try data.write(to: fileURL, options: .atomic)
let data = try JSONEncoder().encode(value)
try data.write(to: url, options: .atomic)
} catch {
Self.logger.error("Failed to save last open connections: \(error.publicLogShape, privacy: .public)")
}
}

func load() -> [UUID] {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
private func read<Value: Decodable>(_ type: Value.Type, from url: URL) -> Value? {
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode([UUID].self, from: data)
return try JSONDecoder().decode(type, from: Data(contentsOf: url))
} catch {
Self.logger.error("Failed to load last open connections: \(error.publicLogShape, privacy: .public)")
return []
return nil
}
}

func clear() {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
try? FileManager.default.removeItem(at: fileURL)
private func modificationDate(of url: URL) -> Date {
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
return attributes?[.modificationDate] as? Date ?? .distantPast
}

private func remove(_ url: URL) {
guard FileManager.default.fileExists(atPath: url.path) else { return }
try? FileManager.default.removeItem(at: url)
}
}
60 changes: 49 additions & 11 deletions apple/TablePro/Core/Storage/RecoveryConnectionList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,60 @@ import Foundation

struct RecoveryCandidate {
let connectionId: UUID
let openedAt: Date
let phase: ConnectionWindowPhase
let isActivated: Bool
let retainsRestoreIntent: Bool
}

struct LastSession: Codable, Equatable {
struct Connection: Codable, Equatable {
let id: UUID
let reconnects: Bool
}

var connections: [Connection]
var shownConnectionId: UUID?

static let empty = LastSession(connections: [], shownConnectionId: nil)

var reconnectingIds: [UUID] {
connections.filter(\.reconnects).map(\.id)
}
}

enum RecoveryConnectionList {
static func connectionIds(from candidates: [RecoveryCandidate]) -> [UUID] {
var seen = Set<UUID>()
return candidates
.filter { $0.isActivated || $0.retainsRestoreIntent }
.map(\.connectionId)
.filter { seen.insert($0).inserted }
// Every strip entry comes back, but a canceled or user-ended one never dials again (#1358).
// Ordered by openedAt, which is how the strip ranks entries nobody dragged.
static func lastSession(
from candidates: [RecoveryCandidate],
endedByUser: (UUID) -> Bool = { _ in false },
shownConnectionId: UUID?
) -> LastSession {
var openedAt: [UUID: Date] = [:]
var reconnects: [UUID: Bool] = [:]
for candidate in candidates where candidate.phase != .closing {
let id = candidate.connectionId
openedAt[id] = min(openedAt[id] ?? candidate.openedAt, candidate.openedAt)
let wantsConnection = candidate.isActivated
|| ConnectionWindowPhaseMachine.retainsRestoreIntent(phase: candidate.phase)
reconnects[id] = (reconnects[id] ?? false) || wantsConnection
}

let ordered = openedAt.keys.sorted { lhs, rhs in
let left = openedAt[lhs] ?? .distantFuture
let right = openedAt[rhs] ?? .distantFuture
guard left == right else { return left < right }
return lhs.uuidString < rhs.uuidString
}
let connections = ordered.map { id in
LastSession.Connection(id: id, reconnects: (reconnects[id] ?? false) && !endedByUser(id))
}
let shown = shownConnectionId.flatMap { openedAt[$0] == nil ? nil : $0 }
return LastSession(connections: connections, shownConnectionId: shown)
}

/// A session a client launched that never showed a connection says nothing about the person's
/// last one, so quitting it leaves the recovery list as it found it.
static func shouldSaveOnQuit(_ connectionIds: [UUID], origin: AppSessionOrigin) -> Bool {
!connectionIds.isEmpty || origin == .user
// A client-launched session that never showed a connection says nothing about the user's last one.
static func shouldSaveOnQuit(_ session: LastSession, origin: AppSessionOrigin) -> Bool {
!session.connections.isEmpty || origin == .user
}
}
Loading
Loading