diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index b33d8e9..4f43c62 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -28,6 +28,7 @@ struct LoopWorkspaceFeature { /// `@State` because the *toggle* lives in the window toolbar, which `AppView` owns — /// and a control and the thing it controls cannot hold the answer separately. var isRailVisible = LoopWorkspaceRail.loadVisible() + var hasCustomRailVisibility = LoopWorkspaceRail.hasStoredVisibility() /// How wide the rail is, after any drag on its edge. Persisted like its visibility: /// someone who widened it to read beats did not mean only this session. var railWidth = LoopWorkspaceRail.loadWidth() @@ -109,6 +110,7 @@ struct LoopWorkspaceFeature { /// and handled up there, the way `.nodeTapped` already is. /// ⌥G, and the toolbar's trailing panel toggle. case railToggled + case mailroomContentChanged(hasContent: Bool) /// The rail's leading edge was dragged. Sent once, on release — a per-frame action /// would put a reducer run and a `UserDefaults` write behind every pixel. case railWidthChanged(CGFloat) @@ -273,9 +275,15 @@ struct LoopWorkspaceFeature { case .railToggled: state.isRailVisible.toggle() + state.hasCustomRailVisibility = true LoopWorkspaceRail.saveVisible(state.isRailVisible) return .none + case .mailroomContentChanged(let hasContent): + guard hasContent, !state.hasCustomRailVisibility else { return .none } + state.isRailVisible = true + return .none + case .railWidthChanged(let width): state.railWidth = LoopWorkspaceRail.clamped(width) LoopWorkspaceRail.saveWidth(state.railWidth) diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 11826f2..2254e97 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -142,8 +142,11 @@ struct LoopWorkspaceRail: View { UserDefaults.standard.set(folded, forKey: summaryFoldedDefaultsKey) } - /// **Off** until someone asks for it. It used to default on, which meant every loop - /// that feeds nothing opened with 212 points of empty panel beside its terminal. + static func hasStoredVisibility() -> Bool { + UserDefaults.standard.object(forKey: visibleDefaultsKey) as? Bool != nil + } + + /// Starts hidden; Mailroom content may reveal it until a person chooses otherwise. static func loadVisible() -> Bool { UserDefaults.standard.object(forKey: visibleDefaultsKey) as? Bool ?? false } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index 33022d2..fe180ea 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -51,6 +51,12 @@ struct LoopWorkspaceView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { expandedBoard } .onReceive(CanvasClock.tick) { now = $0 } + .onChange(of: mailroomHasContent, initial: true) { _, hasContent in + store.send(.mailroomContentChanged(hasContent: hasContent)) + } + .onChange(of: store.id) { _, _ in + store.send(.mailroomContentChanged(hasContent: mailroomHasContent)) + } .onDisappear { store.send(.workspaceLeft) } // The folder header goes in the toolbar, not in the `VStack` above, and the pane // does *not* claim the titlebar inset. Both were tried: `.ignoresSafeArea(.top)` @@ -142,6 +148,10 @@ struct LoopWorkspaceView: View { LoopWorkspaceRail.hasContent(node: store.node, graph: store.graph) } + private var mailroomHasContent: Bool { + MailroomPresentation.hasContent(graph: store.graph, enabled: mailroomEnabled) + } + private var railShortcut: some View { Button("") { store.send(.railToggled) } .keyboardShortcut("g", modifiers: .option) diff --git a/graphcode/Tests/LoopWorkspaceFeatureTests.swift b/graphcode/Tests/LoopWorkspaceFeatureTests.swift index 740c236..47dda5a 100644 --- a/graphcode/Tests/LoopWorkspaceFeatureTests.swift +++ b/graphcode/Tests/LoopWorkspaceFeatureTests.swift @@ -10,7 +10,7 @@ import Testing /// persists via `TerminalLayoutStore`, so these also incidentally cover that the right /// save happens at the right time — a temp-directory-backed store is injected so tests /// never touch the app's real Application Support folder. -@Suite +@Suite(.serialized) struct LoopWorkspaceFeatureTests { /// Records what the reducer asks `TerminalSurfaceClient` to end: the attach going /// away (`retired`) and the zmx sessions being terminated behind it (`killed`). @@ -443,7 +443,10 @@ struct LoopWorkspaceFeatureTests { ) { LoopWorkspaceFeature() } store.exhaustivity = .off - await store.send(.railToggled) { $0.isRailVisible = true } + await store.send(.railToggled) { + $0.isRailVisible = true + $0.hasCustomRailVisibility = true + } #expect(LoopWorkspaceRail.loadVisible()) } @@ -459,3 +462,65 @@ struct LoopWorkspaceFeatureTests { #expect(LoopWorkspaceRail.loadVisible() == false) } } + +extension LoopWorkspaceFeatureTests { + @Test(arguments: [false, true]) + @MainActor + func mailroomContentRevealsTheRailWithoutSavingAUserPreference(existingContent: Bool) async { + var state = makeState() + state.isRailVisible = false + state.hasCustomRailVisibility = false + let store = makeStore(state) + + if !existingContent { + await store.send(.mailroomContentChanged(hasContent: false)) + #expect(!store.state.isRailVisible) + } + await store.send(.mailroomContentChanged(hasContent: true)) { + $0.isRailVisible = true + } + #expect(!store.state.hasCustomRailVisibility) + await store.send(.mailroomContentChanged(hasContent: true)) + await store.send(.mailroomContentChanged(hasContent: false)) + #expect(store.state.isRailVisible) + } + + @Test + @MainActor + func hidingTheRailPreventsMailFromReopeningItAcrossWorkspaces() async { + let key = LoopWorkspaceRail.visibleDefaultsKey + let saved = UserDefaults.standard.object(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + defer { + if let saved { + UserDefaults.standard.set(saved, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + #expect(!LoopWorkspaceRail.hasStoredVisibility()) + let store = makeStore(makeState()) + await store.send(.mailroomContentChanged(hasContent: true)) { + $0.isRailVisible = true + } + #expect(!LoopWorkspaceRail.hasStoredVisibility()) + await store.send(.railToggled) { + $0.isRailVisible = false + $0.hasCustomRailVisibility = true + } + await store.send(.mailroomContentChanged(hasContent: false)) + await store.send(.mailroomContentChanged(hasContent: true)) + #expect(!store.state.isRailVisible) + #expect(LoopWorkspaceRail.hasStoredVisibility()) + #expect(!LoopWorkspaceRail.loadVisible()) + + let reopened = makeStore(makeState()) + #expect(reopened.state.hasCustomRailVisibility) + await reopened.send(.mailroomContentChanged(hasContent: true)) + #expect(!reopened.state.isRailVisible) + await reopened.send(.railToggled) { + $0.isRailVisible = true + } + #expect(LoopWorkspaceRail.loadVisible()) + } +}