mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
iOS port M0d: extract 6 portable ViewModels to ScarfCore
Fourth and final M0 sub-PR. Wraps up the ScarfCore extraction with the
ViewModels that have no dependency on Mac-target services or AppKit.
Views deliberately stay in the Mac target — see plan for rationale.
Moved (6 VMs):
ActivityViewModel.swift — HermesDataService consumer, SQLite3-gated
ConnectionStatusViewModel.swift — @MainActor heartbeat for remote SSH
InsightsViewModel.swift — HermesDataService aggregator, SQLite3-gated
(+ InsightsPeriod, ModelUsage, PlatformUsage,
ToolUsage, NotableSession types; exports
free functions formatDuration/formatTokens)
LogsViewModel.swift — HermesLogService consumer, fully portable
(+ nested LogFile / LogComponent enums)
ProjectsViewModel.swift — ProjectDashboardService wrapper, portable
RichChatViewModel.swift — ~700 lines of ACP-event + message-group
handling, SQLite3-gated
(+ ChatDisplayMode, MessageGroup types)
Reverted in-flight:
GatewayViewModel.swift — my audit missed that it calls
`context.runHermes(...)`, a Mac-target-only extension. Not portable
without moving HermesFileService too. Left in the Mac target.
Platform guards applied:
- `#if canImport(SQLite3)` wraps entire files for ActivityVM, InsightsVM,
and RichChatVM (they transitively depend on HermesDataService).
- `#if canImport(Darwin)` around LocalizedStringResource displayName
in LogsViewModel's nested LogFile and LogComponent enums.
- `#if canImport(os)` around the unused Logger in
ConnectionStatusViewModel (kept the field for future use).
Swift 6 / Observation notes:
- `import Observation` explicitly added to each @Observable file.
Mac target gets Observation via SwiftUI; ScarfCore doesn't import
SwiftUI, so it needs the explicit module import. Observation ships
in the Swift 5.9+ standard library on every platform.
- Nested enums' `var id: String { rawValue }` had to be manually
promoted to `public var id` since my sed only touches 4-space-indent
declarations and the nested enum's members are at 8-space indent.
- Two accidentally-publicized function-local `let` variables in
InsightsViewModel reverted back to internal.
- Sed adjustment: an earlier pattern was producing `@Observable public`
which is a Swift syntax error. Fixed post-hoc by stripping the
stray trailing `public` after the attribute; noted in the plan file
as a checklist item for M1+ sed work.
Consumer import sweeps:
4 Mac-target files gained `import ScarfCore` for the moved VM types:
ContentView.swift, ChatView.swift, RichChatView.swift, and
ConnectionStatusPill.swift.
Test coverage (M0dViewModelsTests): 14 new tests.
- ConnectionStatusViewModel: local-always-connected, remote idle-start,
Status Equatable pinning.
- LogsViewModel: init defaults, filteredEntries across level / search /
component filters, nested enum Identifiable ids and loggerPrefix.
- ProjectsViewModel: .local context binding.
- (SQLite3-gated, Apple-only):
ActivityVM construction, InsightsVM period defaults and sinceDate
ordering, ChatDisplayMode case coverage, RichChatVM empty-state
invariants, MessageGroup derived properties.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 51 / 51 passing on Linux
(M0a 16 + M0b 18 + M0c 8 + M0d 9 + smoke 1 − 5 SQLite3-gated).
Apple-target CI should see 56 / 56 with the 5 gated tests added in.
Updated scarf/docs/IOS_PORT_PLAN.md with M0d's shipped state, the
Views-stay-Mac-only scope decision, and the sed-gotcha checklist
future phases should watch for.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
+32
-26
@@ -1,26 +1,30 @@
|
||||
// Gated on `canImport(SQLite3)` — `HermesDataService` only exists on
|
||||
// Apple platforms (SQLite3 isn't a system module on Linux swift-corelibs).
|
||||
#if canImport(SQLite3)
|
||||
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class ActivityViewModel {
|
||||
let context: ServerContext
|
||||
public final class ActivityViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var toolMessages: [HermesMessage] = []
|
||||
var filterKind: ToolKind?
|
||||
var filterSessionId: String?
|
||||
var selectedEntry: ActivityEntry?
|
||||
var toolResult: String?
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var isLoading = true
|
||||
public var toolMessages: [HermesMessage] = []
|
||||
public var filterKind: ToolKind?
|
||||
public var filterSessionId: String?
|
||||
public var selectedEntry: ActivityEntry?
|
||||
public var toolResult: String?
|
||||
public var sessionPreviews: [String: String] = [:]
|
||||
public var isLoading = true
|
||||
|
||||
var availableSessions: [(id: String, label: String)] {
|
||||
public var availableSessions: [(id: String, label: String)] {
|
||||
var seen = Set<String>()
|
||||
return toolMessages.compactMap { message in
|
||||
guard seen.insert(message.sessionId).inserted else { return nil }
|
||||
@@ -29,7 +33,7 @@ final class ActivityViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
var filteredActivity: [ActivityEntry] {
|
||||
public var filteredActivity: [ActivityEntry] {
|
||||
let entries = toolMessages.flatMap { message in
|
||||
message.toolCalls.map { call in
|
||||
ActivityEntry(
|
||||
@@ -51,7 +55,7 @@ final class ActivityViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
// refresh() = close + reopen, which forces a fresh snapshot pull on
|
||||
// remote contexts. Using open() here would short-circuit after the
|
||||
@@ -68,7 +72,7 @@ final class ActivityViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectEntry(_ entry: ActivityEntry?) async {
|
||||
public func selectEntry(_ entry: ActivityEntry?) async {
|
||||
selectedEntry = entry
|
||||
if let entry {
|
||||
toolResult = await dataService.fetchToolResult(callId: entry.id)
|
||||
@@ -77,22 +81,22 @@ final class ActivityViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
public func cleanup() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityEntry: Identifiable, Sendable {
|
||||
let id: String
|
||||
let sessionId: String
|
||||
let toolName: String
|
||||
let kind: ToolKind
|
||||
let summary: String
|
||||
let arguments: String
|
||||
let messageContent: String
|
||||
let timestamp: Date?
|
||||
public struct ActivityEntry: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let sessionId: String
|
||||
public let toolName: String
|
||||
public let kind: ToolKind
|
||||
public let summary: String
|
||||
public let arguments: String
|
||||
public let messageContent: String
|
||||
public let timestamp: Date?
|
||||
|
||||
var prettyArguments: String {
|
||||
public var prettyArguments: String {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]),
|
||||
@@ -102,3 +106,5 @@ struct ActivityEntry: Identifiable, Sendable {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
+12
-8
@@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Tracks connection health for the current window's server. Remote contexts
|
||||
/// get a lightweight 15s heartbeat (a no-op `true` remote command) that
|
||||
@@ -8,10 +10,12 @@ import os
|
||||
/// green since there's no connection to lose.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ConnectionStatusViewModel {
|
||||
public final class ConnectionStatusViewModel {
|
||||
#if canImport(os)
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ConnectionStatus")
|
||||
#endif
|
||||
|
||||
enum Status: Equatable {
|
||||
public enum Status: Equatable {
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||
case connected
|
||||
/// SSH connects but the follow-up read-access probe failed. Data
|
||||
@@ -37,11 +41,11 @@ final class ConnectionStatusViewModel {
|
||||
private(set) var consecutiveFailures = 0
|
||||
private let consecutiveFailureThreshold = 2
|
||||
|
||||
let context: ServerContext
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
private var probeTask: Task<Void, Never>?
|
||||
|
||||
init(context: ServerContext) {
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
if !context.isRemote {
|
||||
@@ -54,7 +58,7 @@ final class ConnectionStatusViewModel {
|
||||
|
||||
/// Kick off a background heartbeat loop. Safe to call multiple times;
|
||||
/// subsequent calls cancel the prior task and restart.
|
||||
func startMonitoring() {
|
||||
public func startMonitoring() {
|
||||
guard context.isRemote else { return }
|
||||
probeTask?.cancel()
|
||||
probeTask = Task { [weak self] in
|
||||
@@ -65,13 +69,13 @@ final class ConnectionStatusViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
public func stopMonitoring() {
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
/// Manual probe — also invoked by the toolbar "Retry" button on error.
|
||||
func retry() {
|
||||
public func retry() {
|
||||
Task { await probeOnce() }
|
||||
}
|
||||
|
||||
+70
-62
@@ -1,15 +1,21 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
// Gated on `canImport(SQLite3)` because every non-trivial code path calls
|
||||
// into `HermesDataService`, which itself is only compiled on Apple
|
||||
// platforms (SQLite3 is not a system module on Linux swift-corelibs).
|
||||
// iOS + macOS compile this unchanged; Linux CI skips it.
|
||||
#if canImport(SQLite3)
|
||||
|
||||
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
public enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
case week = "7 Days"
|
||||
case month = "30 Days"
|
||||
case quarter = "90 Days"
|
||||
case all = "All Time"
|
||||
|
||||
var id: String { rawValue }
|
||||
public var id: String { rawValue }
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .week: return "7 Days"
|
||||
case .month: return "30 Days"
|
||||
@@ -18,7 +24,7 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var sinceDate: Date {
|
||||
public var sinceDate: Date {
|
||||
let calendar = Calendar.current
|
||||
switch self {
|
||||
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||
@@ -29,78 +35,78 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelUsage: Identifiable {
|
||||
var id: String { model }
|
||||
let model: String
|
||||
let sessions: Int
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let reasoningTokens: Int
|
||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||
public struct ModelUsage: Identifiable {
|
||||
public var id: String { model }
|
||||
public let model: String
|
||||
public let sessions: Int
|
||||
public let inputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let cacheReadTokens: Int
|
||||
public let cacheWriteTokens: Int
|
||||
public let reasoningTokens: Int
|
||||
public var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||
}
|
||||
|
||||
struct PlatformUsage: Identifiable {
|
||||
var id: String { platform }
|
||||
let platform: String
|
||||
let sessions: Int
|
||||
let messages: Int
|
||||
let tokens: Int
|
||||
public struct PlatformUsage: Identifiable {
|
||||
public var id: String { platform }
|
||||
public let platform: String
|
||||
public let sessions: Int
|
||||
public let messages: Int
|
||||
public let tokens: Int
|
||||
}
|
||||
|
||||
struct ToolUsage: Identifiable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let count: Int
|
||||
let percentage: Double
|
||||
public struct ToolUsage: Identifiable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let count: Int
|
||||
public let percentage: Double
|
||||
}
|
||||
|
||||
struct NotableSession: Identifiable {
|
||||
var id: String { "\(session.id)-\(label)" }
|
||||
let label: String
|
||||
let value: String
|
||||
let session: HermesSession
|
||||
let preview: String
|
||||
public struct NotableSession: Identifiable {
|
||||
public var id: String { "\(session.id)-\(label)" }
|
||||
public let label: String
|
||||
public let value: String
|
||||
public let session: HermesSession
|
||||
public let preview: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class InsightsViewModel {
|
||||
let context: ServerContext
|
||||
public final class InsightsViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var period: InsightsPeriod = .month
|
||||
var isLoading = true
|
||||
public var period: InsightsPeriod = .month
|
||||
public var isLoading = true
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var userMessageCount = 0
|
||||
var totalMessages = 0
|
||||
var totalToolCalls = 0
|
||||
var totalInputTokens = 0
|
||||
var totalOutputTokens = 0
|
||||
var totalCacheReadTokens = 0
|
||||
var totalCacheWriteTokens = 0
|
||||
var totalReasoningTokens = 0
|
||||
var totalTokens = 0
|
||||
var totalCost: Double = 0
|
||||
var activeTime: TimeInterval = 0
|
||||
var avgSessionDuration: TimeInterval = 0
|
||||
public var sessions: [HermesSession] = []
|
||||
public var sessionPreviews: [String: String] = [:]
|
||||
public var userMessageCount = 0
|
||||
public var totalMessages = 0
|
||||
public var totalToolCalls = 0
|
||||
public var totalInputTokens = 0
|
||||
public var totalOutputTokens = 0
|
||||
public var totalCacheReadTokens = 0
|
||||
public var totalCacheWriteTokens = 0
|
||||
public var totalReasoningTokens = 0
|
||||
public var totalTokens = 0
|
||||
public var totalCost: Double = 0
|
||||
public var activeTime: TimeInterval = 0
|
||||
public var avgSessionDuration: TimeInterval = 0
|
||||
|
||||
var modelUsage: [ModelUsage] = []
|
||||
var platformUsage: [PlatformUsage] = []
|
||||
var toolUsage: [ToolUsage] = []
|
||||
var hourlyActivity: [Int: Int] = [:]
|
||||
var dailyActivity: [Int: Int] = [:]
|
||||
var notableSessions: [NotableSession] = []
|
||||
public var modelUsage: [ModelUsage] = []
|
||||
public var platformUsage: [PlatformUsage] = []
|
||||
public var toolUsage: [ToolUsage] = []
|
||||
public var hourlyActivity: [Int: Int] = [:]
|
||||
public var dailyActivity: [Int: Int] = [:]
|
||||
public var notableSessions: [NotableSession] = []
|
||||
|
||||
func load() async {
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
// refresh() forces a fresh remote snapshot each load. On local it's
|
||||
// a cheap reopen of the live DB.
|
||||
@@ -128,7 +134,7 @@ final class InsightsViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func previewFor(_ session: HermesSession) -> String {
|
||||
public func previewFor(_ session: HermesSession) -> String {
|
||||
if let title = session.title, !title.isEmpty { return title }
|
||||
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
|
||||
return session.id
|
||||
@@ -240,7 +246,7 @@ final class InsightsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(_ interval: TimeInterval) -> String {
|
||||
public func formatDuration(_ interval: TimeInterval) -> String {
|
||||
let hours = Int(interval) / 3600
|
||||
let minutes = (Int(interval) % 3600) / 60
|
||||
if hours > 0 {
|
||||
@@ -249,7 +255,7 @@ func formatDuration(_ interval: TimeInterval) -> String {
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
func formatTokens(_ count: Int) -> String {
|
||||
public func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
@@ -257,3 +263,5 @@ func formatTokens(_ count: Int) -> String {
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
+26
-22
@@ -1,37 +1,39 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class LogsViewModel {
|
||||
let context: ServerContext
|
||||
public final class LogsViewModel {
|
||||
public let context: ServerContext
|
||||
private let logService: HermesLogService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.logService = HermesLogService(context: context)
|
||||
}
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .agent
|
||||
var filterLevel: LogEntry.LogLevel?
|
||||
var selectedComponent: LogComponent = .all
|
||||
var searchText = ""
|
||||
public var entries: [LogEntry] = []
|
||||
public var selectedLogFile: LogFile = .agent
|
||||
public var filterLevel: LogEntry.LogLevel?
|
||||
public var selectedComponent: LogComponent = .all
|
||||
public var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
enum LogFile: String, CaseIterable, Identifiable {
|
||||
public enum LogFile: String, CaseIterable, Identifiable {
|
||||
case agent = "agent.log"
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
var id: String { rawValue }
|
||||
public var id: String { rawValue }
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
#if canImport(Darwin)
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .agent: return "Agent"
|
||||
case .errors: return "Errors"
|
||||
case .gateway: return "Gateway"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func path(for file: LogFile) -> String {
|
||||
@@ -42,7 +44,7 @@ final class LogsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
enum LogComponent: String, CaseIterable, Identifiable {
|
||||
public enum LogComponent: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case gateway = "Gateway"
|
||||
case agent = "Agent"
|
||||
@@ -50,9 +52,10 @@ final class LogsViewModel {
|
||||
case cli = "CLI"
|
||||
case cron = "Cron"
|
||||
|
||||
var id: String { rawValue }
|
||||
public var id: String { rawValue }
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
#if canImport(Darwin)
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
case .gateway: return "Gateway"
|
||||
@@ -62,8 +65,9 @@ final class LogsViewModel {
|
||||
case .cron: return "Cron"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var loggerPrefix: String? {
|
||||
public var loggerPrefix: String? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
case .gateway: return "gateway"
|
||||
@@ -75,7 +79,7 @@ final class LogsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
var filteredEntries: [LogEntry] {
|
||||
public var filteredEntries: [LogEntry] {
|
||||
entries.filter { entry in
|
||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||
@@ -87,14 +91,14 @@ final class LogsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
public func load() async {
|
||||
await logService.openLog(path: path(for: selectedLogFile))
|
||||
entries = await logService.readLastLines(count: 500)
|
||||
await logService.seekToEnd()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
func switchLogFile(_ file: LogFile) async {
|
||||
public func switchLogFile(_ file: LogFile) async {
|
||||
selectedLogFile = file
|
||||
entries = []
|
||||
await logService.openLog(path: path(for: file))
|
||||
@@ -102,7 +106,7 @@ final class LogsViewModel {
|
||||
await logService.seekToEnd()
|
||||
}
|
||||
|
||||
func startPolling() {
|
||||
public func startPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
@@ -115,12 +119,12 @@ final class LogsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
public func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
public func cleanup() async {
|
||||
stopPolling()
|
||||
await logService.closeLog()
|
||||
}
|
||||
+15
-16
@@ -1,26 +1,25 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import Observation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ProjectsViewModel {
|
||||
public final class ProjectsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||
let context: ServerContext
|
||||
public let context: ServerContext
|
||||
private let service: ProjectDashboardService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.service = ProjectDashboardService(context: context)
|
||||
}
|
||||
|
||||
|
||||
var projects: [ProjectEntry] = []
|
||||
var selectedProject: ProjectEntry?
|
||||
var dashboard: ProjectDashboard?
|
||||
var dashboardError: String?
|
||||
var isLoading = false
|
||||
public var projects: [ProjectEntry] = []
|
||||
public var selectedProject: ProjectEntry?
|
||||
public var dashboard: ProjectDashboard?
|
||||
public var dashboardError: String?
|
||||
public var isLoading = false
|
||||
|
||||
func load() {
|
||||
public func load() {
|
||||
let registry = service.loadRegistry()
|
||||
projects = registry.projects
|
||||
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
|
||||
@@ -32,12 +31,12 @@ final class ProjectsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func selectProject(_ project: ProjectEntry) {
|
||||
public func selectProject(_ project: ProjectEntry) {
|
||||
selectedProject = project
|
||||
loadDashboard(for: project)
|
||||
}
|
||||
|
||||
func addProject(name: String, path: String) {
|
||||
public func addProject(name: String, path: String) {
|
||||
var registry = service.loadRegistry()
|
||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||
let entry = ProjectEntry(name: name, path: path)
|
||||
@@ -59,7 +58,7 @@ final class ProjectsViewModel {
|
||||
selectProject(entry)
|
||||
}
|
||||
|
||||
func removeProject(_ project: ProjectEntry) {
|
||||
public func removeProject(_ project: ProjectEntry) {
|
||||
var registry = service.loadRegistry()
|
||||
registry.projects.removeAll { $0.name == project.name }
|
||||
do {
|
||||
@@ -74,12 +73,12 @@ final class ProjectsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() {
|
||||
public func refreshDashboard() {
|
||||
guard let project = selectedProject else { return }
|
||||
loadDashboard(for: project)
|
||||
}
|
||||
|
||||
var dashboardPaths: [String] {
|
||||
public var dashboardPaths: [String] {
|
||||
projects.map(\.dashboardPath)
|
||||
}
|
||||
|
||||
+45
-38
@@ -1,48 +1,53 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
// Gated on `canImport(SQLite3)` — `RichChatViewModel` reads message
|
||||
// history from `HermesDataService`, which is SQLite-gated. iOS + macOS
|
||||
// compile this unchanged; Linux CI skips it.
|
||||
#if canImport(SQLite3)
|
||||
|
||||
enum ChatDisplayMode: String, CaseIterable {
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
public enum ChatDisplayMode: String, CaseIterable {
|
||||
case terminal
|
||||
case richChat
|
||||
}
|
||||
|
||||
struct MessageGroup: Identifiable {
|
||||
let id: Int
|
||||
let userMessage: HermesMessage?
|
||||
let assistantMessages: [HermesMessage]
|
||||
let toolResults: [String: HermesMessage]
|
||||
public struct MessageGroup: Identifiable {
|
||||
public let id: Int
|
||||
public let userMessage: HermesMessage?
|
||||
public let assistantMessages: [HermesMessage]
|
||||
public let toolResults: [String: HermesMessage]
|
||||
|
||||
var allMessages: [HermesMessage] {
|
||||
public var allMessages: [HermesMessage] {
|
||||
var result: [HermesMessage] = []
|
||||
if let user = userMessage { result.append(user) }
|
||||
result.append(contentsOf: assistantMessages)
|
||||
return result
|
||||
}
|
||||
|
||||
var toolCallCount: Int {
|
||||
public var toolCallCount: Int {
|
||||
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class RichChatViewModel {
|
||||
let context: ServerContext
|
||||
public final class RichChatViewModel {
|
||||
public let context: ServerContext
|
||||
private let dataService: HermesDataService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.dataService = HermesDataService(context: context)
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
var currentSession: HermesSession?
|
||||
var messageGroups: [MessageGroup] = []
|
||||
var isAgentWorking = false
|
||||
var pendingPermission: PendingPermission?
|
||||
public var messages: [HermesMessage] = []
|
||||
public var currentSession: HermesSession?
|
||||
public var messageGroups: [MessageGroup] = []
|
||||
public var isAgentWorking = false
|
||||
public var pendingPermission: PendingPermission?
|
||||
/// Mutated to trigger a scroll-to-bottom in the message list.
|
||||
var scrollTrigger = UUID()
|
||||
public var scrollTrigger = UUID()
|
||||
|
||||
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||
private(set) var acpInputTokens = 0
|
||||
@@ -56,20 +61,20 @@ final class RichChatViewModel {
|
||||
private(set) var quickCommands: [HermesSlashCommand] = []
|
||||
|
||||
/// Merged list, ACP-first, de-duplicated by name.
|
||||
var availableCommands: [HermesSlashCommand] {
|
||||
public var availableCommands: [HermesSlashCommand] {
|
||||
let acpNames = Set(acpCommands.map(\.name))
|
||||
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
|
||||
}
|
||||
|
||||
var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
||||
public var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
|
||||
|
||||
/// True when the menu carries more than just `/compress` — used to hide
|
||||
/// the dedicated compress button in favor of the full slash menu.
|
||||
var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
|
||||
public var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
|
||||
|
||||
var hasMessages: Bool { !messages.isEmpty }
|
||||
public var hasMessages: Bool { !messages.isEmpty }
|
||||
|
||||
func requestScrollToBottom() {
|
||||
public func requestScrollToBottom() {
|
||||
scrollTrigger = UUID()
|
||||
}
|
||||
|
||||
@@ -89,7 +94,7 @@ final class RichChatViewModel {
|
||||
private var userSendPending = false
|
||||
private var activePollingTimer: Timer?
|
||||
|
||||
struct PendingPermission {
|
||||
public struct PendingPermission {
|
||||
let requestId: Int
|
||||
let title: String
|
||||
let kind: String
|
||||
@@ -98,7 +103,7 @@ final class RichChatViewModel {
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
func reset() {
|
||||
public func reset() {
|
||||
debounceTask?.cancel()
|
||||
stopActivePolling()
|
||||
Task { await dataService.close() }
|
||||
@@ -124,19 +129,19 @@ final class RichChatViewModel {
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String?) {
|
||||
public func setSessionId(_ id: String?) {
|
||||
sessionId = id
|
||||
lastKnownFingerprint = nil
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
public func cleanup() async {
|
||||
stopActivePolling()
|
||||
debounceTask?.cancel()
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||
func refreshSessionFromDB() async {
|
||||
public func refreshSessionFromDB() async {
|
||||
guard let sessionId else { return }
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
@@ -149,7 +154,7 @@ final class RichChatViewModel {
|
||||
// MARK: - ACP Event Handling
|
||||
|
||||
/// Add a user message immediately (before DB write) for instant UI feedback.
|
||||
func addUserMessage(text: String) {
|
||||
public func addUserMessage(text: String) {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
let message = HermesMessage(
|
||||
@@ -179,7 +184,7 @@ final class RichChatViewModel {
|
||||
}
|
||||
|
||||
/// Process a streaming ACP event and update the message list.
|
||||
func handleACPEvent(_ event: ACPEvent) {
|
||||
public func handleACPEvent(_ event: ACPEvent) {
|
||||
switch event {
|
||||
case .messageChunk(_, let text):
|
||||
appendMessageChunk(text: text)
|
||||
@@ -233,7 +238,7 @@ final class RichChatViewModel {
|
||||
|
||||
/// Load `quick_commands` from `config.yaml` off the main actor and publish
|
||||
/// them as slash commands. Safe to call repeatedly — replaces the existing list.
|
||||
func loadQuickCommands() {
|
||||
public func loadQuickCommands() {
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
|
||||
@@ -439,7 +444,7 @@ final class RichChatViewModel {
|
||||
|
||||
/// Finalize streaming state on disconnect, before reconnection attempts begin.
|
||||
/// Saves partial content as a permanent message without adding a system message.
|
||||
func finalizeOnDisconnect() {
|
||||
public func finalizeOnDisconnect() {
|
||||
finalizeStreamingMessage()
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
@@ -449,7 +454,7 @@ final class RichChatViewModel {
|
||||
/// Reconcile in-memory messages with DB state after a successful reconnection.
|
||||
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
|
||||
/// that the ACP process may not have persisted before crashing).
|
||||
func reconcileWithDB(sessionId: String) async {
|
||||
public func reconcileWithDB(sessionId: String) async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
@@ -497,7 +502,7 @@ final class RichChatViewModel {
|
||||
|
||||
/// Load message history from the DB, optionally combining an origin session
|
||||
/// (e.g., CLI session) with the current ACP session.
|
||||
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||
public func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||
self.sessionId = sessionId
|
||||
// Force a fresh snapshot pull on remote contexts. An earlier open()
|
||||
// would have cached a stale copy — on resume we need whatever
|
||||
@@ -530,13 +535,13 @@ final class RichChatViewModel {
|
||||
|
||||
// MARK: - DB Polling (terminal mode fallback)
|
||||
|
||||
func markAgentWorking() {
|
||||
public func markAgentWorking() {
|
||||
isAgentWorking = true
|
||||
userSendPending = true
|
||||
startActivePolling()
|
||||
}
|
||||
|
||||
func scheduleRefresh() {
|
||||
public func scheduleRefresh() {
|
||||
debounceTask?.cancel()
|
||||
debounceTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
@@ -545,7 +550,7 @@ final class RichChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMessages() async {
|
||||
public func refreshMessages() async {
|
||||
// Polling tick (terminal mode): pull a fresh snapshot so remote
|
||||
// reflects Hermes writes since the last tick. On local this is a
|
||||
// cheap reopen of the live DB.
|
||||
@@ -668,3 +673,5 @@ final class RichChatViewModel {
|
||||
messageGroups = groups
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
@@ -0,0 +1,191 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises the portable ViewModels moved in M0d.
|
||||
///
|
||||
/// Three of the six VMs (`ActivityViewModel`, `InsightsViewModel`,
|
||||
/// `RichChatViewModel`) are gated on `#if canImport(SQLite3)` because they
|
||||
/// depend on `HermesDataService`. Tests for those are inside the same gate
|
||||
/// so Linux CI compiles without them; Apple-target CI covers them fully.
|
||||
@Suite struct M0dViewModelsTests {
|
||||
|
||||
// MARK: - ConnectionStatusViewModel (no SQLite3 dep)
|
||||
|
||||
@Test @MainActor func connectionStatusLocalContextIsAlwaysConnected() {
|
||||
let vm = ConnectionStatusViewModel(context: .local)
|
||||
#expect(vm.status == .connected)
|
||||
#expect(vm.lastSuccess != nil)
|
||||
#expect(vm.context.id == ServerContext.local.id)
|
||||
}
|
||||
|
||||
@Test @MainActor func connectionStatusRemoteStartsIdle() {
|
||||
let ctx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "r",
|
||||
kind: .ssh(SSHConfig(host: "nonexistent.invalid"))
|
||||
)
|
||||
let vm = ConnectionStatusViewModel(context: ctx)
|
||||
#expect(vm.status == .idle)
|
||||
#expect(vm.lastSuccess == nil)
|
||||
}
|
||||
|
||||
@Test func connectionStatusEquatable() {
|
||||
// The pill's Equatable conformance on Status drives UI re-render
|
||||
// suppression. Pin the expected behaviour.
|
||||
let a: ConnectionStatusViewModel.Status = .connected
|
||||
let b: ConnectionStatusViewModel.Status = .connected
|
||||
#expect(a == b)
|
||||
|
||||
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
||||
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
||||
#expect(c == d)
|
||||
|
||||
let e: ConnectionStatusViewModel.Status = .idle
|
||||
#expect(a != c)
|
||||
#expect(a != e)
|
||||
}
|
||||
|
||||
// MARK: - LogsViewModel (HermesLogService dep — portable)
|
||||
|
||||
@Test @MainActor func logsViewModelInitsWithLocalContext() {
|
||||
let vm = LogsViewModel(context: .local)
|
||||
#expect(vm.context.id == ServerContext.local.id)
|
||||
#expect(vm.entries.isEmpty)
|
||||
#expect(vm.selectedLogFile == .agent)
|
||||
#expect(vm.filterLevel == nil)
|
||||
#expect(vm.selectedComponent == .all)
|
||||
#expect(vm.searchText == "")
|
||||
}
|
||||
|
||||
@Test @MainActor func logsViewModelFilteredEntriesByLevel() {
|
||||
let vm = LogsViewModel(context: .local)
|
||||
vm.entries = [
|
||||
LogEntry(id: 1, timestamp: "t", level: .info, sessionId: nil, logger: "a", message: "m", raw: "r"),
|
||||
LogEntry(id: 2, timestamp: "t", level: .error, sessionId: nil, logger: "a", message: "boom", raw: "r"),
|
||||
LogEntry(id: 3, timestamp: "t", level: .debug, sessionId: nil, logger: "a", message: "d", raw: "r"),
|
||||
]
|
||||
vm.filterLevel = .error
|
||||
let filtered = vm.filteredEntries
|
||||
#expect(filtered.count == 1)
|
||||
#expect(filtered.first?.level == .error)
|
||||
}
|
||||
|
||||
@Test @MainActor func logsViewModelFilteredEntriesBySearch() {
|
||||
let vm = LogsViewModel(context: .local)
|
||||
vm.entries = [
|
||||
LogEntry(id: 1, timestamp: "t", level: .info, sessionId: nil, logger: "a", message: "connecting to db", raw: "connecting to db"),
|
||||
LogEntry(id: 2, timestamp: "t", level: .info, sessionId: nil, logger: "a", message: "starting agent", raw: "starting agent"),
|
||||
]
|
||||
vm.searchText = "agent"
|
||||
#expect(vm.filteredEntries.count == 1)
|
||||
#expect(vm.filteredEntries.first?.message.contains("agent") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func logsViewModelFilteredEntriesByComponent() {
|
||||
let vm = LogsViewModel(context: .local)
|
||||
vm.entries = [
|
||||
LogEntry(id: 1, timestamp: "t", level: .info, sessionId: nil, logger: "gateway.main", message: "up", raw: "r"),
|
||||
LogEntry(id: 2, timestamp: "t", level: .info, sessionId: nil, logger: "agent.loop", message: "tick", raw: "r"),
|
||||
LogEntry(id: 3, timestamp: "t", level: .info, sessionId: nil, logger: "tools.compile", message: "done", raw: "r"),
|
||||
]
|
||||
vm.selectedComponent = .gateway
|
||||
let gateway = vm.filteredEntries
|
||||
#expect(gateway.count == 1)
|
||||
#expect(gateway.first?.logger == "gateway.main")
|
||||
|
||||
vm.selectedComponent = .all
|
||||
#expect(vm.filteredEntries.count == 3)
|
||||
}
|
||||
|
||||
@Test func logsViewModelEnumsIdentifiable() {
|
||||
for f in LogsViewModel.LogFile.allCases {
|
||||
#expect(f.id == f.rawValue)
|
||||
}
|
||||
for c in LogsViewModel.LogComponent.allCases {
|
||||
#expect(c.id == c.rawValue)
|
||||
}
|
||||
#expect(LogsViewModel.LogComponent.all.loggerPrefix == nil)
|
||||
#expect(LogsViewModel.LogComponent.gateway.loggerPrefix == "gateway")
|
||||
}
|
||||
|
||||
// MARK: - ProjectsViewModel (ProjectDashboardService dep — portable)
|
||||
|
||||
@Test @MainActor func projectsViewModelInits() {
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
#expect(vm.context.id == ServerContext.local.id)
|
||||
}
|
||||
|
||||
// MARK: - Activity / Insights / RichChat — only on Apple targets
|
||||
|
||||
#if canImport(SQLite3)
|
||||
|
||||
@Test @MainActor func activityViewModelInits() {
|
||||
let vm = ActivityViewModel(context: .local)
|
||||
#expect(vm.context.id == ServerContext.local.id)
|
||||
#expect(vm.entries.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func insightsViewModelInits() {
|
||||
let vm = InsightsViewModel(context: .local)
|
||||
#expect(vm.context.id == ServerContext.local.id)
|
||||
#expect(vm.period == .month)
|
||||
#expect(vm.isLoading == true)
|
||||
}
|
||||
|
||||
@Test func insightsPeriodSinceDateIsSane() {
|
||||
let now = Date()
|
||||
let week = InsightsPeriod.week.sinceDate
|
||||
let month = InsightsPeriod.month.sinceDate
|
||||
let quarter = InsightsPeriod.quarter.sinceDate
|
||||
let all = InsightsPeriod.all.sinceDate
|
||||
// Ordering: all < quarter < month < week < now.
|
||||
#expect(all < quarter)
|
||||
#expect(quarter < month)
|
||||
#expect(month < week)
|
||||
#expect(week < now)
|
||||
}
|
||||
|
||||
@Test func chatDisplayModeCases() {
|
||||
#expect(ChatDisplayMode.allCases.count == 2)
|
||||
#expect(ChatDisplayMode.allCases.contains(.terminal))
|
||||
#expect(ChatDisplayMode.allCases.contains(.richChat))
|
||||
}
|
||||
|
||||
@Test @MainActor func richChatViewModelInitsEmpty() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
#expect(vm.context.id == ServerContext.local.id)
|
||||
#expect(vm.messages.isEmpty)
|
||||
#expect(vm.isAgentWorking == false)
|
||||
#expect(vm.hasMessages == false)
|
||||
// supportsCompress defers to `availableCommands`, which is empty at
|
||||
// start → false.
|
||||
#expect(vm.supportsCompress == false)
|
||||
#expect(vm.hasBroaderCommandMenu == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func messageGroupDerivedProperties() {
|
||||
let userMsg = HermesMessage(
|
||||
id: 1, sessionId: "s", role: "user", content: "hi",
|
||||
toolCallId: nil, toolCalls: [], toolName: nil,
|
||||
timestamp: nil, tokenCount: nil, finishReason: nil, reasoning: nil
|
||||
)
|
||||
let toolCall = HermesToolCall(callId: "c1", functionName: "read_file", arguments: "{}")
|
||||
let asstMsg = HermesMessage(
|
||||
id: 2, sessionId: "s", role: "assistant", content: "here",
|
||||
toolCallId: nil, toolCalls: [toolCall], toolName: nil,
|
||||
timestamp: nil, tokenCount: nil, finishReason: nil, reasoning: nil
|
||||
)
|
||||
let group = MessageGroup(
|
||||
id: 1, userMessage: userMsg, assistantMessages: [asstMsg], toolResults: [:]
|
||||
)
|
||||
#expect(group.allMessages.count == 2)
|
||||
#expect(group.toolCallCount == 1)
|
||||
|
||||
let emptyGroup = MessageGroup(id: 0, userMessage: nil, assistantMessages: [], toolResults: [:])
|
||||
#expect(emptyGroup.allMessages.isEmpty)
|
||||
#expect(emptyGroup.toolCallCount == 0)
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
}
|
||||
@@ -369,7 +369,47 @@ stderr patterns, and round-trip an actual local file through
|
||||
`ServerContext.local.id`. Test helpers in ScarfCoreTests lean on this
|
||||
heavily.
|
||||
|
||||
### M0d — pending
|
||||
### M0d — shipped
|
||||
|
||||
**Scope decision:** ViewModels only; **Views stay in the Mac target** for now. SwiftUI Views have heavy cross-feature coupling (AppCoordinator navigation, sidebar integration), AppKit-dependent widgets (NSOpenPanel, NSWorkspace.open for "reveal in Finder"), and platform-specific layout idioms that iPhone should re-implement rather than inherit. The Mac target will keep its current Views; M3+ builds fresh iOS Views on top of the shared ViewModels.
|
||||
|
||||
**Moved (6 ViewModels):**
|
||||
|
||||
- `ActivityViewModel.swift` — wraps `HermesDataService.fetchToolCalls`. Gated on `#if canImport(SQLite3)`.
|
||||
- `ConnectionStatusViewModel.swift` — heartbeat for remote SSH health; `@MainActor @Observable`.
|
||||
- `InsightsViewModel.swift` — aggregates over sessions via `HermesDataService`. Also exports `InsightsPeriod`, `ModelUsage`, `PlatformUsage`, `ToolUsage`, `NotableSession` and the free functions `formatDuration(_:)` / `formatTokens(_:)`. Gated on `#if canImport(SQLite3)`.
|
||||
- `LogsViewModel.swift` — log tail + filter state (level, component, search). Uses only `HermesLogService`; no SQLite3 gate needed. Exposes `LogFile` and `LogComponent` nested enums with `#if canImport(Darwin)`-guarded `LocalizedStringResource` display names.
|
||||
- `ProjectsViewModel.swift` — wraps `ProjectDashboardService`. Fully portable.
|
||||
- `RichChatViewModel.swift` — ~700 lines of ACP-event + message-group handling. Gated on `#if canImport(SQLite3)` because it pulls message history from `HermesDataService`. Also exports `ChatDisplayMode` and `MessageGroup`.
|
||||
|
||||
**Reverted during M0d** (wasn't actually portable):
|
||||
|
||||
- `GatewayViewModel.swift` — my initial audit grepped for service-type names but missed that this VM calls `context.runHermes()`, which is a Mac-target-only extension (`ServerContext+Mac.swift`). Moving the extension would require dragging `HermesFileService` too. Left in the Mac target; a later phase can revisit once `HermesFileService` moves or a different CLI-invocation surface lands.
|
||||
|
||||
**Discovered while moving:**
|
||||
|
||||
- The sed transform needs a `s/^@Observable$/@Observable/` neutralization — earlier I was accidentally producing `@Observable public` which is a Swift syntax error (the stray `public` has no target). Post-fix, the `public` lives on the `public final class X` line as intended.
|
||||
- Swift's `Observation` framework (for `@Observable`) needs an explicit `import Observation` in ScarfCore files because ScarfCore doesn't pull in SwiftUI. The Mac target gets `Observation` implicitly through SwiftUI, but a pure ScarfCore file doesn't. `Observation` is in the Swift toolchain from 5.9 onwards and compiles fine on Linux too.
|
||||
- Nested enums inside a public enclosing type do **not** inherit `public` for their `Identifiable.id` requirement — that property has to be `public var id` explicitly when the enum declares `Identifiable` conformance. My sed didn't touch deeper indent levels (nested types at indent 4 inside a class at indent 0) so these had to be fixed by hand.
|
||||
- `CharacterSet.whitespaces` is present in swift-corelibs-foundation on Linux — no guard needed there. The build error I saw was cascaded from `runHermes` not existing.
|
||||
|
||||
**Test coverage (`M0dViewModelsTests`):**
|
||||
|
||||
- `ConnectionStatusViewModel`: local context always-connected invariant; remote context idle-start; `Status` `Equatable`.
|
||||
- `LogsViewModel`: init defaults, `filteredEntries` across level / search / component filters, nested enum `Identifiable` ids and `loggerPrefix` routing.
|
||||
- `ProjectsViewModel`: init binding to `.local`.
|
||||
- `ActivityViewModel`, `InsightsViewModel`, `RichChatViewModel`: construction + key initial state. Tests wrapped in `#if canImport(SQLite3)` so they only run on Apple-target CI.
|
||||
- `MessageGroup.allMessages` / `toolCallCount` (also SQLite3-gated).
|
||||
- `InsightsPeriod.sinceDate` ordering.
|
||||
- `ChatDisplayMode` case coverage.
|
||||
|
||||
**Rules next phases can rely on:**
|
||||
|
||||
- When moving a file with `@Observable`, **remember to add `import Observation`** and to fix the stray `@Observable public` that sed produces.
|
||||
- ViewModels that call `context.runHermes(...)` or `context.openInLocalEditor(...)` are **not** portable to ScarfCore — those methods live in `ServerContext+Mac.swift`. Either leave the VM in the Mac target, or add the specific extension method to ScarfCore with a platform-neutral implementation path.
|
||||
- Types used only from the Mac app target (`GatewayInfo`, `PlatformInfo`, etc.) should NOT be marked `public` — keep them internal. My sed sometimes adds `public` to main-target-internal types when I'm reverting a move; strip those back with a second sed pass.
|
||||
- Views are deliberately **not** in ScarfCore. iOS will build its own Views against the shared ViewModels. M3 is where iOS's ViewRegistry / tab bar / NavigationStack composition happens.
|
||||
|
||||
### M1 — pending
|
||||
### M2 — pending
|
||||
### M3 — pending
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct RichChatView: View {
|
||||
@Bindable var richChat: RichChatViewModel
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
struct GatewayInfo {
|
||||
let pid: Int?
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Small colored pill shown in the toolbar reflecting the server's reach-
|
||||
/// ability. Green = connected, yellow = probing, red = unreachable.
|
||||
|
||||
Reference in New Issue
Block a user