mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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
|
sourceFilename: String? = nil
|
||||||
) throws -> ChatImageAttachment {
|
) throws -> ChatImageAttachment {
|
||||||
guard !rawBytes.isEmpty else { throw EncoderError.empty }
|
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)
|
#if canImport(AppKit)
|
||||||
guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed }
|
guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed }
|
||||||
let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge)
|
let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge)
|
||||||
let mainData = try Self.jpegBytes(from: nsImage, size: targetSize)
|
let mainData = try Self.jpegBytes(from: nsImage, size: targetSize)
|
||||||
let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
||||||
let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize)
|
let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize)
|
||||||
|
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count)
|
||||||
return ChatImageAttachment(
|
return ChatImageAttachment(
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
base64Data: mainData.base64EncodedString(),
|
base64Data: mainData.base64EncodedString(),
|
||||||
@@ -86,6 +88,7 @@ public struct ImageEncoder: Sendable {
|
|||||||
let mainData = try Self.jpegBytes(from: uiImage, size: targetSize)
|
let mainData = try Self.jpegBytes(from: uiImage, size: targetSize)
|
||||||
let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
||||||
let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize)
|
let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize)
|
||||||
|
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: mainData.count)
|
||||||
return ChatImageAttachment(
|
return ChatImageAttachment(
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
base64Data: mainData.base64EncodedString(),
|
base64Data: mainData.base64EncodedString(),
|
||||||
@@ -99,6 +102,7 @@ public struct ImageEncoder: Sendable {
|
|||||||
// input already looks like a JPEG, else refuse. Keeps the
|
// input already looks like a JPEG, else refuse. Keeps the
|
||||||
// package compiling without a hard AppKit/UIKit dep.
|
// package compiling without a hard AppKit/UIKit dep.
|
||||||
if rawBytes.starts(with: [0xFF, 0xD8]) {
|
if rawBytes.starts(with: [0xFF, 0xD8]) {
|
||||||
|
ScarfMon.event(.render, "imageEncoder.bytes", count: 1, bytes: rawBytes.count)
|
||||||
return ChatImageAttachment(
|
return ChatImageAttachment(
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
base64Data: rawBytes.base64EncodedString(),
|
base64Data: rawBytes.base64EncodedString(),
|
||||||
@@ -110,6 +114,7 @@ public struct ImageEncoder: Sendable {
|
|||||||
throw EncoderError.unsupportedFormat
|
throw EncoderError.unsupportedFormat
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize {
|
nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize {
|
||||||
let longest = max(source.width, source.height)
|
let longest = max(source.width, source.height)
|
||||||
|
|||||||
@@ -178,7 +178,11 @@ public struct ModelCatalogService: Sendable {
|
|||||||
/// can keep using the sync method.
|
/// can keep using the sync method.
|
||||||
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
|
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
|
||||||
await Task.detached { [self] in
|
await Task.detached { [self] in
|
||||||
|
let providers = ScarfMon.measure(.diskIO, "modelCatalog.loadProviders") {
|
||||||
self.loadProviders()
|
self.loadProviders()
|
||||||
|
}
|
||||||
|
ScarfMon.event(.diskIO, "modelCatalog.providers.count", count: providers.count)
|
||||||
|
return providers
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +222,11 @@ public struct ModelCatalogService: Sendable {
|
|||||||
/// Issue #59.
|
/// Issue #59.
|
||||||
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
|
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
|
||||||
await Task.detached { [self] in
|
await Task.detached { [self] in
|
||||||
|
let models = ScarfMon.measure(.diskIO, "modelCatalog.loadModels") {
|
||||||
self.loadModels(for: providerID)
|
self.loadModels(for: providerID)
|
||||||
|
}
|
||||||
|
ScarfMon.event(.diskIO, "modelCatalog.models.count", count: models.count)
|
||||||
|
return models
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +343,7 @@ public struct ModelCatalogService: Sendable {
|
|||||||
/// Nous's catalog has no such model and Hermes later failed with
|
/// 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.
|
/// HTTP 404 at runtime. Catch that at save time, not 6 hours later.
|
||||||
public func validateModel(_ modelID: String, for providerID: String) -> ModelValidation {
|
public func validateModel(_ modelID: String, for providerID: String) -> ModelValidation {
|
||||||
|
ScarfMon.measure(.diskIO, "modelCatalog.validateModel") {
|
||||||
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else {
|
guard !trimmed.isEmpty else {
|
||||||
return .invalid(providerName: providerID, suggestions: [])
|
return .invalid(providerName: providerID, suggestions: [])
|
||||||
@@ -377,6 +386,7 @@ public struct ModelCatalogService: Sendable {
|
|||||||
let providerName = providerByID(providerID)?.providerName ?? providerID
|
let providerName = providerByID(providerID)?.providerName ?? providerID
|
||||||
return .invalid(providerName: providerName, suggestions: suggestions)
|
return .invalid(providerName: providerName, suggestions: suggestions)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Decoding
|
// MARK: - Decoding
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public struct ProjectDashboardService: Sendable {
|
|||||||
// MARK: - Registry
|
// MARK: - Registry
|
||||||
|
|
||||||
public func loadRegistry() -> ProjectRegistry {
|
public func loadRegistry() -> ProjectRegistry {
|
||||||
|
// 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 {
|
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
@@ -25,6 +28,7 @@ public struct ProjectDashboardService: Sendable {
|
|||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ final class ChatViewModel {
|
|||||||
/// for the user to pick a model. Replayed verbatim once
|
/// for the user to pick a model. Replayed verbatim once
|
||||||
/// `confirmModelPreflight` writes the chosen model+provider to
|
/// `confirmModelPreflight` writes the chosen model+provider to
|
||||||
/// config.yaml. Cleared on cancel or after replay.
|
/// 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 maxReconnectAttempts = 5
|
||||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||||
@@ -207,13 +207,24 @@ final class ChatViewModel {
|
|||||||
// MARK: - Session Lifecycle
|
// MARK: - Session Lifecycle
|
||||||
|
|
||||||
func startNewSession(projectPath: String? = nil) {
|
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
|
voiceEnabled = false
|
||||||
ttsEnabled = false
|
ttsEnabled = false
|
||||||
isRecording = false
|
isRecording = false
|
||||||
richChatViewModel.reset()
|
richChatViewModel.reset()
|
||||||
|
|
||||||
if displayMode == .richChat {
|
if displayMode == .richChat {
|
||||||
startACPSession(resume: nil, projectPath: projectPath)
|
startACPSession(resume: nil, projectPath: projectPath, initialPrompt: initialPrompt)
|
||||||
} else {
|
} else {
|
||||||
// Terminal mode doesn't surface project attribution today —
|
// Terminal mode doesn't surface project attribution today —
|
||||||
// `hermes chat` uses the shell's cwd, so starting a terminal
|
// `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) {
|
func resumeSession(_ sessionId: String) {
|
||||||
voiceEnabled = false
|
voiceEnabled = false
|
||||||
ttsEnabled = false
|
ttsEnabled = false
|
||||||
@@ -477,7 +500,11 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
// MARK: - ACP Session Management
|
// 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)
|
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
|
||||||
stopACP()
|
stopACP()
|
||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
@@ -491,7 +518,7 @@ final class ChatViewModel {
|
|||||||
// unchanged after the user picks a model.
|
// unchanged after the user picks a model.
|
||||||
let preflight = ModelPreflight.check(fileService.loadConfig())
|
let preflight = ModelPreflight.check(fileService.loadConfig())
|
||||||
if !preflight.isConfigured {
|
if !preflight.isConfigured {
|
||||||
pendingStartArgs = (sessionId, projectPath)
|
pendingStartArgs = (sessionId, projectPath, initialPrompt)
|
||||||
modelPreflightReason = preflight.reason
|
modelPreflightReason = preflight.reason
|
||||||
acpStatus = ""
|
acpStatus = ""
|
||||||
hasActiveProcess = false
|
hasActiveProcess = false
|
||||||
@@ -645,6 +672,18 @@ final class ChatViewModel {
|
|||||||
await loadRecentSessions()
|
await loadRecentSessions()
|
||||||
|
|
||||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
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 {
|
} catch {
|
||||||
acpStatus = "Failed"
|
acpStatus = "Failed"
|
||||||
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||||
@@ -833,7 +872,11 @@ final class ChatViewModel {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if ok {
|
if ok {
|
||||||
if let pending {
|
if let pending {
|
||||||
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
|
self.startACPSession(
|
||||||
|
resume: pending.sessionId,
|
||||||
|
projectPath: pending.projectPath,
|
||||||
|
initialPrompt: pending.initialPrompt
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
|
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
|
/// call `loadRecentSessions()` synchronously after the session id
|
||||||
/// resolves, so the chat sidebar updates immediately.
|
/// resolves, so the chat sidebar updates immediately.
|
||||||
func scheduleSessionsRefresh() {
|
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?.cancel()
|
||||||
sessionsRefreshTask = Task { @MainActor [weak self] in
|
sessionsRefreshTask = Task { @MainActor [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
@@ -888,6 +935,11 @@ final class ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadRecentSessions() async {
|
func loadRecentSessions() async {
|
||||||
|
// 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()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
// Bumped from 10 → 50 so the project filter has enough data to
|
// Bumped from 10 → 50 so the project filter has enough data to
|
||||||
@@ -925,6 +977,11 @@ final class ChatViewModel {
|
|||||||
sessionPreviews = fetchedPreviews
|
sessionPreviews = fetchedPreviews
|
||||||
sessionProjectNames = bundle.names
|
sessionProjectNames = bundle.names
|
||||||
allProjects = bundle.projects
|
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
|
/// Resolved project display name for a recent session, or nil for
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ struct ProjectsView: View {
|
|||||||
@State private var selectedTab: DashboardTab = .dashboard
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
var body: some View {
|
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
|
projectList
|
||||||
.frame(minWidth: 180, maxWidth: 220)
|
.frame(minWidth: 180, maxWidth: 220)
|
||||||
dashboardArea
|
dashboardArea
|
||||||
|
|||||||
@@ -161,11 +161,13 @@ struct CronStatusWidgetView: View {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
let result: (HermesCronJob?, String?, String?) = await Task.detached {
|
let result: (HermesCronJob?, String?, String?) = await Task.detached {
|
||||||
let fs = HermesFileService(context: context)
|
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 {
|
guard let match = jobs.first(where: { $0.id == jobId }) else {
|
||||||
return (nil, nil, "No cron job with id `\(jobId)`.")
|
return (nil, nil, "No cron job with id `\(jobId)`.")
|
||||||
}
|
}
|
||||||
let outputRaw = fs.loadCronOutput(jobId: jobId)
|
|
||||||
let trimmed: String? = {
|
let trimmed: String? = {
|
||||||
guard let outputRaw else { return nil }
|
guard let outputRaw else { return nil }
|
||||||
let stripped = AnsiStripper.strip(outputRaw)
|
let stripped = AnsiStripper.strip(outputRaw)
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ struct ImageWidgetView: View {
|
|||||||
let outcome: WidgetIOResult<NSImage> = await Task.detached {
|
let outcome: WidgetIOResult<NSImage> = await Task.detached {
|
||||||
let transport = context.makeTransport()
|
let transport = context.makeTransport()
|
||||||
do {
|
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) }
|
if let img = NSImage(data: data) { return .success(img) }
|
||||||
return .failure("File is not a recognized image format.")
|
return .failure("File is not a recognized image format.")
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -108,7 +108,10 @@ struct LogTailWidgetView: View {
|
|||||||
let outcome: WidgetIOResult<String> = await Task.detached {
|
let outcome: WidgetIOResult<String> = await Task.detached {
|
||||||
let transport = context.makeTransport()
|
let transport = context.makeTransport()
|
||||||
do {
|
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 {
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
return .failure("File is not UTF-8 — log_tail expects text.")
|
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 outcome: WidgetIOResult<String> = await Task.detached {
|
||||||
let transport = context.makeTransport()
|
let transport = context.makeTransport()
|
||||||
do {
|
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 {
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
return .failure("File is not UTF-8 — markdown_file expects text.")
|
return .failure("File is not UTF-8 — markdown_file expects text.")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user