diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 834e9c8..23b6bd8 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -65,7 +65,38 @@ final class CronViewModel { } func runNow(_ job: HermesCronJob) { - runAndReload(["cron", "run", job.id], success: "Scheduled for next tick") + // `hermes cron run ` only marks the job as due on the next + // scheduler tick — it doesn't actually execute. If the Hermes + // gateway's scheduler isn't running (common during dev + right + // after install), the user's "Run now" click results in zero + // visible effect because the tick never comes. We follow up + // with `hermes cron tick` which runs all due jobs once and + // exits. Redundant-but-harmless when the gateway is running; + // the actual trigger when it isn't. + let svc = fileService + let jobID = job.id + Task.detached { [weak self] in + let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30) + // Give `cron run` a moment to register the queue entry + // before forcing the tick. A few hundred ms is enough; + // longer only delays the user-visible feedback. + try? await Task.sleep(for: .milliseconds(250)) + let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 60) + await MainActor.run { [weak self] in + guard let self else { return } + if runResult.exitCode == 0 && tickResult.exitCode == 0 { + self.message = "Job executed (see Output panel for details)" + } else { + let errOutput = runResult.exitCode != 0 ? runResult.output : tickResult.output + self.message = "Run failed: \(errOutput.prefix(200))" + self.logger.warning("cron runNow failed: run=\(runResult.exitCode), tick=\(tickResult.exitCode) output=\(errOutput)") + } + self.load() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.message = nil + } + } + } } func deleteJob(_ job: HermesCronJob) { diff --git a/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift index 0fddba5..81700ef 100644 --- a/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift +++ b/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift @@ -113,7 +113,11 @@ struct TemplateConfigSheet: View { .foregroundStyle(.secondary) } if let description = field.description, !description.isEmpty { - Text(description) + // Inline markdown so descriptions can include + // `[Create one](https://…)`-style links to token + // generation pages, **bold** emphasis on important + // prerequisites, etc. + TemplateMarkdown.inlineText(description) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift index 98f9a64..e697050 100644 --- a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift +++ b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift @@ -175,7 +175,10 @@ struct TemplateInstallSheet: View { .font(.caption.monospaced()) .foregroundStyle(.secondary) } - Text(manifest.description) + // Inline-only markdown — descriptions are a sentence or two; + // bold/italic/code/links are all that reasonable template + // authors use there. + TemplateMarkdown.inlineText(manifest.description) .font(.subheadline) .foregroundStyle(.secondary) if let author = manifest.author { @@ -220,16 +223,40 @@ struct TemplateInstallSheet: View { private func cronSection(plan: TemplateInstallPlan) -> some View { section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 10) { ForEach(plan.cronJobs, id: \.name) { job in - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: "clock.arrow.circlepath") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 1) { - Text(job.name).font(.callout.monospaced()) - Text("schedule: \(job.schedule)") - .font(.caption) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "clock.arrow.circlepath") .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(job.name).font(.callout.monospaced()) + Text("schedule: \(job.schedule)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + // Prompt preview — disclosed in an expandable + // group so the preview stays compact when the + // user doesn't care to read it. Markdown-rendered + // so prompts that include `code`, **bold**, or + // enumerated steps look right. Tokens like + // {{PROJECT_DIR}} are still visible here — they + // get substituted when the installer calls + // `hermes cron create`. + if let prompt = job.prompt, !prompt.isEmpty { + DisclosureGroup("Prompt") { + ScrollView { + TemplateMarkdown.render(prompt) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 140) + .padding(8) + .background(.quaternary.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .font(.caption) + .padding(.leading, 26) } } } @@ -302,11 +329,10 @@ struct TemplateInstallSheet: View { if let readme = viewModel.readmeBody { section(title: "README", subtitle: nil) { ScrollView { - Text(readme) - .font(.callout) + TemplateMarkdown.render(readme) .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxHeight: 200) + .frame(maxHeight: 260) } } } diff --git a/scarf/scarf/Features/Templates/Views/TemplateMarkdown.swift b/scarf/scarf/Features/Templates/Views/TemplateMarkdown.swift new file mode 100644 index 0000000..5c9c390 --- /dev/null +++ b/scarf/scarf/Features/Templates/Views/TemplateMarkdown.swift @@ -0,0 +1,192 @@ +import SwiftUI +import Foundation + +/// Minimal markdown renderer used by the template install/config UIs. +/// +/// SwiftUI `Text` has built-in inline-markdown support via +/// `AttributedString(markdown:)` — bold, italic, inline code, links. +/// That's enough for field descriptions + template taglines. For +/// longer content (README preview, full doc blocks), this helper adds +/// block-level handling: lines starting with `#`/`##`/`###` render +/// as bigger bold text; lines starting with `-`/`*`/`1.` render as +/// list items with a hanging indent; fenced ``` ``` blocks render as +/// monospaced; blank lines become paragraph breaks. +/// +/// Scope is intentionally small. This isn't a full CommonMark +/// renderer — it's "enough markdown to make template READMEs look +/// right in the install sheet without pulling in a dependency." If +/// the set of templates needs more over time, evolve this file or +/// graduate to a proper library. +enum TemplateMarkdown { + + /// Render a markdown source string as a SwiftUI view. Preserves + /// reading order and approximate visual hierarchy. Safe with + /// untrusted input — we never execute HTML or scripts. + @ViewBuilder + static func render(_ source: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + let blocks = parse(source) + ForEach(blocks.indices, id: \.self) { i in + block(blocks[i]) + } + } + } + + /// Inline-only markdown (bold/italic/code/links) as a single + /// `Text`. Use for short strings where block structure doesn't + /// apply — field labels, one-line descriptions. + static func inlineText(_ source: String) -> Text { + if let attr = try? AttributedString( + markdown: source, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return Text(attr) + } + return Text(source) + } + + // MARK: - Block model + + fileprivate enum Block { + case paragraph(AttributedString) + case heading(level: Int, text: AttributedString) + case bullet(AttributedString) + case numbered(index: Int, text: AttributedString) + case code(String) + } + + // MARK: - Parser + + fileprivate static func parse(_ source: String) -> [Block] { + var blocks: [Block] = [] + var lines = source.components(separatedBy: "\n") + var i = 0 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Fenced code block. + if trimmed.hasPrefix("```") { + var body: [String] = [] + i += 1 + while i < lines.count { + let inner = lines[i] + if inner.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + i += 1 + break + } + body.append(inner) + i += 1 + } + blocks.append(.code(body.joined(separator: "\n"))) + continue + } + + // Heading. + if let headingMatch = trimmed.firstMatch(of: /^(#{1,6})\s+(.*)$/) { + let level = (headingMatch.1).count + let text = String(headingMatch.2) + blocks.append(.heading(level: level, text: renderInline(text))) + i += 1 + continue + } + + // Bullet list. + if let bulletMatch = line.firstMatch(of: /^\s*[-*]\s+(.*)$/) { + let text = String(bulletMatch.1) + blocks.append(.bullet(renderInline(text))) + i += 1 + continue + } + + // Numbered list. + if let numMatch = line.firstMatch(of: /^\s*(\d+)\.\s+(.*)$/) { + let index = Int(String(numMatch.1)) ?? 1 + let text = String(numMatch.2) + blocks.append(.numbered(index: index, text: renderInline(text))) + i += 1 + continue + } + + // Blank line — skip. + if trimmed.isEmpty { + i += 1 + continue + } + + // Paragraph — collect contiguous non-blank lines that + // aren't headings/lists/fences into one paragraph block. + var paragraphLines: [String] = [line] + i += 1 + while i < lines.count { + let next = lines[i] + let nextTrim = next.trimmingCharacters(in: .whitespaces) + if nextTrim.isEmpty { break } + if nextTrim.hasPrefix("```") { break } + if nextTrim.firstMatch(of: /^#{1,6}\s/) != nil { break } + if next.firstMatch(of: /^\s*[-*]\s+/) != nil { break } + if next.firstMatch(of: /^\s*\d+\.\s+/) != nil { break } + paragraphLines.append(next) + i += 1 + } + let joined = paragraphLines.joined(separator: " ") + blocks.append(.paragraph(renderInline(joined))) + } + return blocks + } + + /// Parse inline markdown (bold, italic, inline code, links) into + /// an AttributedString. Falls back to plain text on parse failure. + fileprivate static func renderInline(_ source: String) -> AttributedString { + if let attr = try? AttributedString( + markdown: source, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return attr + } + return AttributedString(source) + } + + // MARK: - Rendering + + @ViewBuilder + fileprivate static func block(_ b: Block) -> some View { + switch b { + case .paragraph(let text): + Text(text) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + case .heading(let level, let text): + headingText(text: text, level: level) + case .bullet(let text): + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("•").font(.callout) + Text(text).font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + case .numbered(let index, let text): + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("\(index).").font(.callout.monospacedDigit()) + Text(text).font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + case .code(let src): + Text(src) + .font(.caption.monospaced()) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + @ViewBuilder + fileprivate static func headingText(text: AttributedString, level: Int) -> some View { + switch level { + case 1: Text(text).font(.title2.bold()).padding(.top, 8) + case 2: Text(text).font(.title3.bold()).padding(.top, 6) + case 3: Text(text).font(.headline).padding(.top, 4) + default: Text(text).font(.subheadline.bold()).padding(.top, 2) + } + } +}