Skip to content

Commit 2e11d52

Browse files
authored
fix: restore sidebar and quit persistence (#1464)
1 parent ee084fb commit 2e11d52

7 files changed

Lines changed: 129 additions & 5 deletions

Pine/Agent/AgentTaskMetadataStore.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,17 +426,24 @@ actor AgentTaskMetadataStore: AgentTaskPersisting {
426426
private static let directoryName = ".pine-agent-tasks-private"
427427

428428
private let storageRoot: URL?
429+
/// Injectable so tests can exercise the production Application Support
430+
/// layout without touching the developer's real home directory. macOS may
431+
/// attach its own ACL to this trusted anchor; only Pine's child directory
432+
/// is required to be ACL-free and mode 0700.
433+
private let applicationSupportDirectory: URL?
429434
private let limits: AgentTaskPersistenceLimits
430435
private let configuration: AgentTaskStoreConfiguration
431436
private let fileManager = FileManager()
432437
private var cleanupCursorByDirectory: [AgentTaskCleanupDirectory: Int] = [:]
433438

434439
init(
435440
storageRoot: URL? = nil,
441+
applicationSupportDirectory: URL? = nil,
436442
limits: AgentTaskPersistenceLimits = AgentTaskPersistenceLimits(),
437443
configuration: AgentTaskStoreConfiguration = AgentTaskStoreConfiguration()
438444
) {
439445
self.storageRoot = storageRoot
446+
self.applicationSupportDirectory = applicationSupportDirectory
440447
self.limits = limits
441448
self.configuration = configuration
442449
}
@@ -740,10 +747,11 @@ actor AgentTaskMetadataStore: AgentTaskPersisting {
740747
if let storageRoot {
741748
return storageRoot.standardizedFileURL
742749
}
743-
guard let base = fileManager.urls(
750+
let base = applicationSupportDirectory ?? fileManager.urls(
744751
for: .applicationSupportDirectory,
745752
in: .userDomainMask
746-
).first else {
753+
).first
754+
guard let base else {
747755
return URL(fileURLWithPath: "/dev/null/pine-agent-tasks")
748756
}
749757
return base.resolvingSymlinksInPath()
@@ -757,7 +765,7 @@ actor AgentTaskMetadataStore: AgentTaskPersisting {
757765
let components = url.standardizedFileURL.pathComponents.dropFirst()
758766
let names = Array(components)
759767
let privateCount = configuration.privateComponentCount
760-
?? (storageRoot == nil ? 2 : 1)
768+
?? 1
761769
guard !names.isEmpty, (1...names.count).contains(privateCount) else {
762770
throw AgentTaskDirectoryError.unsafe
763771
}

Pine/Agent/AgentTaskRegistry.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import Observation
10+
import os
1011

1112
nonisolated private struct AgentTaskTerminalKey: Hashable, Sendable {
1213
let project: AgentTaskProjectIdentity
@@ -1364,6 +1365,10 @@ final class AgentTaskRegistry {
13641365
_ project: AgentTaskProjectIdentity,
13651366
rejection: AgentTaskMetadataRejection
13661367
) {
1368+
let diagnostic = String(describing: rejection)
1369+
Logger.task.error(
1370+
"Agent task metadata load rejected: \(diagnostic, privacy: .public)"
1371+
)
13671372
loadStatusByProject[project.persistenceKey] = .rejected(rejection)
13681373
loadedProjects.remove(project)
13691374
diskRevisionByProject[project] = nil
@@ -1883,8 +1888,11 @@ final class AgentTaskRegistry {
18831888
)
18841889
case .publishedButDurabilityUnknown(_, let revision):
18851890
diskRevisionByProject[project] = .versioned(revision)
1886-
case .rejected:
1887-
break
1891+
case .rejected(let rejection):
1892+
let diagnostic = String(describing: rejection)
1893+
Logger.task.error(
1894+
"Agent task metadata save rejected: \(diagnostic, privacy: .public)"
1895+
)
18881896
}
18891897
let ownsTail = persistenceTailTicket == ticket
18901898
if ownsTail {

Pine/ContentView+Helpers.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,25 @@ extension ContentView {
567567

568568
extension ContentView {
569569

570+
/// A key scene is authoritative evidence that its retained project is
571+
/// visible. Reconcile a restoration race, then self-heal an empty settled
572+
/// tree whose initial load may have been cancelled just before activation.
573+
func reconcileKeyProjectPresentation() {
574+
let wasSuspended = workspace.isSuspended
575+
guard controlActiveState == .key,
576+
registry.reconcileKeyProjectPresentation(projectManager) else {
577+
return
578+
}
579+
if !wasSuspended,
580+
workspace.rootURL != nil,
581+
workspace.rootNodesRevision == 0,
582+
workspace.rootNodes.isEmpty,
583+
!workspace.isLoading,
584+
!workspace.isSuspended {
585+
workspace.refreshFileTreeAsync()
586+
}
587+
}
588+
570589
var totalLineCount: Int {
571590
guard let content = activeTab?.content else { return 1 }
572591
let ns = content as NSString

Pine/ContentView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ struct ContentView: View {
133133
)
134134
}
135135
.task {
136+
reconcileKeyProjectPresentation()
136137
let disposition = restoreSessionIfNeeded()
137138
if case .restored(let result) = disposition, result.didRestoreEditorTabs {
138139
refreshLineDiffs()
@@ -160,6 +161,10 @@ struct ContentView: View {
160161
)
161162
#endif
162163
}
164+
.onChange(of: controlActiveState) { _, newState in
165+
guard newState == .key else { return }
166+
reconcileKeyProjectPresentation()
167+
}
163168
.sheet(isPresented: $showRecoveryDialog) {
164169
RecoveryDialogView(
165170
entries: recoveryEntries,

Pine/ProjectRegistry.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3094,6 +3094,30 @@ final class ProjectRegistry: LSPSettingsObserver {
30943094
return openProjects[canonical] != nil && !backgroundProjects.contains(canonical)
30953095
}
30963096

3097+
/// Repairs a retained manager when AppKit makes its SwiftUI project scene
3098+
/// key without replaying the normal Open Recent admission path. This can
3099+
/// happen when window restoration reuses a WindowGroup scene after an
3100+
/// earlier close callback suspended editor-only services.
3101+
@discardableResult
3102+
func reconcileKeyProjectPresentation(
3103+
_ projectManager: ProjectManager
3104+
) -> Bool {
3105+
guard let rootURL = projectManager.rootURL else { return false }
3106+
let canonical = canonicalProjectURL(rootURL)
3107+
guard openProjects[canonical] === projectManager,
3108+
let identity = agentTaskProjectsByRoot[canonical] else {
3109+
return false
3110+
}
3111+
guard backgroundProjects.contains(canonical) else {
3112+
return projectManager.presentationLifecycle == .visible
3113+
}
3114+
return markProjectWindowOpen(
3115+
canonical,
3116+
identity: identity,
3117+
manager: projectManager
3118+
)
3119+
}
3120+
30973121
/// Checks if a project is already open (including background).
30983122
func isProjectOpen(_ url: URL) -> Bool {
30993123
openProjects[canonicalProjectURL(url)] != nil

PineTests/AgentTaskStoreRaceTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,36 @@ struct AgentTaskStoreRaceTests {
5757
#expect(permissions.intValue & 0o777 == 0o700)
5858
}
5959

60+
@Test("default Application Support anchor may carry system permissions")
61+
func defaultApplicationSupportAnchorCreatesPrivateLeaf() async throws {
62+
let fixture = try StoreRaceFixture()
63+
defer { fixture.cleanup() }
64+
let applicationSupport = fixture.root.appendingPathComponent(
65+
"Application Support"
66+
)
67+
try FileManager.default.createDirectory(
68+
at: applicationSupport,
69+
withIntermediateDirectories: false,
70+
attributes: [.posixPermissions: 0o755]
71+
)
72+
let store = AgentTaskMetadataStore(
73+
applicationSupportDirectory: applicationSupport
74+
)
75+
76+
#expect(await store.save(tasks: [], project: fixture.identity)
77+
== .saved(taskCount: 0))
78+
let privateStorage = applicationSupport.appendingPathComponent(
79+
".pine-agent-tasks-private"
80+
)
81+
let attributes = try FileManager.default.attributesOfItem(
82+
atPath: privateStorage.path
83+
)
84+
let permissions = try #require(
85+
attributes[.posixPermissions] as? NSNumber
86+
)
87+
#expect(permissions.intValue & 0o777 == 0o700)
88+
}
89+
6090
@Test("owned intermediate symlink is rejected without traversal")
6191
func ownedIntermediateSymlinkIsRejected() async throws {
6292
let fixture = try StoreRaceFixture()

PineTests/BackgroundProjectLifecycleTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,36 @@ struct BackgroundProjectLifecycleTests {
115115
#expect(manager.editorServiceResumeCountForTesting == 1)
116116
}
117117

118+
@Test("key restored scene repairs a suspended empty file tree")
119+
func keySceneRepairsSuspendedTree() async throws {
120+
let directory = try makeTempDirectory()
121+
defer { cleanup(directory) }
122+
try "visible".write(
123+
to: directory.appendingPathComponent("visible.txt"),
124+
atomically: true,
125+
encoding: .utf8
126+
)
127+
let registry = makeRegistry()
128+
let manager = try #require(registry.projectManager(for: directory))
129+
130+
registry.closeProjectWindow(directory)
131+
#expect(manager.presentationLifecycle == .backgroundSuspended)
132+
#expect(manager.workspace.isSuspended)
133+
134+
#expect(registry.reconcileKeyProjectPresentation(manager))
135+
await waitUntil {
136+
manager.workspace.rootNodes.contains { $0.name == "visible.txt" }
137+
}
138+
139+
#expect(registry.isWindowOpen(directory))
140+
#expect(manager.presentationLifecycle == .visible)
141+
#expect(!manager.workspace.isSuspended)
142+
#expect(manager.workspace.hasActiveFileWatcherForTesting)
143+
#expect(manager.workspace.rootNodes.contains {
144+
$0.name == "visible.txt"
145+
})
146+
}
147+
118148
@Test("workspace watcher events are fenced while suspended and rearmed")
119149
func workspaceWatcherEventsFollowLifecycle() throws {
120150
let directory = try makeTempDirectory()

0 commit comments

Comments
 (0)