* 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>
479 lines
15 KiB
Swift
479 lines
15 KiB
Swift
import CoreGraphics
|
|
import Foundation
|
|
import Bonsplit
|
|
|
|
enum SessionSnapshotSchema {
|
|
static let currentVersion = 1
|
|
}
|
|
|
|
enum SessionPersistencePolicy {
|
|
static let defaultSidebarWidth: Double = 200
|
|
static let minimumSidebarWidth: Double = 186
|
|
static let maximumSidebarWidth: Double = 600
|
|
static let minimumWindowWidth: Double = 300
|
|
static let minimumWindowHeight: Double = 200
|
|
static let autosaveInterval: TimeInterval = 8.0
|
|
static let maxWindowsPerSnapshot: Int = 12
|
|
static let maxWorkspacesPerWindow: Int = 128
|
|
static let maxPanelsPerWorkspace: Int = 512
|
|
static let maxScrollbackLinesPerTerminal: Int = 4000
|
|
static let maxScrollbackCharactersPerTerminal: Int = 400_000
|
|
|
|
static func sanitizedSidebarWidth(_ candidate: Double?) -> Double {
|
|
let fallback = defaultSidebarWidth
|
|
guard let candidate, candidate.isFinite else { return fallback }
|
|
return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth)
|
|
}
|
|
|
|
static func truncatedScrollback(_ text: String?) -> String? {
|
|
guard let text, !text.isEmpty else { return nil }
|
|
if text.count <= maxScrollbackCharactersPerTerminal {
|
|
return text
|
|
}
|
|
let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal)
|
|
let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart)
|
|
return String(text[safeStart...])
|
|
}
|
|
|
|
/// If truncation starts in the middle of an ANSI CSI escape sequence, advance
|
|
/// to the first printable character after that sequence to avoid replaying
|
|
/// malformed control bytes.
|
|
private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index {
|
|
guard initialStart > text.startIndex else { return initialStart }
|
|
let escape = "\u{001B}"
|
|
|
|
guard let lastEscape = text[..<initialStart].lastIndex(of: Character(escape)) else {
|
|
return initialStart
|
|
}
|
|
let csiMarker = text.index(after: lastEscape)
|
|
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
|
|
return initialStart
|
|
}
|
|
|
|
// If a final CSI byte exists before the truncation boundary, we are not
|
|
// inside a partial sequence.
|
|
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
|
|
return initialStart
|
|
}
|
|
|
|
// We are inside a CSI sequence. Skip to the first character after the
|
|
// sequence terminator if it exists.
|
|
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
|
|
return initialStart
|
|
}
|
|
let next = text.index(after: final)
|
|
return next < text.endIndex ? next : text.endIndex
|
|
}
|
|
|
|
private static func csiFinalByteIndex(
|
|
in text: String,
|
|
from csiMarker: String.Index,
|
|
upperBound: String.Index
|
|
) -> String.Index? {
|
|
var index = text.index(after: csiMarker)
|
|
while index < upperBound {
|
|
guard let scalar = text[index].unicodeScalars.first?.value else {
|
|
index = text.index(after: index)
|
|
continue
|
|
}
|
|
if scalar >= 0x40, scalar <= 0x7E {
|
|
return index
|
|
}
|
|
index = text.index(after: index)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
enum SessionRestorePolicy {
|
|
static func isRunningUnderAutomatedTests(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> Bool {
|
|
if environment["CMUX_UI_TEST_MODE"] == "1" {
|
|
return true
|
|
}
|
|
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
|
|
return true
|
|
}
|
|
if environment["XCTestConfigurationFilePath"] != nil {
|
|
return true
|
|
}
|
|
if environment["XCTestBundlePath"] != nil {
|
|
return true
|
|
}
|
|
if environment["XCTestSessionIdentifier"] != nil {
|
|
return true
|
|
}
|
|
if environment["XCInjectBundle"] != nil {
|
|
return true
|
|
}
|
|
if environment["XCInjectBundleInto"] != nil {
|
|
return true
|
|
}
|
|
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
static func shouldAttemptRestore(
|
|
arguments: [String] = CommandLine.arguments,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
|
) -> Bool {
|
|
if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" {
|
|
return false
|
|
}
|
|
if isRunningUnderAutomatedTests(environment: environment) {
|
|
return false
|
|
}
|
|
|
|
let extraArgs = arguments
|
|
.dropFirst()
|
|
.filter { !$0.hasPrefix("-psn_") }
|
|
|
|
// Any explicit launch argument is treated as an explicit open intent.
|
|
return extraArgs.isEmpty
|
|
}
|
|
}
|
|
|
|
struct SessionRectSnapshot: Codable, Equatable, Sendable {
|
|
let x: Double
|
|
let y: Double
|
|
let width: Double
|
|
let height: Double
|
|
|
|
init(x: Double, y: Double, width: Double, height: Double) {
|
|
self.x = x
|
|
self.y = y
|
|
self.width = width
|
|
self.height = height
|
|
}
|
|
|
|
init(_ rect: CGRect) {
|
|
self.x = Double(rect.origin.x)
|
|
self.y = Double(rect.origin.y)
|
|
self.width = Double(rect.size.width)
|
|
self.height = Double(rect.size.height)
|
|
}
|
|
|
|
var cgRect: CGRect {
|
|
CGRect(x: x, y: y, width: width, height: height)
|
|
}
|
|
}
|
|
|
|
struct SessionDisplaySnapshot: Codable, Sendable {
|
|
var displayID: UInt32?
|
|
var frame: SessionRectSnapshot?
|
|
var visibleFrame: SessionRectSnapshot?
|
|
}
|
|
|
|
enum SessionSidebarSelection: String, Codable, Sendable, Equatable {
|
|
case tabs
|
|
case notifications
|
|
|
|
init(selection: SidebarSelection) {
|
|
switch selection {
|
|
case .tabs:
|
|
self = .tabs
|
|
case .notifications:
|
|
self = .notifications
|
|
}
|
|
}
|
|
|
|
var sidebarSelection: SidebarSelection {
|
|
switch self {
|
|
case .tabs:
|
|
return .tabs
|
|
case .notifications:
|
|
return .notifications
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SessionSidebarSnapshot: Codable, Sendable {
|
|
var isVisible: Bool
|
|
var selection: SessionSidebarSelection
|
|
var width: Double?
|
|
}
|
|
|
|
struct SessionStatusEntrySnapshot: Codable, Sendable {
|
|
var key: String
|
|
var value: String
|
|
var icon: String?
|
|
var color: String?
|
|
var timestamp: TimeInterval
|
|
}
|
|
|
|
struct SessionLogEntrySnapshot: Codable, Sendable {
|
|
var message: String
|
|
var level: String
|
|
var source: String?
|
|
var timestamp: TimeInterval
|
|
}
|
|
|
|
struct SessionProgressSnapshot: Codable, Sendable {
|
|
var value: Double
|
|
var label: String?
|
|
}
|
|
|
|
struct SessionGitBranchSnapshot: Codable, Sendable {
|
|
var branch: String
|
|
var isDirty: Bool
|
|
}
|
|
|
|
struct SessionTerminalPanelSnapshot: Codable, Sendable {
|
|
var workingDirectory: String?
|
|
var scrollback: String?
|
|
}
|
|
|
|
struct SessionBrowserPanelSnapshot: Codable, Sendable {
|
|
var urlString: String?
|
|
var shouldRenderWebView: Bool
|
|
var pageZoom: Double
|
|
var developerToolsVisible: Bool
|
|
var backHistoryURLStrings: [String]?
|
|
var forwardHistoryURLStrings: [String]?
|
|
}
|
|
|
|
struct SessionMarkdownPanelSnapshot: Codable, Sendable {
|
|
var filePath: String
|
|
}
|
|
|
|
struct SessionPanelSnapshot: Codable, Sendable {
|
|
var id: UUID
|
|
var type: PanelType
|
|
var title: String?
|
|
var customTitle: String?
|
|
var directory: String?
|
|
var isPinned: Bool
|
|
var isManuallyUnread: Bool
|
|
var gitBranch: SessionGitBranchSnapshot?
|
|
var listeningPorts: [Int]
|
|
var ttyName: String?
|
|
var terminal: SessionTerminalPanelSnapshot?
|
|
var browser: SessionBrowserPanelSnapshot?
|
|
var markdown: SessionMarkdownPanelSnapshot?
|
|
}
|
|
|
|
enum SessionSplitOrientation: String, Codable, Sendable {
|
|
case horizontal
|
|
case vertical
|
|
|
|
init(_ orientation: SplitOrientation) {
|
|
switch orientation {
|
|
case .horizontal:
|
|
self = .horizontal
|
|
case .vertical:
|
|
self = .vertical
|
|
}
|
|
}
|
|
|
|
var splitOrientation: SplitOrientation {
|
|
switch self {
|
|
case .horizontal:
|
|
return .horizontal
|
|
case .vertical:
|
|
return .vertical
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SessionPaneLayoutSnapshot: Codable, Sendable {
|
|
var panelIds: [UUID]
|
|
var selectedPanelId: UUID?
|
|
}
|
|
|
|
struct SessionSplitLayoutSnapshot: Codable, Sendable {
|
|
var orientation: SessionSplitOrientation
|
|
var dividerPosition: Double
|
|
var first: SessionWorkspaceLayoutSnapshot
|
|
var second: SessionWorkspaceLayoutSnapshot
|
|
}
|
|
|
|
indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable {
|
|
case pane(SessionPaneLayoutSnapshot)
|
|
case split(SessionSplitLayoutSnapshot)
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case type
|
|
case pane
|
|
case split
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let type = try container.decode(String.self, forKey: .type)
|
|
switch type {
|
|
case "pane":
|
|
self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane))
|
|
case "split":
|
|
self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split))
|
|
default:
|
|
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)")
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
switch self {
|
|
case .pane(let pane):
|
|
try container.encode("pane", forKey: .type)
|
|
try container.encode(pane, forKey: .pane)
|
|
case .split(let split):
|
|
try container.encode("split", forKey: .type)
|
|
try container.encode(split, forKey: .split)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SessionWorkspaceSnapshot: Codable, Sendable {
|
|
var processTitle: String
|
|
var customTitle: String?
|
|
var customColor: String?
|
|
var isPinned: Bool
|
|
var currentDirectory: String
|
|
var focusedPanelId: UUID?
|
|
var layout: SessionWorkspaceLayoutSnapshot
|
|
var panels: [SessionPanelSnapshot]
|
|
var statusEntries: [SessionStatusEntrySnapshot]
|
|
var logEntries: [SessionLogEntrySnapshot]
|
|
var progress: SessionProgressSnapshot?
|
|
var gitBranch: SessionGitBranchSnapshot?
|
|
}
|
|
|
|
struct SessionTabManagerSnapshot: Codable, Sendable {
|
|
var selectedWorkspaceIndex: Int?
|
|
var workspaces: [SessionWorkspaceSnapshot]
|
|
}
|
|
|
|
struct SessionWindowSnapshot: Codable, Sendable {
|
|
var frame: SessionRectSnapshot?
|
|
var display: SessionDisplaySnapshot?
|
|
var tabManager: SessionTabManagerSnapshot
|
|
var sidebar: SessionSidebarSnapshot
|
|
}
|
|
|
|
struct AppSessionSnapshot: Codable, Sendable {
|
|
var version: Int
|
|
var createdAt: TimeInterval
|
|
var windows: [SessionWindowSnapshot]
|
|
}
|
|
|
|
enum SessionPersistenceStore {
|
|
static func load(fileURL: URL? = nil) -> AppSessionSnapshot? {
|
|
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil }
|
|
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
|
let decoder = JSONDecoder()
|
|
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
|
|
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
|
|
guard !snapshot.windows.isEmpty else { return nil }
|
|
return snapshot
|
|
}
|
|
|
|
@discardableResult
|
|
static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool {
|
|
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false }
|
|
let directory = fileURL.deletingLastPathComponent()
|
|
do {
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
let data = try encoder.encode(snapshot)
|
|
try data.write(to: fileURL, options: .atomic)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
static func removeSnapshot(fileURL: URL? = nil) {
|
|
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return }
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
|
|
static func defaultSnapshotFileURL(
|
|
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
|
appSupportDirectory: URL? = nil
|
|
) -> URL? {
|
|
let resolvedAppSupport: URL
|
|
if let appSupportDirectory {
|
|
resolvedAppSupport = appSupportDirectory
|
|
} else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
|
resolvedAppSupport = discovered
|
|
} else {
|
|
return nil
|
|
}
|
|
let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
|
? bundleIdentifier!
|
|
: "com.cmuxterm.app"
|
|
let safeBundleId = bundleId.replacingOccurrences(
|
|
of: "[^A-Za-z0-9._-]",
|
|
with: "_",
|
|
options: .regularExpression
|
|
)
|
|
return resolvedAppSupport
|
|
.appendingPathComponent("cmux", isDirectory: true)
|
|
.appendingPathComponent("session-\(safeBundleId).json", isDirectory: false)
|
|
}
|
|
}
|
|
|
|
enum SessionScrollbackReplayStore {
|
|
static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE"
|
|
private static let directoryName = "cmux-session-scrollback"
|
|
private static let ansiEscape = "\u{001B}"
|
|
private static let ansiReset = "\u{001B}[0m"
|
|
|
|
static func replayEnvironment(
|
|
for scrollback: String?,
|
|
tempDirectory: URL = FileManager.default.temporaryDirectory
|
|
) -> [String: String] {
|
|
guard let replayText = normalizedScrollback(scrollback) else { return [:] }
|
|
guard let replayFileURL = writeReplayFile(
|
|
contents: replayText,
|
|
tempDirectory: tempDirectory
|
|
) else {
|
|
return [:]
|
|
}
|
|
return [environmentKey: replayFileURL.path]
|
|
}
|
|
|
|
private static func normalizedScrollback(_ scrollback: String?) -> String? {
|
|
guard let scrollback else { return nil }
|
|
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
|
|
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
|
|
return ansiSafeReplayText(truncated)
|
|
}
|
|
|
|
/// Preserve ANSI color state safely across replay boundaries.
|
|
private static func ansiSafeReplayText(_ text: String) -> String {
|
|
guard text.contains(ansiEscape) else { return text }
|
|
var output = text
|
|
if !output.hasPrefix(ansiReset) {
|
|
output = ansiReset + output
|
|
}
|
|
if !output.hasSuffix(ansiReset) {
|
|
output += ansiReset
|
|
}
|
|
return output
|
|
}
|
|
|
|
private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? {
|
|
guard let data = contents.data(using: .utf8) else { return nil }
|
|
let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true)
|
|
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil
|
|
)
|
|
let fileURL = directory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: false)
|
|
.appendingPathExtension("txt")
|
|
try data.write(to: fileURL, options: .atomic)
|
|
return fileURL
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
}
|