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: 2 additions & 0 deletions graphcode/Sources/Features/App/AppFeature+History.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions graphcode/Sources/Features/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 23 additions & 1 deletion graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
106 changes: 106 additions & 0 deletions graphcode/Tests/ArtifactorySinceYouLookedTests.swift
Original file line number Diff line number Diff line change
@@ -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<LoopWorkspaceFeature> {
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)
}
}
Loading