cmux/Sources/Panels/MarkdownPanel.swift
Lawrence Chen 2596f78380
Add settings to disable pane ring and flash (#1217)
* Add setting to disable unread pane ring

* Add setting to disable pane flash

* Label notification toggles for accessibility

* Clean up notification settings review follow-ups
2026-03-13 03:52:56 -07:00

183 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() {
guard NotificationPaneFlashSettings.isEnabled() else { return }
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()
}
}