mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Merge branch 'claude/pedantic-mcnulty-bac7cc' (iOS UI refactor)
Brings the major iOS UI refactor into scarf-mobile-development on top
of the v0.11 work that landed after the merge base (commit 6808adf).
Reconciled in this merge:
- iOS Chat/ChatView.swift — auto-merged. Their project-chat handoff
(lines 75-148: pendingProjectChat consumer + consumePendingProjectChat
helper) sits cleanly alongside my v0.11 chat additions at lines 350+
(slash command chip + browser sheet), 500+ (/steer toast), 700+
(per-turn stopwatch + git branch chip).
- Mac Features/Skills/Views/SkillsView.swift — manual resolution.
Took their async-wrap of viewModel.load() (the new ScarfCore
SkillsViewModel.load is async) AND kept my v0.11 modifiers
(designMdNpxStatus probe + recomputeSnapshotDiff + .onChange + .task)
+ helpers (recomputeSnapshotDiff, whatsNewPill).
- M5FeatureVMTests.swift — auto-merged. Their 3-line rename of
IOSSkillsViewModel → SkillsViewModel is in a different region from
my Phase 1.10 slash-command tests.
- iOS Skills/SkillsListView.swift — resolved as DELETE (their
refactor replaces it with Skills/Installed/SkillDetailView and
Skills/SkillsView). My v0.11 features there (Spotify info row,
design-md banner, frontmatter chips, What's New pill) get re-ported
to the new files in follow-up commits.
- ScarfCore IOSSkillsViewModel.swift — resolved as DELETE (replaced
by the shared SkillsViewModel in ScarfCore). My parseFrontmatter
function relocates to SkillFrontmatterParser via Phase C.
- ProjectSlashCommandsViewModel.swift — git's location-conflict
heuristic moved my Mac VM into ScarfCore (because the parent dir
was renamed). Manually relocated back to scarf/scarf/Features/Projects/ViewModels/
where it belongs (the file imports ScarfCore as a dependency, can't
live inside it).
Wholesale-accepted (no overlap with v0.11):
- ScarfCore: SkillsScanner, SkillFrontmatterParser, HermesSkillsHubParser,
SkillsViewModel, ProjectSessionsViewModel + new tests.
- iOS Projects/ feature (NEW): ProjectsListView, ProjectDetailView,
ProjectSessionsView_iOS, ProjectSiteView, Widgets/ subdir.
- iOS Skills/ refactor (NEW): SkillsView (3-sub-tab switcher),
Hub/HubBrowseView, Installed/{InstalledSkillsListView, SkillDetailView,
SkillEditorSheet}, Updates/UpdatesView.
- ScarfGoCoordinator: pendingProjectChat, startChatInProject(path:).
- ScarfGoTabRoot: 5-tab nav (Dashboard / Projects / Chat / Skills /
System) replacing the old Chat / Dashboard / Memory / More.
Verified: ScarfCore + Mac + iOS schemes all build clean on first try
post-merge. Phase C/D/E follow-up commits will:
1. Extend SkillsScanner so HermesSkill.allowedTools / relatedSkills /
dependencies populate (currently nil because the new scanner only
parses skill.yaml's required_config).
2. Port my v0.11 iOS Skills features into the new SkillDetailView /
SkillsView (Spotify info row, design-md npx banner, frontmatter
chips, What's New pill).
3. Clean up Mac dead code (HermesFileService.parseSkillFrontmatter,
parseSkillRequiredConfig — superseded by SkillsScanner /
SkillFrontmatterParser).
This commit is contained in:
@@ -5,10 +5,11 @@ import ScarfCore
|
||||
/// `AppCoordinator` pattern: an `@Observable` carrier injected via
|
||||
/// `.environment(_:)` that any view in the tab tree can reach.
|
||||
///
|
||||
/// Single responsibility in M9 scope: route "user tapped a recent
|
||||
/// session in Dashboard" → "open the Chat tab with a resume request."
|
||||
/// Future uses (project-scoped chat handoff, notification deep-link
|
||||
/// → specific session) compose naturally on the same primitive.
|
||||
/// v2.5 expands the surface to include project handoff: tapping
|
||||
/// "New Chat" inside a Project Detail view sets `pendingProjectChat`
|
||||
/// and routes to the Chat tab, where ChatController consumes it and
|
||||
/// dispatches `resetAndStartInProject(_:)` (same wiring the in-Chat
|
||||
/// project picker sheet already uses).
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ScarfGoCoordinator {
|
||||
@@ -23,8 +24,15 @@ final class ScarfGoCoordinator {
|
||||
/// ChatController after it honours the request.
|
||||
var pendingResumeSessionID: String?
|
||||
|
||||
/// If non-nil, the Chat tab should start an in-project session at
|
||||
/// this absolute remote path on next appear instead of a quick
|
||||
/// chat. Consumed (cleared) by ChatController after it kicks off
|
||||
/// `resetAndStartInProject(_:)`. Mirrors Mac's
|
||||
/// `AppCoordinator.pendingProjectChat`.
|
||||
var pendingProjectChat: String?
|
||||
|
||||
enum Tab: Hashable {
|
||||
case chat, dashboard, memory, more
|
||||
case dashboard, projects, chat, skills, system
|
||||
}
|
||||
|
||||
/// Convenience: route to Chat and queue a resume. Dashboard rows
|
||||
@@ -35,6 +43,16 @@ final class ScarfGoCoordinator {
|
||||
pendingResumeSessionID = id
|
||||
selectedTab = .chat
|
||||
}
|
||||
|
||||
/// Convenience: route to Chat and queue a project-scoped session
|
||||
/// start at `path`. Project Detail's "New Chat" toolbar button
|
||||
/// calls this. Clearing `pendingProjectChat` is the consumer's
|
||||
/// responsibility (ChatController) once `resetAndStartInProject`
|
||||
/// has been dispatched.
|
||||
func startChatInProject(path: String) {
|
||||
pendingProjectChat = path
|
||||
selectedTab = .chat
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment key so subviews can pull the coordinator without
|
||||
|
||||
@@ -2,22 +2,26 @@ import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfIOS
|
||||
|
||||
/// ScarfGo's primary navigation surface. Replaces the pre-M8
|
||||
/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/
|
||||
/// Settings lived as NavigationLink rows three-quarters of the way
|
||||
/// down a scrolling List — pass-1 user-visible complaint:
|
||||
/// ScarfGo's primary navigation surface. v2.5 expands the original
|
||||
/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs
|
||||
/// with Chat in the mathematical center:
|
||||
///
|
||||
/// > "We should have the actions for the user in a permanent footer?
|
||||
/// > I don't see any navigation."
|
||||
/// Dashboard | Projects | Chat | Skills | System
|
||||
///
|
||||
/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used
|
||||
/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same
|
||||
/// tree degrades to a bottom tab bar on iPhone and gets a native
|
||||
/// sidebar on iPadOS / macCatalyst if we ever add those targets.
|
||||
/// "Chat in the middle" is the v2.5 product ask — chat is the action
|
||||
/// users come back for, so it's the most thumb-reachable slot on a
|
||||
/// phone-sized device. We stay on Apple's native `TabView` instead of
|
||||
/// drawing a custom raised center button: 5 tabs is exactly the iPhone
|
||||
/// system maximum (no auto-collapse to "More"), and `.sidebarAdaptable`
|
||||
/// continues to give us a real sidebar on iPad / macCatalyst for free.
|
||||
/// Memory drops out of primary slots and lives inside the renamed
|
||||
/// "System" tab (was "More"). Skills graduates from a System sub-row
|
||||
/// into its own primary tab to match v2.5's full Mac parity for skills
|
||||
/// (Installed / Browse Hub / Updates).
|
||||
///
|
||||
/// Each tab wraps its feature view in its own `NavigationStack` so
|
||||
/// push navigation (Cron editor, Memory detail, etc.) stays scoped
|
||||
/// to the tab instead of bleeding across.
|
||||
/// Each tab wraps its feature view in its own `NavigationStack` so push
|
||||
/// navigation (Cron editor, Memory detail, Project detail, etc.) stays
|
||||
/// scoped to the tab instead of bleeding across.
|
||||
struct ScarfGoTabRoot: View {
|
||||
let serverID: ServerID
|
||||
let config: IOSServerConfig
|
||||
@@ -26,8 +30,9 @@ struct ScarfGoTabRoot: View {
|
||||
let onForget: @MainActor () async -> Void
|
||||
|
||||
/// One coordinator per server-connected session. Cross-tab
|
||||
/// signalling (Dashboard row → Chat tab resume, eventually
|
||||
/// notification deep-link → Chat) flows through here.
|
||||
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||
/// through here.
|
||||
@State private var coordinator = ScarfGoCoordinator()
|
||||
|
||||
var body: some View {
|
||||
@@ -39,18 +44,7 @@ struct ScarfGoTabRoot: View {
|
||||
// SSH channel contention.
|
||||
let ctx = config.toServerContext(id: serverID)
|
||||
TabView(selection: $coordinator.selectedTab) {
|
||||
// 1 — Chat: the reason the app is on your phone. Primary
|
||||
// tab; opens straight into the chat surface.
|
||||
NavigationStack {
|
||||
ChatView(config: config, key: key)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.chat)
|
||||
|
||||
// 2 — Dashboard: stats + recent sessions (no surfaces list
|
||||
// anymore — those live in More).
|
||||
// 1 — Dashboard: stats + recent sessions.
|
||||
NavigationStack {
|
||||
DashboardView(config: config, key: key)
|
||||
}
|
||||
@@ -58,33 +52,59 @@ struct ScarfGoTabRoot: View {
|
||||
Label("Dashboard", systemImage: "gauge.with.needle")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.dashboard)
|
||||
.accessibilityLabel("Dashboard tab")
|
||||
|
||||
// 3 — Memory: MEMORY.md + USER.md + SOUL.md.
|
||||
// 2 — Projects: registered projects → per-project dashboard,
|
||||
// site, and sessions. Read-only registry on iOS — add /
|
||||
// rename / archive happens in the Mac app.
|
||||
NavigationStack {
|
||||
MemoryListView(config: config)
|
||||
ProjectsListView(config: config)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Memory", systemImage: "brain.head.profile")
|
||||
Label("Projects", systemImage: "square.grid.2x2")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.memory)
|
||||
.tag(ScarfGoCoordinator.Tab.projects)
|
||||
.accessibilityLabel("Projects tab")
|
||||
|
||||
// 4 — More: Cron, Skills, Settings, plus the destructive
|
||||
// "Forget this server" action. Named "More" because on
|
||||
// iOS 18 with .sidebarAdaptable the system collapses
|
||||
// leftover tabs into a disclosure group with that exact
|
||||
// label automatically; choosing the same word keeps our
|
||||
// More tab visually consistent with the system default.
|
||||
// 3 — Chat: the reason the app is on your phone. Centered
|
||||
// among the 5 tabs for thumb reach + visual prominence.
|
||||
NavigationStack {
|
||||
MoreTab(
|
||||
ChatView(config: config, key: key)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.chat)
|
||||
.accessibilityLabel("Chat tab")
|
||||
|
||||
// 4 — Skills: Installed | Browse Hub | Updates, mirroring
|
||||
// the Mac app's 3-tab skills surface.
|
||||
NavigationStack {
|
||||
SkillsView(config: config)
|
||||
}
|
||||
.tabItem {
|
||||
Label("Skills", systemImage: "lightbulb")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.skills)
|
||||
.accessibilityLabel("Skills tab")
|
||||
|
||||
// 5 — System: server identity, Memory, Cron, Settings, plus
|
||||
// the destructive disconnect / forget actions. Renamed from
|
||||
// "More" to match the user-facing v2.5 vocabulary; the
|
||||
// .sidebarAdaptable system fallback label happens not to
|
||||
// matter here because we never overflow.
|
||||
NavigationStack {
|
||||
SystemTab(
|
||||
config: config,
|
||||
onSoftDisconnect: onSoftDisconnect,
|
||||
onForget: onForget
|
||||
)
|
||||
}
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis.circle")
|
||||
Label("System", systemImage: "gearshape.fill")
|
||||
}
|
||||
.tag(ScarfGoCoordinator.Tab.more)
|
||||
.tag(ScarfGoCoordinator.Tab.system)
|
||||
.accessibilityLabel("System tab")
|
||||
}
|
||||
// Pulls the sidebar-on-iPad affordance into the same code path
|
||||
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
|
||||
@@ -101,14 +121,15 @@ struct ScarfGoTabRoot: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups the features that don't deserve a primary tab on a phone:
|
||||
/// Cron (infrequent edits), Skills (read-only), Settings (read-only
|
||||
/// until M9 scoped editor), plus the destructive server-forget action.
|
||||
/// Server identity + Memory + Cron + Settings + destructive actions.
|
||||
/// "System" reads as configuration / server-meta; the reorganization
|
||||
/// in v2.5 promotes Skills out of here into its own primary tab and
|
||||
/// pulls Memory in from a primary tab into a NavigationLink row.
|
||||
///
|
||||
/// Kept private to this file because we don't expect it to be reused
|
||||
/// elsewhere — if a feature graduates to a primary tab, that's a
|
||||
/// deliberate design decision.
|
||||
private struct MoreTab: View {
|
||||
private struct SystemTab: View {
|
||||
let config: IOSServerConfig
|
||||
let onSoftDisconnect: @MainActor () async -> Void
|
||||
let onForget: @MainActor () async -> Void
|
||||
@@ -131,15 +152,15 @@ private struct MoreTab: View {
|
||||
|
||||
Section("Features") {
|
||||
NavigationLink {
|
||||
CronListView(config: config)
|
||||
MemoryListView(config: config)
|
||||
} label: {
|
||||
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
|
||||
Label("Memory", systemImage: "brain.head.profile")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
NavigationLink {
|
||||
SkillsListView(config: config)
|
||||
CronListView(config: config)
|
||||
} label: {
|
||||
Label("Skills", systemImage: "sparkles")
|
||||
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
NavigationLink {
|
||||
@@ -194,7 +215,7 @@ private struct MoreTab: View {
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle("More")
|
||||
.navigationTitle("System")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"Forget this server?",
|
||||
|
||||
@@ -79,29 +79,37 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
.task {
|
||||
// Dashboard row taps set `pendingResumeSessionID` on the
|
||||
// coordinator before switching to the Chat tab. Honor
|
||||
// that if present, else open a fresh session. Clearing
|
||||
// the coordinator value is the consumer's responsibility
|
||||
// (us) — otherwise a later plain tap on the Chat tab
|
||||
// would accidentally re-resume the old session.
|
||||
// Dashboard row taps set `pendingResumeSessionID`, Project
|
||||
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
|
||||
// a tab switch to .chat alongside the value set; we
|
||||
// consume + clear here on first appear. Resume wins over
|
||||
// project-chat if both somehow get set in a single hop —
|
||||
// but in practice the coordinator never sets both at once.
|
||||
if let sessionID = coordinator?.pendingResumeSessionID {
|
||||
coordinator?.pendingResumeSessionID = nil
|
||||
await controller.startResuming(sessionID: sessionID)
|
||||
} else if let projectPath = coordinator?.pendingProjectChat {
|
||||
coordinator?.pendingProjectChat = nil
|
||||
await consumePendingProjectChat(projectPath)
|
||||
} else {
|
||||
await controller.start()
|
||||
}
|
||||
}
|
||||
// Also react to a coordinator change that happens while Chat
|
||||
// is already mounted (e.g., user is in Chat, switches to
|
||||
// Dashboard, taps a session row — coordinator flips the tab
|
||||
// AND sets pendingResumeSessionID. The `.task` above only
|
||||
// fires on first appear; this is the mid-session hook.)
|
||||
// React to coordinator changes that happen while Chat is
|
||||
// already mounted (e.g., user is in Chat, taps Projects, opens
|
||||
// a project detail, taps "New Chat" — coordinator flips the
|
||||
// tab AND sets pendingProjectChat. The `.task` above only
|
||||
// fires on first appear; these are the mid-session hooks.)
|
||||
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
|
||||
guard let sessionID = new else { return }
|
||||
coordinator?.pendingResumeSessionID = nil
|
||||
Task { await controller.startResuming(sessionID: sessionID) }
|
||||
}
|
||||
.onChange(of: coordinator?.pendingProjectChat) { _, new in
|
||||
guard let projectPath = new else { return }
|
||||
coordinator?.pendingProjectChat = nil
|
||||
Task { await consumePendingProjectChat(projectPath) }
|
||||
}
|
||||
// Deliberately NOT tearing down the ACP session on .onDisappear.
|
||||
// `TabView` unmounts tab content when the user switches tabs
|
||||
// (disappear fires), but `@State var controller` keeps the
|
||||
@@ -144,6 +152,27 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a project absolute path to a `ProjectEntry` via the
|
||||
/// transport-backed registry, then dispatch `resetAndStartInProject`.
|
||||
/// If the path isn't registered (race with a Mac-app removal, or
|
||||
/// SFTP read failure), fall back to a synthesized entry whose name
|
||||
/// is the path's last component — chat still starts and the user
|
||||
/// sees a usable project chip.
|
||||
private func consumePendingProjectChat(_ path: String) async {
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
let entry: ProjectEntry = await Task.detached {
|
||||
let registry = ProjectDashboardService(context: ctx).loadRegistry()
|
||||
if let match = registry.projects.first(where: { $0.path == path }) {
|
||||
return match
|
||||
}
|
||||
return ProjectEntry(
|
||||
name: (path as NSString).lastPathComponent.isEmpty ? path : (path as NSString).lastPathComponent,
|
||||
path: path
|
||||
)
|
||||
}.value
|
||||
await controller.resetAndStartInProject(entry)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Per-project detail view, presented when a row in `ProjectsListView`
|
||||
/// is tapped. Mirrors the Mac three-tab layout (Dashboard | Site |
|
||||
/// Sessions) using a segmented `Picker`. The Site segment is gated on
|
||||
/// the dashboard containing a `webview` widget — empty dashboards or
|
||||
/// dashboards without a site URL hide the segment to match Mac's
|
||||
/// `visibleTabs` logic in `ProjectsView.swift`.
|
||||
///
|
||||
/// "New Chat" toolbar button calls `ScarfGoCoordinator.startChatInProject`
|
||||
/// which sets `pendingProjectChat` and routes to the Chat tab.
|
||||
/// `ChatController` consumes `pendingProjectChat` on next appear and
|
||||
/// dispatches `resetAndStartInProject(_:)` — same wiring the existing
|
||||
/// in-Chat picker sheet uses.
|
||||
struct ProjectDetailView: View {
|
||||
let project: ProjectEntry
|
||||
let config: IOSServerConfig
|
||||
|
||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A2"
|
||||
)!
|
||||
|
||||
@State private var dashboard: ProjectDashboard?
|
||||
@State private var dashboardError: String?
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var selectedTab: DetailTab = .dashboard
|
||||
/// Last-seen mtime on `<project>/.scarf/dashboard.json`. The
|
||||
/// foreground poll task compares this against a fresh stat to
|
||||
/// decide whether to re-parse — cheap when the file is unchanged,
|
||||
/// and the poll only runs while the view is visible.
|
||||
@State private var lastDashboardMtime: Date?
|
||||
|
||||
enum DetailTab: Hashable {
|
||||
case dashboard, site, sessions
|
||||
}
|
||||
|
||||
private var serverContext: ServerContext {
|
||||
config.toServerContext(id: Self.sharedContextID)
|
||||
}
|
||||
|
||||
/// First webview widget across all sections, if any. Nil → Site
|
||||
/// segment hidden. Mirrors Mac `siteWidget`.
|
||||
private var siteWidget: DashboardWidget? {
|
||||
dashboard?
|
||||
.sections
|
||||
.flatMap(\.widgets)
|
||||
.first { $0.type == "webview" }
|
||||
}
|
||||
|
||||
private var visibleTabs: [DetailTab] {
|
||||
var tabs: [DetailTab] = [.dashboard]
|
||||
if siteWidget != nil { tabs.append(.site) }
|
||||
tabs.append(.sessions)
|
||||
return tabs
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tabPicker
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
Divider()
|
||||
tabContent
|
||||
}
|
||||
.navigationTitle(project.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
coordinator?.startChatInProject(path: project.path)
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||
}
|
||||
.accessibilityLabel("Start new chat in \(project.name)")
|
||||
.accessibilityHint("Opens the Chat tab and begins a session scoped to this project")
|
||||
}
|
||||
}
|
||||
.task(id: project.id) { await loadDashboard() }
|
||||
.task(id: project.id) { await pollDashboardMtime() }
|
||||
.refreshable { await loadDashboard() }
|
||||
.onChange(of: visibleTabs) { _, newTabs in
|
||||
// If the user was on Site and a refresh removed the
|
||||
// webview widget, fall back to Dashboard so the segmented
|
||||
// picker doesn't end up out-of-sync with its segments.
|
||||
if !newTabs.contains(selectedTab) {
|
||||
selectedTab = .dashboard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab picker
|
||||
|
||||
@ViewBuilder
|
||||
private var tabPicker: some View {
|
||||
Picker("Section", selection: $selectedTab) {
|
||||
ForEach(visibleTabs, id: \.self) { tab in
|
||||
Text(label(for: tab)).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
private func label(for tab: DetailTab) -> String {
|
||||
switch tab {
|
||||
case .dashboard: return "Dashboard"
|
||||
case .site: return "Site"
|
||||
case .sessions: return "Sessions"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab content
|
||||
|
||||
@ViewBuilder
|
||||
private var tabContent: some View {
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
dashboardTab
|
||||
case .site:
|
||||
if let widget = siteWidget {
|
||||
ProjectSiteView(widget: widget)
|
||||
} else {
|
||||
emptyDashboard
|
||||
}
|
||||
case .sessions:
|
||||
ProjectSessionsView_iOS(project: project)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dashboardTab: some View {
|
||||
if isLoading && dashboard == nil {
|
||||
ProgressView("Loading dashboard…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let dash = dashboard {
|
||||
DashboardWidgetsView(dashboard: dash)
|
||||
} else {
|
||||
emptyDashboard
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyDashboard: some View {
|
||||
ContentUnavailableView {
|
||||
Label("No Dashboard", systemImage: "rectangle.dashed")
|
||||
} description: {
|
||||
Text(dashboardError ?? "This project doesn't have a dashboard at \(project.dashboardPath) yet.")
|
||||
.font(.caption)
|
||||
} actions: {
|
||||
Button("Try Again") {
|
||||
Task { await loadDashboard() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Load the project's dashboard via `ProjectDashboardService` on a
|
||||
/// background task — same `Task.detached` pattern the registry
|
||||
/// loader uses to keep the SFTP read off MainActor.
|
||||
private func loadDashboard() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let ctx = serverContext
|
||||
let proj = project
|
||||
let result: (ProjectDashboard?, String?, Date?) = await Task.detached {
|
||||
let service = ProjectDashboardService(context: ctx)
|
||||
if !service.dashboardExists(for: proj) {
|
||||
return (nil, "No dashboard found at \(proj.dashboardPath)", nil)
|
||||
}
|
||||
let mtime = service.dashboardModificationDate(for: proj)
|
||||
if let loaded = service.loadDashboard(for: proj) {
|
||||
return (loaded, nil, mtime)
|
||||
}
|
||||
return (nil, "Failed to parse dashboard JSON", mtime)
|
||||
}.value
|
||||
dashboard = result.0
|
||||
dashboardError = result.1
|
||||
lastDashboardMtime = result.2
|
||||
}
|
||||
|
||||
/// Poll the dashboard file's mtime every 4 seconds while the view
|
||||
/// is foregrounded; reload on any change. iOS doesn't have an
|
||||
/// inotify-style watcher over SFTP, but a per-view poll is cheap
|
||||
/// (one stat call per tick) and stops the moment the user
|
||||
/// navigates away — the `.task` modifier cancels the loop on view
|
||||
/// disappear automatically.
|
||||
private func pollDashboardMtime() async {
|
||||
let ctx = serverContext
|
||||
let proj = project
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if Task.isCancelled { break }
|
||||
let fresh: Date? = await Task.detached {
|
||||
ProjectDashboardService(context: ctx)
|
||||
.dashboardModificationDate(for: proj)
|
||||
}.value
|
||||
// First tick after a missing-dashboard error: nil → nil is
|
||||
// a no-op; nil → Date triggers a reload (file just appeared).
|
||||
// Date → newer Date triggers a reload. Same Date is a no-op.
|
||||
switch (lastDashboardMtime, fresh) {
|
||||
case (nil, nil), (_, nil):
|
||||
continue
|
||||
case (nil, _):
|
||||
await loadDashboard()
|
||||
case (let prev?, let now?) where now > prev:
|
||||
await loadDashboard()
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS twin of the Mac per-project Sessions tab. Reuses the
|
||||
/// ScarfCore-side `ProjectSessionsViewModel` (promoted from the Mac
|
||||
/// target in v2.5) so attribution + filtering semantics stay
|
||||
/// identical. The "New Chat" button routes into the Chat tab via
|
||||
/// `ScarfGoCoordinator.startChatInProject(path:)`; row taps route via
|
||||
/// `coordinator.resumeSession(_:)`, the same primitive
|
||||
/// `DashboardView` already uses.
|
||||
struct ProjectSessionsView_iOS: View {
|
||||
let project: ProjectEntry
|
||||
|
||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
|
||||
@State private var viewModel: ProjectSessionsViewModel?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
.task(id: project.id) {
|
||||
// Rebuild the VM when the project changes so stale state
|
||||
// from a previously-selected project doesn't bleed
|
||||
// through.
|
||||
viewModel = ProjectSessionsViewModel(
|
||||
context: serverContext,
|
||||
project: project
|
||||
)
|
||||
await viewModel?.load()
|
||||
}
|
||||
.onDisappear {
|
||||
// Release the SQLite handle so it doesn't dangle once
|
||||
// the user leaves this tab. `load()` will re-open next
|
||||
// time. Mirrors ActivityView's disappear cleanup.
|
||||
Task { await viewModel?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Sessions in this project")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
Text("Chats you start here are attributed automatically.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
coordinator?.startChatInProject(path: project.path)
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Start new chat in \(project.name)")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let vm = viewModel {
|
||||
if vm.isLoading && vm.sessions.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
} else if vm.sessions.isEmpty {
|
||||
emptyState(hint: vm.emptyStateHint)
|
||||
} else {
|
||||
sessionList(vm.sessions)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyState(hint: String?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(hint ?? "No sessions yet.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private func sessionList(_ sessions: [HermesSession]) -> some View {
|
||||
List {
|
||||
ForEach(sessions) { session in
|
||||
Button {
|
||||
coordinator?.resumeSession(session.id)
|
||||
} label: {
|
||||
ProjectSessionRow_iOS(session: session)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single row in the per-project Sessions list. Mirrors the Mac
|
||||
/// `ProjectSessionRow` content but uses iOS-friendly text sizing.
|
||||
private struct ProjectSessionRow_iOS: View {
|
||||
let session: HermesSession
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: iconForSource(session.source))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(session.id.prefix(12))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
if let started = formattedStart {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(started)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(session.messageCount)")
|
||||
.font(.caption.monospaced())
|
||||
Text("msgs")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
if let t = session.title, !t.isEmpty { return t }
|
||||
return "Untitled session"
|
||||
}
|
||||
|
||||
private var formattedStart: String? {
|
||||
guard let date = session.startedAt else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func iconForSource(_ source: String) -> String {
|
||||
switch source.lowercased() {
|
||||
case "cli", "acp": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
default: return "message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Full-canvas webview wrapper for the Site sub-tab. Reuses the
|
||||
/// `WebviewWidgetView` representable in its `fullCanvas: true` mode so
|
||||
/// rendering, error handling, and the non-persistent data store all
|
||||
/// stay in one place.
|
||||
struct ProjectSiteView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
WebviewWidgetView(widget: widget, fullCanvas: true)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Top-level Projects tab. Lists registered Scarf projects from
|
||||
/// `~/.hermes/scarf/projects.json`. Folder groupings + archive flags
|
||||
/// from the v2.3 registry schema are honored — archived projects are
|
||||
/// hidden, top-level projects render flat, and any non-empty folder
|
||||
/// labels become a `Section` per folder.
|
||||
///
|
||||
/// Read-only on iOS for v2.5 — add / rename / move / archive happens
|
||||
/// in the Mac app, where the template installer + ConfigEditor live.
|
||||
/// The empty state copy directs users there.
|
||||
struct ProjectsListView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A2"
|
||||
)!
|
||||
|
||||
@State private var projects: [ProjectEntry] = []
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var loadError: String?
|
||||
|
||||
private var serverContext: ServerContext {
|
||||
config.toServerContext(id: Self.sharedContextID)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading && projects.isEmpty {
|
||||
ProgressView("Loading projects…")
|
||||
} else if let err = loadError, projects.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't load projects", systemImage: "exclamationmark.triangle.fill")
|
||||
} description: {
|
||||
Text(err)
|
||||
}
|
||||
} else if visibleProjects.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No projects yet", systemImage: "square.grid.2x2")
|
||||
} description: {
|
||||
Text("Use the Mac app to add and configure projects — they'll appear here automatically.")
|
||||
}
|
||||
} else {
|
||||
projectList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Projects")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(for: ProjectEntry.self) { project in
|
||||
ProjectDetailView(project: project, config: config)
|
||||
}
|
||||
.refreshable { await load() }
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var projectList: some View {
|
||||
let folders = folderLabels
|
||||
List {
|
||||
// Top-level (no folder) projects first, then folder
|
||||
// disclosure sections — same shape as Mac
|
||||
// ProjectsSidebar.swift renders.
|
||||
let topLevel = visibleProjects.filter { ($0.folder ?? "").isEmpty }
|
||||
if !topLevel.isEmpty {
|
||||
Section {
|
||||
ForEach(topLevel) { project in
|
||||
projectRow(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(folders, id: \.self) { folder in
|
||||
Section(folder) {
|
||||
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
|
||||
projectRow(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
|
||||
private func projectRow(_ project: ProjectEntry) -> some View {
|
||||
NavigationLink(value: project) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "folder.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 28)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(project.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(project.path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(project.name), at \(project.path)")
|
||||
.accessibilityHint("Opens project dashboard, site, and sessions")
|
||||
}
|
||||
|
||||
/// Visible projects = registry minus archived, sorted alphabetically.
|
||||
/// Mirrors Mac sidebar's default filter.
|
||||
private var visibleProjects: [ProjectEntry] {
|
||||
projects
|
||||
.filter { !$0.archived }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Distinct, sorted folder labels across the visible set. Empty
|
||||
/// strings are treated as top-level (filtered out here so they
|
||||
/// don't render as a "" section title).
|
||||
private var folderLabels: [String] {
|
||||
let set = Set(visibleProjects.compactMap(\.folder).filter { !$0.isEmpty })
|
||||
return set.sorted()
|
||||
}
|
||||
|
||||
/// Load the project registry over the active transport. Same
|
||||
/// pattern as `ProjectPickerSheet.loadProjects` — wrap the
|
||||
/// synchronous `ProjectDashboardService` calls in `Task.detached`
|
||||
/// so the SFTP read doesn't run on the MainActor.
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let ctx = serverContext
|
||||
do {
|
||||
let loaded: [ProjectEntry] = try await Task.detached {
|
||||
let service = ProjectDashboardService(context: ctx)
|
||||
return service.loadRegistry().projects
|
||||
}.value
|
||||
projects = loaded
|
||||
loadError = nil
|
||||
} catch {
|
||||
loadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import Charts
|
||||
|
||||
// Flattened data point for Charts to avoid complex nested generic inference
|
||||
private struct PlottablePoint: Identifiable {
|
||||
let id = UUID()
|
||||
let seriesName: String
|
||||
let x: String
|
||||
let y: Double
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct ChartWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
private var points: [PlottablePoint] {
|
||||
guard let series = widget.series else { return [] }
|
||||
return series.flatMap { s in
|
||||
let color = parseColor(s.color)
|
||||
return s.data.map { d in
|
||||
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
chartContent
|
||||
.frame(height: 150)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chartContent: some View {
|
||||
switch widget.chartType {
|
||||
case "pie":
|
||||
pieChart
|
||||
case "bar":
|
||||
barChart
|
||||
default:
|
||||
lineChart
|
||||
}
|
||||
}
|
||||
|
||||
private var lineChart: some View {
|
||||
Chart(points) { point in
|
||||
LineMark(
|
||||
x: .value("X", point.x),
|
||||
y: .value("Y", point.y)
|
||||
)
|
||||
.foregroundStyle(point.color)
|
||||
.symbol(by: .value("Series", point.seriesName))
|
||||
}
|
||||
}
|
||||
|
||||
private var barChart: some View {
|
||||
Chart(points) { point in
|
||||
BarMark(
|
||||
x: .value("X", point.x),
|
||||
y: .value("Y", point.y)
|
||||
)
|
||||
.foregroundStyle(point.color)
|
||||
}
|
||||
}
|
||||
|
||||
private var pieChart: some View {
|
||||
Chart(points) { point in
|
||||
SectorMark(
|
||||
angle: .value(point.x, point.y),
|
||||
innerRadius: .ratio(0.5)
|
||||
)
|
||||
.foregroundStyle(point.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS dashboard layout. ScrollView of sections; each section is a
|
||||
/// `LazyVGrid` whose column count is clamped to the device's
|
||||
/// `horizontalSizeClass`. iPhone (compact) → 1 column. iPad / split-
|
||||
/// view (regular) → 2 columns max, even when the dashboard JSON asks
|
||||
/// for 3 (3-column on a 13" iPad portrait still cramps individual
|
||||
/// widgets).
|
||||
///
|
||||
/// Webview widgets in card mode render inline like any other widget.
|
||||
/// The full-canvas Site tab is rendered separately by `ProjectSiteView`
|
||||
/// and excluded from this grid by `ProjectDetailView` before passing
|
||||
/// the dashboard down — so we don't filter here.
|
||||
struct DashboardWidgetsView: View {
|
||||
let dashboard: ProjectDashboard
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if let description = dashboard.description, !description.isEmpty {
|
||||
Text(description)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
ForEach(dashboard.sections) { section in
|
||||
sectionView(section)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionView(_ section: DashboardSection) -> some View {
|
||||
// Filter out webview widgets — those are rendered full-screen
|
||||
// in the Site tab instead. Matches Mac DashboardSectionView.
|
||||
let displayWidgets = section.widgets.filter { $0.type != "webview" }
|
||||
if !displayWidgets.isEmpty {
|
||||
let cols = columnCount(for: section)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !section.title.isEmpty {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: cols),
|
||||
spacing: 10
|
||||
) {
|
||||
ForEach(displayWidgets) { widget in
|
||||
WidgetView(widget: widget)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap the requested column count by available width. Compact
|
||||
/// (iPhone) is always 1; regular (iPad / large split-view) caps at
|
||||
/// 2 to avoid a 3-up layout that crowds chart + table widgets.
|
||||
private func columnCount(for section: DashboardSection) -> Int {
|
||||
switch hSizeClass {
|
||||
case .compact: return 1
|
||||
case .regular: return min(section.columnCount, 2)
|
||||
default: return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget-type dispatcher. Mirrors Mac's `WidgetView` switch in
|
||||
/// `scarf/Features/Projects/Views/ProjectsView.swift`. Unknown types
|
||||
/// fall through to a small placeholder so a manifest from a future
|
||||
/// schema version doesn't crash the UI.
|
||||
struct WidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
switch widget.type {
|
||||
case "stat":
|
||||
StatWidgetView(widget: widget)
|
||||
case "progress":
|
||||
ProgressWidgetView(widget: widget)
|
||||
case "text":
|
||||
TextWidgetView(widget: widget)
|
||||
case "table":
|
||||
TableWidgetView(widget: widget)
|
||||
case "chart":
|
||||
ChartWidgetView(widget: widget)
|
||||
case "list":
|
||||
ListWidgetView(widget: widget)
|
||||
case "webview":
|
||||
WebviewWidgetView(widget: widget)
|
||||
default:
|
||||
unsupportedView
|
||||
}
|
||||
}
|
||||
|
||||
private var unsupportedView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label(widget.title, systemImage: "questionmark.app.dashed")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ListWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
if let icon = widget.icon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let items = widget.items {
|
||||
ForEach(items) { item in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: statusIcon(item.status))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(item.status))
|
||||
Text(item.text)
|
||||
.font(.callout)
|
||||
.strikethrough(item.status == "done")
|
||||
.foregroundStyle(item.status == "done" ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func statusIcon(_ status: String?) -> String {
|
||||
switch status {
|
||||
case "done": return "checkmark.circle.fill"
|
||||
case "active": return "circle.inset.filled"
|
||||
case "pending": return "circle"
|
||||
default: return "circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String?) -> Color {
|
||||
switch status {
|
||||
case "done": return .green
|
||||
case "active": return .blue
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct ProgressWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
private var progressValue: Double {
|
||||
switch widget.value {
|
||||
case .number(let n): return n
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: progressValue) {
|
||||
if let label = widget.label {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tint(parseColor(widget.color))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct StatWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
private var widgetColor: Color {
|
||||
parseColor(widget.color)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
if let icon = widget.icon {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(widgetColor)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let value = widget.value {
|
||||
Text(value.displayString)
|
||||
.font(.system(.title2, design: .monospaced, weight: .semibold))
|
||||
}
|
||||
if let subtitle = widget.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(widgetColor)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TableWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let columns = widget.columns, let rows = widget.rows {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
|
||||
GridRow {
|
||||
ForEach(columns, id: \.self) { col in
|
||||
Text(col)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
GridRow {
|
||||
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
|
||||
Text(cell)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct TextWidgetView: View {
|
||||
let widget: DashboardWidget
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(widget.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let content = widget.content {
|
||||
if widget.format == "markdown" {
|
||||
// SwiftUI's built-in inline markdown via AttributedString.
|
||||
// Doesn't support block elements (lists, tables) the way
|
||||
// Mac's MarkdownContentView does, but covers the common
|
||||
// dashboard cases (bold, italic, links, inline code).
|
||||
Text(attributed(content))
|
||||
.font(.callout)
|
||||
} else {
|
||||
Text(content)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func attributed(_ markdown: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import WebKit
|
||||
|
||||
/// iOS twin of Mac's `WebviewWidgetView`. Same two modes (inline card
|
||||
/// + full-canvas Site tab); the only platform-specific bit is the
|
||||
/// `UIViewRepresentable` wrapper around `WKWebView` (Mac uses
|
||||
/// `NSViewRepresentable`).
|
||||
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)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
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: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(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 updateUIView(_ 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Map a server-supplied color name to a SwiftUI `Color`. iOS twin of
|
||||
/// the Mac helper at `scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift`.
|
||||
/// Unknown names default to `.blue` to keep dashboards visually
|
||||
/// consistent across platforms.
|
||||
func parseColor(_ name: String?) -> Color {
|
||||
switch name?.lowercased() {
|
||||
case "red": return .red
|
||||
case "orange": return .orange
|
||||
case "yellow": return .yellow
|
||||
case "green": return .green
|
||||
case "blue": return .blue
|
||||
case "purple": return .purple
|
||||
case "pink": return .pink
|
||||
case "teal", "cyan": return .teal
|
||||
case "indigo": return .indigo
|
||||
case "mint": return .mint
|
||||
case "brown": return .brown
|
||||
case "gray", "grey": return .gray
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Browse / search the Hermes skills hub. Source picker is a `Menu`
|
||||
/// (more compact than Mac's segmented Picker on a phone-width screen).
|
||||
/// Search submits on Return; empty query falls through to a "browse"
|
||||
/// listing (top results across the chosen source).
|
||||
struct HubBrowseView: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbar: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search skills…", text: $vm.hubQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { vm.searchHub() }
|
||||
Menu {
|
||||
Picker("Source", selection: $vm.hubSource) {
|
||||
ForEach(vm.hubSources, id: \.self) { src in
|
||||
Text(src).tag(src)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(vm.hubSource)
|
||||
.font(.callout)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
vm.searchHub()
|
||||
} label: {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
Button {
|
||||
vm.browseHub()
|
||||
} label: {
|
||||
Label("Browse", systemImage: "books.vertical")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if vm.hubResults.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("Browse the Hub", systemImage: "books.vertical")
|
||||
} description: {
|
||||
Text("Search for a skill or tap Browse to see top results across registries (skills.sh, official, etc.).")
|
||||
.font(.caption)
|
||||
} actions: {
|
||||
Button {
|
||||
vm.browseHub()
|
||||
} label: {
|
||||
Label("Browse top skills", systemImage: "books.vertical")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(vm.isHubLoading)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(vm.hubResults) { hubSkill in
|
||||
HubSkillRow(skill: hubSkill, isInstalling: vm.isHubLoading) {
|
||||
vm.installHubSkill(hubSkill)
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HubSkillRow: View {
|
||||
let skill: HermesHubSkill
|
||||
let isInstalling: Bool
|
||||
let onInstall: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "books.vertical")
|
||||
.foregroundStyle(.tint)
|
||||
.font(.title3)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(skill.name)
|
||||
.font(.callout.monospaced())
|
||||
.fontWeight(.medium)
|
||||
if !skill.source.isEmpty {
|
||||
Text(skill.source)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.tint.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
if !skill.description.isEmpty {
|
||||
Text(skill.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
onInstall()
|
||||
} label: {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isInstalling)
|
||||
.accessibilityLabel("Install \(skill.name)")
|
||||
.accessibilityHint(skill.description)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Installed skills sub-tab. Category-grouped list; tapping a row
|
||||
/// pushes `SkillDetailView` for that skill. Filtering uses the VM's
|
||||
/// `filteredCategories` derivation so the search field works against
|
||||
/// the same model the Mac uses.
|
||||
struct InstalledSkillsListView: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if vm.isLoading && vm.categories.isEmpty {
|
||||
ProgressView("Scanning skills…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.categories.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No skills installed", systemImage: "lightbulb")
|
||||
} description: {
|
||||
Text("Browse the Hub tab to install one, or run `hermes skills install <name>` on the remote.")
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
listContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listContent: some View {
|
||||
List {
|
||||
ForEach(vm.filteredCategories) { category in
|
||||
Section(category.name) {
|
||||
ForEach(category.skills) { skill in
|
||||
NavigationLink {
|
||||
SkillDetailView(skill: skill, vm: vm)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(skill.name)
|
||||
.font(.body)
|
||||
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Installed skill detail. Shows location + required-config warning
|
||||
/// banner + file picker + content viewer. Edit and Uninstall buttons
|
||||
/// live in the toolbar.
|
||||
struct SkillDetailView: View {
|
||||
let skill: HermesSkill
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
@State private var showEditor: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Location") {
|
||||
LabeledContent("Category", value: skill.category)
|
||||
Text(skill.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
if !vm.missingConfig.isEmpty {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Required config not set")
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
Text("Add these keys to ~/.hermes/config.yaml:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(vm.missingConfig, id: \.self) { key in
|
||||
Text("• \(key)")
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
if !skill.files.isEmpty {
|
||||
Section("Files") {
|
||||
ForEach(skill.files, id: \.self) { file in
|
||||
Button {
|
||||
vm.selectFile(file)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(file)
|
||||
.font(.callout.monospaced())
|
||||
Spacer()
|
||||
if vm.selectedFileName == file {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vm.selectedFileName != nil {
|
||||
Section("Content") {
|
||||
if vm.skillContent.isEmpty {
|
||||
Text("(empty file)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
} else if vm.isMarkdownFile {
|
||||
Text(markdown(vm.skillContent))
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(vm.skillContent)
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle(skill.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
// Selecting the skill (re)loads its main file content +
|
||||
// missingConfig diagnostics. Idempotent on re-appears.
|
||||
vm.selectSkill(skill)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if vm.selectedFileName != nil {
|
||||
Button {
|
||||
vm.startEditing()
|
||||
showEditor = true
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
Button(role: .destructive) {
|
||||
vm.uninstallHubSkill(skill.id)
|
||||
} label: {
|
||||
Label("Uninstall", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEditor) {
|
||||
SkillEditorSheet(vm: vm, fileName: vm.selectedFileName ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func markdown(_ raw: String) -> AttributedString {
|
||||
let opts = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||
)
|
||||
return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sheet-presented TextEditor for the currently-selected skill file.
|
||||
/// Save commits via `vm.saveEdit()` (which calls `transport.writeFile`);
|
||||
/// Cancel discards. Validation lives entirely in the VM
|
||||
/// (`isValidSkillPath` guard) so the sheet is purely UI.
|
||||
struct SkillEditorSheet: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
let fileName: String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TextEditor(text: $vm.editText)
|
||||
.font(.footnote.monospaced())
|
||||
.padding(8)
|
||||
.navigationTitle(fileName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
vm.cancelEditing()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") {
|
||||
vm.saveEdit()
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Skills browser. Read-only list grouped by category. Tapping
|
||||
/// a skill shows its files + on-disk path — enough for a user to
|
||||
/// verify what's installed without opening Terminal.
|
||||
struct SkillsListView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
@State private var vm: IOSSkillsViewModel
|
||||
@State private var snapshotDiff: SkillSnapshotDiff?
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
init(config: IOSServerConfig) {
|
||||
self.config = config
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
_vm = State(initialValue: IOSSkillsViewModel(context: ctx))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let diff = snapshotDiff,
|
||||
diff.hasChanges,
|
||||
!diff.previousSnapshotEmpty {
|
||||
Section {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(diff.label)
|
||||
.font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
Button("Seen") {
|
||||
SkillSnapshotService(serverID: Self.sharedContextID)
|
||||
.markSeen(vm.categories.flatMap(\.skills))
|
||||
snapshotDiff = nil
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let err = vm.lastError {
|
||||
Section {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if vm.categories.isEmpty, !vm.isLoading {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("No skills installed")
|
||||
.font(.headline)
|
||||
Text("Skills live under `~/.hermes/skills/<category>/<name>/` on the remote. Install them from the Mac app or by cloning directly.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
} else {
|
||||
ForEach(vm.categories) { category in
|
||||
Section(category.name) {
|
||||
ForEach(category.skills) { skill in
|
||||
NavigationLink {
|
||||
SkillDetailView(skill: skill)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(skill.name)
|
||||
.font(.body)
|
||||
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle("Skills")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay {
|
||||
if vm.isLoading && vm.categories.isEmpty {
|
||||
ProgressView("Scanning skills…")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await vm.load()
|
||||
recomputeSnapshotDiff()
|
||||
}
|
||||
.task {
|
||||
await vm.load()
|
||||
recomputeSnapshotDiff()
|
||||
}
|
||||
}
|
||||
|
||||
/// v2.5 "What's New" diff against the last-seen snapshot for this
|
||||
/// server. First-time users get a silent prime — the pill only
|
||||
/// renders on subsequent loads when something actually changed.
|
||||
private func recomputeSnapshotDiff() {
|
||||
let allSkills = vm.categories.flatMap(\.skills)
|
||||
let svc = SkillSnapshotService(serverID: Self.sharedContextID)
|
||||
let diff = svc.diff(against: allSkills)
|
||||
if diff.previousSnapshotEmpty {
|
||||
svc.markSeen(allSkills)
|
||||
snapshotDiff = nil
|
||||
} else {
|
||||
snapshotDiff = diff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SkillDetailView: View {
|
||||
let skill: HermesSkill
|
||||
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@State private var npxStatus: SkillPrereqService.Status?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Location") {
|
||||
LabeledContent("Category", value: skill.category)
|
||||
Text(skill.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
// v2.5 design-md prereq surface — the skill needs `npx`
|
||||
// (Node.js 18+) on the host. iOS read-only banner: same
|
||||
// wording as the Mac one, no install button (the user is
|
||||
// already going to need a shell to fix this).
|
||||
if skill.name.lowercased() == "design-md",
|
||||
case .missing(let hint) = npxStatus {
|
||||
Section("Prerequisite missing") {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("`npx` not found on the Hermes host.")
|
||||
.font(.callout.weight(.medium))
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if skill.name.lowercased() == "spotify" {
|
||||
Section("Authentication") {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Spotify needs OAuth")
|
||||
.font(.callout.weight(.medium))
|
||||
Text("Run `hermes auth spotify` from the Scarf macOS app or a shell — it opens your browser to complete the OAuth flow. Once authorised, this skill picks up the credentials from `~/.hermes/auth.json` automatically.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "music.note")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if let tools = skill.allowedTools, !tools.isEmpty {
|
||||
Section("Allowed tools") {
|
||||
chipRow(tools)
|
||||
}
|
||||
}
|
||||
if let related = skill.relatedSkills, !related.isEmpty {
|
||||
Section("Related skills") {
|
||||
chipRow(related)
|
||||
}
|
||||
}
|
||||
if let deps = skill.dependencies, !deps.isEmpty {
|
||||
Section("Dependencies") {
|
||||
chipRow(deps)
|
||||
}
|
||||
}
|
||||
|
||||
if !skill.files.isEmpty {
|
||||
Section("Files") {
|
||||
ForEach(skill.files, id: \.self) { file in
|
||||
Text(file)
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(skill.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: skill.id) {
|
||||
// Only probe when this skill needs it. design-md is the
|
||||
// only skill in v2.5 with a host-side prereq surface; the
|
||||
// probe runs once per appear and isn't cached across
|
||||
// navigation events (cheap — single SSH `which` call).
|
||||
guard skill.name.lowercased() == "design-md" else {
|
||||
npxStatus = nil
|
||||
return
|
||||
}
|
||||
let svc = SkillPrereqService(context: serverContext)
|
||||
npxStatus = await svc.probe(binary: "npx")
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a list of strings as wrapping pill chips. Used for
|
||||
/// allowed_tools / related_skills / dependencies sections (v2.5
|
||||
/// SKILL.md frontmatter).
|
||||
@ViewBuilder
|
||||
private func chipRow(_ items: [String]) -> some View {
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
Text(item)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.secondary.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal flow-layout for chip rows (wraps onto multiple lines when
|
||||
/// content overflows the available width). Built-in `Layout` API,
|
||||
/// no third-party dep. Used by the Skills detail view for the v2.5
|
||||
/// allowed_tools / related_skills / dependencies sections.
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 4
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
guard let maxWidth = proposal.width else { return .zero }
|
||||
var rowWidth: CGFloat = 0
|
||||
var totalHeight: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if rowWidth + size.width > maxWidth, rowWidth > 0 {
|
||||
totalHeight += rowHeight + spacing
|
||||
rowWidth = 0
|
||||
rowHeight = 0
|
||||
}
|
||||
rowWidth += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
totalHeight += rowHeight
|
||||
return CGSize(width: maxWidth, height: totalHeight)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > bounds.maxX, x > bounds.minX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Skills tab — 3-tab segmented surface mirroring the Mac
|
||||
/// `SkillsView`. Owns one `SkillsViewModel` (ScarfCore-side, unified
|
||||
/// in v2.5) shared across the three sub-tabs so installed-list state +
|
||||
/// hub query/results + update results all live in one place.
|
||||
///
|
||||
/// Sub-tabs:
|
||||
/// - **Installed**: category-grouped list. Tap a skill to view its
|
||||
/// files, edit content, or uninstall.
|
||||
/// - **Browse Hub**: search + source picker. Tap to install. Calls
|
||||
/// remote `hermes skills search/browse` over SSH.
|
||||
/// - **Updates**: check + update-all buttons. Calls remote
|
||||
/// `hermes skills check / update --yes`.
|
||||
struct SkillsView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
@State private var vm: SkillsViewModel
|
||||
@State private var currentTab: Tab = .installed
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
enum Tab: String, CaseIterable, Identifiable {
|
||||
case installed = "Installed"
|
||||
case hub = "Browse Hub"
|
||||
case updates = "Updates"
|
||||
var id: String { rawValue }
|
||||
var displayName: String { rawValue }
|
||||
}
|
||||
|
||||
init(config: IOSServerConfig) {
|
||||
self.config = config
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
_vm = State(initialValue: SkillsViewModel(context: ctx))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
tabPicker
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
statusBanner
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
.navigationTitle(titleString)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
|
||||
private var titleString: String {
|
||||
vm.totalSkillCount > 0 ? "Skills (\(vm.totalSkillCount))" : "Skills"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tabPicker: some View {
|
||||
Picker("Section", selection: $currentTab) {
|
||||
ForEach(Tab.allCases) { tab in
|
||||
Text(tab.displayName).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let msg = vm.hubMessage {
|
||||
HStack(spacing: 6) {
|
||||
if vm.isHubLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(msg)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
} else if vm.isHubLoading {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Working…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch currentTab {
|
||||
case .installed:
|
||||
InstalledSkillsListView(vm: vm)
|
||||
case .hub:
|
||||
HubBrowseView(vm: vm)
|
||||
case .updates:
|
||||
UpdatesView(vm: vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Updates sub-tab. Mirrors Mac: Check button populates `vm.updates`;
|
||||
/// Update All button is enabled only when there's at least one
|
||||
/// available update. Both calls run remote `hermes skills` over SSH;
|
||||
/// the parse logic is shared with Mac via `HermesSkillsHubParser`.
|
||||
struct UpdatesView: View {
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
vm.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
|
||||
if !vm.updates.isEmpty {
|
||||
Button {
|
||||
vm.updateAll()
|
||||
} label: {
|
||||
Label("Update All", systemImage: "arrow.down.app")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isHubLoading)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if vm.updates.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No updates", systemImage: "checkmark.circle.fill")
|
||||
} description: {
|
||||
Text("Tap Check for Updates to query each installed skill against its source registry.")
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(vm.updates) { update in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(update.identifier)
|
||||
.font(.callout.monospaced())
|
||||
HStack(spacing: 6) {
|
||||
Text(update.currentVersion)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(update.availableVersion)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user