diff --git a/graphcode/Sources/Features/App/AppFeature+History.swift b/graphcode/Sources/Features/App/AppFeature+History.swift index 6136ae84..d65bc578 100644 --- a/graphcode/Sources/Features/App/AppFeature+History.swift +++ b/graphcode/Sources/Features/App/AppFeature+History.swift @@ -93,6 +93,8 @@ extension AppFeature { layout: layout, projectPath: projectPath, projectName: project.graph.project.name) + state.openLoop?.seenArtifactoryPostID = + LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) state.selectedProjectPath = projectPath } diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 4e7e7bb2..50844224 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -729,6 +729,8 @@ extension AppFeature { layout: layout, projectPath: path, projectName: state.projects[id: path]?.graph.project.name ?? path) + state.openLoop?.seenArtifactoryPostID = + LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: path) state.selectedProjectPath = path recordVisit(.loop(projectPath: path, nodeID: nodeID), &state) return .none diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index ff392628..a29683f6 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -27,11 +27,12 @@ enum ArtifactoryPresentation { graph.artifactory.filter { $0.kind == .record } } - /// How many notes this loop's cursor has not covered. Records are excluded: they are - /// folded away by default, and a badge counting mail nobody is being shown is a badge - /// that cannot be cleared. - static func unreadNoteCount(graph: LoopGraph, node: LoopNode) -> Int { - Artifactory.unread(in: notes(in: graph), since: node.lastArtifactoryRead).count + /// How many notes have landed since the human last looked — `seenPostID` is + /// `LoopWorkspaceFeature.seenArtifactoryPostID`, not the loop's sync cursor. Records + /// are excluded: they are folded away by default, and a badge counting mail nobody is + /// being shown is a badge that cannot be cleared. + static func unreadNoteCount(graph: LoopGraph, seenPostID: Int?) -> Int { + Artifactory.unread(in: notes(in: graph), since: seenPostID).count } } @@ -43,8 +44,11 @@ enum ArtifactoryPresentation { /// the whole reason it is here rather than behind a menu: a coordination channel a /// supervisor never sees is the failure mode, not a missing convenience. struct ArtifactorySection: View { - let node: LoopNode let graph: LoopGraph + /// What `SINCE YOU LOOKED` means here: the newest post that was on screen when this + /// person last left a workspace in the project. The same words two sections up mean + /// the same thing — a fact about a person at a screen, never about the loop. + let seenPostID: Int? let isFolded: Bool let onToggleFold: () -> Void /// Posts as "a human" — a click in the app has no `ZMX_SESSION` and no loop identity, @@ -70,11 +74,11 @@ struct ArtifactorySection: View { private var notes: [ArtifactoryPost] { ArtifactoryPresentation.notes(in: graph) } private var records: [ArtifactoryPost] { ArtifactoryPresentation.records(in: graph) } private var unread: Int { - ArtifactoryPresentation.unreadNoteCount(graph: graph, node: node) + ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: seenPostID) } - /// The id the unread rule is drawn above — the first note this loop's cursor has not - /// covered. `nil` when everything is read, which is when nothing should be drawn. + /// The id the unread rule is drawn above — the first note that landed after the human + /// last looked. `nil` when everything is read, which is when nothing should be drawn. private var firstUnreadID: Int? { guard unread > 0 else { return nil } return notes.suffix(unread).first?.id diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index a5493f3f..090cd32f 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -58,6 +58,13 @@ struct LoopWorkspaceFeature { /// Per window rather than on the node: "since *you* looked" is a fact about a person /// at a screen, and the daemon writing it would answer for every window at once. var seenBeatID: String? + /// The newest board post that was on screen when a workspace in this project was + /// last left — the board's counterpart to `seenBeatID`, and a fact about the + /// person at the screen for the same reason. It is *not* the loop's own + /// `lastArtifactoryRead`: that cursor moves when the loop runs `artifactory sync`, + /// which nobody can do from the app, and the rail is what a human reads. Loaded + /// from defaults by whoever builds this state (`AppFeature`), per project. + var seenArtifactoryPostID: Int? var layout: TerminalLayout // The project folder every surface without its own worktree binding should open // in — a loop's shells shouldn't land in the app's own launch directory (usually @@ -290,6 +297,15 @@ struct LoopWorkspaceFeature { case .workspaceLeft: state.seenBeatID = state.node.summary?.current?.id + // Only what was actually on screen counts as looked at: a hidden rail or a + // folded section showed no posts, and marking them seen would clear a badge + // the human never had a chance to read. + if state.isRailVisible, !state.isArtifactoryFolded, + let newest = ArtifactoryPresentation.notes(in: state.graph).last?.id + { + state.seenArtifactoryPostID = newest + LoopWorkspaceRail.saveSeenArtifactoryPost(newest, forProjectPath: state.projectPath) + } return .none case .stopLoopTapped, .showInGraphTapped, .railTargetTapped, .primaryExitAcknowledged: diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index a4dec55c..669f914d 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -32,6 +32,8 @@ struct LoopWorkspaceRail: View { /// Whether the board section is collapsed to its one line, beside the summary's and /// the diagram's own folds. let isArtifactoryFolded: Bool + /// See `LoopWorkspaceFeature.seenArtifactoryPostID`. + let seenArtifactoryPostID: Int? let onSummaryFoldToggled: () -> Void let onSummaryAnswerTapped: () -> Void let onBoardFoldToggled: () -> Void @@ -72,6 +74,26 @@ struct LoopWorkspaceRail: View { UserDefaults.standard.double(forKey: widthDefaultsKey) > 0 } + /// The newest board post that was on screen when a human last left a workspace in + /// this project — what the ARTIFACTORY section's `SINCE YOU LOOKED` rule and its + /// `N NEW` badge are drawn against. Keyed by project because the board is the + /// project's: opening a different loop in the same project is not "not having + /// looked". Persisted, unlike the summary's `seenBeatID`, because a board pointer + /// that reset on relaunch would mark every post new again each morning. + static func seenArtifactoryPostDefaultsKey(forProjectPath path: String) -> String { + "artifactorySeenPostID." + path + } + + static func loadSeenArtifactoryPost(forProjectPath path: String) -> Int? { + let stored = UserDefaults.standard.integer( + forKey: seenArtifactoryPostDefaultsKey(forProjectPath: path)) + return stored > 0 ? stored : nil + } + + static func saveSeenArtifactoryPost(_ id: Int, forProjectPath path: String) { + UserDefaults.standard.set(id, forKey: seenArtifactoryPostDefaultsKey(forProjectPath: path)) + } + static let artifactoryFoldedDefaultsKey = "loopArtifactorySectionFolded" static func loadArtifactoryFolded() -> Bool { @@ -221,7 +243,7 @@ struct LoopWorkspaceRail: View { // thing you read messages in is arranged. if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { ArtifactorySection( - node: node, graph: graph, isFolded: isArtifactoryFolded, + graph: graph, seenPostID: seenArtifactoryPostID, isFolded: isArtifactoryFolded, onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) } footer diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index e97c825e..53e60d38 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -29,6 +29,7 @@ struct LoopWorkspaceView: View { isBoardFolded: store.isBoardFolded, artifactoryEnabled: artifactoryEnabled, isArtifactoryFolded: store.isArtifactoryFolded, + seenArtifactoryPostID: store.seenArtifactoryPostID, onSummaryFoldToggled: { store.send(.summaryFoldToggled) }, onSummaryAnswerTapped: { store.send(.summaryAnswerTapped) }, onBoardFoldToggled: { store.send(.boardFoldToggled) }, diff --git a/graphcode/Tests/ArtifactorySinceYouLookedTests.swift b/graphcode/Tests/ArtifactorySinceYouLookedTests.swift new file mode 100644 index 00000000..428e544a --- /dev/null +++ b/graphcode/Tests/ArtifactorySinceYouLookedTests.swift @@ -0,0 +1,106 @@ +import ArtifactoryKit +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +/// `SINCE YOU LOOKED` in the rail's ARTIFACTORY section is about the person at the +/// screen, exactly as it is two sections up — never the loop's own sync cursor. +@Suite +struct ArtifactorySinceYouLookedTests { + private let projectPath = "/tmp/since-you-looked-\(UUID().uuidString)" + + private func makeStore( + graph: LoopGraph, railVisible: Bool, folded: Bool = false + ) -> TestStoreOf { + let node = graph.nodes.first! + var state = LoopWorkspaceFeature.State( + node: node, graph: graph, layout: .defaultLayout(forNode: node.id), + projectPath: projectPath, projectName: "p") + state.isRailVisible = railVisible + state.isArtifactoryFolded = folded + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-tests-\(UUID().uuidString)", isDirectory: true) + let store = TestStore(initialState: state) { + LoopWorkspaceFeature() + } withDependencies: { + $0.terminalLayoutStore = TerminalLayoutStore(baseDirectory: directory) + } + store.exhaustivity = .off + return store + } + + private func makeGraph(noteIDs: [Int], loopCursor: Int?) -> LoopGraph { + var graph = LoopGraph(project: ProjectRef(path: projectPath, name: "p")) + graph.nodes.append(LoopNode(title: "Worker", lastArtifactoryRead: loopCursor)) + for id in noteIDs { + graph.artifactory.append( + ArtifactoryPost( + id: id, at: Date(), authorID: nil, author: "a human", topic: nil, body: "n\(id)")) + } + return graph + } + + /// The loop may have synced everything; the human has looked at nothing. + @Test + func unreadIsTheHumansNotTheLoops() { + let graph = makeGraph(noteIDs: [1, 2, 3], loopCursor: 3) + #expect(ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: nil) == 3) + #expect(ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: 2) == 1) + #expect(ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: 3) == 0) + } + + @Test + @MainActor + func leavingWithTheBoardOnScreenMarksItLooked() async { + let store = makeStore(graph: makeGraph(noteIDs: [1, 2, 3], loopCursor: nil), railVisible: true) + + await store.send(.workspaceLeft) + + #expect(store.state.seenArtifactoryPostID == 3) + #expect(LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) == 3) + } + + /// A hidden rail showed no posts; leaving must not clear a badge nobody could read. + @Test + @MainActor + func leavingWithTheRailHiddenDoesNotMarkItLooked() async { + let store = makeStore( + graph: makeGraph(noteIDs: [1, 2, 3], loopCursor: nil), railVisible: false) + + await store.send(.workspaceLeft) + + #expect(store.state.seenArtifactoryPostID == nil) + #expect(LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) == nil) + } + + @Test + @MainActor + func leavingWithTheSectionFoldedDoesNotMarkItLooked() async { + let store = makeStore( + graph: makeGraph(noteIDs: [1, 2], loopCursor: nil), railVisible: true, folded: true) + + await store.send(.workspaceLeft) + + #expect(store.state.seenArtifactoryPostID == nil) + } + + /// A record (mirrored `node send`) is not a note: it is folded away, so it must not + /// be what "looked" advances to, or a badge could count something never drawn. + @Test + @MainActor + func lookedAdvancesToTheNewestNoteNotTheNewestRecord() async { + var graph = makeGraph(noteIDs: [1, 2], loopCursor: nil) + graph.artifactory.append( + ArtifactoryPost( + id: 3, at: Date(), authorID: nil, author: "a human", topic: "direct", + body: "@Worker: hi", kind: .record)) + let store = makeStore(graph: graph, railVisible: true) + + await store.send(.workspaceLeft) + + #expect(store.state.seenArtifactoryPostID == 2) + } +}