cmux/Sources/Panels/MarkdownPanel.swift
Ismail Pelaseyed d72b014d6d
feat: add markdown viewer panel with live file watching (#883)
* 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>
2026-03-04 17:48:28 -08:00

182 lines
6.1 KiB
Swift

import Foundation
import Combine
/// A panel that renders a markdown file with live file-watching.
/// When the file changes on disk, the content is automatically reloaded.
@MainActor
final class MarkdownPanel: Panel, ObservableObject {
let id: UUID
let panelType: PanelType = .markdown
/// Absolute path to the markdown file being displayed.
let filePath: String
/// The workspace this panel belongs to.
private(set) var workspaceId: UUID
/// Current markdown content read from the file.
@Published private(set) var content: String = ""
/// Title shown in the tab bar (filename).
@Published private(set) var displayTitle: String = ""
/// SF Symbol icon for the tab bar.
var displayIcon: String? { "doc.richtext" }
/// Whether the file has been deleted or is unreadable.
@Published private(set) var isFileUnavailable: Bool = false
/// Token incremented to trigger focus flash animation.
@Published private(set) var focusFlashToken: Int = 0
// MARK: - File watching
// nonisolated(unsafe) because deinit is not guaranteed to run on the
// main actor, but DispatchSource.cancel() is thread-safe.
private nonisolated(unsafe) var fileWatchSource: DispatchSourceFileSystemObject?
private var fileDescriptor: Int32 = -1
private var isClosed: Bool = false
private let watchQueue = DispatchQueue(label: "com.cmux.markdown-file-watch", qos: .utility)
/// Maximum number of reattach attempts after a file delete/rename event.
private static let maxReattachAttempts = 6
/// Delay between reattach attempts (total window: attempts * delay = 3s).
private static let reattachDelay: TimeInterval = 0.5
// MARK: - Init
init(workspaceId: UUID, filePath: String) {
self.id = UUID()
self.workspaceId = workspaceId
self.filePath = filePath
self.displayTitle = (filePath as NSString).lastPathComponent
loadFileContent()
startFileWatcher()
if isFileUnavailable && fileWatchSource == nil {
// Session restore can create a panel before the file is recreated.
// Retry briefly so atomic-rename recreations can reconnect.
scheduleReattach(attempt: 1)
}
}
// MARK: - Panel protocol
func focus() {
// Markdown panel is read-only; no first responder to manage.
}
func unfocus() {
// No-op for read-only panel.
}
func close() {
isClosed = true
stopFileWatcher()
}
func triggerFlash() {
focusFlashToken += 1
}
// MARK: - File I/O
private func loadFileContent() {
do {
let newContent = try String(contentsOfFile: filePath, encoding: .utf8)
content = newContent
isFileUnavailable = false
} catch {
// Fallback: try ISO Latin-1, which accepts all 256 byte values,
// covering legacy encodings like Windows-1252.
if let data = FileManager.default.contents(atPath: filePath),
let decoded = String(data: data, encoding: .isoLatin1) {
content = decoded
isFileUnavailable = false
} else {
isFileUnavailable = true
}
}
}
// MARK: - File watcher via DispatchSource
private func startFileWatcher() {
let fd = open(filePath, O_EVTONLY)
guard fd >= 0 else { return }
fileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .delete, .rename, .extend],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
if flags.contains(.delete) || flags.contains(.rename) {
// File was deleted or renamed. The old file descriptor points to
// a stale inode, so we must always stop and reattach the watcher
// even if the new file is already readable (atomic save case).
DispatchQueue.main.async {
self.stopFileWatcher()
self.loadFileContent()
if self.isFileUnavailable {
// File not yet replaced retry until it reappears.
self.scheduleReattach(attempt: 1)
} else {
// File already replaced reattach to the new inode immediately.
self.startFileWatcher()
}
}
} else {
// Content changed reload.
DispatchQueue.main.async {
self.loadFileContent()
}
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
fileWatchSource = source
}
/// Retry reattaching the file watcher up to `maxReattachAttempts` times.
/// Each attempt checks if the file has reappeared. Bails out early if
/// the panel has been closed.
private func scheduleReattach(attempt: Int) {
guard attempt <= Self.maxReattachAttempts else { return }
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
guard !self.isClosed else { return }
if FileManager.default.fileExists(atPath: self.filePath) {
self.isFileUnavailable = false
self.loadFileContent()
self.startFileWatcher()
} else {
self.scheduleReattach(attempt: attempt + 1)
}
}
}
}
private func stopFileWatcher() {
if let source = fileWatchSource {
source.cancel()
fileWatchSource = nil
}
// File descriptor is closed by the cancel handler.
fileDescriptor = -1
}
deinit {
// DispatchSource cancel is safe from any thread.
fileWatchSource?.cancel()
}
}