mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat(ios-skills): port v0.11 features to new file structure (Phase D)
Re-port the four v0.11 iOS Skills features that lived in the now- deleted Skills/SkillsListView.swift into the new Installed/SkillDetailView + Skills/SkillsView surfaces. iOS Components/FlowLayout.swift (NEW, promoted helper): - 50-line struct conforming to SwiftUI's Layout protocol; wraps subviews onto multiple lines on overflow. Built-in API, no third- party dep. Originally inline in the deleted SkillsListView; promoted so multiple iOS views can share without duplicating ~30 lines. iOS Skills/Installed/SkillDetailView.swift (extend): - design-md npx prereq banner: yellow "Prerequisite missing" section triggered by .task(id: skill.id) probing `which npx` via SkillPrereqService when the selected skill is design-md. - Spotify info row: green "Authentication" section pointing users at the Mac sheet or shell for OAuth — phone OAuth flows are deferred in v2.5. - SKILL.md frontmatter chip rows: three sections (Allowed tools / Related skills / Dependencies) using a chipRow helper backed by the shared FlowLayout. Each section hides itself when its HermesSkill field is nil — old skills without v0.11 frontmatter show none of these. iOS Skills/SkillsView.swift (extend): - "What's New" pill at the top of the tab (above the sub-tab picker). Driven by SkillSnapshotService.diff() against the per- server last-seen snapshot. First-load primes silently so users don't see "everything is new!" noise on day one. - Recomputes on .task and .refreshable. - "Seen" button persists the current set + dismisses. Verified: iOS build succeeds. The chip-row data path is now end-to-end (SkillsScanner → HermesSkill → SkillDetailView chips) and the snapshot pill matches the Mac SkillsView placement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Minimal flow layout — wraps subviews onto multiple lines when they
|
||||
/// overflow the available width. Built on the Layout protocol; no
|
||||
/// third-party dependency.
|
||||
///
|
||||
/// Used for chip rows that need to wrap on iPhone-narrow screens —
|
||||
/// SKILL.md frontmatter chips (`allowed_tools` / `related_skills` /
|
||||
/// `dependencies`) on the Skills detail view, and any future place
|
||||
/// that wants pill-shaped wrapping content.
|
||||
///
|
||||
/// Promoted from the deleted `iOS Skills/SkillsListView.swift`'s
|
||||
/// inline definition during the v2.5 iOS-design merge so multiple
|
||||
/// views can share it without duplicating ~30 lines.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,23 @@ import ScarfCore
|
||||
/// Installed skill detail. Shows location + required-config warning
|
||||
/// banner + file picker + content viewer. Edit and Uninstall buttons
|
||||
/// live in the toolbar.
|
||||
///
|
||||
/// v2.5 surfaces three Hermes v0.11+ pieces here:
|
||||
/// - Spotify info row (when this is the spotify skill) — points users
|
||||
/// back to Mac for the OAuth flow.
|
||||
/// - design-md `npx` prereq banner — flagged when Node.js isn't on
|
||||
/// the host's PATH.
|
||||
/// - SKILL.md frontmatter chip rows (`allowed_tools` / `related_skills`
|
||||
/// / `dependencies`) — populated by `SkillsScanner` from the file's
|
||||
/// YAML frontmatter, hidden when nil.
|
||||
struct SkillDetailView: View {
|
||||
let skill: HermesSkill
|
||||
@Bindable var vm: SkillsViewModel
|
||||
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@State private var showEditor: Bool = false
|
||||
/// design-md npx probe result. Refetched per-skill via .task(id:).
|
||||
@State private var npxStatus: SkillPrereqService.Status?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -20,6 +32,71 @@ struct SkillDetailView: View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
// v2.5 design-md prereq banner. Only when this is the
|
||||
// design-md skill AND `which npx` came back missing.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// v2.5 Spotify auth note. iOS doesn't run the OAuth flow
|
||||
// in-app (phones + browser callbacks are awkward); points
|
||||
// users at the Mac sheet or shell. Once authed, the iOS
|
||||
// skill picks up the credential from ~/.hermes/auth.json.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// v2.5 SKILL.md frontmatter chip rows. Each section
|
||||
// hides itself when its corresponding HermesSkill field
|
||||
// is nil — old skills without v0.11 frontmatter show
|
||||
// none of these.
|
||||
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 !vm.missingConfig.isEmpty {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
@@ -91,6 +168,19 @@ struct SkillDetailView: View {
|
||||
// missingConfig diagnostics. Idempotent on re-appears.
|
||||
vm.selectSkill(skill)
|
||||
}
|
||||
.task(id: skill.id) {
|
||||
// v2.5: probe `npx` only when this is the design-md skill —
|
||||
// the only skill that surfaces a host-side prereq today.
|
||||
// Cheap (single SSH `which`); not cached across navigations
|
||||
// so users see a fresh result if they install Node and come
|
||||
// back.
|
||||
guard skill.name.lowercased() == "design-md" else {
|
||||
npxStatus = nil
|
||||
return
|
||||
}
|
||||
let svc = SkillPrereqService(context: serverContext)
|
||||
npxStatus = await svc.probe(binary: "npx")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if vm.selectedFileName != nil {
|
||||
@@ -123,4 +213,23 @@ struct SkillDetailView: View {
|
||||
)
|
||||
return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw)
|
||||
}
|
||||
|
||||
/// Render a list of strings as wrapping pill chips (v2.5 SKILL.md
|
||||
/// frontmatter sections). Uses the shared `FlowLayout` from
|
||||
/// `Components/FlowLayout.swift` so chips wrap onto multiple lines
|
||||
/// on iPhone-narrow screens.
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ struct SkillsView: View {
|
||||
|
||||
@State private var vm: SkillsViewModel
|
||||
@State private var currentTab: Tab = .installed
|
||||
/// v2.5 SkillSnapshotService diff against the per-server last-seen
|
||||
/// snapshot. Drives the "What's New" pill at the top of the tab.
|
||||
/// Nil before first compute or when there's nothing changed.
|
||||
@State private var snapshotDiff: SkillSnapshotDiff?
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
@@ -39,6 +43,15 @@ struct SkillsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// v2.5 "What's New" pill — surfaced above the sub-tab
|
||||
// picker when the per-server snapshot diff has changes.
|
||||
// First-load with no prior snapshot silently primes (no
|
||||
// pill, the snapshot just records what's there).
|
||||
if let diff = snapshotDiff,
|
||||
diff.hasChanges,
|
||||
!diff.previousSnapshotEmpty {
|
||||
whatsNewPill(diff: diff)
|
||||
}
|
||||
tabPicker
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
@@ -49,8 +62,54 @@ struct SkillsView: View {
|
||||
}
|
||||
.navigationTitle(titleString)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
.task {
|
||||
await vm.load()
|
||||
recomputeSnapshotDiff()
|
||||
}
|
||||
.refreshable {
|
||||
await vm.load()
|
||||
recomputeSnapshotDiff()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the snapshot diff against the per-server last-seen
|
||||
/// state. First load with no prior snapshot silently primes —
|
||||
/// the pill never renders for users on day one.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// "2 new, 4 updated since you last looked" pill at the top of
|
||||
/// the tab. Tapping "Seen" persists the current set as the new
|
||||
/// baseline + dismisses the pill.
|
||||
@ViewBuilder
|
||||
private func whatsNewPill(diff: SkillSnapshotDiff) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.tint)
|
||||
Text(diff.label)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Button("Seen") {
|
||||
SkillSnapshotService(serverID: Self.sharedContextID)
|
||||
.markSeen(vm.categories.flatMap(\.skills))
|
||||
snapshotDiff = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.tint.opacity(0.1))
|
||||
}
|
||||
|
||||
private var titleString: String {
|
||||
|
||||
Reference in New Issue
Block a user