* Add markdown viewer panel with live file watching Introduce a new PanelType.markdown that renders .md files in a dedicated panel using MarkdownUI (SwiftUI), with live file watching via DispatchSource so content auto-updates when the file changes on disk. - New MarkdownPanel class with file system watcher (write/delete/rename/extend) - New MarkdownPanelView with custom cmux theme (headings, code blocks, tables, blockquotes, inline code, lists, horizontal rules, light/dark mode) - Full workspace integration: SurfaceKind, creation methods, tab subscription - Session persistence: snapshot/restore across app restarts - V2 socket command: markdown.open (validates path, resolves workspace, splits) - CLI command: cmux markdown open <path> with routing flags and help text - Agent skill: skills/cmux-markdown/ with SKILL.md, openai.yaml, and references - Cross-link from skills/cmux/SKILL.md to the new markdown skill - SPM dependency: gonzalezreal/swift-markdown-ui 2.4.1 * Fix unreachable guard in markdown subcommand dispatch Use looksLikePath() to distinguish subcommands from path arguments so the guard can catch unknown subcommands and future subcommands are parsed correctly. * Use .isoLatin1 fallback instead of .ascii for encoding recovery ASCII is a strict subset of UTF-8, so falling back to .ascii after UTF-8 fails is dead code. Use .isoLatin1 which accepts all 256 byte values and covers legacy encodings like Windows-1252. * Mark fileWatchSource as nonisolated(unsafe) for deinit safety deinit is not guaranteed to run on the main actor, so accessing @MainActor-isolated storage is a data race under strict concurrency. DispatchSource.cancel() is thread-safe, so nonisolated(unsafe) is sufficient with a documented invariant that writes only occur on main. * Fix file watcher reattach: retry loop with cancellation guard - Replace one-shot 500ms retry with up to 6 attempts (3s total window) so files that reappear after a slow atomic replace are picked up - Add isClosed flag checked before each retry to prevent restarting the watcher after close()/deinit * Harden path validation in markdown.open command Reject directories and non-absolute paths before panel creation to prevent ambiguous behavior and generic downstream failures. * Always reattach file watcher on delete/rename events After an atomic save (delete old + create new), the DispatchSource still points to the old inode. Previously we only reattached when the file was unreadable, so successful atomic saves left the watcher on a stale inode and live updates silently stopped. Now we always stop and reattach: immediately if the new file is readable, via retry loop if not. * Restore markdown panels even when file is missing at launch MarkdownPanel already handles unavailable files gracefully (shows 'file unavailable' UI and retries via the reattach loop). Dropping the panel on restore lost the user's layout for files that may reappear shortly after (network drives, build artifacts, etc.). * Harden markdown CLI parsing and startup reconnect behavior --------- Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
285 lines
10 KiB
Swift
285 lines
10 KiB
Swift
import SwiftUI
|
|
import MarkdownUI
|
|
|
|
/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI.
|
|
struct MarkdownPanelView: View {
|
|
@ObservedObject var panel: MarkdownPanel
|
|
let isFocused: Bool
|
|
let isVisibleInUI: Bool
|
|
let portalPriority: Int
|
|
let onRequestPanelFocus: () -> Void
|
|
|
|
@State private var focusFlashOpacity: Double = 0.0
|
|
@State private var focusFlashAnimationGeneration: Int = 0
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
Group {
|
|
if panel.isFileUnavailable {
|
|
fileUnavailableView
|
|
} else {
|
|
markdownContentView
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(backgroundColor)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
|
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
|
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
|
.padding(FocusFlashPattern.ringInset)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onRequestPanelFocus()
|
|
}
|
|
.onChange(of: panel.focusFlashToken) { _ in
|
|
triggerFocusFlashAnimation()
|
|
}
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
private var markdownContentView: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// File path breadcrumb
|
|
filePathHeader
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
|
|
// Rendered markdown
|
|
Markdown(panel.content)
|
|
.markdownTheme(cmuxMarkdownTheme)
|
|
.textSelection(.enabled)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var filePathHeader: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "doc.richtext")
|
|
.foregroundColor(.secondary)
|
|
.font(.system(size: 12))
|
|
Text(panel.filePath)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private var fileUnavailableView: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "doc.questionmark")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.secondary)
|
|
Text("File unavailable")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
Text(panel.filePath)
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
Text("The file may have been moved or deleted.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Theme
|
|
|
|
private var backgroundColor: Color {
|
|
colorScheme == .dark
|
|
? Color(nsColor: NSColor(white: 0.12, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.98, alpha: 1.0))
|
|
}
|
|
|
|
private var cmuxMarkdownTheme: Theme {
|
|
let isDark = colorScheme == .dark
|
|
|
|
return Theme()
|
|
// Text
|
|
.text {
|
|
ForegroundColor(isDark ? .white.opacity(0.9) : .primary)
|
|
FontSize(14)
|
|
}
|
|
// Headings
|
|
.heading1 { configuration in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.bold)
|
|
FontSize(28)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
Divider()
|
|
}
|
|
.markdownMargin(top: 24, bottom: 16)
|
|
}
|
|
.heading2 { configuration in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.bold)
|
|
FontSize(22)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
Divider()
|
|
}
|
|
.markdownMargin(top: 20, bottom: 12)
|
|
}
|
|
.heading3 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.semibold)
|
|
FontSize(18)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
.markdownMargin(top: 16, bottom: 8)
|
|
}
|
|
.heading4 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.semibold)
|
|
FontSize(16)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
.markdownMargin(top: 12, bottom: 6)
|
|
}
|
|
.heading5 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.medium)
|
|
FontSize(14)
|
|
ForegroundColor(isDark ? .white : .primary)
|
|
}
|
|
.markdownMargin(top: 10, bottom: 4)
|
|
}
|
|
.heading6 { configuration in
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontWeight(.medium)
|
|
FontSize(13)
|
|
ForegroundColor(isDark ? .white.opacity(0.7) : .secondary)
|
|
}
|
|
.markdownMargin(top: 8, bottom: 4)
|
|
}
|
|
// Code blocks
|
|
.codeBlock { configuration in
|
|
ScrollView(.horizontal, showsIndicators: true) {
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
FontFamilyVariant(.monospaced)
|
|
FontSize(13)
|
|
ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2))
|
|
}
|
|
.padding(12)
|
|
}
|
|
.background(isDark
|
|
? Color(nsColor: NSColor(white: 0.08, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.93, alpha: 1.0)))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.markdownMargin(top: 8, bottom: 8)
|
|
}
|
|
// Inline code
|
|
.code {
|
|
FontFamilyVariant(.monospaced)
|
|
FontSize(13)
|
|
ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7))
|
|
BackgroundColor(isDark
|
|
? Color(nsColor: NSColor(white: 0.18, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.92, alpha: 1.0)))
|
|
}
|
|
// Block quotes
|
|
.blockquote { configuration in
|
|
HStack(spacing: 0) {
|
|
RoundedRectangle(cornerRadius: 1.5)
|
|
.fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4))
|
|
.frame(width: 3)
|
|
configuration.label
|
|
.markdownTextStyle {
|
|
ForegroundColor(isDark ? .white.opacity(0.6) : .secondary)
|
|
FontSize(14)
|
|
}
|
|
.padding(.leading, 12)
|
|
}
|
|
.markdownMargin(top: 8, bottom: 8)
|
|
}
|
|
// Links
|
|
.link {
|
|
ForegroundColor(Color.accentColor)
|
|
}
|
|
// Strong
|
|
.strong {
|
|
FontWeight(.semibold)
|
|
}
|
|
// Tables
|
|
.table { configuration in
|
|
configuration.label
|
|
.markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3)))
|
|
.markdownTableBackgroundStyle(
|
|
.alternatingRows(
|
|
isDark
|
|
? Color(nsColor: NSColor(white: 0.14, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 0.96, alpha: 1.0)),
|
|
isDark
|
|
? Color(nsColor: NSColor(white: 0.10, alpha: 1.0))
|
|
: Color(nsColor: NSColor(white: 1.0, alpha: 1.0))
|
|
)
|
|
)
|
|
.markdownMargin(top: 8, bottom: 8)
|
|
}
|
|
// Thematic break (horizontal rule)
|
|
.thematicBreak {
|
|
Divider()
|
|
.markdownMargin(top: 16, bottom: 16)
|
|
}
|
|
// List items
|
|
.listItem { configuration in
|
|
configuration.label
|
|
.markdownMargin(top: 4, bottom: 4)
|
|
}
|
|
// Paragraphs
|
|
.paragraph { configuration in
|
|
configuration.label
|
|
.markdownMargin(top: 4, bottom: 8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Focus Flash
|
|
|
|
private func triggerFocusFlashAnimation() {
|
|
focusFlashAnimationGeneration &+= 1
|
|
let generation = focusFlashAnimationGeneration
|
|
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
|
|
|
|
for segment in FocusFlashPattern.segments {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
|
|
guard focusFlashAnimationGeneration == generation else { return }
|
|
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
|
|
focusFlashOpacity = segment.targetOpacity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
|
|
switch curve {
|
|
case .easeIn:
|
|
return .easeIn(duration: duration)
|
|
case .easeOut:
|
|
return .easeOut(duration: duration)
|
|
}
|
|
}
|
|
}
|