mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Merge pull request #6 from awizemann/code-quality
Code quality improvements and webview dashboard widget
This commit is contained in:
@@ -30,7 +30,7 @@
|
|||||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||||
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
||||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||||
- **Settings** — Structured config editor for all Hermes settings
|
- **Settings** — Structured config editor for all Hermes settings
|
||||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` an
|
|||||||
|
|
||||||
## Project Dashboards
|
## Project Dashboards
|
||||||
|
|
||||||
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, and rich text — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
|
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
|
||||||
|
|
||||||
### What You Can Build
|
### What You Can Build
|
||||||
|
|
||||||
@@ -153,6 +153,7 @@ Project Dashboards turn Scarf into a customizable monitoring hub for all your pr
|
|||||||
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
||||||
- **Research dashboards** — experiment results, key findings, paper status checklists
|
- **Research dashboards** — experiment results, key findings, paper status checklists
|
||||||
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
||||||
|
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
|
||||||
- **Any project status** — if your agent can measure it, Scarf can display it
|
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
@@ -227,6 +228,23 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
|
|||||||
| `table` | Data table with headers | `columns`, `rows` |
|
| `table` | Data table with headers | `columns`, `rows` |
|
||||||
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
|
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
|
||||||
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||||
|
| `webview` | Embedded web browser | `url`, `height` (default 400) |
|
||||||
|
|
||||||
|
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
|
||||||
|
|
||||||
|
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "Project Report",
|
||||||
|
"url": "http://localhost:8000/dashboard",
|
||||||
|
"height": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
|
||||||
|
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
|
||||||
|
|
||||||
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
|
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
|
||||||
|
|
||||||
@@ -236,7 +254,7 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
|
|||||||
|
|
||||||
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
|
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
|
||||||
|
|
||||||
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||||
|
|
||||||
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
||||||
|
|
||||||
|
|||||||
@@ -141,13 +141,29 @@ Create `.scarf/dashboard.json` in your project root:
|
|||||||
|
|
||||||
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
|
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
|
||||||
|
|
||||||
|
### webview — Embedded web browser
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "Project Dashboard",
|
||||||
|
"url": "http://localhost:8000",
|
||||||
|
"height": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url`: Any URL — local servers, file paths, or remote pages
|
||||||
|
- `height`: Height in points (optional, default: 400)
|
||||||
|
|
||||||
|
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
|
||||||
|
|
||||||
## Agent Instructions
|
## Agent Instructions
|
||||||
|
|
||||||
To have your Hermes agent generate a dashboard, include these instructions:
|
To have your Hermes agent generate a dashboard, include these instructions:
|
||||||
|
|
||||||
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
|
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
|
||||||
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
|
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
|
||||||
> containing stat, progress, text, table, chart, and list widgets. Register the project
|
> containing stat, progress, text, table, chart, list, and webview widgets. Register the project
|
||||||
> in `~/.hermes/scarf/projects.json` if not already registered.
|
> in `~/.hermes/scarf/projects.json` if not already registered.
|
||||||
|
|
||||||
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
|
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
enum HermesPaths: Sendable {
|
enum HermesPaths: Sendable {
|
||||||
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
|
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
||||||
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
|
?? NSHomeDirectory()
|
||||||
|
|
||||||
|
nonisolated static let home: String = userHome + "/.hermes"
|
||||||
nonisolated static let stateDB: String = home + "/state.db"
|
nonisolated static let stateDB: String = home + "/state.db"
|
||||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||||
nonisolated static let memoriesDir: String = home + "/memories"
|
nonisolated static let memoriesDir: String = home + "/memories"
|
||||||
@@ -15,7 +18,32 @@ enum HermesPaths: Sendable {
|
|||||||
nonisolated static let skillsDir: String = home + "/skills"
|
nonisolated static let skillsDir: String = home + "/skills"
|
||||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||||
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
|
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||||
nonisolated static let scarfDir: String = home + "/scarf"
|
nonisolated static let scarfDir: String = home + "/scarf"
|
||||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - SQLite Constants
|
||||||
|
|
||||||
|
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
|
||||||
|
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
|
||||||
|
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
|
||||||
|
// MARK: - Query Defaults
|
||||||
|
|
||||||
|
enum QueryDefaults: Sendable {
|
||||||
|
nonisolated static let sessionLimit = 100
|
||||||
|
nonisolated static let messageSearchLimit = 50
|
||||||
|
nonisolated static let toolCallLimit = 50
|
||||||
|
nonisolated static let sessionPreviewLimit = 10
|
||||||
|
nonisolated static let previewContentLength = 100
|
||||||
|
nonisolated static let logLineLimit = 200
|
||||||
|
nonisolated static let defaultSilenceThreshold = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File Size Formatting
|
||||||
|
|
||||||
|
enum FileSizeUnit: Sendable {
|
||||||
|
nonisolated static let kilobyte = 1_024.0
|
||||||
|
nonisolated static let megabyte = 1_048_576.0
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ struct HermesToolPlatform: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum KnownPlatforms {
|
enum KnownPlatforms {
|
||||||
|
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||||
static let all: [HermesToolPlatform] = [
|
static let all: [HermesToolPlatform] = [
|
||||||
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
|
cli,
|
||||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||||
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ struct DashboardWidget: Codable, Sendable, Identifiable {
|
|||||||
|
|
||||||
// List
|
// List
|
||||||
let items: [ListItem]?
|
let items: [ListItem]?
|
||||||
|
|
||||||
|
// Webview
|
||||||
|
let url: String?
|
||||||
|
let height: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Widget Value (String or Number)
|
// MARK: - Widget Value (String or Number)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ actor HermesDataService {
|
|||||||
db = nil
|
db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessions(limit: Int = 100) -> [HermesSession] {
|
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT id, source, user_id, model, title, parent_session_id,
|
SELECT id, source, user_id, model, title, parent_session_id,
|
||||||
@@ -59,7 +59,7 @@ actor HermesDataService {
|
|||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
@@ -68,7 +68,7 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
|
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
||||||
@@ -82,7 +82,7 @@ actor HermesDataService {
|
|||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
sqlite3_bind_text(stmt, 1, query, -1, sqliteTransient)
|
||||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
@@ -92,7 +92,7 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
|
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
||||||
@@ -114,10 +114,10 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
|
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||||
guard let db else { return [:] }
|
guard let db else { return [:] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.session_id, substr(m.content, 1, 100)
|
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT session_id, MIN(id) as min_id
|
SELECT session_id, MIN(id) as min_id
|
||||||
@@ -149,13 +149,15 @@ actor HermesDataService {
|
|||||||
let totalInputTokens: Int
|
let totalInputTokens: Int
|
||||||
let totalOutputTokens: Int
|
let totalOutputTokens: Int
|
||||||
let totalCostUSD: Double
|
let totalCostUSD: Double
|
||||||
|
|
||||||
|
static let empty = SessionStats(
|
||||||
|
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||||
|
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStats() -> SessionStats {
|
func fetchStats() -> SessionStats {
|
||||||
guard let db else {
|
guard let db else { return .empty }
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
@@ -163,16 +165,9 @@ actor HermesDataService {
|
|||||||
FROM sessions
|
FROM sessions
|
||||||
"""
|
"""
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||||
guard sqlite3_step(stmt) == SQLITE_ROW else {
|
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
return SessionStats(
|
return SessionStats(
|
||||||
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
||||||
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
||||||
@@ -344,7 +339,12 @@ actor HermesDataService {
|
|||||||
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
||||||
guard let json, !json.isEmpty,
|
guard let json, !json.isEmpty,
|
||||||
let data = json.data(using: .utf8) else { return [] }
|
let data = json.data(using: .utf8) else { return [] }
|
||||||
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? []
|
do {
|
||||||
|
return try JSONDecoder().decode([HermesToolCall].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct HermesFileService: Sendable {
|
|||||||
showReasoning: values["display.show_reasoning"] == "true",
|
showReasoning: values["display.show_reasoning"] == "true",
|
||||||
verbose: values["agent.verbose"] == "true",
|
verbose: values["agent.verbose"] == "true",
|
||||||
autoTTS: values["voice.auto_tts"] != "false",
|
autoTTS: values["voice.auto_tts"] != "false",
|
||||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200
|
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,12 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
func loadGatewayState() -> GatewayState? {
|
func loadGatewayState() -> GatewayState? {
|
||||||
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
||||||
return try? JSONDecoder().decode(GatewayState.self, from: data)
|
do {
|
||||||
|
return try JSONDecoder().decode(GatewayState.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Memory
|
// MARK: - Memory
|
||||||
@@ -77,8 +82,13 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
func loadCronJobs() -> [HermesCronJob] {
|
func loadCronJobs() -> [HermesCronJob] {
|
||||||
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
||||||
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
|
do {
|
||||||
return file?.jobs ?? []
|
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||||
|
return file.jobs
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCronOutput(jobId: String) -> String? {
|
func loadCronOutput(jobId: String) -> String? {
|
||||||
@@ -123,7 +133,13 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSkillContent(path: String) -> String {
|
func loadSkillContent(path: String) -> String {
|
||||||
readFile(path) ?? ""
|
// Validate path stays within the skills directory to prevent traversal
|
||||||
|
guard !path.contains(".."),
|
||||||
|
path.hasPrefix(HermesPaths.skillsDir) else {
|
||||||
|
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return readFile(path) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hermes Process
|
// MARK: - Hermes Process
|
||||||
@@ -156,6 +172,10 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeFile(_ path: String, content: String) {
|
private func writeFile(_ path: String, content: String) {
|
||||||
try? content.write(toFile: path, atomically: true, encoding: .utf8)
|
do {
|
||||||
|
try content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ actor HermesLogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeLog() {
|
func closeLog() {
|
||||||
try? fileHandle?.close()
|
do {
|
||||||
|
try fileHandle?.close()
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
fileHandle = nil
|
fileHandle = nil
|
||||||
currentPath = nil
|
currentPath = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLastLines(count: Int = 200) -> [LogEntry] {
|
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||||
guard let path = currentPath,
|
guard let path = currentPath,
|
||||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||||
let content = String(data: data, encoding: .utf8) ?? ""
|
let content = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ struct ProjectDashboardService: Sendable {
|
|||||||
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
return (try? JSONDecoder().decode(ProjectRegistry.self, from: data))
|
do {
|
||||||
?? ProjectRegistry(projects: [])
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
||||||
|
return ProjectRegistry(projects: [])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveRegistry(_ registry: ProjectRegistry) {
|
func saveRegistry(_ registry: ProjectRegistry) {
|
||||||
let dir = HermesPaths.scarfDir
|
let dir = HermesPaths.scarfDir
|
||||||
if !FileManager.default.fileExists(atPath: dir) {
|
if !FileManager.default.fileExists(atPath: dir) {
|
||||||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||||
// Pretty-print for readability (agents may read this file)
|
// Pretty-print for readability (agents may read this file)
|
||||||
@@ -33,7 +42,12 @@ struct ProjectDashboardService: Sendable {
|
|||||||
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
|
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return try? JSONDecoder().decode(ProjectDashboard.self, from: data)
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dashboardExists(for project: ProjectEntry) -> Bool {
|
func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ final class DashboardViewModel {
|
|||||||
private let dataService = HermesDataService()
|
private let dataService = HermesDataService()
|
||||||
private let fileService = HermesFileService()
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
var stats = HermesDataService.SessionStats(
|
var stats = HermesDataService.SessionStats.empty
|
||||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
|
||||||
)
|
|
||||||
var recentSessions: [HermesSession] = []
|
var recentSessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
var sessionPreviews: [String: String] = [:]
|
||||||
var config = HermesConfig.empty
|
var config = HermesConfig.empty
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum DashboardTab: String, CaseIterable {
|
||||||
|
case dashboard = "Dashboard"
|
||||||
|
case site = "Site"
|
||||||
|
}
|
||||||
|
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@State private var viewModel = ProjectsViewModel()
|
@State private var viewModel = ProjectsViewModel()
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
@@ -76,18 +82,36 @@ struct ProjectsView: View {
|
|||||||
|
|
||||||
// MARK: - Dashboard Area
|
// MARK: - Dashboard Area
|
||||||
|
|
||||||
|
/// First webview widget found across all sections, if any.
|
||||||
|
private var siteWidget: DashboardWidget? {
|
||||||
|
viewModel.dashboard?.sections
|
||||||
|
.flatMap(\.widgets)
|
||||||
|
.first { $0.type == "webview" }
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var dashboardArea: some View {
|
private var dashboardArea: some View {
|
||||||
if let dashboard = viewModel.dashboard {
|
if let dashboard = viewModel.dashboard {
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
|
||||||
dashboardHeader(dashboard)
|
dashboardHeader(dashboard)
|
||||||
ForEach(dashboard.sections) { section in
|
.padding(.horizontal)
|
||||||
DashboardSectionView(section: section)
|
.padding(.top)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
if siteWidget != nil {
|
||||||
|
tabBar
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
switch selectedTab {
|
||||||
|
case .dashboard:
|
||||||
|
widgetsTab(dashboard)
|
||||||
|
case .site:
|
||||||
|
if let widget = siteWidget {
|
||||||
|
siteTab(widget)
|
||||||
|
} else {
|
||||||
|
widgetsTab(dashboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
||||||
}
|
}
|
||||||
} else if let error = viewModel.dashboardError {
|
} else if let error = viewModel.dashboardError {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
@@ -112,6 +136,48 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tabBar: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||||
|
Button {
|
||||||
|
selectedTab = tab
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||||
|
.font(.caption)
|
||||||
|
Text(tab.rawValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||||
|
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func widgetsTab(_ dashboard: ProjectDashboard) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ForEach(dashboard.sections) { section in
|
||||||
|
DashboardSectionView(section: section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func siteTab(_ widget: DashboardWidget) -> some View {
|
||||||
|
WebviewWidgetView(widget: widget, fullCanvas: true)
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
|
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -152,7 +218,13 @@ struct ProjectsView: View {
|
|||||||
struct DashboardSectionView: View {
|
struct DashboardSectionView: View {
|
||||||
let section: DashboardSection
|
let section: DashboardSection
|
||||||
|
|
||||||
|
/// Filter out webview widgets — those are rendered in the Site tab instead.
|
||||||
|
private var displayWidgets: [DashboardWidget] {
|
||||||
|
section.widgets.filter { $0.type != "webview" }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if !displayWidgets.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(section.title)
|
Text(section.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -160,13 +232,14 @@ struct DashboardSectionView: View {
|
|||||||
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
|
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
|
||||||
spacing: 12
|
spacing: 12
|
||||||
) {
|
) {
|
||||||
ForEach(section.widgets) { widget in
|
ForEach(displayWidgets) { widget in
|
||||||
WidgetView(widget: widget)
|
WidgetView(widget: widget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Widget Dispatcher
|
// MARK: - Widget Dispatcher
|
||||||
|
|
||||||
@@ -188,6 +261,8 @@ struct WidgetView: View {
|
|||||||
ChartWidgetView(widget: widget)
|
ChartWidgetView(widget: widget)
|
||||||
case "list":
|
case "list":
|
||||||
ListWidgetView(widget: widget)
|
ListWidgetView(widget: widget)
|
||||||
|
case "webview":
|
||||||
|
WebviewWidgetView(widget: widget)
|
||||||
default:
|
default:
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "questionmark.square.dashed")
|
Image(systemName: "questionmark.square.dashed")
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct WebviewWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
var fullCanvas: Bool = false
|
||||||
|
|
||||||
|
private var webURL: URL? {
|
||||||
|
guard let urlString = widget.url else { return nil }
|
||||||
|
return URL(string: urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var viewHeight: CGFloat {
|
||||||
|
CGFloat(widget.height ?? 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if fullCanvas {
|
||||||
|
fullCanvasView
|
||||||
|
} else {
|
||||||
|
cardView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full Canvas (Site tab)
|
||||||
|
|
||||||
|
private var fullCanvasView: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let url = webURL {
|
||||||
|
WebViewRepresentable(url: url)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Invalid URL", systemImage: "globe")
|
||||||
|
} description: {
|
||||||
|
Text(widget.url ?? "No URL provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card (inline widget)
|
||||||
|
|
||||||
|
private var cardView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
if let icon = widget.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if let urlString = widget.url {
|
||||||
|
Text(urlString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let url = webURL {
|
||||||
|
WebViewRepresentable(url: url)
|
||||||
|
.frame(height: viewHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Invalid URL", systemImage: "globe")
|
||||||
|
} description: {
|
||||||
|
Text(widget.url ?? "No URL provided")
|
||||||
|
}
|
||||||
|
.frame(height: viewHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKWebView Wrapper
|
||||||
|
|
||||||
|
private struct WebViewRepresentable: NSViewRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = .nonPersistent()
|
||||||
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
webView.navigationDelegate = context.coordinator
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||||
|
if webView.url != url {
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,10 +158,10 @@ final class SessionsViewModel {
|
|||||||
let fileSize: String
|
let fileSize: String
|
||||||
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
|
||||||
let size = attrs[.size] as? Int {
|
let size = attrs[.size] as? Int {
|
||||||
if size >= 1_048_576 {
|
if Double(size) >= FileSizeUnit.megabyte {
|
||||||
fileSize = String(format: "%.1f MB", Double(size) / 1_048_576)
|
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
|
||||||
} else {
|
} else {
|
||||||
fileSize = String(format: "%.0f KB", Double(size) / 1_024)
|
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileSize = "unknown"
|
fileSize = "unknown"
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ final class SettingsViewModel {
|
|||||||
config = fileService.loadConfig()
|
config = fileService.loadConfig()
|
||||||
gatewayState = fileService.loadGatewayState()
|
gatewayState = fileService.loadGatewayState()
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
do {
|
||||||
|
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||||
|
rawConfigYAML = ""
|
||||||
|
}
|
||||||
personalities = parsePersonalities()
|
personalities = parsePersonalities()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ToolsViewModel {
|
final class ToolsViewModel {
|
||||||
var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0]
|
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||||
var toolsets: [HermesToolset] = []
|
var toolsets: [HermesToolset] = []
|
||||||
var mcpStatus: String = ""
|
var mcpStatus: String = ""
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
@@ -30,7 +30,13 @@ final class ToolsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPlatforms() {
|
private func loadPlatforms() {
|
||||||
let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
let config: String
|
||||||
|
do {
|
||||||
|
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||||
|
config = ""
|
||||||
|
}
|
||||||
var platforms: [HermesToolPlatform] = []
|
var platforms: [HermesToolPlatform] = []
|
||||||
var inSection = false
|
var inSection = false
|
||||||
for line in config.components(separatedBy: "\n") {
|
for line in config.components(separatedBy: "\n") {
|
||||||
@@ -54,9 +60,10 @@ final class ToolsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms
|
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
|
||||||
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) {
|
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
|
||||||
selectedPlatform = availablePlatforms[0]
|
let first = availablePlatforms.first {
|
||||||
|
selectedPlatform = first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user