forked from manaflow-ai/cmux
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathMarkdownPanel.swift
More file actions
281 lines (239 loc) · 9.68 KB
/
Copy pathMarkdownPanel.swift
File metadata and controls
281 lines (239 loc) · 9.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import Foundation
import Bonsplit
import Combine
// MARK: - MarkdownSearchState
/// Observable state for find-in-markdown. Owned by MarkdownPanel while find is open.
final class MarkdownSearchState: ObservableObject {
/// The current search needle typed by the user.
@Published var needle: String = ""
/// All case-insensitive match ranges in the raw markdown string.
@Published var matches: [Range<String.Index>] = []
/// Index of the currently selected match, or nil when no matches.
@Published var currentIndex: Int? = nil
}
// MARK: - MarkdownPanel
/// 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
/// Non-nil while the find bar is visible.
@Published var searchState: MarkdownSearchState? = nil
// 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: - Find state plumbing
/// Cancellable for the needle-change subscription that recomputes matches.
private var searchSubscription: AnyCancellable? = nil
// 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(reason: WorkspaceAttentionFlashReason) {
_ = reason
guard NotificationPaneFlashSettings.isEnabled() else { return }
focusFlashToken += 1
}
// MARK: - Find in Markdown
/// Open the find bar (or re-focus it if already open).
func startFind() {
if searchState == nil {
let state = MarkdownSearchState()
searchState = state
// Re-run match computation whenever the needle changes.
searchSubscription = state.$needle
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.recomputeMatches()
}
}
recomputeMatches()
#if DEBUG
dlog("markdown.find.start panel=\(id.uuidString.prefix(5))")
#endif
}
/// Advance to the next match (wraps around).
func findNext() {
guard let state = searchState, !state.matches.isEmpty else { return }
let count = state.matches.count
state.currentIndex = (state.currentIndex.map { $0 + 1 } ?? 0) % count
#if DEBUG
dlog("markdown.find.next panel=\(id.uuidString.prefix(5)) idx=\(state.currentIndex ?? -1)/\(count)")
#endif
}
/// Advance to the previous match (wraps around).
func findPrevious() {
guard let state = searchState, !state.matches.isEmpty else { return }
let count = state.matches.count
state.currentIndex = state.currentIndex.map { ($0 - 1 + count) % count } ?? (count - 1)
#if DEBUG
dlog("markdown.find.previous panel=\(id.uuidString.prefix(5)) idx=\(state.currentIndex ?? -1)/\(count)")
#endif
}
/// Close the find bar and discard search state.
func hideFind() {
searchSubscription = nil
searchState = nil
#if DEBUG
dlog("markdown.find.hide panel=\(id.uuidString.prefix(5))")
#endif
}
/// Walk the raw markdown content and collect all case-insensitive matches.
private func recomputeMatches() {
guard let state = searchState else { return }
guard !state.needle.isEmpty else {
state.matches = []
state.currentIndex = nil
return
}
var result: [Range<String.Index>] = []
var searchRange = content.startIndex..<content.endIndex
while let range = content.range(of: state.needle, options: .caseInsensitive, range: searchRange) {
result.append(range)
guard range.upperBound < content.endIndex else { break }
searchRange = range.upperBound..<content.endIndex
}
state.matches = result
if result.isEmpty {
state.currentIndex = nil
} else if state.currentIndex == nil {
state.currentIndex = 0
} else if let current = state.currentIndex, current >= result.count {
state.currentIndex = result.count - 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()
}
}