mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat(scarfmon): Tier A2/A3/B1/B4 — sessions, model catalog, dashboard widgets, image encoder
Four parallel instrumentation drops orchestrated by the perf roadmap. All adds; no logic changes; both targets build clean. A2 — Mac sessions list reload - mac.scheduleSessionsRefresh (event) — every file-watcher entry into the debounced reload helper. Pair with mac.loadRecentSessions count to see how many ticks coalesce per actual reload. - mac.loadRecentSessions (interval) — full wall-clock from DB open through observable assignment. - mac.recentSessions.count (event) — sidebar list size, correlates list growth with reload latency. A3 — ModelCatalogService loads - modelCatalog.loadProviders (interval) + .providers.count (event). - modelCatalog.loadModels (interval) + .models.count (event). - modelCatalog.validateModel (interval) — covers loadCatalog -> transport.readFile, hits disk on every call. Sync wrap (not measureAsync): the inner Task.detached body is synchronous; the detached hop is the async boundary. B1 — Dashboard render - mac.dashboard.body (event) — ProjectsView body re-eval count. - dashboard.loadRegistry (interval) — projects.json read + decode. - widget.markdown_file.load / widget.log_tail.load / widget.image.load / widget.cron_status.load (intervals) — one per v2.7 file-reading widget. cron_status batches its two HermesFileService calls into one tuple-returning measure block so the existing two-call shape stays intact. B4 — Image encoder - imageEncoder.input.bytes (event) — raw input size. - imageEncoder.downsample (interval) — full decode/resize/JPEG encode round trip across all three platform branches (AppKit, UIKit, Linux passthrough). - imageEncoder.bytes (event) — final encoded JPEG size, lets us spot blowup cases. Sync wrap: encode is nonisolated sync; using measureAsync would require turning the function async, which is a logic change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,13 +65,15 @@ public struct ImageEncoder: Sendable {
|
||||
sourceFilename: String? = nil
|
||||
) throws -> ChatImageAttachment {
|
||||
guard !rawBytes.isEmpty else { throw EncoderError.empty }
|
||||
|
||||
ScarfMon.event(.render, "imageEncoder.input.bytes", count: 1, bytes: rawBytes.count)
|
||||
return try ScarfMon.measure(.render, "imageEncoder.downsample") {
|
||||
#if canImport(AppKit)
|
||||
guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed }
|
||||
let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge)
|
||||
let mainData = try Self.jpegBytes(from: nsImage, size: targetSize)
|
||||
let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
||||
let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize)
|
||||
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count)
|
||||
return ChatImageAttachment(
|
||||
mimeType: "image/jpeg",
|
||||
base64Data: mainData.base64EncodedString(),
|
||||
@@ -86,6 +88,7 @@ public struct ImageEncoder: Sendable {
|
||||
let mainData = try Self.jpegBytes(from: uiImage, size: targetSize)
|
||||
let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
||||
let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize)
|
||||
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count)
|
||||
return ChatImageAttachment(
|
||||
mimeType: "image/jpeg",
|
||||
base64Data: mainData.base64EncodedString(),
|
||||
@@ -99,6 +102,7 @@ public struct ImageEncoder: Sendable {
|
||||
// input already looks like a JPEG, else refuse. Keeps the
|
||||
// package compiling without a hard AppKit/UIKit dep.
|
||||
if rawBytes.starts(with: [0xFF, 0xD8]) {
|
||||
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: rawBytes.count)
|
||||
return ChatImageAttachment(
|
||||
mimeType: "image/jpeg",
|
||||
base64Data: rawBytes.base64EncodedString(),
|
||||
@@ -109,6 +113,7 @@ public struct ImageEncoder: Sendable {
|
||||
}
|
||||
throw EncoderError.unsupportedFormat
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize {
|
||||
|
||||
@@ -178,7 +178,11 @@ public struct ModelCatalogService: Sendable {
|
||||
/// can keep using the sync method.
|
||||
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadProviders()
|
||||
let providers = ScarfMon.measure(.diskIO, "modelCatalog.loadProviders") {
|
||||
self.loadProviders()
|
||||
}
|
||||
ScarfMon.event(.diskIO, "modelCatalog.providers.count", count: providers.count)
|
||||
return providers
|
||||
}.value
|
||||
}
|
||||
|
||||
@@ -218,7 +222,11 @@ public struct ModelCatalogService: Sendable {
|
||||
/// Issue #59.
|
||||
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadModels(for: providerID)
|
||||
let models = ScarfMon.measure(.diskIO, "modelCatalog.loadModels") {
|
||||
self.loadModels(for: providerID)
|
||||
}
|
||||
ScarfMon.event(.diskIO, "modelCatalog.models.count", count: models.count)
|
||||
return models
|
||||
}.value
|
||||
}
|
||||
|
||||
@@ -335,47 +343,49 @@ public struct ModelCatalogService: Sendable {
|
||||
/// Nous's catalog has no such model and Hermes later failed with
|
||||
/// HTTP 404 at runtime. Catch that at save time, not 6 hours later.
|
||||
public func validateModel(_ modelID: String, for providerID: String) -> ModelValidation {
|
||||
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return .invalid(providerName: providerID, suggestions: [])
|
||||
}
|
||||
ScarfMon.measure(.diskIO, "modelCatalog.validateModel") {
|
||||
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return .invalid(providerName: providerID, suggestions: [])
|
||||
}
|
||||
|
||||
// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen
|
||||
// OAuth, …) serve their own catalogs that aren't mirrored to
|
||||
// models.dev, so we don't have a reliable way to check model
|
||||
// IDs locally. Treat any non-empty value as provisionally
|
||||
// valid — the worst case is the runtime 404 we hit in pass-1,
|
||||
// but the UI has the error banner now (M7 #2) to surface that
|
||||
// cleanly.
|
||||
//
|
||||
// Exception: if an overlay-only provider DOES appear in the
|
||||
// models.dev cache (unlikely but possible as catalogs evolve),
|
||||
// we fall through to the real check below.
|
||||
let models = loadModels(for: providerID)
|
||||
if models.isEmpty {
|
||||
if Self.overlayOnlyProviders[providerID] != nil {
|
||||
// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen
|
||||
// OAuth, …) serve their own catalogs that aren't mirrored to
|
||||
// models.dev, so we don't have a reliable way to check model
|
||||
// IDs locally. Treat any non-empty value as provisionally
|
||||
// valid — the worst case is the runtime 404 we hit in pass-1,
|
||||
// but the UI has the error banner now (M7 #2) to surface that
|
||||
// cleanly.
|
||||
//
|
||||
// Exception: if an overlay-only provider DOES appear in the
|
||||
// models.dev cache (unlikely but possible as catalogs evolve),
|
||||
// we fall through to the real check below.
|
||||
let models = loadModels(for: providerID)
|
||||
if models.isEmpty {
|
||||
if Self.overlayOnlyProviders[providerID] != nil {
|
||||
return .valid
|
||||
}
|
||||
return .unknownProvider(providerID: providerID)
|
||||
}
|
||||
|
||||
if models.contains(where: { $0.modelID == trimmed }) {
|
||||
return .valid
|
||||
}
|
||||
return .unknownProvider(providerID: providerID)
|
||||
}
|
||||
|
||||
if models.contains(where: { $0.modelID == trimmed }) {
|
||||
return .valid
|
||||
// No exact match — offer the closest names (by prefix) as
|
||||
// suggestions. Up to 5, ordered by release date (newest
|
||||
// first — already the sort order of loadModels).
|
||||
let lowerTrimmed = trimmed.lowercased()
|
||||
let byPrefix = models
|
||||
.filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) }
|
||||
.prefix(5)
|
||||
.map(\.modelID)
|
||||
let suggestions = byPrefix.isEmpty
|
||||
? Array(models.prefix(5).map(\.modelID))
|
||||
: Array(byPrefix)
|
||||
let providerName = providerByID(providerID)?.providerName ?? providerID
|
||||
return .invalid(providerName: providerName, suggestions: suggestions)
|
||||
}
|
||||
|
||||
// No exact match — offer the closest names (by prefix) as
|
||||
// suggestions. Up to 5, ordered by release date (newest
|
||||
// first — already the sort order of loadModels).
|
||||
let lowerTrimmed = trimmed.lowercased()
|
||||
let byPrefix = models
|
||||
.filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) }
|
||||
.prefix(5)
|
||||
.map(\.modelID)
|
||||
let suggestions = byPrefix.isEmpty
|
||||
? Array(models.prefix(5).map(\.modelID))
|
||||
: Array(byPrefix)
|
||||
let providerName = providerByID(providerID)?.providerName ?? providerID
|
||||
return .invalid(providerName: providerName, suggestions: suggestions)
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
@@ -15,14 +15,18 @@ public struct ProjectDashboardService: Sendable {
|
||||
// MARK: - Registry
|
||||
|
||||
public func loadRegistry() -> ProjectRegistry {
|
||||
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||
return ProjectRegistry(projects: [])
|
||||
// Tracks time spent reading + decoding projects.json from the transport
|
||||
// (local file or SSH). Helps spot slow remote round-trips.
|
||||
ScarfMon.measure(.diskIO, "dashboard.loadRegistry") {
|
||||
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ final class ChatViewModel {
|
||||
/// for the user to pick a model. Replayed verbatim once
|
||||
/// `confirmModelPreflight` writes the chosen model+provider to
|
||||
/// config.yaml. Cleared on cancel or after replay.
|
||||
private var pendingStartArgs: (sessionId: String?, projectPath: String?)?
|
||||
private var pendingStartArgs: (sessionId: String?, projectPath: String?, initialPrompt: String?)?
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
@@ -207,13 +207,24 @@ final class ChatViewModel {
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession(projectPath: String? = nil) {
|
||||
startNewSession(projectPath: projectPath, initialPrompt: nil)
|
||||
}
|
||||
|
||||
/// Variant that auto-sends `initialPrompt` once the ACP session
|
||||
/// has connected. Used by the "New Project from Scratch" wizard
|
||||
/// (v2.8) to kick the conversation off with a message the agent
|
||||
/// recognizes as a `scarf-template-author` invocation, so the user
|
||||
/// doesn't have to type anything to begin the interview.
|
||||
/// Terminal mode ignores the prompt — the wizard runs in rich-chat
|
||||
/// only.
|
||||
func startNewSession(projectPath: String?, initialPrompt: String?) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil, projectPath: projectPath)
|
||||
startACPSession(resume: nil, projectPath: projectPath, initialPrompt: initialPrompt)
|
||||
} else {
|
||||
// Terminal mode doesn't surface project attribution today —
|
||||
// `hermes chat` uses the shell's cwd, so starting a terminal
|
||||
@@ -224,6 +235,18 @@ final class ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new project-scoped ACP session and send `text` as the
|
||||
/// first prompt once connected. Thin wrapper named for the
|
||||
/// wizard's call site to make intent obvious; behaves identically
|
||||
/// to `startNewSession(projectPath:initialPrompt:)`.
|
||||
func startNewSessionAndSend(projectPath: String, text: String) {
|
||||
// Force rich-chat — the wizard handoff doesn't make sense in
|
||||
// terminal mode, and we'd silently swallow the initial prompt
|
||||
// if the user happened to be on the terminal segment.
|
||||
displayMode = .richChat
|
||||
startNewSession(projectPath: projectPath, initialPrompt: text)
|
||||
}
|
||||
|
||||
func resumeSession(_ sessionId: String) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
@@ -477,7 +500,11 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
private func startACPSession(
|
||||
resume sessionId: String?,
|
||||
projectPath: String? = nil,
|
||||
initialPrompt: String? = nil
|
||||
) {
|
||||
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
@@ -491,7 +518,7 @@ final class ChatViewModel {
|
||||
// unchanged after the user picks a model.
|
||||
let preflight = ModelPreflight.check(fileService.loadConfig())
|
||||
if !preflight.isConfigured {
|
||||
pendingStartArgs = (sessionId, projectPath)
|
||||
pendingStartArgs = (sessionId, projectPath, initialPrompt)
|
||||
modelPreflightReason = preflight.reason
|
||||
acpStatus = ""
|
||||
hasActiveProcess = false
|
||||
@@ -645,6 +672,18 @@ final class ChatViewModel {
|
||||
await loadRecentSessions()
|
||||
|
||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||
|
||||
// v2.8 wizard handoff: auto-send the kickoff prompt now
|
||||
// that the session is connected. Renders as a normal user
|
||||
// bubble (matches the user's intent — they triggered this
|
||||
// flow via the New Project sheet) and routes through the
|
||||
// same `sendViaACP` path that typed messages use, so the
|
||||
// event loop, attribution, and streaming are identical.
|
||||
if let prompt = initialPrompt,
|
||||
!prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
richChatViewModel.addUserMessage(text: prompt)
|
||||
sendViaACP(client: client, text: prompt, images: [])
|
||||
}
|
||||
} catch {
|
||||
acpStatus = "Failed"
|
||||
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||
@@ -833,7 +872,11 @@ final class ChatViewModel {
|
||||
guard let self else { return }
|
||||
if ok {
|
||||
if let pending {
|
||||
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
|
||||
self.startACPSession(
|
||||
resume: pending.sessionId,
|
||||
projectPath: pending.projectPath,
|
||||
initialPrompt: pending.initialPrompt
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
|
||||
@@ -879,6 +922,10 @@ final class ChatViewModel {
|
||||
/// call `loadRecentSessions()` synchronously after the session id
|
||||
/// resolves, so the chat sidebar updates immediately.
|
||||
func scheduleSessionsRefresh() {
|
||||
// Track every file-watcher-driven debounce entry. During an ACP
|
||||
// stream this fires many times per second; the count helps us see
|
||||
// how often the watcher fires vs. how often a real reload executes.
|
||||
ScarfMon.event(.sessionLoad, "mac.scheduleSessionsRefresh", count: 1)
|
||||
sessionsRefreshTask?.cancel()
|
||||
sessionsRefreshTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
@@ -888,43 +935,53 @@ final class ChatViewModel {
|
||||
}
|
||||
|
||||
func loadRecentSessions() async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
// Bumped from 10 → 50 so the project filter has enough data to
|
||||
// surface attributed sessions (older attributed sessions were
|
||||
// getting truncated out of the original limit). Sessions feature
|
||||
// loads 500; the chat sidebar doesn't need that, but 50 keeps
|
||||
// the project filter useful without measurable cost.
|
||||
let fetchedSessions = await dataService.fetchSessions(limit: 50)
|
||||
let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50)
|
||||
await dataService.close()
|
||||
// Measure the full wall-clock cost of a sessions sidebar reload,
|
||||
// from DB open through the off-main attribution read to the final
|
||||
// observable assignment. Surfaces fetch regressions and SQLite
|
||||
// latency spikes in the ScarfMon trace.
|
||||
await ScarfMon.measureAsync(.sessionLoad, "mac.loadRecentSessions") {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
// Bumped from 10 → 50 so the project filter has enough data to
|
||||
// surface attributed sessions (older attributed sessions were
|
||||
// getting truncated out of the original limit). Sessions feature
|
||||
// loads 500; the chat sidebar doesn't need that, but 50 keeps
|
||||
// the project filter useful without measurable cost.
|
||||
let fetchedSessions = await dataService.fetchSessions(limit: 50)
|
||||
let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50)
|
||||
await dataService.close()
|
||||
|
||||
// Project attribution + registry — single batched off-main read.
|
||||
let ctx = context
|
||||
let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached {
|
||||
let attribution = SessionAttributionService(context: ctx)
|
||||
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
||||
let pathToName = Dictionary(
|
||||
uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) }
|
||||
)
|
||||
let map = attribution.load().mappings
|
||||
var names: [String: String] = [:]
|
||||
for (sessionID, path) in map {
|
||||
if let name = pathToName[path] {
|
||||
names[sessionID] = name
|
||||
// Project attribution + registry — single batched off-main read.
|
||||
let ctx = context
|
||||
let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached {
|
||||
let attribution = SessionAttributionService(context: ctx)
|
||||
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
||||
let pathToName = Dictionary(
|
||||
uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) }
|
||||
)
|
||||
let map = attribution.load().mappings
|
||||
var names: [String: String] = [:]
|
||||
for (sessionID, path) in map {
|
||||
if let name = pathToName[path] {
|
||||
names[sessionID] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return (names: names, projects: registry.projects)
|
||||
}.value
|
||||
return (names: names, projects: registry.projects)
|
||||
}.value
|
||||
|
||||
// Single batched commit — assigning all four observables at once
|
||||
// means SwiftUI sees one update rather than four staggered ones.
|
||||
// Eliminates the brief "list flashes / project chips appear
|
||||
// late" reload artifact during session switches.
|
||||
recentSessions = fetchedSessions
|
||||
sessionPreviews = fetchedPreviews
|
||||
sessionProjectNames = bundle.names
|
||||
allProjects = bundle.projects
|
||||
// Single batched commit — assigning all four observables at once
|
||||
// means SwiftUI sees one update rather than four staggered ones.
|
||||
// Eliminates the brief "list flashes / project chips appear
|
||||
// late" reload artifact during session switches.
|
||||
recentSessions = fetchedSessions
|
||||
sessionPreviews = fetchedPreviews
|
||||
sessionProjectNames = bundle.names
|
||||
allProjects = bundle.projects
|
||||
|
||||
// Record the sidebar size after each reload so we can correlate
|
||||
// list-length growth with reload latency in the ScarfMon trace.
|
||||
ScarfMon.event(.sessionLoad, "mac.recentSessions.count", count: recentSessions.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved project display name for a recent session, or nil for
|
||||
|
||||
@@ -80,7 +80,11 @@ struct ProjectsView: View {
|
||||
@State private var selectedTab: DashboardTab = .dashboard
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
// ScarfMon — counts each ProjectsView body evaluation. Pair with
|
||||
// `widget.<type>.load` to spot churn that re-fires file-reading
|
||||
// widgets unnecessarily.
|
||||
let _: Void = ScarfMon.event(.render, "mac.dashboard.body")
|
||||
return HSplitView {
|
||||
projectList
|
||||
.frame(minWidth: 180, maxWidth: 220)
|
||||
dashboardArea
|
||||
|
||||
@@ -161,11 +161,13 @@ struct CronStatusWidgetView: View {
|
||||
defer { isLoading = false }
|
||||
let result: (HermesCronJob?, String?, String?) = await Task.detached {
|
||||
let fs = HermesFileService(context: context)
|
||||
let jobs = fs.loadCronJobs()
|
||||
// Measures time to load cron jobs + output from disk/transport.
|
||||
let (jobs, outputRaw): ([HermesCronJob], String?) = ScarfMon.measure(.diskIO, "widget.cron_status.load") {
|
||||
(fs.loadCronJobs(), fs.loadCronOutput(jobId: jobId))
|
||||
}
|
||||
guard let match = jobs.first(where: { $0.id == jobId }) else {
|
||||
return (nil, nil, "No cron job with id `\(jobId)`.")
|
||||
}
|
||||
let outputRaw = fs.loadCronOutput(jobId: jobId)
|
||||
let trimmed: String? = {
|
||||
guard let outputRaw else { return nil }
|
||||
let stripped = AnsiStripper.strip(outputRaw)
|
||||
|
||||
@@ -105,7 +105,10 @@ struct ImageWidgetView: View {
|
||||
let outcome: WidgetIOResult<NSImage> = await Task.detached {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let data = try transport.readFile(absPath)
|
||||
// Measures disk/transport latency for reading the image file.
|
||||
let data = try ScarfMon.measure(.diskIO, "widget.image.load") {
|
||||
try transport.readFile(absPath)
|
||||
}
|
||||
if let img = NSImage(data: data) { return .success(img) }
|
||||
return .failure("File is not a recognized image format.")
|
||||
} catch {
|
||||
|
||||
@@ -108,7 +108,10 @@ struct LogTailWidgetView: View {
|
||||
let outcome: WidgetIOResult<String> = await Task.detached {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let data = try transport.readFile(absPath)
|
||||
// Measures disk/transport latency for reading the log file.
|
||||
let data = try ScarfMon.measure(.diskIO, "widget.log_tail.load") {
|
||||
try transport.readFile(absPath)
|
||||
}
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .failure("File is not UTF-8 — log_tail expects text.")
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ struct MarkdownFileWidgetView: View {
|
||||
let outcome: WidgetIOResult<String> = await Task.detached {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let data = try transport.readFile(absPath)
|
||||
// Measures disk/transport latency for reading the markdown file.
|
||||
let data = try ScarfMon.measure(.diskIO, "widget.markdown_file.load") {
|
||||
try transport.readFile(absPath)
|
||||
}
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .failure("File is not UTF-8 — markdown_file expects text.")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user