Skip to content
Open
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
15 changes: 15 additions & 0 deletions Sources/Conan/Views/MainSectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,24 @@ struct MainSectionView: View {
.textFieldStyle(.roundedBorder)
.font(.caption)
.onSubmit(start)
if let setup = store.lastSetup {
Button {
store.resumeAll()
} label: {
Label(resumeTitle(setup), systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.font(.caption)
}
}
}

private func resumeTitle(_ setup: SessionSetup) -> String {
let sides = setup.sides.count
let suffix = sides == 0 ? "" : " + \(sides) side\(sides == 1 ? "" : "s")"
return "Resume \(setup.mainProject)\(suffix)"
}

private var trimmed: String {
projectInput.trimmingCharacters(in: .whitespacesAndNewlines)
}
Expand Down
39 changes: 38 additions & 1 deletion Sources/ConanCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,53 @@ public struct SideProject: Identifiable, Codable, Equatable, Sendable {
}
}

/// The setup that was running when "Stop all" was last pressed (or when an
/// interrupted session was recovered), so it can be restarted with fresh
/// timestamps via "Resume all".
public struct SessionSetup: Codable, Equatable, Sendable {
public struct Side: Codable, Equatable, Sendable {
public var name: String
public var percent: Double
public var tags: [String]

public init(name: String, percent: Double, tags: [String] = []) {
self.name = name
self.percent = percent
self.tags = tags
}
}

public var mainProject: String
public var mainTags: [String]
public var sides: [Side]

public init(mainProject: String, mainTags: [String], sides: [Side]) {
self.mainProject = mainProject
self.mainTags = mainTags
self.sides = sides
}

public init(main: MainSession, sides: [SideProject]) {
self.mainProject = main.project
self.mainTags = main.tags
self.sides = sides.map { Side(name: $0.name, percent: $0.percent, tags: $0.tags) }
}
}

/// Everything persisted to `state.json` for crash recovery. Dates are encoded as
/// epoch seconds (the coder sets `.secondsSince1970`).
public struct PersistedState: Codable, Equatable, Sendable {
public var main: MainSession?
public var sideProjects: [SideProject]
public var lastSeen: Date
// Optional so state.json written by older versions still decodes (missing
// key → nil via synthesized Codable).
public var lastSetup: SessionSetup?

public init(main: MainSession?, sideProjects: [SideProject], lastSeen: Date) {
public init(main: MainSession?, sideProjects: [SideProject], lastSeen: Date, lastSetup: SessionSetup? = nil) {
self.main = main
self.sideProjects = sideProjects
self.lastSeen = lastSeen
self.lastSetup = lastSetup
}
}
29 changes: 25 additions & 4 deletions Sources/ConanCore/SessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Combine
public final class SessionStore: ObservableObject {
@Published public private(set) var main: MainSession?
@Published public private(set) var sideProjects: [SideProject] = []
@Published public private(set) var lastSetup: SessionSetup?
@Published public private(set) var todayReport: WatsonReport?
@Published public private(set) var projects: [String] = []
@Published public private(set) var recentCombos: [ProjectTags] = []
Expand Down Expand Up @@ -53,19 +54,31 @@ public final class SessionStore: ObservableObject {
persist()
}

/// Stop the main project and every side project at once.
/// Stop the main project and every side project at once. Remembers the
/// stopped setup so "Resume all" can restart it.
public func stopAll() {
guard main != nil else { return }
guard let main else { return }
lastSetup = SessionSetup(main: main, sides: sideProjects)
let commands = Accrual.flushCommands(main: main, sides: sideProjects, at: clock())
// Drop watson's baton *before* niling main so a concurrent reconcile can't
// re-adopt the frame we're closing, and `watson stop` can't re-write it.
dropRunningFrame()
main = nil
self.main = nil
sideProjects = []
persist()
run(commands)
}

/// Restart the exact setup that was running at the last "Stop all" (or
/// interrupted by a quit/crash), with fresh timestamps.
public func resumeAll() {
guard main == nil, let setup = lastSetup else { return }
startMain(project: setup.mainProject, tags: setup.mainTags)
for side in setup.sides {
addSide(project: side.name, percent: side.percent, tags: side.tags)
}
}

// MARK: - Side projects

public func addSide(project: String, percent: Double, tags: [String] = []) {
Expand Down Expand Up @@ -140,6 +153,7 @@ public final class SessionStore: ObservableObject {
/// projects — writing the main frame here would double-count it — pinned to the
/// stop time watson recorded when we can find it.
private func flushSidesForExternalStop(_ main: MainSession, defaultStop: Date) {
lastSetup = SessionSetup(main: main, sides: sideProjects) // resumable like any other stop
let stop = externalStop(for: main) ?? defaultStop
let sides = sideProjects
self.main = nil
Expand Down Expand Up @@ -229,7 +243,7 @@ public final class SessionStore: ObservableObject {
}

private func persist() {
let state = PersistedState(main: main, sideProjects: sideProjects, lastSeen: clock())
let state = PersistedState(main: main, sideProjects: sideProjects, lastSeen: clock(), lastSetup: lastSetup)
do {
try FileManager.default.createDirectory(
at: stateURL.deletingLastPathComponent(),
Expand Down Expand Up @@ -268,6 +282,13 @@ public final class SessionStore: ObservableObject {
let lastSeen = state?.lastSeen ?? clock()
let external = currentRunningFrame()

lastSetup = state?.lastSetup
// An interrupted setup supersedes any older snapshot, so "Resume all"
// restores what was actually running when the session ended.
if let recovered = recoveredMain {
lastSetup = SessionSetup(main: recovered, sides: recoveredSides)
}

switch (recoveredMain, external) {
case (nil, nil):
return
Expand Down
198 changes: 198 additions & 0 deletions Tests/ConanTests/SessionStoreResumeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import XCTest
@testable import ConanCore

/// Minimal `WatsonClient` that only simulates watson's `state` file (the
/// running frame), so reconcile paths can be driven from tests.
private final class StateOnlyMockWatson: WatsonClient, @unchecked Sendable {
private let lock = NSLock()
private var state: WatsonRunningFrame?

/// Simulate a terminal-side change (`watson start`/`watson stop`).
func setExternal(_ frame: WatsonRunningFrame?) {
lock.lock(); state = frame; lock.unlock()
}

func version() throws -> String { "mock" }
func projects() throws -> [String] { [] }
func add(_ command: WatsonAddCommand) throws {}
func recentLog() throws -> [WatsonLogFrame] { [] }
func reportDay() throws -> WatsonReport {
WatsonReport(projects: [], time: 0, timespan: .init(from: "", to: ""))
}

func runningFrame() throws -> WatsonRunningFrame? {
lock.lock(); defer { lock.unlock() }; return state
}

func setRunningFrame(_ frame: WatsonRunningFrame) throws {
lock.lock(); state = frame; lock.unlock()
}

func clearRunningFrame() throws {
lock.lock(); state = nil; lock.unlock()
}

func stopTime(project: String, startEpoch: Int) throws -> Date? { nil }
}

@MainActor
final class SessionStoreResumeTests: XCTestCase {
private var tempDir: URL!
private var stateURL: URL!
private var now = Date(timeIntervalSince1970: 1_700_000_000)

override func setUpWithError() throws {
tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("conan-store-tests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
stateURL = tempDir.appendingPathComponent("state.json")
now = Date(timeIntervalSince1970: 1_700_000_000)
}

override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDir)
}

private func makeStore() -> SessionStore {
SessionStore(watson: nil, stateURL: stateURL, clock: { self.now })
}

private func startTypicalSetup(_ store: SessionStore) {
store.startMain(project: "acme", tags: ["dev"])
store.addSide(project: "admin", percent: 0.1, tags: ["ops"])
store.addSide(project: "mail", percent: 0.05)
}

private var typicalSetup: SessionSetup {
SessionSetup(
mainProject: "acme",
mainTags: ["dev"],
sides: [
SessionSetup.Side(name: "admin", percent: 0.1, tags: ["ops"]),
SessionSetup.Side(name: "mail", percent: 0.05, tags: []),
]
)
}

func testStopAllCapturesLastSetup() {
let store = makeStore()
startTypicalSetup(store)
now = now.addingTimeInterval(600)

store.stopAll()

XCTAssertNil(store.main)
XCTAssertTrue(store.sideProjects.isEmpty)
XCTAssertEqual(store.lastSetup, typicalSetup)
}

func testResumeAllRestartsSetupWithFreshTimestamps() {
let store = makeStore()
startTypicalSetup(store)
now = now.addingTimeInterval(600)
store.stopAll()
let resumeTime = now.addingTimeInterval(1_800)
now = resumeTime

store.resumeAll()

XCTAssertEqual(store.main?.project, "acme")
XCTAssertEqual(store.main?.tags, ["dev"])
XCTAssertEqual(store.main?.start, resumeTime)
XCTAssertEqual(store.sideProjects.map(\.name), ["admin", "mail"])
XCTAssertEqual(store.sideProjects.map(\.percent), [0.1, 0.05])
XCTAssertEqual(store.sideProjects.map(\.tags), [["ops"], []])
XCTAssertEqual(store.sideProjects.map(\.intervalStart), [resumeTime, resumeTime])
}

func testResumeAllNoOpWhenAlreadyRunning() {
let store = makeStore()
startTypicalSetup(store)
store.stopAll()
store.startMain(project: "other")

store.resumeAll()

XCTAssertEqual(store.main?.project, "other")
XCTAssertTrue(store.sideProjects.isEmpty)
}

func testResumeAllNoOpWithoutSnapshot() {
let store = makeStore()

store.resumeAll()

XCTAssertNil(store.main)
XCTAssertNil(store.lastSetup)
}

func testLastSetupSurvivesRelaunch() {
let store = makeStore()
startTypicalSetup(store)
now = now.addingTimeInterval(600)
store.stopAll()

let relaunched = makeStore()

XCTAssertEqual(relaunched.lastSetup, typicalSetup)
}

func testOldStateFileWithoutLastSetupKeyDecodes() throws {
let legacy = #"{"main": null, "sideProjects": [], "lastSeen": 1700000000}"#
try legacy.data(using: .utf8)!.write(to: stateURL)

let store = makeStore()

XCTAssertNil(store.main)
XCTAssertNil(store.lastSetup)
}

func testCrashRecoverySnapshotsInterruptedSetup() {
let store = makeStore()
startTypicalSetup(store)
now = now.addingTimeInterval(600)
// No stopAll: simulate quit/crash while tracking. state.json still holds
// the open session from the last persist.

let relaunched = makeStore()

XCTAssertNil(relaunched.main)
XCTAssertTrue(relaunched.sideProjects.isEmpty)
XCTAssertEqual(relaunched.lastSetup, typicalSetup)
}

func testTerminalStopSnapshotsSetupForResume() {
let watson = StateOnlyMockWatson()
let store = SessionStore(watson: watson, stateURL: stateURL, clock: { self.now })
store.startMain(project: "acme", tags: ["dev"])
store.addSide(project: "admin", percent: 0.1, tags: ["ops"])
now = now.addingTimeInterval(600)

watson.setExternal(nil) // terminal `watson stop`
store.reconcile()

XCTAssertNil(store.main)
XCTAssertEqual(
store.lastSetup,
SessionSetup(
mainProject: "acme",
mainTags: ["dev"],
sides: [SessionSetup.Side(name: "admin", percent: 0.1, tags: ["ops"])]
)
)
}

func testSetPercentBeforeStopAllSnapshotsCurrentPercent() {
let store = makeStore()
store.startMain(project: "acme")
store.addSide(project: "admin", percent: 0.1)
let sideID = store.sideProjects[0].id
now = now.addingTimeInterval(300)
store.setPercent(sideID, percent: 0.25)
now = now.addingTimeInterval(300)

store.stopAll()

XCTAssertEqual(store.lastSetup?.sides.map(\.percent), [0.25])
}
}