mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked SwiftUI's render loop, causing toggle controls to appear as solid blue rectangles instead of proper switches. All hermes subprocess and file I/O calls are now async via Task.detached, toggle uses optimistic state update for immediate visual feedback, and pipe file handles are properly closed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,56 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ToolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
|
||||
|
||||
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||
var toolsets: [HermesToolset] = []
|
||||
var mcpStatus: String = ""
|
||||
var isLoading = false
|
||||
var availablePlatforms: [HermesToolPlatform] = []
|
||||
|
||||
func load() {
|
||||
loadPlatforms()
|
||||
loadTools(for: selectedPlatform)
|
||||
loadMCPStatus()
|
||||
@MainActor
|
||||
func load() async {
|
||||
isLoading = true
|
||||
await loadPlatforms()
|
||||
await loadTools(for: selectedPlatform)
|
||||
await loadMCPStatus()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func switchPlatform(_ platform: HermesToolPlatform) {
|
||||
@MainActor
|
||||
func switchPlatform(_ platform: HermesToolPlatform) async {
|
||||
selectedPlatform = platform
|
||||
loadTools(for: platform)
|
||||
await loadTools(for: platform)
|
||||
}
|
||||
|
||||
func toggleTool(_ tool: HermesToolset) {
|
||||
let action = tool.enabled ? "disable" : "enable"
|
||||
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
|
||||
if result.exitCode == 0 {
|
||||
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
|
||||
@MainActor
|
||||
func toggleTool(_ tool: HermesToolset) async {
|
||||
guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
|
||||
toolsets[idx].enabled.toggle()
|
||||
let newEnabled = toolsets[idx].enabled
|
||||
|
||||
let action = newEnabled ? "enable" : "disable"
|
||||
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
|
||||
|
||||
if result.exitCode != 0 {
|
||||
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
|
||||
toolsets[idx].enabled = !newEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPlatforms() {
|
||||
@MainActor
|
||||
private func loadPlatforms() async {
|
||||
let config: String
|
||||
do {
|
||||
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
config = try await Task.detached {
|
||||
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
}.value
|
||||
} catch {
|
||||
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||
config = ""
|
||||
}
|
||||
var platforms: [HermesToolPlatform] = []
|
||||
@@ -67,15 +83,15 @@ final class ToolsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTools(for platform: HermesToolPlatform) {
|
||||
isLoading = true
|
||||
let result = runHermes(["tools", "list", "--platform", platform.name])
|
||||
@MainActor
|
||||
private func loadTools(for platform: HermesToolPlatform) async {
|
||||
let result = await runHermes(["tools", "list", "--platform", platform.name])
|
||||
toolsets = parseToolsList(result.output)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadMCPStatus() {
|
||||
let result = runHermes(["mcp", "list"])
|
||||
@MainActor
|
||||
private func loadMCPStatus() async {
|
||||
let result = await runHermes(["mcp", "list"])
|
||||
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@@ -121,21 +137,32 @@ final class ToolsViewModel {
|
||||
return "🔧"
|
||||
}
|
||||
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
|
||||
await Task.detached {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
return (output, process.terminationStatus)
|
||||
} catch {
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
return ("", -1)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ToolsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Tools")
|
||||
.onAppear { viewModel.load() }
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
|
||||
private var platformPicker: some View {
|
||||
@@ -23,7 +23,7 @@ struct ToolsView: View {
|
||||
get: { viewModel.selectedPlatform.name },
|
||||
set: { name in
|
||||
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||
viewModel.switchPlatform(platform)
|
||||
Task { await viewModel.switchPlatform(platform) }
|
||||
}
|
||||
}
|
||||
)) {
|
||||
@@ -46,7 +46,7 @@ struct ToolsView: View {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.toolsets) { tool in
|
||||
ToolRow(tool: tool) {
|
||||
viewModel.toggleTool(tool)
|
||||
await viewModel.toggleTool(tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ struct ToolsView: View {
|
||||
|
||||
struct ToolRow: View {
|
||||
let tool: HermesToolset
|
||||
let onToggle: () -> Void
|
||||
let onToggle: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -95,7 +95,7 @@ struct ToolRow: View {
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { tool.enabled },
|
||||
set: { _ in onToggle() }
|
||||
set: { _ in Task { await onToggle() } }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
|
||||
Reference in New Issue
Block a user