From 61bb943c7a4ce18d75a70dab11ff2308b5094b08 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 13:59:42 +0200 Subject: [PATCH 01/17] feat(source-edit): byte-faithful file format and in-place writer DocumentFileFormat detects encoding, BOM and line-ending style from the raw bytes and re-encodes LF text so an unchanged document saves byte for byte; DocumentFileWriter overwrites an existing regular file in place (keeps inode, permissions, xattrs, creation date, links; refuses read-only and non-regular files) with read-back verification and a best-effort restore. MarkdownDocument.decode now strips the BOM on the bytes, so the text never carries U+FEFF regardless of the Foundation version. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 20 + QuickMD/QuickMD/DocumentFileFormat.swift | 178 +++++++++ QuickMD/QuickMD/DocumentFileWriter.swift | 165 +++++++++ QuickMD/QuickMD/MarkdownDocument.swift | 43 ++- .../DocumentFileFormatTests.swift | 303 ++++++++++++++++ .../DocumentFileWriterTests.swift | 341 ++++++++++++++++++ 6 files changed, 1044 insertions(+), 6 deletions(-) create mode 100644 QuickMD/QuickMD/DocumentFileFormat.swift create mode 100644 QuickMD/QuickMD/DocumentFileWriter.swift create mode 100644 QuickMD/QuickMDTests/DocumentFileFormatTests.swift create mode 100644 QuickMD/QuickMDTests/DocumentFileWriterTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index cbd1e67..075bbc4 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -121,6 +121,12 @@ A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000110 /* DocumentReadingPosition.swift */; }; A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000110 /* DocumentReadingPosition.swift */; }; A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000111 /* ExternalEditorLineTests.swift */; }; + A1B2C3D401000120 /* DocumentFileFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000120 /* DocumentFileFormat.swift */; }; + A1B2C3D401000121 /* DocumentFileFormat.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000120 /* DocumentFileFormat.swift */; }; + A1B2C3D401000122 /* DocumentFileWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000121 /* DocumentFileWriter.swift */; }; + A1B2C3D401000123 /* DocumentFileWriter.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000121 /* DocumentFileWriter.swift */; }; + A1B2C3D401000124 /* DocumentFileFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000122 /* DocumentFileFormatTests.swift */; }; + A1B2C3D401000125 /* DocumentFileWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000123 /* DocumentFileWriterTests.swift */; }; A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000F0 /* HeadingLayoutTests.swift */; }; /* End PBXBuildFile section */ @@ -142,6 +148,10 @@ A1B2C3D4000000C1 /* DocumentSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSelectionTests.swift; sourceTree = ""; }; A1B2C3D400000110 /* DocumentReadingPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentReadingPosition.swift; sourceTree = ""; }; A1B2C3D400000111 /* ExternalEditorLineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalEditorLineTests.swift; sourceTree = ""; }; + A1B2C3D400000120 /* DocumentFileFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileFormat.swift; sourceTree = ""; }; + A1B2C3D400000121 /* DocumentFileWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileWriter.swift; sourceTree = ""; }; + A1B2C3D400000122 /* DocumentFileFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileFormatTests.swift; sourceTree = ""; }; + A1B2C3D400000123 /* DocumentFileWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileWriterTests.swift; sourceTree = ""; }; A1B2C3D4000000F0 /* HeadingLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingLayoutTests.swift; sourceTree = ""; }; A1B2C3D400000001 /* QuickMDApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMDApp.swift; sourceTree = ""; }; A1B2C3D400000002 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; @@ -270,6 +280,8 @@ A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, A1B2C3D400000111 /* ExternalEditorLineTests.swift */, + A1B2C3D400000122 /* DocumentFileFormatTests.swift */, + A1B2C3D400000123 /* DocumentFileWriterTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -290,6 +302,8 @@ A1B2C3D400000081 /* ImageLoader.swift */, A1B2C3D4000000C0 /* DocumentSelection.swift */, A1B2C3D400000110 /* DocumentReadingPosition.swift */, + A1B2C3D400000120 /* DocumentFileFormat.swift */, + A1B2C3D400000121 /* DocumentFileWriter.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -573,6 +587,8 @@ A1B2C3D401000082 /* ImageLoader.swift in Sources */, A1B2C3D4010000C0 /* DocumentSelection.swift in Sources */, A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */, + A1B2C3D401000120 /* DocumentFileFormat.swift in Sources */, + A1B2C3D401000122 /* DocumentFileWriter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -614,6 +630,10 @@ A1B2C3D4010000C2 /* DocumentSelectionTests.swift in Sources */, A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */, A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */, + A1B2C3D401000121 /* DocumentFileFormat.swift in Sources (Tests) */, + A1B2C3D401000123 /* DocumentFileWriter.swift in Sources (Tests) */, + A1B2C3D401000124 /* DocumentFileFormatTests.swift in Sources */, + A1B2C3D401000125 /* DocumentFileWriterTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QuickMD/QuickMD/DocumentFileFormat.swift b/QuickMD/QuickMD/DocumentFileFormat.swift new file mode 100644 index 0000000..d3ed402 --- /dev/null +++ b/QuickMD/QuickMD/DocumentFileFormat.swift @@ -0,0 +1,178 @@ +import Foundation + +/// How a text file is stored on disk: encoding, byte-order mark, line ending. +/// +/// Source Edit (v1.12) writes the user's fix back IN the file's own format, so +/// a one-character edit produces a one-character diff — never a re-encoded or +/// re-terminated file. The invariant (pinned per fixture in +/// `DocumentFileFormatTests`): for a file with uniform line endings, +/// `encode(normalizeLineEndings(decode(bytes))) == bytes`. +/// +/// Pure value type, no actor isolation (the CI SDK is older — see constraints). +struct DocumentFileFormat: Equatable, Sendable { + + /// The encodings `MarkdownDocument.decode` can produce. UTF-16 only ever + /// comes from a file with a BOM, so its byte order is always known. + enum Encoding: Equatable, Sendable { + case utf8 + case utf16LittleEndian + case utf16BigEndian + case isoLatin1 + + /// The Foundation encoding, e.g. for `String.localizedName(of:)` in + /// the "cannot store some of the characters" prompt. + var stringEncoding: String.Encoding { + switch self { + case .utf8: return .utf8 + case .utf16LittleEndian: return .utf16LittleEndian + case .utf16BigEndian: return .utf16BigEndian + case .isoLatin1: return .isoLatin1 + } + } + + var byteOrderMark: Data { + switch self { + case .utf8: return Data([0xEF, 0xBB, 0xBF]) + case .utf16LittleEndian: return Data([0xFF, 0xFE]) + case .utf16BigEndian: return Data([0xFE, 0xFF]) + case .isoLatin1: return Data() + } + } + } + + enum LineEnding: String, Equatable, Sendable { + case lf = "\n" + case crlf = "\r\n" + case cr = "\r" + } + + /// Fresh file bytes, read once: the format to write back AND the + /// LF-normalized text the editor starts from. Returned together so the + /// decoding, BOM handling and normalization behind both can never drift. + struct Decoded: Equatable, Sendable { + let format: DocumentFileFormat + let text: String + /// More than one line-ending style in the file. Saving writes them + /// all as `format.lineEnding` — the one accepted, documented way a + /// save may change bytes the user did not touch. + let hasMixedLineEndings: Bool + /// Saving the unchanged text would reproduce the file byte for byte + /// (`format.encode(text) == data`). False when the decoder repaired + /// or dropped something (odd-length UTF-16, …) or the line endings + /// are mixed; the edit session refuses to edit in the first case + /// rather than silently rewrite bytes the user never saw. + let isByteExact: Bool + } + + var encoding: Encoding + /// True for UTF-8 files that start with EF BB BF, and always for UTF-16 + /// (it is only detected with a BOM, and is written back with one). + var hasBOM: Bool + var lineEnding: LineEnding + + /// The single entry point for the edit session. Reuses + /// `MarkdownDocument.decodeDetectingEncoding` (the viewer's own decoder, + /// which decodes past the BOM) and `normalizeLineEndings`. Nil only when + /// `decode` would be nil too. + static func decode(_ data: Data) -> Decoded? { + guard let decoded = MarkdownDocument.decodeDetectingEncoding(data) else { return nil } + let raw = decoded.text + let encoding: Encoding + let hasBOM: Bool + switch decoded.encoding { + case .utf16LittleEndian: + encoding = .utf16LittleEndian + hasBOM = true + case .utf16BigEndian: + encoding = .utf16BigEndian + hasBOM = true + case .isoLatin1: + encoding = .isoLatin1 + hasBOM = false + case .utf8: + encoding = .utf8 + hasBOM = data.starts(with: Encoding.utf8.byteOrderMark) + default: + assertionFailure("decodeDetectingEncoding returned \(decoded.encoding)") + encoding = .utf8 + hasBOM = data.starts(with: Encoding.utf8.byteOrderMark) + } + let counts = LineEndingCounts(raw) + let format = DocumentFileFormat(encoding: encoding, hasBOM: hasBOM, lineEnding: counts.dominant) + let text = MarkdownDocument.normalizeLineEndings(raw) + // One extra encode per edit session (≤ 5 MB) — cheap insurance. + return Decoded(format: format, text: text, + hasMixedLineEndings: counts.isMixed, + isByteExact: format.encode(text) == data) + } + + /// Mixed files get the dominant style; ties and break-free text get LF + /// (VS Code's policy). + static func dominantLineEnding(in text: String) -> LineEnding { + LineEndingCounts(text).dominant + } + + /// Line breaks counted on the NOT yet normalized text. The scalar view is + /// load-bearing: "\r\n" is one Character, so a Character scan would never + /// see its "\r" (constraints: "Line endings normalized ONCE"). Only CR and + /// LF count — U+0085, U+2028 and U+2029 are content, exactly as + /// `normalizeLineEndings` (and the parser) treat them. + private struct LineEndingCounts { + var lf = 0, crlf = 0, cr = 0 + + init(_ text: String) { + var pendingCR = false + for scalar in text.unicodeScalars { + if pendingCR { + pendingCR = false + if scalar == "\n" { + crlf += 1 + continue + } + cr += 1 + } + if scalar == "\r" { + pendingCR = true + } else if scalar == "\n" { + lf += 1 + } + } + if pendingCR { cr += 1 } + } + + var dominant: LineEnding { + if crlf > lf && crlf > cr { return .crlf } + if cr > lf && cr > crlf { return .cr } + return .lf + } + + var isMixed: Bool { [lf, crlf, cr].filter { $0 > 0 }.count > 1 } + } + + /// LF-normalized text → file bytes: LF becomes the file's line ending, + /// then encode, then the BOM. Nil when the encoding cannot represent the + /// text (a Latin-1 file and "ł") — the caller offers `utf8Fallback`. + /// Trailing newlines and trailing whitespace pass through untouched. + func encode(_ text: String) -> Data? { + let terminated = lineEnding == .lf + ? text + : text.replacingOccurrences(of: "\n", with: lineEnding.rawValue) + let body: Data + if encoding == .utf8 { + body = Data(terminated.utf8) + } else { + guard let encoded = terminated.data(using: encoding.stringEncoding, + allowLossyConversion: false) else { + return nil + } + body = encoded + } + return hasBOM ? encoding.byteOrderMark + body : body + } + + /// "Save as UTF-8": UTF-8 without a BOM, keeping the line ending — the + /// only thing that changes is what had to. + var utf8Fallback: DocumentFileFormat { + DocumentFileFormat(encoding: .utf8, hasBOM: false, lineEnding: lineEnding) + } +} diff --git a/QuickMD/QuickMD/DocumentFileWriter.swift b/QuickMD/QuickMD/DocumentFileWriter.swift new file mode 100644 index 0000000..79ac1a6 --- /dev/null +++ b/QuickMD/QuickMD/DocumentFileWriter.swift @@ -0,0 +1,165 @@ +import Foundation +import Darwin + +/// Writes new bytes into an EXISTING file, in place (Source Edit, v1.12). +/// +/// In place is the only strategy that keeps everything about the file except +/// its content — inode, hard links, symlinks, POSIX permissions, extended +/// attributes (Finder tags), creation date — and that refuses a read-only +/// file. `Data.write(options: .atomic)` and `replaceItemAt` both swap in a new +/// inode: the first drops xattrs, resets the creation date and overwrites a +/// 0444 file; the second leaves 0600 on APFS. Both also need a sibling temp +/// file, which the sandbox does not allow next to a user's document. +/// (Spec Evidence 2.) +/// +/// Synchronous and free of AppKit / actor isolation: the caller decides where +/// it runs, and the CI SDK is older (see constraints). +enum DocumentFileWriter { + + enum WriteError: Error, LocalizedError { + /// What became of the file's previous content after a failure. + enum Original: Equatable { + /// Failed before the first byte was written. + case untouched + /// The previous bytes were written back and read back intact. + case restored + /// The restore attempt failed too: the file may be damaged. + case notRestored + } + + case missing(URL) + case readOnly(URL) + /// A directory, FIFO, device… — anything but a regular file once + /// symlinks are followed. Opening a FIFO for writing would block. + case notRegularFile(URL) + case writeFailed(URL, underlying: Error, original: Original) + case verificationFailed(URL, original: Original) + + var errorDescription: String? { + switch self { + case .missing(let url): + return "“\(url.lastPathComponent)” no longer exists." + case .readOnly(let url): + return "“\(url.lastPathComponent)” is read-only." + case .notRegularFile(let url): + return "“\(url.lastPathComponent)” is not a regular file and cannot be saved in place." + case .writeFailed(let url, _, _): + return "“\(url.lastPathComponent)” could not be saved." + case .verificationFailed(let url, _): + return "“\(url.lastPathComponent)” could not be saved: the file did not contain the saved text when read back." + } + } + + var failureReason: String? { + switch self { + case .missing, .readOnly, .notRegularFile: + return nil + case .writeFailed(_, let underlying, _): + return underlying.localizedDescription + case .verificationFailed: + return nil + } + } + + var recoverySuggestion: String? { + switch self { + case .missing: + return "Save a copy to keep your changes." + case .readOnly: + return "Change its permissions in the Finder, or save a copy." + case .notRegularFile: + return "Save a copy to keep your changes." + case .writeFailed(_, _, let original), .verificationFailed(_, let original): + switch original { + case .untouched: + return "The file was not changed. Your edits are still in the editor." + case .restored: + return "The file's previous content was restored. Your edits are still in the editor." + case .notRestored: + return "The file's previous content could not be restored, so it may be damaged. Your edits are still in the editor — save a copy." + } + } + } + } + + /// Replaces the content of the file at `url` with `data`, then reads it + /// back to verify. `original` is what the file held before (the session's + /// `baseBytes`): if anything goes wrong once writing has started, ONE + /// best-effort attempt puts it back, and the error says whether that worked. + /// `restoring:` is what the CALLER last knew to be on disk: compare the + /// disk with it immediately before calling (the conflict check), because + /// a failed write restores exactly these bytes. + static func write(_ data: Data, to url: URL, restoring original: Data) throws { + try write(data, to: url, restoring: original, + overwrite: overwriteInPlace, readBack: readBack) + } + + /// The in-place step: replaces the content and flips `touched` once the + /// file may have been modified. + typealias Overwrite = (_ url: URL, _ data: Data, _ touched: inout Bool) throws -> Void + + /// Test seam: `overwrite` and `readBack` are injectable so the rollback + /// and verification paths can be driven without a failing disk. The + /// public overload above always passes the real ones. + static func write(_ data: Data, to url: URL, restoring original: Data, + overwrite: Overwrite, readBack: (URL) -> Data?) throws { + let path = url.path + // Follows symlinks, so a dangling link counts as missing. + guard FileManager.default.fileExists(atPath: path) else { + throw WriteError.missing(url) + } + // stat (not lstat): a symlink to a regular file is fine. Checked + // before opening — `FileHandle(forWritingTo:)` on a FIFO blocks until + // a reader appears, which would hang the main thread. + var info = stat() + guard stat(path, &info) == 0, info.st_mode & S_IFMT == S_IFREG else { + throw WriteError.notRegularFile(url) + } + guard FileManager.default.isWritableFile(atPath: path) else { + throw WriteError.readOnly(url) + } + + func restore() -> WriteError.Original { + var ignored = false + guard (try? overwrite(url, original, &ignored)) != nil, + readBack(url) == original else { + return .notRestored + } + return .restored + } + + var touched = false + do { + try overwrite(url, data, &touched) + } catch { + throw WriteError.writeFailed(url, underlying: error, + original: touched ? restore() : .untouched) + } + + guard readBack(url) == data else { + throw WriteError.verificationFailed(url, original: restore()) + } + } + + /// The in-place sequence. `FileHandle(forWritingTo:)` opens without + /// O_CREAT — a file that vanished since the checks above is an error, + /// never a new file. `touched` flips right before the first write so the + /// caller knows whether a restore is needed. + private static func overwriteInPlace(_ url: URL, _ data: Data, _ touched: inout Bool) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seek(toOffset: 0) + touched = true + try handle.write(contentsOf: data) + // Without this a shorter text would leave the old tail behind. + try handle.truncate(atOffset: UInt64(data.count)) + // After synchronize the bytes are on disk; a close error must not + // turn a good save into a failure (and a restore) — the read-back + // decides. + try handle.synchronize() + } + + private static func readBack(_ url: URL) -> Data? { + try? Data(contentsOf: url) + } +} diff --git a/QuickMD/QuickMD/MarkdownDocument.swift b/QuickMD/QuickMD/MarkdownDocument.swift index d70127c..c6ab578 100644 --- a/QuickMD/QuickMD/MarkdownDocument.swift +++ b/QuickMD/QuickMD/MarkdownDocument.swift @@ -30,20 +30,51 @@ struct MarkdownDocument: FileDocument, Sendable { /// editors), then Latin-1 as a lossless last resort so legacy text files /// still open instead of failing with a corrupt-file error. static func decode(_ data: Data) -> String? { - if let string = String(data: data, encoding: .utf8) { - return string + decodeDetectingEncoding(data)?.text + } + + /// `decode` plus the encoding that produced the text — UTF-16 reported + /// with its byte order (taken from the BOM). This is the ONE place that + /// decides how a file is read: `DocumentFileFormat` derives the encoding + /// it writes back from here, so viewing and saving can never disagree. + /// + /// The BOM is detected on the BYTES and only the rest is decoded, so the + /// text never carries it no matter what Foundation does (`encode` re-adds + /// it from `hasBOM`), and a U+FEFF right after the BOM stays content. + static func decodeDetectingEncoding(_ data: Data) -> (text: String, encoding: String.Encoding)? { + if data.starts(with: utf8BOM) { + // Not `String(data:encoding: .utf8)`: it strips a BOM from the + // remainder too, so EF BB BF EF BB BF would lose three bytes on a + // round trip. Strict stdlib decode: invalid bytes would come back + // as U+FFFD, i.e. not byte-equal, and fall through to Latin-1. + let body = data.dropFirst(utf8BOM.count) + let string = String(decoding: body, as: UTF8.self) + if string.utf8.elementsEqual(body) { + return (string, .utf8) + } + } else if let string = String(data: data, encoding: .utf8) { + return (string, .utf8) } if data.count >= 2 { let b0 = data[data.startIndex] let b1 = data[data.index(after: data.startIndex)] - let hasUTF16BOM = (b0 == 0xFF && b1 == 0xFE) || (b0 == 0xFE && b1 == 0xFF) - if hasUTF16BOM, let string = String(data: data, encoding: .utf16) { - return string + let encoding: String.Encoding? + switch (b0, b1) { + case (0xFF, 0xFE): encoding = .utf16LittleEndian + case (0xFE, 0xFF): encoding = .utf16BigEndian + default: encoding = nil + } + // The byte-order-specific encodings keep a leading U+FEFF as text. + if let encoding, let string = String(data: data.dropFirst(2), encoding: encoding) { + return (string, encoding) } } - return String(data: data, encoding: .isoLatin1) + guard let string = String(data: data, encoding: .isoLatin1) else { return nil } + return (string, .isoLatin1) } + private static let utf8BOM: [UInt8] = [0xEF, 0xBB, 0xBF] + /// The parser, the Mermaid bridge, and section extraction all assume LF /// line endings. Normalize once at the document boundary: CRLF (Windows) /// and lone CR (classic Mac) both become LF. Without this, `\r` survives diff --git a/QuickMD/QuickMDTests/DocumentFileFormatTests.swift b/QuickMD/QuickMDTests/DocumentFileFormatTests.swift new file mode 100644 index 0000000..7e8138e --- /dev/null +++ b/QuickMD/QuickMDTests/DocumentFileFormatTests.swift @@ -0,0 +1,303 @@ +import XCTest + +/// Source Edit's byte-faithful format (S-D8). The core invariant, per +/// fixture: `encode(normalize(decode(bytes))) == bytes` for a file with +/// uniform line endings — a save without edits must not change one byte. +final class DocumentFileFormatTests: XCTestCase { + + private func utf8(_ string: String) -> Data { Data(string.utf8) } + + /// Decodes, checks the detected format, and checks the round trip. + private func assertRoundTrip(_ bytes: Data, + encoding: DocumentFileFormat.Encoding, + hasBOM: Bool, + lineEnding: DocumentFileFormat.LineEnding, + text expectedText: String? = nil, + file: StaticString = #filePath, line: UInt = #line) { + guard let decoded = DocumentFileFormat.decode(bytes) else { + return XCTFail("decode returned nil", file: file, line: line) + } + XCTAssertEqual(decoded.format.encoding, encoding, file: file, line: line) + XCTAssertEqual(decoded.format.hasBOM, hasBOM, file: file, line: line) + XCTAssertEqual(decoded.format.lineEnding, lineEnding, file: file, line: line) + XCTAssertFalse(decoded.text.unicodeScalars.contains("\r"), "text must be LF-normalized", + file: file, line: line) + XCTAssertNotEqual(decoded.text.unicodeScalars.first, "\u{FEFF}", "text must not carry a BOM", + file: file, line: line) + if let expectedText { + XCTAssertEqual(decoded.text, expectedText, file: file, line: line) + } + XCTAssertEqual(decoded.format.encode(decoded.text), bytes, "round trip changed the bytes", + file: file, line: line) + XCTAssertTrue(decoded.isByteExact, file: file, line: line) + XCTAssertFalse(decoded.hasMixedLineEndings, file: file, line: line) + } + + // MARK: - Round trips + + func testUTF8LF() { + assertRoundTrip(utf8("# Zażółć\n\ngęślą jaźń\n"), encoding: .utf8, hasBOM: false, lineEnding: .lf) + } + + func testUTF8CRLF() { + assertRoundTrip(utf8("# Title\r\n\r\nbody\r\n"), encoding: .utf8, hasBOM: false, lineEnding: .crlf, + text: "# Title\n\nbody\n") + } + + func testUTF8CR() { + assertRoundTrip(utf8("# Title\r\rbody\r"), encoding: .utf8, hasBOM: false, lineEnding: .cr, + text: "# Title\n\nbody\n") + } + + func testUTF8WithBOM() { + let bytes = Data([0xEF, 0xBB, 0xBF]) + utf8("---\ntitle: x\n---\nbody\n") + assertRoundTrip(bytes, encoding: .utf8, hasBOM: true, lineEnding: .lf, + text: "---\ntitle: x\n---\nbody\n") + } + + func testUTF8WithBOMAndCRLF() { + let bytes = Data([0xEF, 0xBB, 0xBF]) + utf8("a\r\nb\r\n") + assertRoundTrip(bytes, encoding: .utf8, hasBOM: true, lineEnding: .crlf, text: "a\nb\n") + } + + func testUTF16LittleEndian() { + let bytes = Data([0xFF, 0xFE]) + "# Łódź\nok 😀\n".data(using: .utf16LittleEndian)! + assertRoundTrip(bytes, encoding: .utf16LittleEndian, hasBOM: true, lineEnding: .lf, + text: "# Łódź\nok 😀\n") + } + + func testUTF16BigEndian() { + let bytes = Data([0xFE, 0xFF]) + "# Łódź\nok 😀\n".data(using: .utf16BigEndian)! + assertRoundTrip(bytes, encoding: .utf16BigEndian, hasBOM: true, lineEnding: .lf, + text: "# Łódź\nok 😀\n") + } + + func testUTF16LittleEndianCRLF() { + let bytes = Data([0xFF, 0xFE]) + "a\r\nb\r\n".data(using: .utf16LittleEndian)! + assertRoundTrip(bytes, encoding: .utf16LittleEndian, hasBOM: true, lineEnding: .crlf, text: "a\nb\n") + } + + func testUTF16BigEndianCRLF() { + let bytes = Data([0xFE, 0xFF]) + "a\r\nb\r\n".data(using: .utf16BigEndian)! + assertRoundTrip(bytes, encoding: .utf16BigEndian, hasBOM: true, lineEnding: .crlf, text: "a\nb\n") + } + + func testLatin1() { + // 0xE9 = "é" in Latin-1; a lone 0xE9 is not valid UTF-8. + let bytes = Data([0x63, 0x61, 0x66, 0xE9, 0x0A, 0x78, 0x0A]) + XCTAssertNil(String(data: bytes, encoding: .utf8), "fixture must not be valid UTF-8") + assertRoundTrip(bytes, encoding: .isoLatin1, hasBOM: false, lineEnding: .lf, text: "café\nx\n") + } + + func testLatin1CRLF() { + let bytes = Data([0x63, 0x61, 0x66, 0xE9, 0x0D, 0x0A, 0x78, 0x0D, 0x0A]) + assertRoundTrip(bytes, encoding: .isoLatin1, hasBOM: false, lineEnding: .crlf, text: "café\nx\n") + } + + func testEmptyFile() { + assertRoundTrip(Data(), encoding: .utf8, hasBOM: false, lineEnding: .lf, text: "") + } + + func testNoTrailingNewline() { + assertRoundTrip(utf8("a\r\nlast line"), encoding: .utf8, hasBOM: false, lineEnding: .crlf, + text: "a\nlast line") + } + + func testSeveralTrailingNewlines() { + assertRoundTrip(utf8("a\r\n\r\n\r\n"), encoding: .utf8, hasBOM: false, lineEnding: .crlf, + text: "a\n\n\n") + } + + func testTrailingSpaces() { + // Two trailing spaces are a Markdown hard break — they must survive. + assertRoundTrip(utf8("line \r\nnext \t\r\n "), encoding: .utf8, hasBOM: false, lineEnding: .crlf, + text: "line \nnext \t\n ") + } + + func testLatin1C1ControlBytes() { + // 0x80–0x9F are C1 controls in Latin-1 (0x85 = U+0085 NEL); invalid + // as standalone UTF-8. They must come back as the same bytes, and NEL + // is content, not a line break. + let bytes = Data([0x61, 0x80, 0x85, 0x9F, 0x62, 0x0A]) + assertRoundTrip(bytes, encoding: .isoLatin1, hasBOM: false, lineEnding: .lf, + text: "a\u{80}\u{85}\u{9F}b\n") + } + + func testLatin1LoneCR() { + assertRoundTrip(Data([0x63, 0xE9, 0x0D, 0x78]), encoding: .isoLatin1, hasBOM: false, lineEnding: .cr, + text: "c\u{E9}\nx") + } + + func testNULBytes() { + assertRoundTrip(Data([0x61, 0x00, 0x62, 0x0A, 0x00]), encoding: .utf8, hasBOM: false, lineEnding: .lf, + text: "a\u{0}b\n\u{0}") + } + + func testUnicodeLineSeparatorsAreContent() { + // U+2028, U+2029 and U+0085 are neither counted nor normalized. + let source = "a\u{2028}b\u{2029}c\u{0085}d\r\ne\u{2028}\u{2029}\u{0085}\r\n" + assertRoundTrip(utf8(source), encoding: .utf8, hasBOM: false, lineEnding: .crlf, + text: "a\u{2028}b\u{2029}c\u{0085}d\ne\u{2028}\u{2029}\u{0085}\n") + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\u{2028}b\u{2029}c\u{0085}d"), .lf) + } + + // MARK: - Byte-exact safety net + + func testMixedLineEndingsAreFlaggedAndNotByteExact() throws { + let decoded = try XCTUnwrap(DocumentFileFormat.decode(utf8("a\r\nb\r\nc\n"))) + XCTAssertTrue(decoded.hasMixedLineEndings) + XCTAssertFalse(decoded.isByteExact) + XCTAssertEqual(decoded.format.lineEnding, .crlf) + } + + func testCRCRLFIsOneCROneCRLF() throws { + // "\r\r\n" = a lone CR then a CRLF: a 1:1 tie → LF, and mixed. + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\r\r\nb"), .lf) + let decoded = try XCTUnwrap(DocumentFileFormat.decode(utf8("a\r\r\nb"))) + XCTAssertEqual(decoded.text, "a\n\nb") + XCTAssertTrue(decoded.hasMixedLineEndings) + XCTAssertFalse(decoded.isByteExact) + } + + /// Whatever Foundation does with malformed UTF-16, the session must end up + /// either with a byte-exact round trip or a `false` answer — never a + /// "repaired" text that claims to match the file. + func testMalformedUTF16IsByteExactOrRefused() throws { + let cases: [(String, Data)] = [ + ("odd-length LE", Data([0xFF, 0xFE, 0x61, 0x00, 0x62])), + ("odd-length BE", Data([0xFE, 0xFF, 0x00, 0x61, 0x00])), + ("lone high surrogate LE", Data([0xFF, 0xFE, 0x00, 0xD8, 0x61, 0x00])), + ("lone low surrogate BE", Data([0xFE, 0xFF, 0xDC, 0x00, 0x00, 0x61])), + ] + for (name, bytes) in cases { + let decoded = try XCTUnwrap(DocumentFileFormat.decode(bytes), name) + let roundTrip = decoded.format.encode(decoded.text) + XCTAssertEqual(decoded.isByteExact, roundTrip == bytes, name) + XCTAssertTrue(roundTrip == bytes || !decoded.isByteExact, name) + print("MalformedUTF16 \(name): encoding=\(decoded.format.encoding) isByteExact=\(decoded.isByteExact)") + } + } + + // MARK: - Line-ending policy + + func testMixedLineEndingsPickDominant() { + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\r\nb\r\nc\nd"), .crlf) + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\nb\nc\r\nd"), .lf) + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\rb\rc\nd\r\n"), .cr) + XCTAssertEqual(DocumentFileFormat.decode(utf8("a\r\nb\r\nc\n"))?.format.lineEnding, .crlf) + } + + func testLineEndingTieAndNoBreakPickLF() { + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\r\nb\nc"), .lf) + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\r\nb\rc"), .lf) + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "no break"), .lf) + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: ""), .lf) + } + + func testCRLFIsNotCountedAsCRPlusLF() { + // "\r\n" is ONE Character; the scan must run on scalars and pair them. + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "\r\n\r\n\r"), .crlf) + XCTAssertEqual(DocumentFileFormat.dominantLineEnding(in: "a\r"), .cr) + } + + // MARK: - Encoding limits and the UTF-8 fallback + + func testLatin1CannotRepresentPolishLetter() { + let format = DocumentFileFormat(encoding: .isoLatin1, hasBOM: false, lineEnding: .lf) + XCTAssertNil(format.encode("zażółć")) + XCTAssertEqual(format.encode("café"), Data([0x63, 0x61, 0x66, 0xE9])) + } + + func testUTF8FallbackKeepsLineEnding() { + let latin1 = DocumentFileFormat(encoding: .isoLatin1, hasBOM: false, lineEnding: .crlf) + let fallback = latin1.utf8Fallback + XCTAssertEqual(fallback, DocumentFileFormat(encoding: .utf8, hasBOM: false, lineEnding: .crlf)) + XCTAssertEqual(fallback.encode("ł\nb\n"), utf8("ł\r\nb\r\n")) + + let utf16 = DocumentFileFormat(encoding: .utf16BigEndian, hasBOM: true, lineEnding: .cr) + XCTAssertEqual(utf16.utf8Fallback.encode("a\n"), utf8("a\r"), "fallback drops the BOM") + } + + // MARK: - MarkdownDocument.decode never returns the BOM + + /// Independent of Foundation's own BOM handling: the BOM is recognised on + /// the bytes and only the remainder is decoded. + func testDecodeNeverReturnsLeadingBOM() { + let bom = Data([0xEF, 0xBB, 0xBF]) + XCTAssertEqual(MarkdownDocument.decode(bom + utf8("---\nx: 1\n---\n")), "---\nx: 1\n---\n") + XCTAssertEqual(MarkdownDocument.decode(bom), "") + + var utf16 = Data([0xFF, 0xFE]) + utf16.append("# h".data(using: .utf16LittleEndian)!) + XCTAssertEqual(MarkdownDocument.decode(utf16), "# h") + } + + // MARK: - BOM is handled on the bytes, never by Foundation + + /// A U+FEFF right after the BOM is CONTENT and must survive the round + /// trip — `String(data:encoding: .utf8)` would strip it as a second BOM. + func testUTF8DoubleBOMRoundTrip() { + let bom = Data([0xEF, 0xBB, 0xBF]) + assertRoundTripKeepingFEFF(bom + bom + utf8("a\n"), encoding: .utf8, text: "\u{FEFF}a\n") + } + + func testUTF16LittleEndianDoubleBOMRoundTrip() { + let bytes = Data([0xFF, 0xFE, 0xFF, 0xFE]) + "a\r\n".data(using: .utf16LittleEndian)! + assertRoundTripKeepingFEFF(bytes, encoding: .utf16LittleEndian, text: "\u{FEFF}a\n") + } + + func testUTF16BigEndianDoubleBOMRoundTrip() { + let bytes = Data([0xFE, 0xFF, 0xFE, 0xFF]) + "a\r\n".data(using: .utf16BigEndian)! + assertRoundTripKeepingFEFF(bytes, encoding: .utf16BigEndian, text: "\u{FEFF}a\n") + } + + func testFileThatIsOnlyABOM() { + // Through `DocumentFileFormat.decode` (assertRoundTrip) — format, + // empty text, byte-exact. + assertRoundTrip(Data([0xEF, 0xBB, 0xBF]), encoding: .utf8, hasBOM: true, lineEnding: .lf, text: "") + assertRoundTrip(Data([0xFF, 0xFE]), encoding: .utf16LittleEndian, hasBOM: true, lineEnding: .lf, text: "") + assertRoundTrip(Data([0xFE, 0xFF]), encoding: .utf16BigEndian, hasBOM: true, lineEnding: .lf, text: "") + } + + func testUTF8BOMWithInvalidBodyFallsBackToLatin1() { + // Same outcome as before the BOM was handled on the bytes. + let bytes = Data([0xEF, 0xBB, 0xBF, 0x63, 0xE9]) + assertRoundTrip(bytes, encoding: .isoLatin1, hasBOM: false, lineEnding: .lf) + } + + /// `assertRoundTrip` minus its no-leading-U+FEFF check: here the + /// U+FEFF is the point. + private func assertRoundTripKeepingFEFF(_ bytes: Data, encoding: DocumentFileFormat.Encoding, text: String, + file: StaticString = #filePath, line: UInt = #line) { + guard let decoded = DocumentFileFormat.decode(bytes) else { + return XCTFail("decode returned nil", file: file, line: line) + } + XCTAssertEqual(decoded.format.encoding, encoding, file: file, line: line) + XCTAssertTrue(decoded.format.hasBOM, file: file, line: line) + XCTAssertEqual(decoded.text, text, file: file, line: line) + XCTAssertEqual(MarkdownDocument.decode(bytes).map(MarkdownDocument.normalizeLineEndings), text, + "viewer and editor must agree", file: file, line: line) + XCTAssertEqual(decoded.format.encode(decoded.text), bytes, "round trip changed the bytes", + file: file, line: line) + XCTAssertTrue(decoded.isByteExact, file: file, line: line) + } + + func testDecodeKeepsFEFFInsideText() { + XCTAssertEqual(MarkdownDocument.decode(utf8("a\u{FEFF}b")), "a\u{FEFF}b") + } + + func testDecodeDetectingEncodingMatchesDecode() { + let samples: [Data] = [ + utf8("plain"), + Data([0xFF, 0xFE]) + "x".data(using: .utf16LittleEndian)!, + Data([0xFE, 0xFF]) + "x".data(using: .utf16BigEndian)!, + Data([0x63, 0xE9]), + ] + let expected: [String.Encoding] = [.utf8, .utf16LittleEndian, .utf16BigEndian, .isoLatin1] + for (data, encoding) in zip(samples, expected) { + let detected = MarkdownDocument.decodeDetectingEncoding(data) + XCTAssertEqual(detected?.encoding, encoding) + XCTAssertEqual(detected?.text, MarkdownDocument.decode(data)) + } + } +} diff --git a/QuickMD/QuickMDTests/DocumentFileWriterTests.swift b/QuickMD/QuickMDTests/DocumentFileWriterTests.swift new file mode 100644 index 0000000..ff0ed66 --- /dev/null +++ b/QuickMD/QuickMDTests/DocumentFileWriterTests.swift @@ -0,0 +1,341 @@ +import XCTest +import Darwin + +/// Source Edit's in-place writer (S-D7 step 3) on real files: the content +/// changes and NOTHING else does — inode, permissions, xattrs, symlinks +/// (spec Evidence 2) — and a read-only or missing file is refused untouched. +final class DocumentFileWriterTests: XCTestCase { + + private var directory: URL! + + override func setUpWithError() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("DocumentFileWriterTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + if let directory { + try? FileManager.default.removeItem(at: directory) + } + } + + private func makeFile(_ content: Data, named name: String = "doc.md") throws -> URL { + let url = directory.appendingPathComponent(name) + try content.write(to: url) + return url + } + + private func attribute(_ key: FileAttributeKey, _ url: URL) throws -> Any? { + try FileManager.default.attributesOfItem(atPath: url.path)[key] + } + + private func inode(_ url: URL) throws -> UInt64? { + (try attribute(.systemFileNumber, url) as? NSNumber)?.uint64Value + } + + // MARK: - Content + + func testLongerContentReplacesFile() throws { + let old = Data("short\n".utf8) + let new = Data("a much longer replacement text\n".utf8) + let url = try makeFile(old) + try DocumentFileWriter.write(new, to: url, restoring: old) + XCTAssertEqual(try Data(contentsOf: url), new) + } + + func testShorterContentLeavesNoStaleTail() throws { + let old = Data("a much longer original text\n".utf8) + let new = Data("short\n".utf8) + let url = try makeFile(old) + try DocumentFileWriter.write(new, to: url, restoring: old) + XCTAssertEqual(try Data(contentsOf: url), new) + } + + func testEmptyDataProducesEmptyFile() throws { + let old = Data("content\n".utf8) + let url = try makeFile(old) + try DocumentFileWriter.write(Data(), to: url, restoring: old) + XCTAssertEqual(try Data(contentsOf: url), Data()) + } + + // MARK: - Metadata survives + + func testInodeUnchanged() throws { + let old = Data("one\n".utf8) + let url = try makeFile(old) + let before = try inode(url) + XCTAssertNotNil(before) + try DocumentFileWriter.write(Data("two two\n".utf8), to: url, restoring: old) + XCTAssertEqual(try inode(url), before) + } + + func testPermissionsUnchanged() throws { + let old = Data("one\n".utf8) + let url = try makeFile(old) + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: url.path) + try DocumentFileWriter.write(Data("two\n".utf8), to: url, restoring: old) + XCTAssertEqual((try attribute(.posixPermissions, url) as? NSNumber)?.intValue, 0o640) + } + + func testExtendedAttributeSurvives() throws { + let old = Data("one\n".utf8) + let url = try makeFile(old) + let name = "pl.falami.studio.QuickMD.test" + let value = Array("tag-value".utf8) + XCTAssertEqual(setxattr(url.path, name, value, value.count, 0, 0), 0) + + try DocumentFileWriter.write(Data("two\n".utf8), to: url, restoring: old) + + var buffer = [UInt8](repeating: 0, count: 64) + let length = getxattr(url.path, name, &buffer, buffer.count, 0, 0) + XCTAssertEqual(length, value.count) + XCTAssertEqual(Array(buffer.prefix(max(length, 0))), value) + } + + func testSymlinkTargetUpdatedAndLinkKept() throws { + let old = Data("target\n".utf8) + let target = try makeFile(old, named: "target.md") + let link = directory.appendingPathComponent("link.md") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + let new = Data("through the link\n".utf8) + try DocumentFileWriter.write(new, to: link, restoring: old) + + XCTAssertEqual(try Data(contentsOf: target), new) + XCTAssertEqual(try attribute(.type, link) as? FileAttributeType, .typeSymbolicLink) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: link.path), target.path) + } + + func testHardLinkSeesNewContent() throws { + let old = Data("shared\n".utf8) + let url = try makeFile(old) + let other = directory.appendingPathComponent("other-name.md") + try FileManager.default.linkItem(at: url, to: other) + + let new = Data("written through the first name\n".utf8) + try DocumentFileWriter.write(new, to: url, restoring: old) + + XCTAssertEqual(try Data(contentsOf: other), new) + XCTAssertEqual(try inode(other), try inode(url)) + XCTAssertEqual((try attribute(.referenceCount, url) as? NSNumber)?.intValue, 2) + } + + func testCreationDateUnchanged() throws { + let old = Data("one\n".utf8) + let url = try makeFile(old) + // A date well in the past, so a replaced file could not match by accident. + let created = Date(timeIntervalSince1970: 1_600_000_000) + try FileManager.default.setAttributes([.creationDate: created], ofItemAtPath: url.path) + try DocumentFileWriter.write(Data("two two\n".utf8), to: url, restoring: old) + XCTAssertEqual(try attribute(.creationDate, url) as? Date, created) + } + + // MARK: - Refusals + + func testReadOnlyFileRefusedAndUntouched() throws { + let old = Data("read only\n".utf8) + let url = try makeFile(old) + try FileManager.default.setAttributes([.posixPermissions: 0o444], ofItemAtPath: url.path) + + XCTAssertThrowsError(try DocumentFileWriter.write(Data("new\n".utf8), to: url, restoring: old)) { error in + guard case DocumentFileWriter.WriteError.readOnly = error else { + return XCTFail("expected .readOnly, got \(error)") + } + } + XCTAssertEqual(try Data(contentsOf: url), old) + XCTAssertEqual((try attribute(.posixPermissions, url) as? NSNumber)?.intValue, 0o444) + } + + func testMissingFileRefusedAndNotCreated() throws { + let url = directory.appendingPathComponent("gone.md") + XCTAssertThrowsError(try DocumentFileWriter.write(Data("new\n".utf8), to: url, restoring: Data())) { error in + guard case DocumentFileWriter.WriteError.missing = error else { + return XCTFail("expected .missing, got \(error)") + } + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + } + + func testDanglingSymlinkCountsAsMissing() throws { + let link = directory.appendingPathComponent("dangling.md") + try FileManager.default.createSymbolicLink(at: link, + withDestinationURL: directory.appendingPathComponent("nowhere.md")) + XCTAssertThrowsError(try DocumentFileWriter.write(Data("x".utf8), to: link, restoring: Data())) { error in + guard case DocumentFileWriter.WriteError.missing = error else { + return XCTFail("expected .missing, got \(error)") + } + } + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("nowhere.md").path)) + } + + func testDirectoryRefusedAsNotRegularFile() throws { + let url = directory.appendingPathComponent("folder.md", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + let inside = url.appendingPathComponent("inside.txt") + try Data("keep".utf8).write(to: inside) + + XCTAssertThrowsError(try DocumentFileWriter.write(Data("x".utf8), to: url, restoring: Data())) { error in + guard case DocumentFileWriter.WriteError.notRegularFile = error else { + return XCTFail("expected .notRegularFile, got \(error)") + } + } + XCTAssertEqual(try attribute(.type, url) as? FileAttributeType, .typeDirectory) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: url.path), ["inside.txt"]) + XCTAssertEqual(try Data(contentsOf: inside), Data("keep".utf8)) + } + + /// Opening a FIFO for writing blocks until a reader appears — the type + /// check must refuse it BEFORE any open (a regression hangs this test). + func testFIFORefusedWithoutBlocking() throws { + let url = directory.appendingPathComponent("pipe.md") + XCTAssertEqual(mkfifo(url.path, 0o644), 0) + XCTAssertThrowsError(try DocumentFileWriter.write(Data("x".utf8), to: url, restoring: Data())) { error in + guard case DocumentFileWriter.WriteError.notRegularFile = error else { + return XCTFail("expected .notRegularFile, got \(error)") + } + } + } + + // MARK: - Rollback (through the injectable seam) + + private struct InjectedFailure: Error {} + + /// The real read-back, for seam calls that only fake one step. + private func diskRead(_ url: URL) -> Data? { try? Data(contentsOf: url) } + + private func original(of error: Error) -> DocumentFileWriter.WriteError.Original? { + switch error { + case DocumentFileWriter.WriteError.writeFailed(_, _, let original), + DocumentFileWriter.WriteError.verificationFailed(_, let original): + return original + default: + return nil + } + } + + func testFailureAfterFirstByteRestoresOriginal() throws { + let old = Data("original content\n".utf8) + let url = try makeFile(old) + var calls = 0 + XCTAssertThrowsError(try DocumentFileWriter.write( + Data("new".utf8), to: url, restoring: old, + overwrite: { url, data, touched in + calls += 1 + if calls == 1 { + touched = true + try Data("half-wri".utf8).write(to: url) // a torn write + throw InjectedFailure() + } + try data.write(to: url) + }, + readBack: diskRead)) { error in + guard case DocumentFileWriter.WriteError.writeFailed(_, let underlying, _) = error else { + return XCTFail("expected .writeFailed, got \(error)") + } + XCTAssertTrue(underlying is InjectedFailure) + XCTAssertEqual(self.original(of: error), .restored) + } + XCTAssertEqual(calls, 2, "one write, one restore") + XCTAssertEqual(try Data(contentsOf: url), old) + } + + func testFailedRestoreReportsNotRestored() throws { + let old = Data("original content\n".utf8) + let url = try makeFile(old) + var calls = 0 + XCTAssertThrowsError(try DocumentFileWriter.write( + Data("new".utf8), to: url, restoring: old, + overwrite: { url, _, touched in + calls += 1 + touched = true + try Data("torn".utf8).write(to: url) + throw InjectedFailure() + }, + readBack: diskRead)) { error in + guard case DocumentFileWriter.WriteError.writeFailed = error else { + return XCTFail("expected .writeFailed, got \(error)") + } + XCTAssertEqual(self.original(of: error), .notRestored) + } + XCTAssertEqual(calls, 2, "exactly one restore attempt") + } + + func testFailureBeforeFirstByteReportsUntouched() throws { + let old = Data("original content\n".utf8) + let url = try makeFile(old) + var calls = 0 + XCTAssertThrowsError(try DocumentFileWriter.write( + Data("new".utf8), to: url, restoring: old, + overwrite: { _, _, _ in + calls += 1 + throw InjectedFailure() + }, + readBack: diskRead)) { error in + guard case DocumentFileWriter.WriteError.writeFailed = error else { + return XCTFail("expected .writeFailed, got \(error)") + } + XCTAssertEqual(self.original(of: error), .untouched) + } + XCTAssertEqual(calls, 1, "nothing written, nothing to restore") + XCTAssertEqual(try Data(contentsOf: url), old) + } + + func testReadBackMismatchRestoresAndReportsVerificationFailed() throws { + let old = Data("original content\n".utf8) + let new = Data("new content\n".utf8) + let url = try makeFile(old) + var reads = 0 + XCTAssertThrowsError(try DocumentFileWriter.write( + new, to: url, restoring: old, + overwrite: { url, data, touched in + touched = true + try data.write(to: url) + }, + readBack: { url in + reads += 1 + return reads == 1 ? Data("garbled".utf8) : try? Data(contentsOf: url) + })) { error in + guard case DocumentFileWriter.WriteError.verificationFailed = error else { + return XCTFail("expected .verificationFailed, got \(error)") + } + XCTAssertEqual(self.original(of: error), .restored) + } + XCTAssertEqual(try Data(contentsOf: url), old) + } + + func testReadBackMismatchWithFailedRestoreReportsNotRestored() throws { + let old = Data("original content\n".utf8) + let url = try makeFile(old) + XCTAssertThrowsError(try DocumentFileWriter.write( + Data("new\n".utf8), to: url, restoring: old, + overwrite: { url, data, touched in + touched = true + try data.write(to: url) + }, + readBack: { _ in Data("garbled".utf8) })) { error in + guard case DocumentFileWriter.WriteError.verificationFailed = error else { + return XCTFail("expected .verificationFailed, got \(error)") + } + XCTAssertEqual(self.original(of: error), .notRestored) + } + } + + // MARK: - Error copy + + func testErrorDescriptionsNameTheFile() { + let url = URL(fileURLWithPath: "/x/notes.md") + typealias E = DocumentFileWriter.WriteError + let errors: [E] = [ + .missing(url), + .readOnly(url), + .writeFailed(url, underlying: CocoaError(.fileWriteUnknown), original: .restored), + .verificationFailed(url, original: .notRestored), + .notRegularFile(url), + ] + for error in errors { + XCTAssertTrue(error.localizedDescription.contains("“notes.md”"), error.localizedDescription) + } + XCTAssertEqual(E.readOnly(url).localizedDescription, "“notes.md” is read-only.") + } +} From 1ed01ac978b0354f5c1b29772a7634e504e4800f Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 14:07:52 +0200 Subject: [PATCH 02/17] feat(source-edit): editor text helpers; file watcher re-arms after a missing file returns SourceEditSupport: line/offset mapping on UTF-16, block lookup for a source line, literal selection lookup, and newline / indent / outdent edits returned as single undoable replacements (NSString core, one pass on large documents). FileWatcher: after the watched file disappears, poll for the path once a second and re-arm when it is back (git checkout, stash pop); a permission error stops the poll instead of retrying forever. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 10 + QuickMD/QuickMD/FileWatchManager.swift | 91 +++- QuickMD/QuickMD/SourceEditSupport.swift | 450 ++++++++++++++++ QuickMD/QuickMDTests/FileWatcherTests.swift | 129 +++++ .../QuickMDTests/SourceEditSupportTests.swift | 500 ++++++++++++++++++ 5 files changed, 1169 insertions(+), 11 deletions(-) create mode 100644 QuickMD/QuickMD/SourceEditSupport.swift create mode 100644 QuickMD/QuickMDTests/SourceEditSupportTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index cbd1e67..2d15476 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -121,6 +121,9 @@ A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000110 /* DocumentReadingPosition.swift */; }; A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000110 /* DocumentReadingPosition.swift */; }; A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000111 /* ExternalEditorLineTests.swift */; }; + A1B2C3D401000130 /* SourceEditSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000130 /* SourceEditSupport.swift */; }; + A1B2C3D401000131 /* SourceEditSupport.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000130 /* SourceEditSupport.swift */; }; + A1B2C3D401000132 /* SourceEditSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000131 /* SourceEditSupportTests.swift */; }; A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000F0 /* HeadingLayoutTests.swift */; }; /* End PBXBuildFile section */ @@ -142,6 +145,8 @@ A1B2C3D4000000C1 /* DocumentSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSelectionTests.swift; sourceTree = ""; }; A1B2C3D400000110 /* DocumentReadingPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentReadingPosition.swift; sourceTree = ""; }; A1B2C3D400000111 /* ExternalEditorLineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalEditorLineTests.swift; sourceTree = ""; }; + A1B2C3D400000130 /* SourceEditSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSupport.swift; sourceTree = ""; }; + A1B2C3D400000131 /* SourceEditSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSupportTests.swift; sourceTree = ""; }; A1B2C3D4000000F0 /* HeadingLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingLayoutTests.swift; sourceTree = ""; }; A1B2C3D400000001 /* QuickMDApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMDApp.swift; sourceTree = ""; }; A1B2C3D400000002 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; @@ -270,6 +275,7 @@ A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, A1B2C3D400000111 /* ExternalEditorLineTests.swift */, + A1B2C3D400000131 /* SourceEditSupportTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -290,6 +296,7 @@ A1B2C3D400000081 /* ImageLoader.swift */, A1B2C3D4000000C0 /* DocumentSelection.swift */, A1B2C3D400000110 /* DocumentReadingPosition.swift */, + A1B2C3D400000130 /* SourceEditSupport.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -573,6 +580,7 @@ A1B2C3D401000082 /* ImageLoader.swift in Sources */, A1B2C3D4010000C0 /* DocumentSelection.swift in Sources */, A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */, + A1B2C3D401000130 /* SourceEditSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -614,6 +622,8 @@ A1B2C3D4010000C2 /* DocumentSelectionTests.swift in Sources */, A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */, A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */, + A1B2C3D401000131 /* SourceEditSupport.swift in Sources (Tests) */, + A1B2C3D401000132 /* SourceEditSupportTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QuickMD/QuickMD/FileWatchManager.swift b/QuickMD/QuickMD/FileWatchManager.swift index c4f8c29..d59015d 100644 --- a/QuickMD/QuickMD/FileWatchManager.swift +++ b/QuickMD/QuickMD/FileWatchManager.swift @@ -9,6 +9,18 @@ import Foundation /// dead inode. On those events the watcher re-opens the path and re-arms; /// only when the path itself is gone does it report the file as missing. /// +/// A missing file is not the end: git checkout / stash pop, or an editor that +/// deletes and recreates after a pause, put it back at the same path later. +/// kqueue cannot watch a path that does not exist, so while the file is +/// missing a one-second main-queue timer checks whether the path exists again +/// (`access`, no descriptor); once it does, the watcher re-arms and reports a +/// (debounced) change. `onFileMissing` fires once per disappearance, never per +/// poll. Polling happens only while the path is ABSENT (ENOENT / ENOTDIR): a +/// path that exists but cannot be opened or seen (EACCES / EPERM — a sandbox +/// or permission denial) would fail the same way every second for the life of +/// the tab, so the watcher then stays missing without polling, as it always +/// did before polling existed. +/// /// All callbacks fire on the main queue (the DispatchSource and the debounce /// both target `.main`). Changes are debounced 250 ms so an editor that /// writes multiple times in quick succession coalesces into one reload. @@ -23,19 +35,26 @@ final class FileWatcher { var onChange: (() -> Void)? /// Fired when the file disappears from its path (moved or deleted and not - /// re-created by an atomic save). + /// re-created by an atomic save). If it comes back later, `onChange` + /// follows. var onFileMissing: (() -> Void)? private var source: DispatchSourceFileSystemObject? private var url: URL? private var debounce: DispatchWorkItem? + /// Non-nil only while the file is missing (see the type comment). + private var missingPoll: DispatchSourceTimer? private static let debounceInterval: TimeInterval = 0.25 + private static let missingPollInterval: TimeInterval = 1 + + deinit { stop() } func start(watching url: URL) { stop() self.url = url - arm() + let error = arm() + if error != 0 { fileWentMissing(error) } } func stop() { @@ -43,17 +62,17 @@ final class FileWatcher { debounce = nil source?.cancel() // cancel handler closes the descriptor source = nil + stopMissingPoll() } // MARK: - Private - private func arm() { - guard let url else { return } + /// Opens the path and installs the kqueue source. Returns 0 when armed, + /// otherwise `open(2)`'s errno — the caller decides what the failure means. + private func arm() -> Int32 { + guard let url else { return ENOENT } let fd = open(url.path, O_EVTONLY) - guard fd >= 0 else { - onFileMissing?() - return - } + guard fd >= 0 else { return errno } let src = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, @@ -67,6 +86,56 @@ final class FileWatcher { } source = src src.resume() + return 0 + } + + /// The path is not there (as opposed to there but off-limits). + private static func isAbsence(_ error: Int32) -> Bool { + error == ENOENT || error == ENOTDIR + } + + /// Reports the disappearance ONCE; the poll that follows is silent. + private func fileWentMissing(_ error: Int32) { + onFileMissing?() + if Self.isAbsence(error) { startMissingPoll() } + } + + private func startMissingPoll() { + stopMissingPoll() + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + Self.missingPollInterval, + repeating: Self.missingPollInterval, + leeway: .milliseconds(100)) + timer.setEventHandler { [weak self] in + self?.pollMissingFile() + } + missingPoll = timer + timer.resume() + } + + private func stopMissingPoll() { + missingPoll?.cancel() + missingPoll = nil + } + + private func pollMissingFile() { + guard let url else { return stopMissingPoll() } + if access(url.path, F_OK) != 0 { + // Still absent: keep waiting. Not visible for another reason + // (a parent folder became unreadable): give up, stay missing. + if !Self.isAbsence(errno) { stopMissingPoll() } + return + } + let error = arm() + if error == 0 { + stopMissingPoll() + scheduleChange() + } else if !Self.isAbsence(error) { + // Back, but cannot be opened (EACCES / EPERM): retrying every + // second would never succeed — stay missing. + stopMissingPoll() + } + // Absent again between `access` and `open`: already reported, keep polling. } private func handle(events: DispatchSource.FileSystemEvent) { @@ -76,11 +145,11 @@ final class FileWatcher { // as a change. An empty path means the document is really gone. source?.cancel() source = nil - guard let url, FileManager.default.fileExists(atPath: url.path) else { - onFileMissing?() + let error = arm() + guard error == 0 else { + fileWentMissing(error) return } - arm() scheduleChange() } else if events.contains(.write) || events.contains(.extend) { scheduleChange() diff --git a/QuickMD/QuickMD/SourceEditSupport.swift b/QuickMD/QuickMD/SourceEditSupport.swift new file mode 100644 index 0000000..fb69e07 --- /dev/null +++ b/QuickMD/QuickMD/SourceEditSupport.swift @@ -0,0 +1,450 @@ +import Foundation + +// MARK: - Source Edit helpers (v1.12 S-D3, S-D5, S-D10, S-D14b) +// +// Pure functions behind the source editor: line <-> position mapping, the +// "which block holds this line" landing rule, the selection carry-over lookup +// and the indentation edits. No view code, so the file is compiled into BOTH +// the app and the test target and every rule is unit-tested directly. +// +// Positions are UTF-16 offsets / NSRange throughout — that is what NSTextView +// speaks, and it is exact for emoji, combining marks and CJK, where Swift +// `Character` counts are not. +// +// The text is assumed to be LF-only: the editor buffer never contains U+000D +// (normalised on entry and on every edit, constraint 14). A "line" is what the +// parser means by it — the text split on U+000A and nothing else (NSString's +// `lineRange(for:)` would also break at U+2028 / U+0085 and disagree with +// `MarkdownBlock.sourceLine`). A trailing newline therefore ends with an empty +// last line, and an empty text is one empty line. +// +// WHICH OVERLOAD TO CALL: every function's core takes an `NSString`. The editor +// must pass its storage's backing string as is — `textView.textStorage! +// .mutableString` — never `textView.string`: bridging a MUTABLE NSString to a +// Swift `String` copies it (up to ~10 MB at the size cap) on every Return or +// Tab. The `String` overloads are one-line forwards for callers that already +// hold a Swift string (`currentText`, tests); `String as NSString` is a free +// wrapper, so they cost nothing. + +enum SourceEditSupport { + + /// One replacement the editor applies as a single undoable change: + /// `shouldChangeText(in: range, replacementString: replacement)`, replace, + /// `didChangeText()`, then `setSelectedRange(selection)`. + struct TextEdit: Equatable { + /// Range in the text BEFORE the edit. + let range: NSRange + let replacement: String + /// Selection to set AFTER the edit, in the new text. + let selection: NSRange + } + + // MARK: - Lines + + /// UTF-16 offset of the start of 0-based `line`. Clamped: a negative line + /// is line 0, a line past the end is the last line. + /// + /// Scans from offset 0 — meant for one call per user action (entering the + /// editor, a ToC click). To map many lines at once use `lineStarts(in:)`. + static func lineStart(_ line: Int, in text: NSString) -> Int { + guard line > 0 else { return 0 } + var seen = 0 + var start = 0 + forEachNewline(in: text, from: 0, to: text.length) { offset in + seen += 1 + start = offset + 1 + return seen < line + } + return start + } + + /// Start offset of EVERY line, in one pass: `lineStarts(in:)[n]` is + /// `lineStart(n, in:)`. Never empty (an empty text has line 0 at 0). + static func lineStarts(in text: NSString) -> [Int] { + var starts = [0] + forEachNewline(in: text, from: 0, to: text.length) { offset in + starts.append(offset + 1) + return true + } + return starts + } + + /// The 0-based line containing UTF-16 `offset` (clamped to `0...length`). + /// An offset right after a newline belongs to the next line — it is the + /// caret position at that line's start. Scans from 0, like `lineStart`. + static func line(containing offset: Int, in text: NSString) -> Int { + let end = min(max(offset, 0), text.length) + var count = 0 + forEachNewline(in: text, from: 0, to: end) { _ in + count += 1 + return true + } + return count + } + + /// The range of 0-based `line` without its newline (clamped like + /// `lineStart`). + static func lineRange(_ line: Int, in text: NSString) -> NSRange { + let start = lineStart(line, in: text) + return NSRange(location: start, length: lineEnd(from: start, in: text) - start) + } + + /// Number of lines (`"\n"`-separated; never 0). + static func lineCount(in text: NSString) -> Int { + line(containing: text.length, in: text) + 1 + } + + static func lineStart(_ line: Int, in text: String) -> Int { lineStart(line, in: text as NSString) } + static func lineStarts(in text: String) -> [Int] { lineStarts(in: text as NSString) } + static func line(containing offset: Int, in text: String) -> Int { line(containing: offset, in: text as NSString) } + static func lineRange(_ line: Int, in text: String) -> NSRange { lineRange(line, in: text as NSString) } + static func lineCount(in text: String) -> Int { lineCount(in: text as NSString) } + + // MARK: - Landing block (S-D10) + + /// Index of the block that contains 0-based source `line`: among the + /// blocks with the greatest `sourceLine <= line`, the FIRST one (several + /// blocks share a line when images sit side by side). A line before the + /// first block lands on block 0; an empty list has no answer. + /// + /// Relies on `sourceLine` being non-decreasing across the parser's + /// output, hence a binary search rather than a scan. + static func blockIndex(containingLine line: Int, in blocks: [MarkdownBlock]) -> Int? { + guard !blocks.isEmpty else { return nil } + // First index whose sourceLine > line. + var low = 0, high = blocks.count + while low < high { + let mid = (low + high) / 2 + if blocks[mid].sourceLine <= line { low = mid + 1 } else { high = mid } + } + guard low > 0 else { return 0 } + let target = blocks[low - 1].sourceLine + // First index whose sourceLine == target (>= target, given the order). + var first = 0 + high = low - 1 + while first < high { + let mid = (first + high) / 2 + if blocks[mid].sourceLine < target { first = mid + 1 } else { high = mid } + } + return first + } + + // MARK: - Selection carry-over (S-D14b) + + /// Where the rendered selection is in the source: the first LITERAL + /// occurrence of `selectedText` (trimmed of whitespace and newlines) + /// inside 0-based source lines `lines` — from the start of the first line + /// to the end of the last one, its newline excluded, so a match can never + /// extend outside them. Lines past the end of the text are ignored. + /// + /// Nil for an empty selection, an empty or out-of-text line range, or no + /// match. Literal on purpose: rendered `bold` is found inside `**bold**`, + /// but anything the renderer rewrote (entities, joined soft breaks, list + /// markers) is simply not found — no second Markdown grammar here. + static func sourceRange(ofSelection selectedText: String, in text: NSString, + lines: Range) -> NSRange? { + let needle = selectedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return nil } + let lower = max(lines.lowerBound, 0) + guard lines.upperBound > lower else { return nil } + // Start of `lower`, or nil when the text has fewer lines. + var start: Int? = lower == 0 ? 0 : nil + var seen = 0 + if lower > 0 { + forEachNewline(in: text, from: 0, to: text.length) { offset in + seen += 1 + if seen == lower { start = offset + 1 } + return seen < lower + } + } + guard let start else { return nil } + // End of line `upperBound - 1`: skip the newlines of the lines in between. + var end = text.length + var remaining = lines.upperBound - lower + forEachNewline(in: text, from: start, to: text.length) { offset in + remaining -= 1 + if remaining == 0 { end = offset } + return remaining > 0 + } + let found = text.range(of: needle, options: .literal, + range: NSRange(location: start, length: end - start)) + return found.location == NSNotFound ? nil : found + } + + static func sourceRange(ofSelection selectedText: String, in text: String, + lines: Range) -> NSRange? { + sourceRange(ofSelection: selectedText, in: text as NSString, lines: lines) + } + + // MARK: - Indentation (S-D5) + + /// Width in spaces of one outdent step when the unit is a tab: a line + /// indented with spaces in a tab-indented document still loses one level. + static let spacesPerIndent = 4 + + /// The document's indent unit: a tab if any line starts with one, else + /// four spaces. Computed once per document; the user's file decides. + static func indentUnit(for text: NSString) -> String { + if text.length > 0, text.character(at: 0) == tab { return "\t" } + let found = text.range(of: "\n\t", options: .literal) + return found.location == NSNotFound ? String(repeating: " ", count: spacesPerIndent) : "\t" + } + + /// Return: a newline plus the caret line's leading spaces / tabs — only the + /// part BEFORE the caret (Return inside the indentation splits it). A + /// selection is replaced, measured from its start. No list continuation + /// (non-goal). Costs the caret line's length, not the document's. + static func newlineEdit(in text: NSString, selection: NSRange) -> TextEdit { + let range = clamped(selection, in: text) + let caret = range.location + let start = lineStart(containing: caret, in: text) + let indentEnd = min(leadingWhitespaceEnd(from: start, in: text), caret) + let replacement = "\n" + text.substring(with: NSRange(location: start, length: indentEnd - start)) + let after = caret + (replacement as NSString).length + return TextEdit(range: range, replacement: replacement, + selection: NSRange(location: after, length: 0)) + } + + /// Tab. A selection that crosses a newline prefixes every touched, + /// non-empty line with `unit` — including one whole line selected with its + /// newline, which must indent, not be replaced by a tab. A caret or a + /// selection within one line is replaced by `unit`. Nil when there is + /// nothing to change (every touched line is empty). The selection keeps + /// covering the same text. + static func indentEdit(in text: NSString, selection: NSRange, unit: String) -> TextEdit? { + let selection = clamped(selection, in: text) + guard selection.length > 0, + lineEnd(from: selection.location, in: text) < NSMaxRange(selection) else { + let after = selection.location + (unit as NSString).length + return TextEdit(range: selection, replacement: unit, + selection: NSRange(location: after, length: 0)) + } + let unitLength = (unit as NSString).length + return linePrefixEdit(in: text, selection: selection, prefix: Array(unit.utf16)) { _, length in + length > 0 ? LinePrefixChange(removed: 0, inserted: unitLength) : nil + } + } + + /// Shift-Tab: removes one level from the start of every touched line (a + /// caret or a single-line selection touches its own line) — one tab, or + /// up to one unit's width of spaces. Lines without leading whitespace are + /// left alone; nil when no line changes. + static func outdentEdit(in text: NSString, selection: NSRange, unit: String) -> TextEdit? { + let selection = clamped(selection, in: text) + let width = unit == "\t" ? spacesPerIndent : max((unit as NSString).length, 1) + return linePrefixEdit(in: text, selection: selection, prefix: []) { line, length in + guard length > 0 else { return nil } + if line.pointee == tab { return LinePrefixChange(removed: 1, inserted: 0) } + var removed = 0 + while removed < min(width, length), line[removed] == space { removed += 1 } + return removed > 0 ? LinePrefixChange(removed: removed, inserted: 0) : nil + } + } + + static func indentUnit(for text: String) -> String { indentUnit(for: text as NSString) } + static func newlineEdit(in text: String, selection: NSRange) -> TextEdit { + newlineEdit(in: text as NSString, selection: selection) + } + static func indentEdit(in text: String, selection: NSRange, unit: String) -> TextEdit? { + indentEdit(in: text as NSString, selection: selection, unit: unit) + } + static func outdentEdit(in text: String, selection: NSRange, unit: String) -> TextEdit? { + outdentEdit(in: text as NSString, selection: selection, unit: unit) + } + + // MARK: - Private + + private static let newline: unichar = 0x0A + private static let tab: unichar = 0x09 + private static let space: unichar = 0x20 + + /// Units copied per `getCharacters` call — keeps the scan linear and + /// allocation-free per unit on both NSString-backed storage (the text + /// view's) and native Swift strings (tests, `currentText`). + /// + /// The loops below walk raw pointers on purpose: pointer arithmetic is + /// transparent and stays fast in -Onone builds, while `for i in 0.. Bool) { + guard from < to else { return } + var buffer = [unichar](repeating: 0, count: min(chunkSize, to - from)) + var position = from + while position < to { + let count = min(chunkSize, to - position) + let keepGoing: Bool = buffer.withUnsafeMutableBufferPointer { units in + let base = units.baseAddress! + ns.getCharacters(base, range: NSRange(location: position, length: count)) + var unit = base + let end = base + count + while unit < end { + if unit.pointee == newline, !body(position + (unit - base)) { return false } + unit += 1 + } + return true + } + if !keepGoing { return } + position += count + } + } + + /// Start of the line containing `offset`, scanning BACKWARDS — cost is + /// the line's length, not the document's (Return runs per keystroke). + private static func lineStart(containing offset: Int, in ns: NSString) -> Int { + guard offset > 0 else { return 0 } + var buffer = [unichar](repeating: 0, count: min(chunkSize, offset)) + var end = offset + while end > 0 { + let count = min(chunkSize, end) + let start = end - count + let found: Int? = buffer.withUnsafeMutableBufferPointer { units in + let base = units.baseAddress! + ns.getCharacters(base, range: NSRange(location: start, length: count)) + var unit = base + count + while unit > base { + unit -= 1 + if unit.pointee == newline { return start + (unit - base) + 1 } + } + return nil + } + if let found { return found } + end = start + } + return 0 + } + + /// Offset of the newline ending the line that contains `offset` (or the + /// text's length on the last line). + private static func lineEnd(from offset: Int, in ns: NSString) -> Int { + var end = ns.length + forEachNewline(in: ns, from: offset, to: ns.length) { found in + end = found + return false + } + return end + } + + /// End of the run of spaces / tabs starting at `start`. + private static func leadingWhitespaceEnd(from start: Int, in ns: NSString) -> Int { + var position = start + while position < ns.length { + let unit = ns.character(at: position) + guard unit == space || unit == tab else { break } + position += 1 + } + return position + } + + private static func clamped(_ range: NSRange, in ns: NSString) -> NSRange { + let location = min(max(range.location, 0), ns.length) + return NSRange(location: location, length: min(max(range.length, 0), ns.length - location)) + } + + /// What one line's start becomes: `removed` leading units dropped, then + /// `inserted` units of the prefix written. + private struct LinePrefixChange { + let removed: Int + let inserted: Int + } + + /// A change positioned in the document (`at` = the line's start offset). + private struct LineChange { + let at: Int + let removed: Int + let inserted: Int + } + + /// Builds ONE replacement for the lines `selection` touches — from the + /// start's line to the end's line, except that a selection ending at + /// column 0 does not touch that last line (dragging down over whole lines + /// ends there) — in a single pass: the span is copied out once, `rule` + /// looks at each line (pointer to its first unit, its length) and the new + /// text is assembled by block copies, never per-line substrings. The + /// selection is mapped through the changes. + private static func linePrefixEdit( + in ns: NSString, selection: NSRange, prefix: [unichar], + rule: (UnsafePointer, Int) -> LinePrefixChange? + ) -> TextEdit? { + let selectionEnd = NSMaxRange(selection) + let spanStart = lineStart(containing: selection.location, in: ns) + let endsAtColumnZero = selection.length > 0 && ns.character(at: selectionEnd - 1) == newline + let spanEnd = endsAtColumnZero ? selectionEnd - 1 : lineEnd(from: selectionEnd, in: ns) + let spanLength = spanEnd - spanStart + + var source = [unichar](repeating: 0, count: max(spanLength, 1)) + var changes: [LineChange] = [] + source.withUnsafeMutableBufferPointer { units in + let base = units.baseAddress! + if spanLength > 0 { + ns.getCharacters(base, range: NSRange(location: spanStart, length: spanLength)) + } + let end = base + spanLength + var lineHead = base + while true { + var lineTail = lineHead + while lineTail < end, lineTail.pointee != newline { lineTail += 1 } + if let change = rule(UnsafePointer(lineHead), lineTail - lineHead) { + changes.append(LineChange(at: spanStart + (lineHead - base), + removed: change.removed, inserted: change.inserted)) + } + guard lineTail < end else { break } + lineHead = lineTail + 1 + } + } + guard !changes.isEmpty else { return nil } + + let netChange = changes.reduce(0) { $0 + $1.inserted - $1.removed } + var output = [unichar](repeating: 0, count: max(spanLength + netChange, 1)) + source.withUnsafeBufferPointer { sourceUnits in + output.withUnsafeMutableBufferPointer { outputUnits in + prefix.withUnsafeBufferPointer { prefixUnits in + let from = sourceUnits.baseAddress! + var to = outputUnits.baseAddress! + var cursor = 0 // offset into the span + func copy(_ pointer: UnsafePointer, _ count: Int) { + guard count > 0 else { return } + UnsafeMutableRawPointer(to).copyMemory(from: pointer, + byteCount: count * MemoryLayout.stride) + to += count + } + for change in changes { + let lineOffset = change.at - spanStart + copy(from + cursor, lineOffset - cursor) + if change.inserted > 0 { copy(prefixUnits.baseAddress!, change.inserted) } + cursor = lineOffset + change.removed + } + copy(from + cursor, spanLength - cursor) + } + } + } + let replacement = String(utf16CodeUnits: output, count: spanLength + netChange) + + let start = map(selection.location, through: changes) + let end = map(selectionEnd, through: changes) + return TextEdit(range: NSRange(location: spanStart, length: spanLength), + replacement: replacement, + selection: NSRange(location: start, length: end - start)) + } + + /// Where `offset` lands after `changes`. A position AT a line start stays + /// at that line start (the new indent falls inside the selection); one + /// inside removed indentation moves to the line start; anything after + /// shifts by the net change. That is what makes indent then outdent + /// restore the original selection. + private static func map(_ offset: Int, through changes: [LineChange]) -> Int { + var delta = 0 + for change in changes { + if offset <= change.at { break } + if offset < change.at + change.removed { + return change.at + delta + } + delta += change.inserted - change.removed + } + return offset + delta + } +} diff --git a/QuickMD/QuickMDTests/FileWatcherTests.swift b/QuickMD/QuickMDTests/FileWatcherTests.swift index e9283f7..0e194ce 100644 --- a/QuickMD/QuickMDTests/FileWatcherTests.swift +++ b/QuickMD/QuickMDTests/FileWatcherTests.swift @@ -89,6 +89,135 @@ final class FileWatcherTests: XCTestCase { wait(for: [missing], timeout: 3) } + /// git checkout / stash pop: the file is gone for a while, then back at + /// the same path. The watcher polls once a second while missing, re-arms + /// and reports a change — and keeps watching the new file afterwards. + func testRecreatedFileFiresOnChangeAndRearms() throws { + let missing = expectation(description: "onFileMissing after delete") + watcher.onFileMissing = { missing.fulfill() } + watcher.start(watching: fileURL) + + try FileManager.default.removeItem(at: fileURL) + wait(for: [missing], timeout: 3) + + let back = expectation(description: "onChange after the file is recreated") + watcher.onFileMissing = { XCTFail("recreation must not be reported as missing") } + watcher.onChange = { back.fulfill() } + try "recreated".write(to: fileURL, atomically: false, encoding: .utf8) + wait(for: [back], timeout: 4) + + let again = expectation(description: "onChange after a write to the recreated file") + watcher.onChange = { again.fulfill() } + let handle = try FileHandle(forWritingTo: fileURL) + handle.seekToEndOfFile() + handle.write(Data(" more".utf8)) + try handle.close() + wait(for: [again], timeout: 3) + } + + func testStartOnMissingPathThenCreatedFiresOnChange() throws { + let later = tempDir.appendingPathComponent("not-yet.md") + var missingCount = 0 + watcher.onFileMissing = { missingCount += 1 } + let created = expectation(description: "onChange once the file appears") + watcher.onChange = { created.fulfill() } + watcher.start(watching: later) + XCTAssertEqual(missingCount, 1, "a path that does not exist is reported missing at start") + + try "now here".write(to: later, atomically: false, encoding: .utf8) + wait(for: [created], timeout: 4) + XCTAssertEqual(missingCount, 1) + } + + /// Gone, back, gone again, back again: each disappearance is reported, + /// each return recovers — the re-armed source is a full watcher again. + func testSecondDisappearanceAfterRearmIsReportedAndRecovers() throws { + var missingCount = 0 + watcher.start(watching: fileURL) + + for round in 1...2 { + let missing = expectation(description: "onFileMissing, round \(round)") + watcher.onFileMissing = { + missingCount += 1 + missing.fulfill() + } + try FileManager.default.removeItem(at: fileURL) + wait(for: [missing], timeout: 3) + XCTAssertEqual(missingCount, round) + + let back = expectation(description: "recovery, round \(round)") + watcher.onChange = { back.fulfill() } + try "round \(round)".write(to: fileURL, atomically: false, encoding: .utf8) + wait(for: [back], timeout: 4) + watcher.onChange = nil + } + XCTAssertEqual(missingCount, 2) + } + + func testFileMissingFiresOncePerDisappearance() throws { + var missingCount = 0 + watcher.onFileMissing = { missingCount += 1 } + watcher.onChange = { XCTFail("nothing came back") } + watcher.start(watching: fileURL) + + try FileManager.default.removeItem(at: fileURL) + // Long enough for two polls. + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { settle.fulfill() } + wait(for: [settle], timeout: 4) + XCTAssertEqual(missingCount, 1, "the poll must not re-report the missing file") + } + + /// The file comes back but cannot be opened (mode 000 → EACCES): the poll + /// stops instead of failing every second, and the watcher stays missing — + /// restoring the permissions later is NOT noticed (pre-poll behaviour). + func testPermissionDeniedAfterReturnStopsThePoll() throws { + var missingCount = 0 + let missing = expectation(description: "onFileMissing after delete") + watcher.onFileMissing = { + missingCount += 1 + missing.fulfill() + } + watcher.onChange = { XCTFail("an unopenable file must not be reported as changed") } + watcher.start(watching: fileURL) + + try FileManager.default.removeItem(at: fileURL) + wait(for: [missing], timeout: 3) + try "locked".write(to: fileURL, atomically: false, encoding: .utf8) + XCTAssertEqual(chmod(fileURL.path, 0), 0) + defer { chmod(fileURL.path, 0o644) } + + // One poll sees it, fails to open it, stops. + let denied = expectation(description: "a poll ran") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { denied.fulfill() } + wait(for: [denied], timeout: 3) + + XCTAssertEqual(chmod(fileURL.path, 0o644), 0) + let settle = expectation(description: "no poll after the denial") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { settle.fulfill() } + wait(for: [settle], timeout: 3) + XCTAssertEqual(missingCount, 1) + } + + func testStopWhileMissingCancelsThePoll() throws { + let missing = expectation(description: "onFileMissing after delete") + watcher.onFileMissing = { missing.fulfill() } + watcher.start(watching: fileURL) + + try FileManager.default.removeItem(at: fileURL) + wait(for: [missing], timeout: 3) + + watcher.stop() + watcher.onChange = { XCTFail("a stopped watcher must not report the recreated file") } + watcher.onFileMissing = { XCTFail("a stopped watcher must not report anything") } + try "recreated".write(to: fileURL, atomically: false, encoding: .utf8) + + // Longer than one poll interval plus the debounce. + let settle = expectation(description: "settle") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { settle.fulfill() } + wait(for: [settle], timeout: 3) + } + // MARK: - ExternalEditorManager func testKnownEditorBundleIDsAreUnique() { diff --git a/QuickMD/QuickMDTests/SourceEditSupportTests.swift b/QuickMD/QuickMDTests/SourceEditSupportTests.swift new file mode 100644 index 0000000..c89290d --- /dev/null +++ b/QuickMD/QuickMDTests/SourceEditSupportTests.swift @@ -0,0 +1,500 @@ +import XCTest +import SwiftUI + +/// Source Edit helpers (v1.12 S-D3, S-D5, S-D10, S-D14b): line <-> UTF-16 +/// position mapping, the landing block, the selection carry-over lookup and the +/// indentation edits. +/// +/// All texts are LF-only — the editor buffer never holds U+000D (constraint +/// 14), so CRLF is deliberately not a case here. Positions are UTF-16, so +/// every mapping is also checked with emoji (surrogate pairs), combining marks +/// and CJK in front of the position. +final class SourceEditSupportTests: XCTestCase { + + private typealias S = SourceEditSupport + + private func utf16(_ s: String) -> Int { (s as NSString).length } + + /// Applies an edit the way the text view will. + private func apply(_ edit: S.TextEdit, to text: String) -> String { + (text as NSString).replacingCharacters(in: edit.range, with: edit.replacement) + } + + // MARK: - Lines + + func testLineStartEmptyText() { + XCTAssertEqual(S.lineStart(0, in: ""), 0) + XCTAssertEqual(S.lineStart(5, in: ""), 0) + XCTAssertEqual(S.lineStart(-1, in: ""), 0) + XCTAssertEqual(S.lineRange(0, in: ""), NSRange(location: 0, length: 0)) + XCTAssertEqual(S.line(containing: 0, in: ""), 0) + XCTAssertEqual(S.lineCount(in: ""), 1) + } + + func testSingleLineWithoutNewline() { + let text = "hello" + XCTAssertEqual(S.lineStart(0, in: text), 0) + XCTAssertEqual(S.lineStart(3, in: text), 0, "past the end clamps to the last line") + XCTAssertEqual(S.lineRange(0, in: text), NSRange(location: 0, length: 5)) + XCTAssertEqual(S.line(containing: 5, in: text), 0) + XCTAssertEqual(S.lineCount(in: text), 1) + } + + func testTrailingNewlineMakesAnEmptyLastLine() { + let text = "a\nbc\n" + XCTAssertEqual(S.lineCount(in: text), 3) + XCTAssertEqual(S.lineStart(1, in: text), 2) + XCTAssertEqual(S.lineStart(2, in: text), 5) + XCTAssertEqual(S.lineStart(9, in: text), 5, "clamped to the empty last line") + XCTAssertEqual(S.lineRange(1, in: text), NSRange(location: 2, length: 2)) + XCTAssertEqual(S.lineRange(2, in: text), NSRange(location: 5, length: 0)) + XCTAssertEqual(S.line(containing: 1, in: text), 0, "the newline belongs to its line") + XCTAssertEqual(S.line(containing: 2, in: text), 1, "after a newline = next line") + XCTAssertEqual(S.line(containing: 5, in: text), 2) + } + + func testClamping() { + let text = "one\ntwo\nthree" + XCTAssertEqual(S.lineStart(-3, in: text), 0) + XCTAssertEqual(S.lineStart(99, in: text), 8) + XCTAssertEqual(S.lineRange(99, in: text), NSRange(location: 8, length: 5)) + XCTAssertEqual(S.lineRange(-1, in: text), NSRange(location: 0, length: 3)) + XCTAssertEqual(S.line(containing: -10, in: text), 0) + XCTAssertEqual(S.line(containing: 1000, in: text), 2) + } + + func testEmptyLinesInTheMiddle() { + let text = "a\n\n\nb" + XCTAssertEqual(S.lineCount(in: text), 4) + XCTAssertEqual(S.lineRange(1, in: text), NSRange(location: 2, length: 0)) + XCTAssertEqual(S.lineRange(2, in: text), NSRange(location: 3, length: 0)) + XCTAssertEqual(S.lineStart(3, in: text), 4) + XCTAssertEqual(S.line(containing: 3, in: text), 2) + } + + func testUTF16OffsetsAfterEmojiCombiningMarksAndCJK() { + // 👍🏽 = 4 UTF-16 units (two surrogate pairs), "é" written e + U+0301 = 2, + // CJK = 1 each. One Swift Character each — a Character count would be wrong. + let first = "👍🏽 e\u{0301}" + let second = "日本語 🎉x" + let text = first + "\n" + second + "\nend" + XCTAssertEqual(S.lineStart(1, in: text), utf16(first) + 1) + XCTAssertEqual(S.lineStart(2, in: text), utf16(first) + 1 + utf16(second) + 1) + XCTAssertEqual(S.lineRange(1, in: text), + NSRange(location: utf16(first) + 1, length: utf16(second))) + // Offset of "x", directly after a surrogate pair. + let x = (text as NSString).range(of: "x").location + XCTAssertEqual(S.line(containing: x, in: text), 1) + // An offset INSIDE a surrogate pair still maps to that pair's line. + let party = (text as NSString).range(of: "🎉").location + XCTAssertEqual(S.line(containing: party + 1, in: text), 1) + XCTAssertEqual(S.line(containing: utf16(first), in: text), 0) + XCTAssertEqual(S.line(containing: utf16(first) + 1, in: text), 1) + } + + func testOnlyLineFeedSeparatesLines() { + // U+2028 / U+0085 are line breaks to NSString.lineRange but not to the + // parser, whose sourceLine counts "\n" only. + let text = "a\u{2028}b\u{0085}c\nd" + XCTAssertEqual(S.lineCount(in: text), 2) + XCTAssertEqual(S.lineStart(1, in: text), 6) + } + + func testLongLinesCrossChunkBoundaries() { + // Lines longer than the internal 4096-unit chunk, with emoji on the seams. + let long = String(repeating: "ab😀", count: 3000) // 12 000 units + let text = long + "\n" + long + "\n" + long + let length = utf16(long) + XCTAssertEqual(S.lineStart(1, in: text), length + 1) + XCTAssertEqual(S.lineStart(2, in: text), 2 * (length + 1)) + XCTAssertEqual(S.line(containing: 2 * length + 1, in: text), 1) + XCTAssertEqual(S.line(containing: 2 * length + 2, in: text), 2) + XCTAssertEqual(S.lineRange(1, in: text), NSRange(location: length + 1, length: length)) + // Return at the end of a long line scans backwards across chunks. + let edit = S.newlineEdit(in: " " + long, selection: NSRange(location: 4 + length, length: 0)) + XCTAssertEqual(edit.replacement, "\n ") + // Indenting lines longer than a chunk, selection from mid line 0 to mid line 2. + let indent = S.indentEdit(in: text, selection: NSRange(location: 5, length: 2 * length), unit: "\t") + XCTAssertEqual(indent.map { apply($0, to: text) }, "\t" + long + "\n\t" + long + "\n\t" + long) + XCTAssertEqual(indent?.selection, NSRange(location: 6, length: 2 * length + 2)) + } + + // MARK: Large documents + // + // These catch QUADRATIC behaviour — which on 200k lines takes minutes, not + // seconds — not millisecond regressions: the bound is deliberately loose + // (Debug build, shared CI runner). Measured locally in Debug: well under + // half a second per test. The text is an NSMutableString, like the text + // storage the editor passes in. + + private static let largeLine = "Some *Markdown* line with ünïcödé and 😀 in it." + private static let quadraticGuard: TimeInterval = 10 + + private func largeDocument() -> NSMutableString { + NSMutableString(string: Array(repeating: Self.largeLine, count: 200_000).joined(separator: "\n")) + } + + func testLargeDocumentLookupsAreLinear() { + let text = largeDocument() + let lineLength = utf16(Self.largeLine) + XCTAssertGreaterThan(text.length, 9_000_000) + let started = Date() + let lastStart = S.lineStart(199_999, in: text) + let lineNumber = S.line(containing: text.length - 1, in: text) + let range = S.lineRange(150_000, in: text) + let starts = S.lineStarts(in: text) + let found = S.sourceRange(ofSelection: "ünïcödé", in: text, lines: 199_990..<200_000) + let unit = S.indentUnit(for: text) + let newline = S.newlineEdit(in: text, selection: NSRange(location: text.length, length: 0)) + let elapsed = Date().timeIntervalSince(started) + print("SourceEditSupport large lookups: \(elapsed) s") + + XCTAssertEqual(lastStart, text.length - lineLength) + XCTAssertEqual(lineNumber, 199_999) + XCTAssertEqual(range.length, lineLength) + XCTAssertEqual(starts.count, 200_000) + XCTAssertEqual(starts[150_000], range.location) + XCTAssertEqual(starts.last, lastStart) + XCTAssertEqual(found?.location, starts[199_990] + (Self.largeLine as NSString).range(of: "ünïcödé").location) + XCTAssertEqual(unit, " ") + XCTAssertEqual(newline.replacement, "\n") + XCTAssertLessThan(elapsed, Self.quadraticGuard, "lookups on 200k lines took \(elapsed) s") + } + + func testLargeDocumentSelectAllIndentAndOutdent() { + let text = largeDocument() + let original = text.copy() as! NSString + let all = NSRange(location: 0, length: text.length) + let started = Date() + guard let indent = S.indentEdit(in: text, selection: all, unit: "\t") else { return XCTFail() } + text.replaceCharacters(in: indent.range, with: indent.replacement) + guard let outdent = S.outdentEdit(in: text, selection: indent.selection, unit: "\t") else { return XCTFail() } + text.replaceCharacters(in: outdent.range, with: outdent.replacement) + let elapsed = Date().timeIntervalSince(started) + print("SourceEditSupport large indent + outdent: \(elapsed) s") + + XCTAssertEqual(indent.range, all) + XCTAssertEqual(indent.selection, NSRange(location: 0, length: all.length + 200_000)) + XCTAssertEqual(text as NSString, original, "indent then outdent restores the text") + XCTAssertEqual(outdent.selection, all) + XCTAssertLessThan(elapsed, Self.quadraticGuard, "indent + outdent of 200k lines took \(elapsed) s") + } + + // MARK: - Batch line starts and the NSString core + + func testLineStartsMatchesLineStart() { + for text in ["", "x", "a\n", "a\nbc\n\nd", "😀\n日本\ne\u{0301}\n"] { + let starts = S.lineStarts(in: text) + XCTAssertEqual(starts.count, S.lineCount(in: text), text.debugDescription) + for (line, start) in starts.enumerated() { + XCTAssertEqual(start, S.lineStart(line, in: text), "\(text.debugDescription) line \(line)") + } + } + XCTAssertEqual(S.lineStarts(in: ""), [0]) + XCTAssertEqual(S.lineStarts(in: "a\nbc\n"), [0, 2, 5]) + } + + func testMutableStringCoreAgreesWithStringOverloads() { + let string = " a\n\tb 😀\n c" + let mutable = NSMutableString(string: string) + XCTAssertEqual(S.lineStart(2, in: mutable), S.lineStart(2, in: string)) + XCTAssertEqual(S.indentUnit(for: mutable), S.indentUnit(for: string)) + let all = NSRange(location: 0, length: mutable.length) + XCTAssertEqual(S.indentEdit(in: mutable, selection: all, unit: " "), + S.indentEdit(in: string, selection: all, unit: " ")) + XCTAssertEqual(S.outdentEdit(in: mutable, selection: all, unit: " "), + S.outdentEdit(in: string, selection: all, unit: " ")) + XCTAssertEqual(S.newlineEdit(in: mutable, selection: NSRange(location: 5, length: 0)), + S.newlineEdit(in: string, selection: NSRange(location: 5, length: 0))) + } + + // MARK: - Landing block + + private func blocks(_ lines: [Int]) -> [MarkdownBlock] { + lines.enumerated().map { MarkdownBlock.text(index: $0.offset, AttributedString("b"), sourceLine: $0.element) } + } + + func testBlockIndexEmpty() { + XCTAssertNil(S.blockIndex(containingLine: 0, in: [])) + XCTAssertNil(S.blockIndex(containingLine: 7, in: [])) + } + + func testBlockIndexBasic() { + let list = blocks([0, 2, 5, 9]) + XCTAssertEqual(S.blockIndex(containingLine: 0, in: list), 0) + XCTAssertEqual(S.blockIndex(containingLine: 1, in: list), 0, "line inside block 0") + XCTAssertEqual(S.blockIndex(containingLine: 2, in: list), 1) + XCTAssertEqual(S.blockIndex(containingLine: 8, in: list), 2) + XCTAssertEqual(S.blockIndex(containingLine: 9, in: list), 3) + XCTAssertEqual(S.blockIndex(containingLine: 500, in: list), 3, "after the last block") + } + + func testBlockIndexBeforeFirstBlock() { + // Front matter / blank lines above the first block. + let list = blocks([4, 6]) + XCTAssertEqual(S.blockIndex(containingLine: 0, in: list), 0) + XCTAssertEqual(S.blockIndex(containingLine: 3, in: list), 0) + XCTAssertEqual(S.blockIndex(containingLine: -1, in: list), 0) + } + + func testBlockIndexSharedLinesPicksTheFirst() { + // Three images on line 3, two blocks on the last line. + let list = blocks([0, 3, 3, 3, 7, 10, 10]) + XCTAssertEqual(S.blockIndex(containingLine: 3, in: list), 1) + XCTAssertEqual(S.blockIndex(containingLine: 5, in: list), 1) + XCTAssertEqual(S.blockIndex(containingLine: 10, in: list), 5) + XCTAssertEqual(S.blockIndex(containingLine: 99, in: list), 5) + XCTAssertEqual(S.blockIndex(containingLine: 2, in: blocks([2, 2, 2])), 0) + XCTAssertEqual(S.blockIndex(containingLine: 1, in: blocks([2, 2, 2])), 0) + } + + func testBlockIndexMatchesLinearRule() { + let list = blocks([1, 1, 2, 4, 4, 4, 5, 8, 8, 12]) + for line in -1...14 { + let lastAtOrBefore = list.lastIndex { $0.sourceLine <= line } + let expected = lastAtOrBefore.map { last in + list.firstIndex { $0.sourceLine == list[last].sourceLine }! + } ?? 0 + XCTAssertEqual(S.blockIndex(containingLine: line, in: list), expected, "line \(line)") + } + } + + // MARK: - Selection carry-over + + func testSelectionFoundInsideMarkup() { + let text = "# Title\n\nSome **bold** text with a tpyo here.\nNext line" + let range = S.sourceRange(ofSelection: "tpyo", in: text, lines: 2..<3) + XCTAssertEqual(range.map { (text as NSString).substring(with: $0) }, "tpyo") + XCTAssertEqual(S.sourceRange(ofSelection: "bold", in: text, lines: 2..<3), + (text as NSString).range(of: "bold")) + } + + func testSelectionIsTrimmed() { + let text = "alpha beta\ngamma" + XCTAssertEqual(S.sourceRange(ofSelection: " beta\n", in: text, lines: 0..<1), + NSRange(location: 6, length: 4)) + } + + func testSelectionNilCases() { + let text = "alpha beta\ngamma\ndelta" + XCTAssertNil(S.sourceRange(ofSelection: "", in: text, lines: 0..<3)) + XCTAssertNil(S.sourceRange(ofSelection: " \n\t", in: text, lines: 0..<3)) + XCTAssertNil(S.sourceRange(ofSelection: "omega", in: text, lines: 0..<3)) + XCTAssertNil(S.sourceRange(ofSelection: "gamma", in: text, lines: 0..<1), "outside the line range") + XCTAssertNil(S.sourceRange(ofSelection: "alpha", in: text, lines: 1..<1), "empty range") + XCTAssertNil(S.sourceRange(ofSelection: "alpha", in: text, lines: 5..<9), "past the end") + XCTAssertNil(S.sourceRange(ofSelection: "alpha", in: "", lines: 0..<1)) + // A match that would run past the last line of the range. + XCTAssertNil(S.sourceRange(ofSelection: "gamma\ndelta", in: text, lines: 1..<2)) + // The trailing newline of the last line is not part of the range. + XCTAssertNil(S.sourceRange(ofSelection: "x\ny", in: "x\ny", lines: 0..<1)) + } + + func testSelectionFirstOccurrenceWithinRangeOnly() { + let text = "word one\nword two\nword three" + XCTAssertEqual(S.sourceRange(ofSelection: "word", in: text, lines: 1..<3), + NSRange(location: 9, length: 4), "the occurrence on line 0 is outside") + XCTAssertEqual(S.sourceRange(ofSelection: "word", in: text, lines: 0..<3), + NSRange(location: 0, length: 4)) + } + + func testSelectionSpanningLinesAndClampedUpperBound() { + let text = "first para\nsecond para\nthird" + XCTAssertEqual(S.sourceRange(ofSelection: "para\nsecond", in: text, lines: 0..<2), + NSRange(location: 6, length: 11)) + XCTAssertEqual(S.sourceRange(ofSelection: "third", in: text, lines: 2..<50), + NSRange(location: 23, length: 5)) + XCTAssertEqual(S.sourceRange(ofSelection: "first", in: text, lines: -2..<1), + NSRange(location: 0, length: 5)) + } + + func testSelectionIsLiteralAndUTF16() { + // Precomposed "é" (U+00E9) must NOT match e + U+0301: literal, not canonical. + let decomposed = "caf" + "e\u{0301}" + XCTAssertNil(S.sourceRange(ofSelection: "caf\u{00E9}", in: decomposed, lines: 0..<1)) + let text = "😀😀 日本 target\n" + let range = S.sourceRange(ofSelection: "target", in: text, lines: 0..<1) + XCTAssertEqual(range, NSRange(location: 8, length: 6)) + XCTAssertEqual(S.sourceRange(ofSelection: "*", in: "a*b", lines: 0..<1), + NSRange(location: 1, length: 1), "no regex/glob semantics") + } + + // MARK: - Indent unit + + func testIndentUnit() { + XCTAssertEqual(S.indentUnit(for: ""), " ") + XCTAssertEqual(S.indentUnit(for: "plain\n spaces\n"), " ") + XCTAssertEqual(S.indentUnit(for: "\tfirst line"), "\t") + XCTAssertEqual(S.indentUnit(for: "a\n b\n\tc"), "\t") + XCTAssertEqual(S.indentUnit(for: "a\tb\n c\t"), " ", "a tab not at a line start does not count") + } + + // MARK: - Return + + func testNewlineKeepsLeadingWhitespace() { + let text = " \t- item" + let edit = S.newlineEdit(in: text, selection: NSRange(location: 9, length: 0)) + XCTAssertEqual(edit.range, NSRange(location: 9, length: 0)) + XCTAssertEqual(edit.replacement, "\n \t") + XCTAssertEqual(edit.selection, NSRange(location: 13, length: 0)) + XCTAssertEqual(apply(edit, to: text), " \t- item\n \t") + } + + func testNewlineOnlyWhitespaceBeforeTheCaret() { + let text = "a\n code" + // Caret inside the indentation (column 3 of line 1). + let edit = S.newlineEdit(in: text, selection: NSRange(location: 5, length: 0)) + XCTAssertEqual(edit.replacement, "\n ") + // Caret at column 0: no indentation is carried. + XCTAssertEqual(S.newlineEdit(in: text, selection: NSRange(location: 2, length: 0)).replacement, "\n") + } + + func testNewlineWithoutIndentAndEdges() { + XCTAssertEqual(S.newlineEdit(in: "", selection: NSRange(location: 0, length: 0)).replacement, "\n") + XCTAssertEqual(S.newlineEdit(in: "abc", selection: NSRange(location: 3, length: 0)).replacement, "\n") + // Caret on the empty last line after a trailing newline. + let edit = S.newlineEdit(in: " x\n", selection: NSRange(location: 6, length: 0)) + XCTAssertEqual(edit.replacement, "\n") + } + + func testNewlineReplacesSelection() { + let text = " keep DROP rest" + let edit = S.newlineEdit(in: text, selection: NSRange(location: 9, length: 5)) + XCTAssertEqual(apply(edit, to: text), " keep \n rest") + XCTAssertEqual(edit.selection, NSRange(location: 14, length: 0)) + } + + func testNewlineAfterEmoji() { + let text = "x\n\t😀 y" + let caret = utf16(text) + let edit = S.newlineEdit(in: text, selection: NSRange(location: caret, length: 0)) + XCTAssertEqual(edit.replacement, "\n\t") + XCTAssertEqual(edit.selection.location, caret + 2) + } + + // MARK: - Tab / Shift-Tab + + func testTabWithCaretInsertsUnit() { + let text = "ab" + let edit = S.indentEdit(in: text, selection: NSRange(location: 1, length: 0), unit: " ") + XCTAssertEqual(edit.map { apply($0, to: text) }, "a b") + XCTAssertEqual(edit?.selection, NSRange(location: 5, length: 0)) + } + + func testTabWithSingleLineSelectionReplacesIt() { + let text = "one two\nthree" + let edit = S.indentEdit(in: text, selection: NSRange(location: 4, length: 3), unit: "\t") + XCTAssertEqual(edit.map { apply($0, to: text) }, "one \t\nthree") + XCTAssertEqual(edit?.selection, NSRange(location: 5, length: 0)) + } + + func testTabOnEmptyText() { + let edit = S.indentEdit(in: "", selection: NSRange(location: 0, length: 0), unit: "\t") + XCTAssertEqual(edit.map { apply($0, to: "") }, "\t") + } + + func testTabIndentsTouchedLinesSkippingEmptyAndColumnZeroEnd() { + let text = "a\nb\n\nc\nd" + // From inside line 0 to column 0 of line 4 ("d"): lines 0–3 touched, 2 is empty. + let selection = NSRange(location: 1, length: 6) + let edit = S.indentEdit(in: text, selection: selection, unit: " ") + XCTAssertEqual(edit.map { apply($0, to: text) }, " a\n b\n\n c\nd") + // Same text still selected: "\nb\n\nc\n" moved by the inserted units. + XCTAssertEqual(edit?.selection, NSRange(location: 3, length: 10)) + XCTAssertEqual(edit.map { (apply($0, to: text) as NSString).substring(with: $0.selection) }, + "\n b\n\n c\n") + } + + func testTabOnWholeLineSelectedWithItsNewlineIndents() { + // Triple-click selects a line including its newline: indent, don't replace. + let text = "one\ntwo\nthree" + let edit = S.indentEdit(in: text, selection: NSRange(location: 4, length: 4), unit: "\t") + XCTAssertEqual(edit.map { apply($0, to: text) }, "one\n\ttwo\nthree") + XCTAssertEqual(edit?.selection, NSRange(location: 4, length: 5), + "a selection starting at column 0 takes the new indent in") + } + + func testTabNilWhenAllTouchedLinesAreEmpty() { + XCTAssertNil(S.indentEdit(in: "\n\n\n", selection: NSRange(location: 0, length: 2), unit: "\t")) + } + + func testIndentIsOneEditCoveringTheTouchedLines() { + let text = "keep\nx\ny\nkeep" + let edit = S.indentEdit(in: text, selection: NSRange(location: 5, length: 3), unit: "\t") + XCTAssertEqual(edit?.range, NSRange(location: 5, length: 3)) + XCTAssertEqual(edit?.replacement, "\tx\n\ty") + } + + func testShiftTabRemovesOneLevel() { + let text = "\ttab\n six\n two\nnone\n" + let unit = " " + let edit = S.outdentEdit(in: text, selection: NSRange(location: 0, length: utf16(text)), unit: unit) + XCTAssertEqual(edit.map { apply($0, to: text) }, "tab\n six\ntwo\nnone\n") + } + + func testShiftTabWithTabUnitRemovesTabOrFourSpaces() { + let text = "\t\ta\n b" + let edit = S.outdentEdit(in: text, selection: NSRange(location: 0, length: utf16(text)), unit: "\t") + XCTAssertEqual(edit.map { apply($0, to: text) }, "\ta\n b") + } + + func testShiftTabCaretOutdentsItsLine() { + let text = "a\n bc\nd" + let edit = S.outdentEdit(in: text, selection: NSRange(location: 11, length: 0), unit: " ") + XCTAssertEqual(edit.map { apply($0, to: text) }, "a\n bc\nd") + XCTAssertEqual(edit?.selection, NSRange(location: 7, length: 0), "caret stays before \"c\"") + // Caret inside the removed indentation moves to the line start. + let inside = S.outdentEdit(in: text, selection: NSRange(location: 4, length: 0), unit: " ") + XCTAssertEqual(inside?.selection, NSRange(location: 2, length: 0)) + } + + func testShiftTabNilWithoutLeadingWhitespace() { + XCTAssertNil(S.outdentEdit(in: "a\nb", selection: NSRange(location: 0, length: 3), unit: "\t")) + XCTAssertNil(S.outdentEdit(in: "", selection: NSRange(location: 0, length: 0), unit: " ")) + XCTAssertNil(S.outdentEdit(in: "x\n\n", selection: NSRange(location: 2, length: 0), unit: " ")) + } + + func testShiftTabSkipsColumnZeroEndLine() { + let text = " a\n b" + let edit = S.outdentEdit(in: text, selection: NSRange(location: 0, length: 4), unit: " ") + XCTAssertEqual(edit.map { apply($0, to: text) }, "a\n b") + } + + /// Indent then outdent restores text AND selection, for several shapes. + func testIndentOutdentRoundTrips() { + let text = "# Head 😀\n item one\n\n\tcode é\nlast line" + let length = utf16(text) + let cases: [NSRange] = [ + NSRange(location: 0, length: length), // everything + NSRange(location: 3, length: 20), // mid-line to mid-line + NSRange(location: 0, length: S.lineStart(2, in: text)), // whole lines 0–1 + NSRange(location: S.lineStart(1, in: text) + 2, length: S.lineStart(4, in: text) - S.lineStart(1, in: text)), // ends at column 2 + ] + for unit in [" ", "\t"] { + for selection in cases { + guard let indent = S.indentEdit(in: text, selection: selection, unit: unit) else { + XCTFail("indent \(selection) produced nothing"); continue + } + let indented = apply(indent, to: text) + guard let outdent = S.outdentEdit(in: indented, selection: indent.selection, unit: unit) else { + XCTFail("outdent \(selection) produced nothing"); continue + } + // A tab-indented line indented with spaces loses its spaces + // first, so compare against the same text the indent produced. + XCTAssertEqual(apply(outdent, to: indented), text, "unit \(unit.debugDescription), \(selection)") + XCTAssertEqual(outdent.selection, selection, "unit \(unit.debugDescription), \(selection)") + } + } + } + + func testCaretTabThenShiftTabRoundTrip() { + let text = "line\n x" + let caret = NSRange(location: 6, length: 0) + guard let indent = S.indentEdit(in: text, selection: caret, unit: " ") else { return XCTFail() } + let indented = apply(indent, to: text) + XCTAssertEqual(indented, "line\n x") + guard let outdent = S.outdentEdit(in: indented, selection: indent.selection, unit: " ") else { return XCTFail() } + XCTAssertEqual(apply(outdent, to: indented), text) + XCTAssertEqual(outdent.selection, NSRange(location: 6, length: 0)) + } +} From 92082725c332cb5bdbf8068ee1b6b31b55874093 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 14:17:18 +0200 Subject: [PATCH 03/17] feat(source-edit): close and quit guard for windows with unsaved source edits EditCloseGuard is a forwarding proxy in front of SwiftUI's window delegate: a dirty session turns every close path (red button, tab close, Cmd-W, quit) into a Save / Don't Save / Cancel sheet, with a synchronous last-resort prompt if a window closes without consulting the delegate. It keeps the underlying NSDocument clean on purpose, so it disables automatic termination itself while text is unsaved. No session installs a guard yet, so behaviour is unchanged. The app's Close command and the missing-file banner now go through EditCloseGuard.requestClose (plain close() for clean windows, as before). Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 10 + QuickMD/QuickMD/EditCloseGuard.swift | 491 ++++++++++ QuickMD/QuickMD/MarkdownView.swift | 2 +- QuickMD/QuickMD/QuickMDApp.swift | 4 +- .../QuickMDTests/EditCloseGuardTests.swift | 849 ++++++++++++++++++ 5 files changed, 1354 insertions(+), 2 deletions(-) create mode 100644 QuickMD/QuickMD/EditCloseGuard.swift create mode 100644 QuickMD/QuickMDTests/EditCloseGuardTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index cbd1e67..6ad1fb6 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -121,6 +121,9 @@ A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000110 /* DocumentReadingPosition.swift */; }; A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000110 /* DocumentReadingPosition.swift */; }; A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000111 /* ExternalEditorLineTests.swift */; }; + A1B2C3D401000140 /* EditCloseGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; }; + A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; }; + A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000141 /* EditCloseGuardTests.swift */; }; A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000F0 /* HeadingLayoutTests.swift */; }; /* End PBXBuildFile section */ @@ -142,6 +145,8 @@ A1B2C3D4000000C1 /* DocumentSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSelectionTests.swift; sourceTree = ""; }; A1B2C3D400000110 /* DocumentReadingPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentReadingPosition.swift; sourceTree = ""; }; A1B2C3D400000111 /* ExternalEditorLineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalEditorLineTests.swift; sourceTree = ""; }; + A1B2C3D400000140 /* EditCloseGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuard.swift; sourceTree = ""; }; + A1B2C3D400000141 /* EditCloseGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuardTests.swift; sourceTree = ""; }; A1B2C3D4000000F0 /* HeadingLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingLayoutTests.swift; sourceTree = ""; }; A1B2C3D400000001 /* QuickMDApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMDApp.swift; sourceTree = ""; }; A1B2C3D400000002 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; @@ -270,6 +275,7 @@ A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, A1B2C3D400000111 /* ExternalEditorLineTests.swift */, + A1B2C3D400000141 /* EditCloseGuardTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -290,6 +296,7 @@ A1B2C3D400000081 /* ImageLoader.swift */, A1B2C3D4000000C0 /* DocumentSelection.swift */, A1B2C3D400000110 /* DocumentReadingPosition.swift */, + A1B2C3D400000140 /* EditCloseGuard.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -573,6 +580,7 @@ A1B2C3D401000082 /* ImageLoader.swift in Sources */, A1B2C3D4010000C0 /* DocumentSelection.swift in Sources */, A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */, + A1B2C3D401000140 /* EditCloseGuard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -614,6 +622,8 @@ A1B2C3D4010000C2 /* DocumentSelectionTests.swift in Sources */, A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */, A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */, + A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */, + A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QuickMD/QuickMD/EditCloseGuard.swift b/QuickMD/QuickMD/EditCloseGuard.swift new file mode 100644 index 0000000..fa66805 --- /dev/null +++ b/QuickMD/QuickMD/EditCloseGuard.swift @@ -0,0 +1,491 @@ +import AppKit + +// MARK: - Edit Close Guard + +/// Keeps a document window that holds unsaved source edits from closing (or +/// the app from quitting) without asking the user first. +/// +/// The app is `DocumentGroup(viewing:)`: the window's delegate is SwiftUI's own +/// controller and the window sits on a SwiftUI-owned NSDocument that +/// autosaves in place. That NSDocument must NEVER become edited — AppKit would +/// autosave the ORIGINAL text over the file — so the guard never touches +/// `updateChangeCount` or `window.undoManager`. Instead it sits in front of the +/// original delegate as a forwarding proxy: it answers `windowShouldClose` and +/// `windowWillClose` itself and hands every other delegate message to the +/// original (`responds(to:)` + `forwardingTarget(for:)`). Measured on macOS 27: +/// the proxy stays installed, `isDocumentEdited` sticks, and the red close +/// button, `performClose` and the tab's close button all reach +/// `windowShouldClose` before AppKit's document close machinery. +/// +/// Because the NSDocument stays clean, AppKit also does not know the app holds +/// unsaved text: the guard disables automatic termination itself while dirty +/// (Info.plist has `NSSupportsAutomaticTermination`, which would otherwise let +/// the system kill a hidden app without asking `applicationShouldTerminate`). +/// +/// The guard knows nothing about the editor: the edit session supplies +/// `Handlers` (dirty check, the confirmation sheet, a synchronous last resort). +/// +/// Main-thread by convention, without an explicit `@MainActor` annotation — the +/// SwiftUI view code that installs and drives this is nonisolated on older +/// SDKs (CI) and an isolated call there is a hard compile error. (Newer SDKs +/// may infer main-actor isolation from the NSWindowDelegate conformance; what +/// matters is that we do not ADD one and that every call site is main-thread.) +/// Same reasoning as `FileWatcher`. +final class EditCloseGuard: NSObject, NSWindowDelegate { + + /// Supplied by the window's edit session. + /// + /// Contract for the session: + /// - The closures capture the session WEAKLY, and the session does not hold + /// the window strongly: window → guard → handlers → session → window + /// would keep every closed document alive. + /// - The guard treats "a sheet is attached" as "confirmation in progress" + /// and assumes nothing else. So: a sheet stays attached to the window + /// from the moment `confirm` is called until `completion` is called; any + /// follow-up sheet (save conflict, save error, save panel) is begun + /// synchronously inside the previous sheet's completion handler; saving + /// is synchronous. "Confirming but no sheet attached" is treated as a + /// lost confirmation and replaced by a fresh one. + /// - `confirm` calls `completion(true)` only once `isDirty()` already + /// returns false — the save succeeded, or Don't Save really reverted the + /// buffer — in the quit path too. Debug builds assert this. + struct Handlers { + /// True while the session holds text that is not on disk. + var isDirty: () -> Bool + /// Ask the user (a sheet on the window). `completion(true)` = saved or + /// discarded, the window may close; `false` = cancelled or the save + /// failed. The guard never starts a second one while a sheet is up. + var confirm: (NSWindow, @escaping (Bool) -> Void) -> Void + /// Last resort when the window is already closing and cannot be kept: + /// must return only after the text was saved or discarded (no Cancel). + var resolveSynchronously: (NSWindow) -> Void + } + + // MARK: Injection points (tests replace these; the app never does) + + /// Whether a sheet is attached to the window. + static var hasAttachedSheet: (NSWindow) -> Bool = { $0.attachedSheet != nil } + /// The window a sheet is attached to (nil for a normal window). + static var sheetParent: (NSWindow) -> NSWindow? = { $0.sheetParent } + static var disableAutomaticTermination: (String) -> Void = { + ProcessInfo.processInfo.disableAutomaticTermination($0) + } + static var enableAutomaticTermination: (String) -> Void = { + ProcessInfo.processInfo.enableAutomaticTermination($0) + } + static let automaticTerminationReason = "Unsaved source edits" + #if DEBUG + /// Reports a broken `Handlers` contract (tests record instead of trapping). + static var contractViolation: (String) -> Void = { assertionFailure($0) } + #endif + + // MARK: State + + /// Weak like `NSWindow.delegate` itself; the window retains the guard. + private(set) weak var window: NSWindow? + /// The delegate the guard replaced (SwiftUI's controller in the app). + private(set) weak var original: NSWindowDelegate? + private(set) var handlers: Handlers + + /// A `confirm` was started and has not completed. Only a pending + /// confirmation with a sheet attached blocks a new one (`isConfirmationPending`). + private(set) var isConfirming = false + /// Identifies the current confirmation, so a completion from a confirmation + /// that was given up on (sheet gone without completing) is ignored. + private var confirmationGeneration = 0 + /// The user answered Save / Don't Save. Lives only for the close or quit + /// step that follows (cleared on the next main-queue turn): a confirmed + /// close must not trip the willClose fallback, and the immediate re-entry + /// of a quit must not ask again — but a later close of a window that + /// stayed open must ask again. + private(set) var isResolved = false + /// The window has closed; the guard is out of the delegate chain and the + /// review registry. + private(set) var isClosed = false + /// Mirrors whether this guard currently holds an automatic-termination + /// disable, so enable/disable stay strictly balanced. + private(set) var disablesAutomaticTermination = false + private var willCloseObserver: NSObjectProtocol? + + private static var associationKey: UInt8 = 0 + + /// Every guard installed and not yet closed, held weakly — the quit review + /// enumerates these without keeping windows (or guards) alive. + private static var registry: [WeakGuard] = [] + private struct WeakGuard { weak var value: EditCloseGuard? } + + private init(window: NSWindow, handlers: Handlers) { + self.window = window + self.handlers = handlers + super.init() + } + + deinit { + if let willCloseObserver { + NotificationCenter.default.removeObserver(willCloseObserver) + } + if disablesAutomaticTermination { + Self.enableAutomaticTermination(Self.automaticTerminationReason) + } + } + + // MARK: - Installation + + /// Installs the guard as `window.delegate` (once per window; a second call + /// replaces the handlers, resets the per-operation state and returns the + /// same guard). The window retains the guard through an associated object, + /// so it lives exactly as long as the window. + /// + /// A guard whose window already closed is out of the delegate chain for + /// good; if the same NSWindow is shown again (`isReleasedWhenClosed == + /// false`), a fresh guard replaces it — reusing the closed one would make + /// every later close silent. + @discardableResult + static func install(on window: NSWindow, handlers: Handlers) -> EditCloseGuard { + if let existing = guardFor(window), !existing.isClosed { + existing.handlers = handlers + // A sheet on screen keeps its latch and generation: its answer + // must still land, and ⌘W must not start a second sheet. + if !existing.isConfirmationPending { + existing.isResolved = false + existing.isConfirming = false + existing.confirmationGeneration += 1 + } + existing.reattachIfReplaced() + return existing + } + let closeGuard = EditCloseGuard(window: window, handlers: handlers) + objc_setAssociatedObject(window, &associationKey, closeGuard, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + closeGuard.original = window.delegate + window.delegate = closeGuard + // Backstop for a window whose delegate was replaced behind our back: + // the willClose fallback must still run. Synchronous (queue nil) so it + // runs before the window is gone. It never touches the delegate — if + // it ran before the delegate's own willClose registration, swapping the + // delegate here would make the original miss `windowWillClose` + // (measured: the swapped-in delegate is not called for the post in + // flight, the swapped-out one is skipped). + closeGuard.willCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: window, queue: nil + ) { [weak closeGuard] _ in + closeGuard?.windowIsClosing() + } + registry.removeAll { $0.value == nil } + registry.append(WeakGuard(value: closeGuard)) + return closeGuard + } + + static func guardFor(_ window: NSWindow) -> EditCloseGuard? { + objc_getAssociatedObject(window, &associationKey) as? EditCloseGuard + } + + /// Guards of open windows, in installation order. + static func liveGuards() -> [EditCloseGuard] { + registry.removeAll { $0.value == nil } + return registry.compactMap(\.value).filter { $0.window != nil && !$0.isClosed } + } + + /// Open windows whose edits are unresolved — the quit review walks this list. + static func guardsNeedingReview() -> [EditCloseGuard] { + liveGuards().filter(\.needsReview) + } + + /// Unsaved edits the user has not yet answered for. + var needsReview: Bool { + !isClosed && !isResolved && handlers.isDirty() + } + + /// A confirmation sheet is actually on screen for this window. + var isConfirmationPending: Bool { + guard isConfirming, let window else { return false } + return Self.hasAttachedSheet(window) + } + + /// Mirrors the session's dirty flag to the close button's dot and to the + /// app's automatic-termination state. Nothing NSDocument-related is + /// involved (see the type comment). Also the natural moment to check the + /// guard is still the delegate: if something replaced it, that object + /// becomes the new `original` and the guard re-installs. + func setEdited(_ edited: Bool) { + guard !isClosed else { return } + if edited { isResolved = false } + reattachIfReplaced() + window?.isDocumentEdited = edited + if edited && !disablesAutomaticTermination { + disablesAutomaticTermination = true + Self.disableAutomaticTermination(Self.automaticTerminationReason) + } else if !edited && disablesAutomaticTermination { + disablesAutomaticTermination = false + Self.enableAutomaticTermination(Self.automaticTerminationReason) + } + } + + private func reattachIfReplaced() { + guard let window, window.delegate !== self else { return } + original = window.delegate + window.delegate = self + } + + // MARK: - Forwarding + + override func responds(to aSelector: Selector!) -> Bool { + super.responds(to: aSelector) || (original?.responds(to: aSelector) ?? false) + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + if let original, original.responds(to: aSelector) { return original } + return super.forwardingTarget(for: aSelector) + } + + // MARK: - Closing + + func windowShouldClose(_ sender: NSWindow) -> Bool { + guard needsReview else { + return original?.windowShouldClose?(sender) ?? true + } + // After Save / Don't Save the window closes with `close()` without + // asking `original.windowShouldClose` again — fine for SwiftUI's + // controller, which has no veto of its own for a clean viewer document. + beginConfirmation { [weak sender] confirmed in + if confirmed { sender?.close() } + } + return false + } + + /// Implemented here rather than only forwarded: if the original is already + /// gone, a forwarded `windowWillClose:` has no target and AppKit raises + /// "unrecognized selector" (measured). Resolve first (the window is still + /// whole), then let the original clean up, then leave the delegate chain. + func windowWillClose(_ notification: Notification) { + windowIsClosing() + original?.windowWillClose?(notification) + guard let window, window.delegate === self else { return } + // Swapping now is safe: the delegate set for this post was already + // called (it is us), and the new one is not called for it again. + window.delegate = original + } + + /// What a close request (Close command, banner button) should do. A sheet + /// window is never the target: ⌘W while the unsaved-changes sheet is up + /// must not close the SHEET (its completion would never fire) — the + /// request goes to the sheet's parent. + enum CloseRequest: Equatable { + case ignore + /// The target's confirmation sheet is already up — beep, do nothing. + case busy(NSWindow) + case confirm(NSWindow) + case close(NSWindow) + } + + static func closeRequest(for window: NSWindow?) -> CloseRequest { + guard let window else { return .ignore } + let target = sheetParent(window) ?? window + if let closeGuard = guardFor(target), closeGuard.needsReview { + return closeGuard.isConfirmationPending ? .busy(target) : .confirm(target) + } + // Clean or unguarded: exactly what ⌘W did before (`close()` on the + // key window, even if that is a foreign sheet). + return .close(window) + } + + /// The app's own Close command and the missing-file banner call this + /// instead of `close()`, which never consults the delegate. A clean (or + /// unguarded) window closes exactly as before — `close()`, not + /// `performClose`, which needs an enabled close button and would change + /// what a clean ⌘W does today. + static func requestClose(_ window: NSWindow?) { + switch closeRequest(for: window) { + case .ignore: + break + case .busy: + NSSound.beep() + case .confirm(let target): + guardFor(target)?.beginConfirmation { [weak target] confirmed in + if confirmed { target?.close() } + } + case .close(let window): + window.close() + } + } + + /// Runs `handlers.confirm` unless one is already on screen. A latch whose + /// sheet is gone without a completion is stale: it is replaced by a fresh + /// confirmation and the old completion, if it ever comes, is ignored. + /// `then` receives the user's answer; a positive answer marks the edits + /// resolved for the duration of the close/quit step it triggers. + fileprivate func beginConfirmation(then: @escaping (Bool) -> Void) { + guard let window, !isClosed, !isConfirmationPending else { return } + isConfirming = true + confirmationGeneration += 1 + let generation = confirmationGeneration + handlers.confirm(window) { [weak self] confirmed in + guard let self, self.isConfirming, generation == self.confirmationGeneration else { return } + self.isConfirming = false + guard confirmed else { + then(false) + return + } + #if DEBUG + if self.handlers.isDirty() { + Self.contractViolation("EditCloseGuard: confirm reported true while the session is still dirty") + } + #endif + self.isResolved = true + then(true) + DispatchQueue.main.async { [weak self] in + guard let self, self.window != nil else { return } + self.isResolved = false + } + } + } + + /// The window is closing (delegate callback or the notification backstop, + /// whichever comes first; runs once). If edits are still unresolved, some + /// path closed the window without asking the delegate (other macOS + /// versions were not probed) — it cannot be kept, so resolve synchronously: + /// never lose text silently. Then leave the review registry and give back + /// the automatic-termination disable. + private func windowIsClosing() { + guard !isClosed else { return } + if let window, needsReview { + handlers.resolveSynchronously(window) + } + isClosed = true + isConfirming = false + if let willCloseObserver { + NotificationCenter.default.removeObserver(willCloseObserver) + self.willCloseObserver = nil + } + if disablesAutomaticTermination { + disablesAutomaticTermination = false + Self.enableAutomaticTermination(Self.automaticTerminationReason) + } + Self.registry.removeAll { $0.value == nil || $0.value === self } + } +} + +// MARK: - Unsaved Changes Alert + +/// The standard macOS unsaved-changes prompt, built once so the edit session +/// only wires callbacks. The sheet has Save (default, Return), Don't Save, +/// Cancel (Escape); the app-modal variant — used when the window is already +/// closing and cannot be kept — has no Cancel. +enum UnsavedChangesAlert { + + enum Choice: Equatable { + case save, discard, cancel + } + + static let saveIdentifier = "source-unsaved-save" + static let discardIdentifier = "source-unsaved-discard" + static let cancelIdentifier = "source-unsaved-cancel" + + static func makeAlert(fileName: String, allowsCancel: Bool) -> NSAlert { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Do you want to save the changes you made to “\(fileName)”?" + alert.informativeText = "Your changes will be lost if you don’t save them." + + let save = alert.addButton(withTitle: "Save") + save.keyEquivalent = "\r" + save.setAccessibilityIdentifier(saveIdentifier) + + let discard = alert.addButton(withTitle: "Don’t Save") + discard.keyEquivalent = "d" + discard.keyEquivalentModifierMask = [.command] + discard.setAccessibilityIdentifier(discardIdentifier) + + if allowsCancel { + let cancel = alert.addButton(withTitle: "Cancel") + cancel.keyEquivalent = "\u{1b}" + cancel.setAccessibilityIdentifier(cancelIdentifier) + } + return alert + } + + /// Maps the button order of `makeAlert` (Save, Don't Save, Cancel). + static func choice(for response: NSApplication.ModalResponse) -> Choice { + switch response { + case .alertFirstButtonReturn: return .save + case .alertSecondButtonReturn: return .discard + default: return .cancel + } + } + + /// The no-Cancel variant: only an explicit Don't Save click discards. Any + /// other response (abort / stop from outside the alert) saves — text is + /// never thrown away without the user asking for it. + static func modalChoice(for response: NSApplication.ModalResponse) -> Choice { + response == .alertSecondButtonReturn ? .discard : .save + } + + /// Window-modal sheet with Cancel. + static func beginSheet(on window: NSWindow, fileName: String, + completion: @escaping (Choice) -> Void) { + let alert = makeAlert(fileName: fileName, allowsCancel: true) + alert.beginSheetModal(for: window) { response in + completion(choice(for: response)) + } + } + + /// App-modal, Save / Don't Save only — for the willClose fallback, where + /// the window is going away regardless. Returns `.save` or `.discard`. + static func runModal(fileName: String) -> Choice { + let alert = makeAlert(fileName: fileName, allowsCancel: false) + return modalChoice(for: alert.runModal()) + } +} + +// MARK: - App Delegate + +/// `@NSApplicationDelegateAdaptor` target: reviews unsaved source edits before +/// the app quits. One dirty window at a time — cancel the quit, bring the +/// window forward, show its sheet, and ask to terminate again once the user +/// answered; the next round handles the next window or quits. Cancel (or a +/// failed save) ends the quit. +/// +/// `.terminateCancel` + terminate-again rather than `.terminateLater`: the +/// prototype verified this flow, while `.terminateLater` runs the app in the +/// modal-panel run-loop mode until `reply(toApplicationShouldTerminate:)`, +/// which has not been tested with the save flow. Known cost: a logout or +/// restart that asked us to quit is interrupted (the user starts it again) +/// instead of resumed. +final class QuickMDAppDelegate: NSObject, NSApplicationDelegate { + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + Self.reviewBeforeQuit( + EditCloseGuard.guardsNeedingReview(), + bringForward: Self.present, + terminateAgain: { NSApp.terminate(nil) } + ) + } + + /// The quit decision, separated from NSApp so it can be tested without + /// terminating anything. + static func reviewBeforeQuit(_ pending: [EditCloseGuard], + bringForward: (NSWindow) -> Void, + terminateAgain: @escaping () -> Void) -> NSApplication.TerminateReply { + // Intended: a quit that arrives while a close confirmation is already + // on screen only cancels and shows that sheet. Its answer closes the + // window; nothing is queued to resume the quit. + if let busy = pending.first(where: \.isConfirmationPending), let window = busy.window { + bringForward(window) + return .terminateCancel + } + guard let first = pending.first, let window = first.window else { return .terminateNow } + bringForward(window) + first.beginConfirmation { confirmed in + if confirmed { terminateAgain() } + } + return .terminateCancel + } + + /// The window may be minimised, the app hidden or in the background (quit + /// from the Dock or a logout): the sheet must be where the user looks. + static func present(_ window: NSWindow) { + if window.isMiniaturized { window.deminiaturize(nil) } + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + } +} diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index ab8b560..e11940b 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -307,7 +307,7 @@ struct MarkdownView: View { .lineLimit(1) .truncationMode(.middle) Button("Close Tab") { - NSApp.keyWindow?.close() + EditCloseGuard.requestClose(NSApp.keyWindow) } .controlSize(.small) } diff --git a/QuickMD/QuickMD/QuickMDApp.swift b/QuickMD/QuickMD/QuickMDApp.swift index 761ca0c..837d0ca 100644 --- a/QuickMD/QuickMD/QuickMDApp.swift +++ b/QuickMD/QuickMD/QuickMDApp.swift @@ -13,6 +13,8 @@ enum AppURLs { @main struct QuickMDApp: App { + @NSApplicationDelegateAdaptor(QuickMDAppDelegate.self) private var appDelegate + init() { // Premium UX: every QuickMD document opens as a tab in an existing window // (instead of stacking standalone windows). Overrides the system-wide @@ -29,7 +31,7 @@ struct QuickMDApp: App { .commands { CommandGroup(replacing: .saveItem) { Button("Close") { - NSApp.keyWindow?.close() + EditCloseGuard.requestClose(NSApp.keyWindow) } .keyboardShortcut("w", modifiers: .command) Divider() diff --git a/QuickMD/QuickMDTests/EditCloseGuardTests.swift b/QuickMD/QuickMDTests/EditCloseGuardTests.swift new file mode 100644 index 0000000..e0167b4 --- /dev/null +++ b/QuickMD/QuickMDTests/EditCloseGuardTests.swift @@ -0,0 +1,849 @@ +import XCTest +import AppKit + +/// EditCloseGuard (S-D11) driven headlessly: plain NSWindows that are never +/// ordered on screen, handlers injected as closures, no real sheet (the +/// guard's sheet / automatic-termination hooks are replaced by recorders). +/// What the real red button / tab close button / ⌘Q do with the guard is E2E +/// territory; here we pin the delegate forwarding, the confirm flow, the +/// willClose fallback, the termination bookkeeping and the quit decision. +final class EditCloseGuardTests: XCTestCase { + + /// Records what the guard forwards to the window's original delegate. + private final class RecordingDelegate: NSObject, NSWindowDelegate { + var shouldCloseAnswer = true + var shouldCloseCalls = 0 + var didResizeCalls = 0 + var willCloseCalls = 0 + + func windowShouldClose(_ sender: NSWindow) -> Bool { + shouldCloseCalls += 1 + return shouldCloseAnswer + } + + func windowDidResize(_ notification: Notification) { + didResizeCalls += 1 + } + + func windowWillClose(_ notification: Notification) { + willCloseCalls += 1 + } + } + + /// Windows that currently show a (fake) confirmation sheet. + private static var sheetsUp = Set() + + /// Scriptable session: dirty flag, captured confirm completions, counters. + private final class FakeSession { + var dirty = false + var confirmCalls = 0 + var resolveCalls = 0 + var pendingCompletion: ((Bool) -> Void)? + private var sheetWindow: ObjectIdentifier? + + var handlers: EditCloseGuard.Handlers { + EditCloseGuard.Handlers( + // Weak: a guard can outlive its test's session (another test's + // quit review still enumerates it); a gone session is clean. + isDirty: { [weak self] in self?.dirty ?? false }, + confirm: { [weak self] window, completion in + guard let self else { return } + self.confirmCalls += 1 + self.pendingCompletion = completion + self.sheetWindow = ObjectIdentifier(window) + EditCloseGuardTests.sheetsUp.insert(ObjectIdentifier(window)) + }, + resolveSynchronously: { [weak self] _ in self?.resolveCalls += 1 } + ) + } + + /// The user clicked a button: the sheet goes away, then the completion. + func answer(_ confirmed: Bool) { + dropSheet() + let completion = pendingCompletion + pendingCompletion = nil + completion?(confirmed) + } + + /// The sheet vanished without its completion ever firing. + func dropSheet() { + if let sheetWindow { EditCloseGuardTests.sheetsUp.remove(sheetWindow) } + sheetWindow = nil + } + } + + /// What the injected hooks record. Owned by the test case and captured + /// WEAKLY by the hooks: a guard from an earlier test that deinits later + /// (and gives back its termination disable) then calls into nothing + /// instead of into a dead or unrelated test. + private final class Recorder { + var disableCount = 0 + var enableCount = 0 + var contractViolations = 0 + var sheetParents: [ObjectIdentifier: NSWindow] = [:] + } + + private var recorder = Recorder() + private var savedHasAttachedSheet: ((NSWindow) -> Bool)! + private var savedSheetParent: ((NSWindow) -> NSWindow?)! + private var savedDisable: ((String) -> Void)! + private var savedEnable: ((String) -> Void)! + #if DEBUG + private var savedContractViolation: ((String) -> Void)! + #endif + + private var disableCount: Int { recorder.disableCount } + private var enableCount: Int { recorder.enableCount } + private var contractViolations: Int { recorder.contractViolations } + private var sheetParents: [ObjectIdentifier: NSWindow] { + get { recorder.sheetParents } + set { recorder.sheetParents = newValue } + } + + override func setUp() { + super.setUp() + Self.sheetsUp = [] + recorder = Recorder() + savedHasAttachedSheet = EditCloseGuard.hasAttachedSheet + savedSheetParent = EditCloseGuard.sheetParent + savedDisable = EditCloseGuard.disableAutomaticTermination + savedEnable = EditCloseGuard.enableAutomaticTermination + let recorder = recorder + EditCloseGuard.hasAttachedSheet = { Self.sheetsUp.contains(ObjectIdentifier($0)) } + EditCloseGuard.sheetParent = { [weak recorder] in recorder?.sheetParents[ObjectIdentifier($0)] } + EditCloseGuard.disableAutomaticTermination = { [weak recorder] _ in recorder?.disableCount += 1 } + EditCloseGuard.enableAutomaticTermination = { [weak recorder] _ in recorder?.enableCount += 1 } + #if DEBUG + savedContractViolation = EditCloseGuard.contractViolation + EditCloseGuard.contractViolation = { [weak recorder] _ in recorder?.contractViolations += 1 } + #endif + } + + override func tearDown() { + EditCloseGuard.hasAttachedSheet = savedHasAttachedSheet + EditCloseGuard.sheetParent = savedSheetParent + EditCloseGuard.disableAutomaticTermination = savedDisable + EditCloseGuard.enableAutomaticTermination = savedEnable + #if DEBUG + EditCloseGuard.contractViolation = savedContractViolation + #endif + Self.sheetsUp = [] + super.tearDown() + } + + private func makeWindow() -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled, .closable], + backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + return window + } + + /// `windowWillClose` is the observable sign that `close()` ran — the + /// window is never visible, so `isVisible` cannot tell. + private func closeCounter(for window: NSWindow) -> () -> Int { + final class Box { var count = 0; var token: NSObjectProtocol? } + let box = Box() + box.token = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: window, queue: nil + ) { _ in box.count += 1 } + addTeardownBlock { if let token = box.token { NotificationCenter.default.removeObserver(token) } } + return { box.count } + } + + /// Lets the guard's next-turn work (clearing `isResolved`) run. + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 2) + } + + // MARK: - Installation and forwarding + + func testInstallIsIdempotentAndKeepsOriginal() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let first = FakeSession(), second = FakeSession() + + let installed = EditCloseGuard.install(on: window, handlers: first.handlers) + let again = EditCloseGuard.install(on: window, handlers: second.handlers) + + XCTAssertTrue(installed === again) + XCTAssertTrue(EditCloseGuard.guardFor(window) === installed) + XCTAssertTrue(window.delegate === installed) + XCTAssertTrue(installed.original === original, "re-install must not adopt the guard itself as original") + + // The second call replaced the handlers. + second.dirty = true + XCTAssertFalse(installed.windowShouldClose(window)) + XCTAssertEqual(second.confirmCalls, 1) + XCTAssertEqual(first.confirmCalls, 0) + } + + /// Re-install with a latch but no sheet on screen (the session was rebuilt + /// and its sheet is gone): the per-operation state is reset. + func testReinstallResetsPendingState() { + let window = makeWindow() + let first = FakeSession() + first.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: first.handlers) + let closes = closeCounter(for: window) + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertTrue(closeGuard.isConfirming) + let lostCompletion = first.pendingCompletion + first.dropSheet() + + let second = FakeSession() + second.dirty = true + EditCloseGuard.install(on: window, handlers: second.handlers) + XCTAssertFalse(closeGuard.isConfirming) + XCTAssertFalse(closeGuard.isResolved) + + // The superseded confirmation's answer is ignored… + first.dirty = false + lostCompletion?(true) + XCTAssertEqual(closes(), 0) + // …and the new handlers are asked. + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertEqual(second.confirmCalls, 1) + } + + /// Re-install while OUR sheet is up only swaps the handlers: the sheet's + /// answer still lands and ⌘W does not start a second sheet. + func testReinstallWhileSheetPendingKeepsTheConfirmation() { + let window = makeWindow() + let first = FakeSession() + first.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: first.handlers) + let closes = closeCounter(for: window) + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertTrue(closeGuard.isConfirmationPending) + + let second = FakeSession() + second.dirty = true + EditCloseGuard.install(on: window, handlers: second.handlers) + XCTAssertTrue(closeGuard.isConfirmationPending) + + EditCloseGuard.requestClose(window) + XCTAssertEqual(first.confirmCalls + second.confirmCalls, 1, "no second sheet") + + second.dirty = false // the new handlers' session is the one that saved + first.answer(true) + XCTAssertEqual(closes(), 1, "the pending sheet's Save answer is not dropped") + } + + /// The same NSWindow shown again after a close gets a working guard. + func testInstallAfterCloseCreatesFreshGuard() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let first = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + window.close() + XCTAssertTrue(first.isClosed) + XCTAssertTrue(window.delegate === original) + + let session = FakeSession() + session.dirty = true + let fresh = EditCloseGuard.install(on: window, handlers: session.handlers) + XCTAssertFalse(fresh === first) + XCTAssertFalse(fresh.isClosed) + XCTAssertTrue(EditCloseGuard.guardFor(window) === fresh) + XCTAssertTrue(window.delegate === fresh) + XCTAssertTrue(fresh.original === original) + XCTAssertTrue(EditCloseGuard.guardsNeedingReview().contains { $0 === fresh }) + + XCTAssertFalse(fresh.windowShouldClose(window), "a later dirty close asks") + XCTAssertEqual(session.confirmCalls, 1) + } + + func testForwardsUnimplementedDelegateMethodsToOriginal() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + + let didResize = #selector(NSWindowDelegate.windowDidResize(_:)) + let didMiniaturize = #selector(NSWindowDelegate.windowDidMiniaturize(_:)) + XCTAssertTrue(closeGuard.responds(to: didResize), "only the original implements it") + XCTAssertFalse(closeGuard.responds(to: didMiniaturize), "neither implements it") + + let delegate: NSWindowDelegate = closeGuard + delegate.windowDidResize?(Notification(name: NSWindow.didResizeNotification, object: window)) + XCTAssertEqual(original.didResizeCalls, 1) + } + + // MARK: - windowShouldClose + + func testCleanSessionForwardsOriginalAnswer() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let session = FakeSession() + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + + original.shouldCloseAnswer = true + XCTAssertTrue(closeGuard.windowShouldClose(window)) + original.shouldCloseAnswer = false + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertEqual(original.shouldCloseCalls, 2) + XCTAssertEqual(session.confirmCalls, 0) + } + + func testCleanSessionWithoutOriginalAllowsClose() { + let window = makeWindow() + let session = FakeSession() + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + XCTAssertTrue(closeGuard.windowShouldClose(window)) + } + + func testDirtySessionConfirmsAndClosesOnTrue() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertEqual(session.confirmCalls, 1) + XCTAssertEqual(original.shouldCloseCalls, 0, "a dirty close is not forwarded") + XCTAssertEqual(closes(), 0) + + session.dirty = false // the session saved + session.answer(true) + XCTAssertEqual(closes(), 1) + XCTAssertEqual(session.resolveCalls, 0) + #if DEBUG + XCTAssertEqual(contractViolations, 0) + #endif + } + + func testDirtySessionCancelKeepsWindow() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + + XCTAssertFalse(closeGuard.windowShouldClose(window)) + session.answer(false) + XCTAssertEqual(closes(), 0) + XCTAssertFalse(closeGuard.isConfirming) + + // A later request asks again. + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertEqual(session.confirmCalls, 2) + } + + func testSecondRequestWhilePendingDoesNotConfirmAgain() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + + XCTAssertFalse(closeGuard.windowShouldClose(window)) + XCTAssertFalse(closeGuard.windowShouldClose(window)) + EditCloseGuard.requestClose(window) + XCTAssertEqual(session.confirmCalls, 1) + XCTAssertTrue(closeGuard.isConfirmationPending) + } + + func testStaleConfirmationLatchHeals() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + + XCTAssertFalse(closeGuard.windowShouldClose(window)) + let lostCompletion = session.pendingCompletion + session.dropSheet() // sheet gone, completion never fired + XCTAssertTrue(closeGuard.isConfirming) + XCTAssertFalse(closeGuard.isConfirmationPending) + + EditCloseGuard.requestClose(window) + XCTAssertEqual(session.confirmCalls, 2, "a stale latch starts a fresh confirmation") + + session.dirty = false + lostCompletion?(true) + XCTAssertEqual(closes(), 0, "the abandoned confirmation's answer is ignored") + session.answer(true) + XCTAssertEqual(closes(), 1) + } + + // MARK: - requestClose + + func testRequestCloseNilWindowDoesNothing() { + XCTAssertEqual(EditCloseGuard.closeRequest(for: nil), .ignore) + EditCloseGuard.requestClose(nil) // must not crash + } + + func testRequestCloseWithoutGuardCloses() { + let window = makeWindow() + let closes = closeCounter(for: window) + XCTAssertEqual(EditCloseGuard.closeRequest(for: window), .close(window)) + EditCloseGuard.requestClose(window) + XCTAssertEqual(closes(), 1) + } + + func testRequestCloseCleanGuardCloses() { + let window = makeWindow() + let session = FakeSession() + EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + + EditCloseGuard.requestClose(window) + XCTAssertEqual(closes(), 1) + XCTAssertEqual(session.confirmCalls, 0) + XCTAssertEqual(session.resolveCalls, 0) + } + + func testRequestCloseDirtyGuardConfirms() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + + XCTAssertEqual(EditCloseGuard.closeRequest(for: window), .confirm(window)) + EditCloseGuard.requestClose(window) + XCTAssertEqual(session.confirmCalls, 1) + XCTAssertEqual(closes(), 0) + XCTAssertEqual(EditCloseGuard.closeRequest(for: window), .busy(window)) + + session.answer(false) + XCTAssertEqual(closes(), 0) + + EditCloseGuard.requestClose(window) + session.dirty = false + session.answer(true) + XCTAssertEqual(closes(), 1) + } + + /// ⌘W while the sheet is up: the key window is the SHEET. The request must + /// target the parent and never close the sheet. + func testCloseRequestFromSheetTargetsParent() { + let parent = makeWindow(), sheet = makeWindow() + sheetParents[ObjectIdentifier(sheet)] = parent + let session = FakeSession() + session.dirty = true + EditCloseGuard.install(on: parent, handlers: session.handlers) + let sheetCloses = closeCounter(for: sheet) + let parentCloses = closeCounter(for: parent) + + XCTAssertEqual(EditCloseGuard.closeRequest(for: sheet), .confirm(parent)) + EditCloseGuard.requestClose(sheet) + XCTAssertEqual(session.confirmCalls, 1) + + XCTAssertEqual(EditCloseGuard.closeRequest(for: sheet), .busy(parent)) + EditCloseGuard.requestClose(sheet) // beeps + XCTAssertEqual(session.confirmCalls, 1) + XCTAssertEqual(sheetCloses(), 0) + XCTAssertEqual(parentCloses(), 0) + + session.dirty = false + session.answer(true) + XCTAssertEqual(parentCloses(), 1) + } + + func testCloseRequestFromSheetOfCleanWindowKeepsTodaysBehaviour() { + let parent = makeWindow(), sheet = makeWindow() + sheetParents[ObjectIdentifier(sheet)] = parent + EditCloseGuard.install(on: parent, handlers: FakeSession().handlers) + XCTAssertEqual(EditCloseGuard.closeRequest(for: sheet), .close(sheet)) + } + + // MARK: - willClose fallback and leaving the chain + + func testWillCloseWhileDirtyResolvesExactlyOnce() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + EditCloseGuard.install(on: window, handlers: session.handlers) + + window.close() // bypasses the delegate, like NSApp.keyWindow?.close() did + XCTAssertEqual(session.resolveCalls, 1) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + XCTAssertEqual(session.resolveCalls, 1) + } + + func testWillCloseAfterConfirmedCloseDoesNotResolve() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + + XCTAssertFalse(closeGuard.windowShouldClose(window)) + // Even if the session (wrongly) still reports dirty after "Don't + // Save", the confirmed close must not prompt a second time. + session.answer(true) + XCTAssertEqual(closes(), 1) + XCTAssertEqual(session.resolveCalls, 0) + #if DEBUG + XCTAssertEqual(contractViolations, 1, "debug builds flag the broken contract") + #endif + } + + func testWillCloseWhileCleanDoesNotResolve() { + let window = makeWindow() + let session = FakeSession() + EditCloseGuard.install(on: window, handlers: session.handlers) + window.close() + XCTAssertEqual(session.resolveCalls, 0) + } + + func testCloseRestoresOriginalDelegateAndLeavesRegistry() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + XCTAssertTrue(EditCloseGuard.liveGuards().contains { $0 === closeGuard }) + + window.close() + + XCTAssertTrue(window.delegate === original) + XCTAssertEqual(original.willCloseCalls, 1, "the original still sees windowWillClose, once") + XCTAssertTrue(closeGuard.isClosed) + XCTAssertFalse(EditCloseGuard.liveGuards().contains { $0 === closeGuard }) + XCTAssertFalse(EditCloseGuard.guardsNeedingReview().contains { $0 === closeGuard }, + "a closed but still alive window is never reviewed") + } + + /// Delegate re-installed by setEdited → the delegate's notification + /// registration now comes after the guard's backstop observer. The + /// original must still get windowWillClose exactly once. + func testCloseAfterReattachStillDeliversWillCloseOnce() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + let replacement = RecordingDelegate() + window.delegate = replacement + closeGuard.setEdited(false) + XCTAssertTrue(window.delegate === closeGuard) + + window.close() + XCTAssertEqual(replacement.willCloseCalls, 1) + XCTAssertEqual(original.willCloseCalls, 0) + XCTAssertTrue(window.delegate === replacement) + } + + func testOriginalDeallocatedBeforeCloseDoesNotCrash() { + let window = makeWindow() + weak var weakOriginal: RecordingDelegate? + var closeGuard: EditCloseGuard! + autoreleasepool { + let original = RecordingDelegate() // implements windowWillClose + weakOriginal = original + window.delegate = original + closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + } + XCTAssertNil(weakOriginal) + XCTAssertNil(closeGuard.original) + + window.close() // windowWillClose: must not reach a dead forwarding target + XCTAssertNil(window.delegate) + XCTAssertTrue(closeGuard.isClosed) + } + + // MARK: - setEdited and automatic termination + + func testSetEditedMirrorsDocumentEdited() { + let window = makeWindow() + let closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + closeGuard.setEdited(true) + XCTAssertTrue(window.isDocumentEdited) + closeGuard.setEdited(false) + XCTAssertFalse(window.isDocumentEdited) + } + + func testSetEditedReinstallsAfterDelegateReplaced() { + let window = makeWindow() + let original = RecordingDelegate() + window.delegate = original + let closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + + let replacement = RecordingDelegate() + window.delegate = replacement + closeGuard.setEdited(true) + + XCTAssertTrue(window.delegate === closeGuard) + XCTAssertTrue(closeGuard.original === replacement) + XCTAssertTrue(window.isDocumentEdited) + + let delegate: NSWindowDelegate = closeGuard + delegate.windowDidResize?(Notification(name: NSWindow.didResizeNotification, object: window)) + XCTAssertEqual(replacement.didResizeCalls, 1) + XCTAssertEqual(original.didResizeCalls, 0) + } + + func testAutomaticTerminationBalancedAcrossTransitionsAndClose() { + let window = makeWindow() + let closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + + closeGuard.setEdited(true) + XCTAssertEqual(disableCount, 1) + closeGuard.setEdited(true) + XCTAssertEqual(disableCount, 1, "a repeated true is not a transition") + closeGuard.setEdited(false) + XCTAssertEqual(enableCount, 1) + closeGuard.setEdited(false) + XCTAssertEqual(enableCount, 1) + closeGuard.setEdited(true) + XCTAssertEqual(disableCount, 2) + + window.close() + XCTAssertEqual(enableCount, 2, "closing gives the disable back") + closeGuard.setEdited(true) + XCTAssertEqual(disableCount, 2, "a closed guard never disables again") + XCTAssertEqual(disableCount, enableCount) + } + + func testAutomaticTerminationReenabledWhenDeallocatedWhileDirty() { + weak var weakGuard: EditCloseGuard? + autoreleasepool { + let window = makeWindow() + let closeGuard = EditCloseGuard.install(on: window, handlers: FakeSession().handlers) + weakGuard = closeGuard + closeGuard.setEdited(true) + } + XCTAssertNil(weakGuard) + XCTAssertEqual(disableCount, 1) + XCTAssertEqual(enableCount, 1) + } + + // MARK: - Lifetime + + func testGuardDoesNotKeepWindowAlive() { + weak var weakWindow: NSWindow? + weak var weakGuard: EditCloseGuard? + let session = FakeSession() + session.dirty = true + autoreleasepool { + let window = makeWindow() + weakWindow = window + weakGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + XCTAssertTrue(EditCloseGuard.guardsNeedingReview().contains { $0 === weakGuard }) + } + XCTAssertNil(weakWindow) + XCTAssertNil(weakGuard) + XCTAssertTrue(EditCloseGuard.guardsNeedingReview().allSatisfy { $0.window != nil }) + } + + func testResolvedFlagLastsOnlyForTheConfirmedStep() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + let clean = makeWindow() + let cleanGuard = EditCloseGuard.install(on: clean, handlers: FakeSession().handlers) + + XCTAssertTrue(EditCloseGuard.guardsNeedingReview().contains { $0 === closeGuard }) + XCTAssertFalse(EditCloseGuard.guardsNeedingReview().contains { $0 === cleanGuard }) + + // Quit-path style confirmation (no close follows), session stays dirty. + _ = QuickMDAppDelegate.reviewBeforeQuit([closeGuard], bringForward: { _ in }, terminateAgain: {}) + session.answer(true) + XCTAssertTrue(closeGuard.isResolved) + XCTAssertFalse(closeGuard.needsReview) + + drainMainQueue() + XCTAssertFalse(closeGuard.isResolved, "cleared on the next main-queue turn") + XCTAssertTrue(closeGuard.needsReview) + } + + // MARK: - Quit decision + + func testQuitWithoutPendingGuardsTerminatesNow() { + var terminateRequests = 0 + let reply = QuickMDAppDelegate.reviewBeforeQuit([], bringForward: { _ in XCTFail("nothing to show") }, + terminateAgain: { terminateRequests += 1 }) + XCTAssertEqual(reply, .terminateNow) + XCTAssertEqual(terminateRequests, 0) + } + + /// The real input of applicationShouldTerminate with open windows that are + /// clean, or were dirty and saved: nothing is reviewed. + func testQuitWithOnlyCleanGuardsTerminatesNow() { + let clean = makeWindow(), saved = makeWindow() + let cleanSession = FakeSession(), savedSession = FakeSession() + savedSession.dirty = true + EditCloseGuard.install(on: clean, handlers: cleanSession.handlers) + let savedGuard = EditCloseGuard.install(on: saved, handlers: savedSession.handlers) + savedGuard.setEdited(true) + savedSession.dirty = false + savedGuard.setEdited(false) + + let reply = QuickMDAppDelegate.reviewBeforeQuit( + EditCloseGuard.guardsNeedingReview(), + bringForward: { _ in XCTFail("nothing to show") }, terminateAgain: { XCTFail("no retry") }) + XCTAssertEqual(reply, .terminateNow) + XCTAssertEqual(cleanSession.confirmCalls + savedSession.confirmCalls, 0) + } + + func testQuitWithDirtyGuardCancelsConfirmsAndRetries() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + var shown: [NSWindow] = [] + var terminateRequests = 0 + + let reply = QuickMDAppDelegate.reviewBeforeQuit([closeGuard], bringForward: { shown.append($0) }, + terminateAgain: { terminateRequests += 1 }) + XCTAssertEqual(reply, .terminateCancel) + XCTAssertEqual(shown.count, 1) + XCTAssertTrue(shown.first === window) + XCTAssertEqual(session.confirmCalls, 1) + + // Second ⌘Q while the sheet is up: still cancelled, no second sheet. + let again = QuickMDAppDelegate.reviewBeforeQuit([closeGuard], bringForward: { shown.append($0) }, + terminateAgain: { terminateRequests += 1 }) + XCTAssertEqual(again, .terminateCancel) + XCTAssertEqual(session.confirmCalls, 1) + XCTAssertEqual(shown.count, 2, "the pending sheet is brought forward again") + + session.dirty = false + session.answer(true) + XCTAssertEqual(terminateRequests, 1) + XCTAssertFalse(EditCloseGuard.guardsNeedingReview().contains { $0 === closeGuard }) + } + + func testQuitCancelledStopsTerminating() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + var terminateRequests = 0 + + let reply = QuickMDAppDelegate.reviewBeforeQuit([closeGuard], bringForward: { _ in }, + terminateAgain: { terminateRequests += 1 }) + XCTAssertEqual(reply, .terminateCancel) + session.answer(false) + XCTAssertEqual(terminateRequests, 0) + XCTAssertTrue(EditCloseGuard.guardsNeedingReview().contains { $0 === closeGuard }) + } + + /// A confirms → terminate again → B asked → terminate again → terminate now. + func testQuitChainAcrossTwoDirtyGuards() { + let windowA = makeWindow(), windowB = makeWindow() + let sessionA = FakeSession(), sessionB = FakeSession() + sessionA.dirty = true + sessionB.dirty = true + let guardA = EditCloseGuard.install(on: windowA, handlers: sessionA.handlers) + let guardB = EditCloseGuard.install(on: windowB, handlers: sessionB.handlers) + var shown: [NSWindow] = [] + var replies: [NSApplication.TerminateReply] = [] + + func terminate() { + let pending = EditCloseGuard.guardsNeedingReview().filter { $0 === guardA || $0 === guardB } + replies.append(QuickMDAppDelegate.reviewBeforeQuit(pending, bringForward: { shown.append($0) }, + terminateAgain: terminate)) + } + + terminate() + XCTAssertEqual(replies, [.terminateCancel]) + XCTAssertTrue(shown.last === windowA) + + sessionA.dirty = false + sessionA.answer(true) + XCTAssertEqual(replies, [.terminateCancel, .terminateCancel]) + XCTAssertTrue(shown.last === windowB) + XCTAssertEqual(sessionB.confirmCalls, 1) + + sessionB.dirty = false + sessionB.answer(true) + XCTAssertEqual(replies, [.terminateCancel, .terminateCancel, .terminateNow]) + XCTAssertEqual(sessionA.confirmCalls, 1) + } + + /// Two dirty tabs, ⌘Q, Don't Save on A (session wrongly stays dirty), + /// Cancel on B, the user keeps editing A and clicks its red button: A must + /// be asked again. + func testResolvedQuitAnswerDoesNotLeakIntoLaterClose() { + let windowA = makeWindow(), windowB = makeWindow() + let sessionA = FakeSession(), sessionB = FakeSession() + sessionA.dirty = true + sessionB.dirty = true + let guardA = EditCloseGuard.install(on: windowA, handlers: sessionA.handlers) + let guardB = EditCloseGuard.install(on: windowB, handlers: sessionB.handlers) + let closesA = closeCounter(for: windowA) + var replies: [NSApplication.TerminateReply] = [] + + func terminate() { + let pending = EditCloseGuard.guardsNeedingReview().filter { $0 === guardA || $0 === guardB } + replies.append(QuickMDAppDelegate.reviewBeforeQuit(pending, bringForward: { _ in }, + terminateAgain: terminate)) + } + + terminate() + sessionA.answer(true) // Don't Save on A, still dirty + XCTAssertEqual(sessionB.confirmCalls, 1) + sessionB.answer(false) // Cancel on B: the quit ends + XCTAssertEqual(replies, [.terminateCancel, .terminateCancel]) + + drainMainQueue() + XCTAssertFalse(guardA.isResolved) + XCTAssertFalse(guardA.windowShouldClose(windowA), "A is asked again") + XCTAssertEqual(sessionA.confirmCalls, 2) + XCTAssertEqual(closesA(), 0) + } + + /// ⌘Q while a close confirmation is up: cancel, show that sheet, queue nothing. + func testQuitWhileCloseConfirmationPendingShowsThatSheet() { + let window = makeWindow() + let session = FakeSession() + session.dirty = true + let closeGuard = EditCloseGuard.install(on: window, handlers: session.handlers) + let closes = closeCounter(for: window) + XCTAssertFalse(closeGuard.windowShouldClose(window)) + + var shown: [NSWindow] = [] + var terminateRequests = 0 + let reply = QuickMDAppDelegate.reviewBeforeQuit(EditCloseGuard.guardsNeedingReview(), + bringForward: { shown.append($0) }, + terminateAgain: { terminateRequests += 1 }) + XCTAssertEqual(reply, .terminateCancel) + XCTAssertTrue(shown.first === window) + XCTAssertEqual(session.confirmCalls, 1) + + session.dirty = false + session.answer(true) + XCTAssertEqual(closes(), 1) + XCTAssertEqual(terminateRequests, 0) + } + + // MARK: - Alert + + func testUnsavedAlertButtonsAndIdentifiers() { + let sheet = UnsavedChangesAlert.makeAlert(fileName: "notes.md", allowsCancel: true) + XCTAssertEqual(sheet.messageText, "Do you want to save the changes you made to “notes.md”?") + XCTAssertEqual(sheet.informativeText, "Your changes will be lost if you don’t save them.") + XCTAssertEqual(sheet.buttons.map(\.title), ["Save", "Don’t Save", "Cancel"]) + XCTAssertEqual(sheet.buttons.map { $0.accessibilityIdentifier() }, + ["source-unsaved-save", "source-unsaved-discard", "source-unsaved-cancel"]) + XCTAssertEqual(sheet.buttons[0].keyEquivalent, "\r") + XCTAssertEqual(sheet.buttons[2].keyEquivalent, "\u{1b}") + + let modal = UnsavedChangesAlert.makeAlert(fileName: "notes.md", allowsCancel: false) + XCTAssertEqual(modal.buttons.map(\.title), ["Save", "Don’t Save"]) + + XCTAssertEqual(UnsavedChangesAlert.choice(for: .alertFirstButtonReturn), .save) + XCTAssertEqual(UnsavedChangesAlert.choice(for: .alertSecondButtonReturn), .discard) + XCTAssertEqual(UnsavedChangesAlert.choice(for: .alertThirdButtonReturn), .cancel) + } + + func testModalVariantNeverDiscardsWithoutExplicitClick() { + XCTAssertEqual(UnsavedChangesAlert.modalChoice(for: .alertFirstButtonReturn), .save) + XCTAssertEqual(UnsavedChangesAlert.modalChoice(for: .alertSecondButtonReturn), .discard) + XCTAssertEqual(UnsavedChangesAlert.modalChoice(for: .abort), .save) + XCTAssertEqual(UnsavedChangesAlert.modalChoice(for: .stop), .save) + XCTAssertEqual(UnsavedChangesAlert.modalChoice(for: .cancel), .save) + } +} From f8c8269fac7919b2466901557e0970031335a837 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 14:36:25 +0200 Subject: [PATCH 04/17] feat(source-edit): source editor surface (NSTextView, own undo, column geometry) SourceEditorController builds a TextKit 1 plain-text editor in the rendered document's column, with its own UndoManager (never the window's, which belongs to SwiftUI's autosaving NSDocument), CR-free buffer, indentation edits from SourceEditSupport as single undo steps, the system find bar, and caret / scroll-to-line API for the edit session. Not mounted yet. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 14 + QuickMD/QuickMD/SourceEditorController.swift | 656 +++++++++++++++++ QuickMD/QuickMD/Views/SourceEditorView.swift | 20 + .../SourceEditorControllerTests.swift | 666 ++++++++++++++++++ 4 files changed, 1356 insertions(+) create mode 100644 QuickMD/QuickMD/SourceEditorController.swift create mode 100644 QuickMD/QuickMD/Views/SourceEditorView.swift create mode 100644 QuickMD/QuickMDTests/SourceEditorControllerTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index 4e4d30c..3e7dccc 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -130,6 +130,10 @@ A1B2C3D401000130 /* SourceEditSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000130 /* SourceEditSupport.swift */; }; A1B2C3D401000131 /* SourceEditSupport.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000130 /* SourceEditSupport.swift */; }; A1B2C3D401000132 /* SourceEditSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000131 /* SourceEditSupportTests.swift */; }; + A1B2C3D401000150 /* SourceEditorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000150 /* SourceEditorController.swift */; }; + A1B2C3D401000151 /* SourceEditorController.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000150 /* SourceEditorController.swift */; }; + A1B2C3D401000152 /* SourceEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000151 /* SourceEditorView.swift */; }; + A1B2C3D401000153 /* SourceEditorControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000152 /* SourceEditorControllerTests.swift */; }; A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000F0 /* HeadingLayoutTests.swift */; }; /* End PBXBuildFile section */ @@ -157,6 +161,9 @@ A1B2C3D400000123 /* DocumentFileWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileWriterTests.swift; sourceTree = ""; }; A1B2C3D400000130 /* SourceEditSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSupport.swift; sourceTree = ""; }; A1B2C3D400000131 /* SourceEditSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSupportTests.swift; sourceTree = ""; }; + A1B2C3D400000150 /* SourceEditorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorController.swift; sourceTree = ""; }; + A1B2C3D400000151 /* SourceEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorView.swift; sourceTree = ""; }; + A1B2C3D400000152 /* SourceEditorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorControllerTests.swift; sourceTree = ""; }; A1B2C3D4000000F0 /* HeadingLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingLayoutTests.swift; sourceTree = ""; }; A1B2C3D400000001 /* QuickMDApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMDApp.swift; sourceTree = ""; }; A1B2C3D400000002 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; @@ -288,6 +295,7 @@ A1B2C3D400000122 /* DocumentFileFormatTests.swift */, A1B2C3D400000123 /* DocumentFileWriterTests.swift */, A1B2C3D400000131 /* SourceEditSupportTests.swift */, + A1B2C3D400000152 /* SourceEditorControllerTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -311,6 +319,7 @@ A1B2C3D400000120 /* DocumentFileFormat.swift */, A1B2C3D400000121 /* DocumentFileWriter.swift */, A1B2C3D400000130 /* SourceEditSupport.swift */, + A1B2C3D400000150 /* SourceEditorController.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -360,6 +369,7 @@ A1B2C3D400000063 /* FontPickerView.swift */, A1B2C3D40000006C /* VirtualBlockList.swift */, A1B2C3D40000006D /* SVGBlockView.swift */, + A1B2C3D400000151 /* SourceEditorView.swift */, ); path = Views; sourceTree = ""; @@ -597,6 +607,8 @@ A1B2C3D401000120 /* DocumentFileFormat.swift in Sources */, A1B2C3D401000122 /* DocumentFileWriter.swift in Sources */, A1B2C3D401000130 /* SourceEditSupport.swift in Sources */, + A1B2C3D401000150 /* SourceEditorController.swift in Sources */, + A1B2C3D401000152 /* SourceEditorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -644,6 +656,8 @@ A1B2C3D401000125 /* DocumentFileWriterTests.swift in Sources */, A1B2C3D401000131 /* SourceEditSupport.swift in Sources (Tests) */, A1B2C3D401000132 /* SourceEditSupportTests.swift in Sources */, + A1B2C3D401000151 /* SourceEditorController.swift in Sources (Tests) */, + A1B2C3D401000153 /* SourceEditorControllerTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift new file mode 100644 index 0000000..a6037df --- /dev/null +++ b/QuickMD/QuickMD/SourceEditorController.swift @@ -0,0 +1,656 @@ +import AppKit +import SwiftUI + +// MARK: - Source editor (v1.12 S-D1, S-D3–S-D6, S-D13, S-D15) +// +// The raw-Markdown editing surface: one NSTextView in an NSScrollView, plus the +// small controller API the Source Edit session drives. AppKit only — the +// SwiftUI wrapper (`Views/SourceEditorView.swift`) is nothing but wiring — so +// this file is compiled into BOTH the app and the test target and the whole +// editor is exercised headlessly. +// +// Deliberately NOT built from the document's text machinery +// (`SelfSizingTextView`, `configureForSelfSizing`, the selection layout +// manager, the link delegate): all of that is read-only by design and refuses +// first responder. This is a plain, editable, plain-text TextKit 1 view. +// +// Main-thread by convention, deliberately NOT @MainActor — SwiftUI view +// callbacks that create/drive this are nonisolated on the older SDK the CI +// runner builds with (constraints "CI builds on an OLDER SDK"). + +/// The editor's text view. A subclass only so it has its own type in the view +/// hierarchy (accessibility, debugging); all behaviour lives in the +/// controller, which is its delegate. +final class SourceTextView: NSTextView {} + +/// The editor's scroll view. Reports `tile()` — the one place AppKit lays out +/// the clip view after a resize, a scroller change or the find bar appearing — +/// so the column geometry is recomputed from the clip width it just set; the +/// end of a live resize, when the deferred exact scroll position lands; and +/// the wheel, which cancels that deferred position (the user moved on). +final class SourceEditorScrollView: NSScrollView { + var onTile: (() -> Void)? + var onEndLiveResize: (() -> Void)? + var onScrollWheel: (() -> Void)? + + override func scrollWheel(with event: NSEvent) { + onScrollWheel?() + super.scrollWheel(with: event) + } + + override func tile() { + super.tile() + onTile?() + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + onEndLiveResize?() + } +} + +/// Owns the editor's text view and everything about it the session needs: +/// the buffer, the caret, scrolling, find, style and its own undo stack. +/// +/// An `NSObject` because it is the text view's delegate. +final class SourceEditorController: NSObject, NSTextViewDelegate { + + /// Everything the editor's look depends on. Equal styles are a no-op in + /// `apply(style:)`, so SwiftUI may hand the same one on every update. + struct Style: Equatable { + var theme: MarkdownTheme + var fontScale: Double + var isReadingLayout: Bool + + /// `MarkdownTheme` is not `Equatable`; compare what the editor reads + /// from it. Colours as well as the name: a custom theme re-imported + /// under the same name may change them. + static func == (lhs: Style, rhs: Style) -> Bool { + lhs.fontScale == rhs.fontScale + && lhs.isReadingLayout == rhs.isReadingLayout + && lhs.theme.name == rhs.theme.name + && lhs.theme.isDark == rhs.theme.isDark + && lhs.theme.textColor == rhs.theme.textColor + && lhs.theme.backgroundColor == rhs.theme.backgroundColor + && lhs.theme.fonts == rhs.theme.fonts + } + } + + /// The user changed the buffer (typing, paste, undo, redo, `replaceAll`). + /// Never fired by `load` or `apply(style:)`. + var onChange: (() -> Void)? + /// Escape reached the text view. Consumed here so it never opens + /// NSTextView's completion list; the session decides what Esc means. + var onEscape: (() -> Void)? + + /// The editor's OWN undo stack, handed to the text view through + /// `undoManager(for:)`. Never `window.undoManager`: that one belongs to + /// SwiftUI's viewer NSDocument, which autosaves in place — if it ever + /// became edited AppKit would write the ORIGINAL text over the file + /// (spec S-D1). + let undoManager = UndoManager() + + /// The editing view. Internal for the session (first-responder checks) + /// and for the tests, which inspect its configuration. + let textView: SourceTextView + + private let textStorage: NSTextStorage + private let layoutManager: NSLayoutManager + private let textContainer: NSTextContainer + private var scrollView: SourceEditorScrollView? + private(set) var style: Style? + + /// `SourceEditSupport.indentUnit(for:)` scans the buffer, so it is cached + /// and dropped on every change: only a Tab that follows an edit pays for + /// a scan, ordinary typing never does. + private var cachedIndentUnit: String? + /// A line-to-top scroll asked for before the view had a width (the + /// session places the caret right after mounting the editor, before the + /// first layout pass). Performed by the first geometry update that can. + private var pendingTopOffset: Int? + private var isUpdatingGeometry = false + + /// The top line being kept across a relayout (width, inset or font + /// change) until its EXACT position lands — see `relayoutKeepingTop`. + private var pendingAnchor: TopAnchor? + /// Where the estimated restore put the clip; if the clip has moved far + /// since, the user scrolled and the exact restore must not yank them back. + private var pendingAnchorClipY: CGFloat = 0 + private var exactRestoreWork: DispatchWorkItem? + /// Quiet time after the last relayout before the exact restore runs — + /// the rendered list's width debounce (`VirtualBlockList`), for the same + /// reason: sidebars animate over 0.2 s and every frame changes the width. + private static let exactRestoreDelay: TimeInterval = 0.1 + + override init() { + // An explicit TextKit 1 stack: an NSTextView created with a container + // that already belongs to an NSLayoutManager never builds TextKit 2. + textStorage = NSTextStorage() + layoutManager = NSLayoutManager() + // Lay out only what is looked at — a 5 MB buffer would otherwise be + // laid out in full on every edit near the top. + layoutManager.allowsNonContiguousLayout = true + textStorage.addLayoutManager(layoutManager) + textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) + // The container's width is set from the column geometry, never from + // the view (`updateGeometry`); padding 0 like the rendered text, so the + // first character sits exactly on the column's edge. + textContainer.widthTracksTextView = false + textContainer.heightTracksTextView = false + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + textView = SourceTextView(frame: .zero, textContainer: textContainer) + super.init() + configureTextView() + } + + private func configureTextView() { + let view = textView + view.delegate = self + view.isEditable = true + view.isSelectable = true + view.allowsUndo = true + // Plain text: the buffer is the file. Nothing may add attributes, + // images or "smart" rewrites the user did not type. + view.isRichText = false + view.importsGraphics = false + view.allowsImageEditing = false + view.usesFontPanel = false + view.usesRuler = false + view.isRulerVisible = false + view.usesInspectorBar = false + view.allowsDocumentBackgroundColorChange = false + view.isAutomaticQuoteSubstitutionEnabled = false + view.isAutomaticDashSubstitutionEnabled = false + view.isAutomaticTextReplacementEnabled = false + view.isAutomaticSpellingCorrectionEnabled = false + view.isAutomaticTextCompletionEnabled = false + view.isAutomaticLinkDetectionEnabled = false + view.isAutomaticDataDetectionEnabled = false + view.smartInsertDeleteEnabled = false + view.isGrammarCheckingEnabled = false + // Off by default; the context menu can still turn it on. + view.isContinuousSpellCheckingEnabled = false + // ⌘F while editing is the text view's own find bar (S-D6). + view.usesFindBar = true + view.isIncrementalSearchingEnabled = true + // Soft wrap at the column edge, no horizontal scrolling. + view.isHorizontallyResizable = false + view.isVerticallyResizable = true + view.minSize = .zero + view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + view.autoresizingMask = [.width] + view.drawsBackground = true + view.setAccessibilityIdentifier("source-editor") + } + + // MARK: - Hierarchy + + /// Builds the scroll view once and returns it on every later call (the + /// representable's `makeNSView`). The text view exists from `init`, so + /// `load` / `text` work before this is ever called. + func makeScrollView(style: Style) -> NSScrollView { + if let scrollView { + apply(style: style) + return scrollView + } + let scrollView = SourceEditorScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + // Same as the rendered list: a scroller that autohides changes the + // clip width, and the column must be the list's column. + scrollView.autohidesScrollers = false + scrollView.borderType = .noBorder + scrollView.drawsBackground = true + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = NSEdgeInsets() + scrollView.documentView = textView + scrollView.onTile = { [weak self] in self?.updateGeometry() } + scrollView.onEndLiveResize = { [weak self] in self?.finishRelayout() } + scrollView.onScrollWheel = { [weak self] in self?.dropPendingAnchor() } + self.scrollView = scrollView + apply(style: style) + return scrollView + } + + // MARK: - Style + + /// Theme / zoom / layout changes. Attribute-only: the storage is restyled + /// directly (no `shouldChangeText`), so nothing is registered for undo and + /// `onChange` does not fire; the selection is untouched and the line at + /// the top of the visible area stays there. + func apply(style newStyle: Style) { + guard newStyle != style else { return } + let fontChanged = style.map { Self.font(for: $0) != Self.font(for: newStyle) } ?? true + let colorChanged = style.map { $0.theme.textColor != newStyle.theme.textColor } ?? true + style = newStyle + + let theme = newStyle.theme + let appearance = NSAppearance(named: theme.isDark ? .darkAqua : .aqua) + // Nothing else in the app sets an appearance: a dark theme on a light + // system would get light scrollers, selection and find bar otherwise. + scrollView?.appearance = appearance + textView.appearance = appearance + let background = NSColor(theme.backgroundColor) + textView.backgroundColor = background + scrollView?.backgroundColor = background + textView.insertionPointColor = NSColor(theme.textColor) + + if fontChanged || colorChanged { + relayoutKeepingTop { + let attributes = baseAttributes + textView.typingAttributes = attributes + if textStorage.length > 0 { + textStorage.beginEditing() + textStorage.setAttributes(attributes, range: NSRange(location: 0, length: textStorage.length)) + textStorage.endEditing() + } + } + } + updateGeometry() + } + + /// The code font at the current zoom — the same face and size as the + /// rendered code blocks (`CodeBlockView`, `BlockLayout.Code`). + private static func font(for style: Style) -> NSFont { + style.theme.fonts.appKit(size: BlockLayout.Code.codeFontSize * CGFloat(style.fontScale), + monospaced: true) + } + + /// Font + colour for the whole buffer (plain text: one run). + private var baseAttributes: [NSAttributedString.Key: Any] { + guard let style else { return [:] } + return [.font: Self.font(for: style), .foregroundColor: NSColor(style.theme.textColor)] + } + + // MARK: - Column geometry + + /// Where the text column sits in a clip view of a given width. + struct ColumnGeometry: Equatable { + /// `textContainerInset.width`: the column's left edge. + let horizontalInset: CGFloat + /// The text container's width: where lines wrap. + let columnWidth: CGFloat + /// `textContainerInset.height`: air above the first and below the last line. + let verticalInset: CGFloat + } + + /// The rendered document's column, reproduced so the text does not jump + /// sideways when the mode toggles. `VirtualBlockList.currentContentWidth()` + /// computes `inner = floor(clip) − 2 × contentHorizontalPadding` (32 pt) + /// and, in reading mode, `floor(min(inner, 720))`; its cells centre that + /// column in the `floor(clip)`-wide table column, i.e. the column starts + /// at `(floor(clip) − column) / 2` — 32 pt exactly in the standard layout, + /// never less in reading mode. The vertical gap is the list's visible gap + /// above the first block (`LayoutStyle.verticalPadding`: 24 / 48 pt). + static func columnGeometry(clipWidth: CGFloat, isReadingLayout: Bool) -> ColumnGeometry { + let available = floor(max(clipWidth, 0)) + let inner = available - 2 * BlockLayout.Document.contentHorizontalPadding + let column = isReadingLayout + ? max(0, floor(min(inner, BlockLayout.Document.readingMaxContentWidth))) + : max(0, inner) + let vertical = isReadingLayout + ? BlockLayout.Document.readingContentVerticalPadding + : BlockLayout.Document.contentVerticalPadding + return ColumnGeometry(horizontalInset: max(0, (available - column) / 2), + columnWidth: column, verticalInset: vertical) + } + + /// Applies `columnGeometry` for the current clip width. Called from the + /// scroll view's `tile()` (resizes) and from `apply(style:)` (reading + /// layout toggles). Cheap when nothing changed; a new column keeps the + /// top line (`relayoutKeepingTop`). + private func updateGeometry() { + guard !isUpdatingGeometry, let scrollView, let style else { return } + isUpdatingGeometry = true + defer { isUpdatingGeometry = false } + let clip = scrollView.contentView.bounds.size + let geometry = Self.columnGeometry(clipWidth: clip.width, isReadingLayout: style.isReadingLayout) + let inset = NSSize(width: geometry.horizontalInset, height: geometry.verticalInset) + // At least as tall as the clip, so a click below a short text still + // lands in the text view (and places the caret at the end). + textView.minSize = NSSize(width: 0, height: clip.height) + if textView.frame.width != clip.width || textView.frame.height < clip.height { + textView.setFrameSize(NSSize(width: clip.width, height: max(textView.frame.height, clip.height))) + } + if textView.textContainerInset != inset || textContainer.size.width != geometry.columnWidth { + relayoutKeepingTop { + if textView.textContainerInset != inset { + textView.textContainerInset = inset + } + if textContainer.size.width != geometry.columnWidth { + textContainer.size = NSSize(width: geometry.columnWidth, + height: CGFloat.greatestFiniteMagnitude) + } + } + } + if geometry.columnWidth > 0, let offset = pendingTopOffset { + pendingTopOffset = nil + scrollToTop(characterOffset: offset) + } + } + + /// Runs a change that invalidates the layout (column width, inset, font) + /// and keeps the line at the top of the visible area where it was. + /// + /// In two steps, because exact costs: after a relayout, the exact position + /// of a line N characters in needs those N characters laid out again — + /// measured ~1.7 s for the end of a 5 MB buffer — and a resize or a + /// sidebar animation changes the width every frame. So every change + /// restores the anchor at once through the layout manager's ESTIMATE + /// (non-contiguous layout lays out only around it, ~1 ms: the same text + /// stays on screen, only the scroller is approximate), and the exact + /// position lands once the changes stop — 100 ms later, or at the end of + /// a live resize — unless the user has scrolled in between. + private func relayoutKeepingTop(_ change: () -> Void) { + let anchor = pendingAnchor ?? topVisibleAnchor() + change() + guard let anchor, textContainer.size.width > 0, textStorage.length > 0 else { + pendingAnchor = nil + return + } + let glyph = layoutManager.glyphIndexForCharacter(at: min(anchor.character, textStorage.length - 1)) + let estimatedTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY + scrollLaidOut(toY: estimatedTop - anchor.offset) + // Laying out the screen around it can refine the line's estimate; + // follow it once so the same text is really at the top. + let refinedTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil, + withoutAdditionalLayout: true).minY + if refinedTop != estimatedTop { scrollClip(toY: refinedTop - anchor.offset) } + pendingAnchor = anchor + pendingAnchorClipY = scrollView?.contentView.bounds.minY ?? 0 + exactRestoreWork?.cancel() + exactRestoreWork = nil + // During a live resize the exact restore waits for its end + // (`onEndLiveResize`): a pause in the drag must not stall it. + guard scrollView?.inLiveResize != true else { return } + let work = DispatchWorkItem { [weak self] in self?.finishRelayout() } + exactRestoreWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.exactRestoreDelay, execute: work) + } + + /// The exact half of `relayoutKeepingTop`. + private func finishRelayout() { + exactRestoreWork?.cancel() + exactRestoreWork = nil + guard let anchor = pendingAnchor else { return } + pendingAnchor = nil + // AppKit itself nudges the clip by a line or two while background + // layout refines the estimate; more than half a screen is a scroll + // by the user (scroller drag, keyboard paging — the wheel already + // dropped the anchor), and their position wins. + guard let scrollView else { return } + let clip = scrollView.contentView.bounds + guard abs(clip.minY - pendingAnchorClipY) <= clip.height / 2 else { return } + restore(anchor) + } + + /// An explicit scroll supersedes a top line still being kept. + private func dropPendingAnchor() { + exactRestoreWork?.cancel() + exactRestoreWork = nil + pendingAnchor = nil + } + + // MARK: - Buffer + + /// The buffer (LF only). A copy — for saving and comparing, not per keystroke. + var text: String { textStorage.string } + + /// Replaces everything WITHOUT an undo entry and without `onChange`: + /// entering the editor, adopting a clean external change. The undo stack + /// is cleared (it described a different text), the caret goes to 0. + func load(_ text: String) { + let normalized = MarkdownDocument.normalizeLineEndings(text) + textStorage.beginEditing() + textStorage.replaceCharacters(in: NSRange(location: 0, length: textStorage.length), + with: NSAttributedString(string: normalized, attributes: baseAttributes)) + textStorage.endEditing() + cachedIndentUnit = nil + undoManager.removeAllActions() + textView.setSelectedRange(NSRange(location: 0, length: 0)) + pendingTopOffset = nil + dropPendingAnchor() + scrollClip(toY: 0) + } + + /// Replaces everything as ONE undoable edit (Discard Changes, Load Disk + /// Version — ⌘Z brings the previous text back). Fires `onChange`. The + /// caret keeps its offset where the new text is long enough. + func replaceAll(with text: String) { + let normalized = MarkdownDocument.normalizeLineEndings(text) + let caret = textView.selectedRange().location + let newLength = (normalized as NSString).length + perform(range: NSRange(location: 0, length: textStorage.length), replacement: normalized, + selection: NSRange(location: min(caret, newLength), length: 0), scrollToSelection: false) + } + + // MARK: - Caret and scrolling + + /// 0-based line of the selection start. Scans the buffer up to the caret: + /// for user actions (leaving, saving), not per keystroke. + var caretLine: Int { + SourceEditSupport.line(containing: textView.selectedRange().location, in: textStorage.mutableString) + } + + /// Caret at the start of 0-based `line` (clamped) and that line scrolled + /// to the top of the visible area — where the reader's eyes already are + /// (S-D3). "Top" keeps the column's vertical inset above the line, the + /// same gap a `.top` jump leaves in the rendered list; a line too close to + /// the end to reach the top is shown with the document's end at the bottom. + func placeCaret(atLine line: Int) { + let offset = SourceEditSupport.lineStart(line, in: textStorage.mutableString) + textView.setSelectedRange(NSRange(location: offset, length: 0)) + scrollToTop(characterOffset: offset) + } + + /// Selects `range` (clamped) and scrolls it into view. + func select(_ range: NSRange) { + let length = textStorage.length + let location = min(max(range.location, 0), length) + let clamped = NSRange(location: location, length: min(max(range.length, 0), length - location)) + textView.setSelectedRange(clamped) + dropPendingAnchor() + textView.scrollRangeToVisible(clamped) + } + + /// 0-based `line` at the top of the visible area, caret untouched (ToC + /// clicks while editing, S-D13). + func scroll(toLine line: Int) { + scrollToTop(characterOffset: SourceEditSupport.lineStart(line, in: textStorage.mutableString)) + } + + func focus() { + textView.window?.makeFirstResponder(textView) + } + + /// Scrolls so the line containing `offset` starts `verticalInset` below + /// the top of the visible area: the clip's origin goes to the line's top + /// in CONTAINER coordinates (`textContainerOrigin.y` is that inset). + private func scrollToTop(characterOffset offset: Int) { + guard scrollView != nil, textContainer.size.width > 0 else { + pendingTopOffset = offset + return + } + pendingTopOffset = nil + dropPendingAnchor() + scrollLaidOut(toY: lineTop(atCharacter: offset)) + } + + /// Scrolls the clip to container-y `y` after laying out the screenful it + /// will show: near the end of the buffer the rest of the text decides how + /// far the clip may scroll, and an estimated remainder would leave the + /// clip past the view's real end once layout catches up. + private func scrollLaidOut(toY y: CGFloat) { + guard let scrollView else { return } + let visible = NSRect(x: 0, y: max(y, 0), width: textContainer.size.width, + height: scrollView.contentView.bounds.height) + layoutManager.ensureLayout(forBoundingRect: visible, in: textContainer) + textView.sizeToFit() + scrollClip(toY: y) + } + + /// Top of the line fragment holding character `index`, in container + /// coordinates — EXACT, not estimated: with non-contiguous layout the + /// position of a line far into the buffer is a guess until everything + /// above it has been laid out, so layout is ensured up to it first. + private func lineTop(atCharacter index: Int) -> CGFloat { + let length = textStorage.length + let index = min(max(index, 0), length) + if index == length { + // The empty line after a trailing newline (or of an empty text) + // is the extra line fragment, which exists only once layout has + // reached the end. + layoutManager.ensureLayout(for: textContainer) + if layoutManager.extraLineFragmentTextContainer != nil { + return layoutManager.extraLineFragmentRect.minY + } + guard length > 0 else { return 0 } + return lineFragmentTop(atCharacter: length - 1) + } + layoutManager.ensureLayout(forCharacterRange: NSRange(location: 0, length: index + 1)) + return lineFragmentTop(atCharacter: index) + } + + private func lineFragmentTop(atCharacter index: Int) -> CGFloat { + let glyph = layoutManager.glyphIndexForCharacter(at: index) + return layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil, + withoutAdditionalLayout: true).minY + } + + private func scrollClip(toY y: CGFloat) { + guard let scrollView else { return } + let clip = scrollView.contentView + var bounds = clip.bounds + bounds.origin = NSPoint(x: 0, y: y) + clip.scroll(to: clip.constrainBoundsRect(bounds).origin) + scrollView.reflectScrolledClipView(clip) + } + + /// The character at the top of the visible area and how far its line + /// starts above that top — what `apply(style:)` puts back. + private struct TopAnchor { + let character: Int + let offset: CGFloat + } + + private func topVisibleAnchor() -> TopAnchor? { + guard let scrollView, textStorage.length > 0, textContainer.size.width > 0 else { return nil } + let top = scrollView.contentView.bounds.minY + guard top > 0 else { return nil } + let glyph = layoutManager.glyphIndex(for: NSPoint(x: 0, y: top), in: textContainer) + var line = NSRange() + let lineTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: &line).minY + // The fragment's FIRST character: after a rewrap that is the + // character whose line goes back to the top. + let character = layoutManager.characterIndexForGlyph(at: line.location) + return TopAnchor(character: character, offset: lineTop - top) + } + + private func restore(_ anchor: TopAnchor) { + guard textContainer.size.width > 0 else { return } + scrollLaidOut(toY: lineTop(atCharacter: anchor.character) - anchor.offset) + } + + // MARK: - Find (S-D6) + + var isFindBarVisible: Bool { scrollView?.isFindBarVisible ?? false } + + func showFind() { performFinder(.showFindInterface) } + func findNext() { performFinder(.nextMatch) } + func findPrevious() { performFinder(.previousMatch) } + func hideFind() { performFinder(.hideFindInterface) } + + /// `performTextFinderAction(_:)` reads the action from the sender's `tag`. + private func performFinder(_ action: NSTextFinder.Action) { + let sender = NSMenuItem() + sender.tag = action.rawValue + textView.performTextFinderAction(sender) + } + + // MARK: - Edits + + /// One replacement through NSTextView's own change path, as ONE undo + /// group of its own: `shouldChangeText` registers the undo and consults + /// the delegate, `didChangeText` posts `textDidChange`. Coalescing is + /// broken on both sides so the edit never merges with surrounding typing. + private func perform(range: NSRange, replacement: String, selection: NSRange, + scrollToSelection: Bool = true) { + undoManager.beginUndoGrouping() + defer { undoManager.endUndoGrouping() } + textView.breakUndoCoalescing() + guard textView.shouldChangeText(in: range, replacementString: replacement) else { return } + textStorage.replaceCharacters(in: range, + with: NSAttributedString(string: replacement, attributes: baseAttributes)) + textView.didChangeText() + textView.breakUndoCoalescing() + textView.setSelectedRange(selection) + if scrollToSelection { textView.scrollRangeToVisible(selection) } + } + + private func perform(_ edit: SourceEditSupport.TextEdit) { + perform(range: edit.range, replacement: edit.replacement, selection: edit.selection) + } + + private var indentUnit: String { + if let cachedIndentUnit { return cachedIndentUnit } + let unit = SourceEditSupport.indentUnit(for: textStorage.mutableString) + cachedIndentUnit = unit + return unit + } + + // MARK: - NSTextViewDelegate + + func undoManager(for view: NSTextView) -> UndoManager? { + undoManager + } + + /// The buffer never contains U+000D. A change carrying a CR (paste, drop, + /// Services, an input method) is refused and re-inserted LF-normalised + /// through the normal insertion path, so undo records exactly what landed + /// in the buffer. Scalar check (constraint 14): Swift hides "\r" inside + /// "\r\n" from Character-based `contains`. Per keystroke this looks at the + /// typed characters only. + func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, + replacementString: String?) -> Bool { + guard let replacementString, replacementString.unicodeScalars.contains("\r") else { return true } + textView.insertText(MarkdownDocument.normalizeLineEndings(replacementString), + replacementRange: affectedCharRange) + return false + } + + /// Return keeps the line's indentation, Tab / Shift-Tab indent and outdent + /// (S-D5); Escape goes to the session and never opens completion. + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + let selection = textView.selectedRange() + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + perform(SourceEditSupport.newlineEdit(in: textStorage.mutableString, selection: selection)) + return true + case #selector(NSResponder.insertTab(_:)): + if let edit = SourceEditSupport.indentEdit(in: textStorage.mutableString, + selection: selection, unit: indentUnit) { + perform(edit) + } + return true + case #selector(NSResponder.insertBacktab(_:)): + if let edit = SourceEditSupport.outdentEdit(in: textStorage.mutableString, + selection: selection, unit: indentUnit) { + perform(edit) + } + return true + case #selector(NSResponder.cancelOperation(_:)): + onEscape?() + return true + default: + return false + } + } + + func textDidChange(_ notification: Notification) { + cachedIndentUnit = nil + // A kept top line is a character offset; an edit may have moved it. + dropPendingAnchor() + onChange?() + } +} diff --git a/QuickMD/QuickMD/Views/SourceEditorView.swift b/QuickMD/QuickMD/Views/SourceEditorView.swift new file mode 100644 index 0000000..b9c5e70 --- /dev/null +++ b/QuickMD/QuickMD/Views/SourceEditorView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +// MARK: - Source editor view (v1.12 S-D4) + +/// SwiftUI host for the source editor. Wiring only: the controller builds the +/// hierarchy once and owns all behaviour (`SourceEditorController.swift`), so +/// the session keeps talking to the same text view — and the same undo stack — +/// however often SwiftUI re-creates this struct. +struct SourceEditorView: NSViewRepresentable { + let controller: SourceEditorController + let style: SourceEditorController.Style + + func makeNSView(context: Context) -> NSScrollView { + controller.makeScrollView(style: style) + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + controller.apply(style: style) + } +} diff --git a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift new file mode 100644 index 0000000..079e778 --- /dev/null +++ b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift @@ -0,0 +1,666 @@ +import XCTest +import SwiftUI +import AppKit + +/// The source editor surface (v1.12 S-D1, S-D3–S-D6, S-D13, S-D15), headless: +/// the views are created in the test process and hosted in a window that is +/// never ordered on screen. +/// +/// Undo: AppKit groups undo by run-loop event, and a test has no events — so +/// `groupsByEvent` is off and each simulated user action is wrapped in its own +/// group (`userAction`), which is exactly the group the event would open. +/// Actions that must register NOTHING run outside a group: an empty group +/// still counts as an undo step. +final class SourceEditorControllerTests: XCTestCase { + + private var windows: [NSWindow] = [] + + override func setUp() { + super.setUp() + _ = NSApplication.shared + } + + override func tearDown() { + windows.forEach { $0.close() } + windows.removeAll() + super.tearDown() + } + + // MARK: - Helpers + + private let light = MarkdownTheme.cached(for: .light) + private let dark = MarkdownTheme.theme(named: ThemeName.dracula, colorScheme: .light) + + private func style(_ theme: MarkdownTheme? = nil, scale: Double = 1, + reading: Bool = false) -> SourceEditorController.Style { + SourceEditorController.Style(theme: theme ?? light, fontScale: scale, isReadingLayout: reading) + } + + /// A controller whose scroll view fills an offscreen window's content. + private func makeEditor(_ text: String = "", width: CGFloat = 800, height: CGFloat = 600, + style editorStyle: SourceEditorController.Style? = nil) + -> (SourceEditorController, NSScrollView, NSWindow) { + let controller = SourceEditorController() + let scrollView = controller.makeScrollView(style: editorStyle ?? style()) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let content = window.contentView! + scrollView.frame = content.bounds + scrollView.autoresizingMask = [.width, .height] + content.addSubview(scrollView) + content.layoutSubtreeIfNeeded() + windows.append(window) + controller.load(text) + // No events here: AppKit's per-event group would never close and + // swallow every action of the test into one undo step. + controller.undoManager.groupsByEvent = false + return (controller, scrollView, window) + } + + /// Lets the deferred exact top-line restore (100 ms after the last + /// relayout) run. + private func settle() { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + private func userAction(_ controller: SourceEditorController, _ body: () -> Void) { + controller.undoManager.beginUndoGrouping() + body() + controller.undoManager.endUndoGrouping() + } + + private func command(_ controller: SourceEditorController, _ selector: Selector) { + userAction(controller) { controller.textView.doCommand(by: selector) } + } + + private func type(_ controller: SourceEditorController, _ string: String) { + userAction(controller) { + controller.textView.insertText(string, replacementRange: controller.textView.selectedRange()) + } + } + + /// Top of the line fragment holding `offset`, container coordinates, + /// measured after laying out the WHOLE text — the ground truth an + /// estimate would miss. + private func exactLineTop(_ controller: SourceEditorController, offset: Int) -> CGFloat { + let layoutManager = controller.textView.layoutManager! + layoutManager.ensureLayout(for: controller.textView.textContainer!) + let length = controller.textView.textStorage!.length + if offset == length, layoutManager.extraLineFragmentTextContainer != nil { + return layoutManager.extraLineFragmentRect.minY + } + let glyph = layoutManager.glyphIndexForCharacter(at: min(offset, max(length - 1, 0))) + return layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY + } + + private func numberedLines(_ count: Int, width: Int = 24) -> String { + (0.. [CGFloat] { + var result: [CGFloat] = [] + let resolve = { + let c = color.usingColorSpace(.sRGB)! + result = [c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent] + } + if let appearance { appearance.performAsCurrentDrawingAppearance(resolve) } else { resolve() } + return result + } + + private func assertSameColor(_ lhs: NSColor?, _ rhs: Color, appearance: NSAppearance?, + file: StaticString = #filePath, line: UInt = #line) { + guard let lhs else { return XCTFail("missing colour", file: file, line: line) } + let a = rgba(lhs, in: appearance), b = rgba(NSColor(rhs), in: appearance) + for (x, y) in zip(a, b) { XCTAssertEqual(x, y, accuracy: 0.002, file: file, line: line) } + } + + // MARK: - Configuration (S-D4, S-D15) + + func testConfigurationIsPlainTextTextKit1WithFindBar() { + let (controller, scrollView, _) = makeEditor("hello") + let view = controller.textView + XCTAssertNil(view.textLayoutManager, "must never fall into TextKit 2") + XCTAssertNotNil(view.layoutManager) + XCTAssertTrue(view.layoutManager!.allowsNonContiguousLayout) + XCTAssertFalse(view.textContainer!.widthTracksTextView) + XCTAssertEqual(view.textContainer!.lineFragmentPadding, 0) + XCTAssertTrue(view.isEditable) + XCTAssertTrue(view.isSelectable) + XCTAssertTrue(view.allowsUndo) + XCTAssertFalse(view.isRichText) + XCTAssertFalse(view.importsGraphics) + XCTAssertFalse(view.usesFontPanel) + XCTAssertFalse(view.usesRuler) + XCTAssertFalse(view.isRulerVisible) + XCTAssertFalse(view.isAutomaticQuoteSubstitutionEnabled) + XCTAssertFalse(view.isAutomaticDashSubstitutionEnabled) + XCTAssertFalse(view.isAutomaticTextReplacementEnabled) + XCTAssertFalse(view.isAutomaticSpellingCorrectionEnabled) + XCTAssertFalse(view.isAutomaticTextCompletionEnabled) + XCTAssertFalse(view.isAutomaticLinkDetectionEnabled) + XCTAssertFalse(view.isAutomaticDataDetectionEnabled) + XCTAssertFalse(view.smartInsertDeleteEnabled) + XCTAssertFalse(view.isGrammarCheckingEnabled) + XCTAssertFalse(view.isContinuousSpellCheckingEnabled) + XCTAssertTrue(view.usesFindBar) + XCTAssertTrue(view.isIncrementalSearchingEnabled) + XCTAssertFalse(view.isHorizontallyResizable) + XCTAssertEqual(view.accessibilityIdentifier(), "source-editor") + XCTAssertFalse(scrollView.autohidesScrollers) + XCTAssertFalse(scrollView.hasHorizontalScroller) + XCTAssertTrue(scrollView.documentView === view) + XCTAssertTrue(controller.makeScrollView(style: style()) === scrollView, "built once") + } + + // MARK: - Undo manager (S-D1) + + func testTextViewUsesTheControllersUndoManagerNotTheWindows() { + let (controller, _, window) = makeEditor("abc") + XCTAssertTrue(controller.textView.undoManager === controller.undoManager) + XCTAssertFalse(controller.textView.undoManager === window.undoManager) + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + type(controller, "d") + XCTAssertTrue(controller.undoManager.canUndo) + XCTAssertFalse(window.undoManager?.canUndo ?? false, "nothing may land on the window's stack") + } + + // MARK: - Buffer + + func testLoadReplacesEverythingWithoutUndoOrChange() { + let (controller, _, _) = makeEditor("old") + var changes = 0 + controller.onChange = { changes += 1 } + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + type(controller, "!") + XCTAssertEqual(changes, 1) + controller.load("first\r\nsecond\rthird") + XCTAssertEqual(controller.text, "first\nsecond\nthird") + XCTAssertFalse(controller.text.unicodeScalars.contains("\r")) + XCTAssertFalse(controller.undoManager.canUndo, "load clears the stack") + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 0, length: 0)) + XCTAssertEqual(changes, 1, "load never fires onChange") + } + + func testReplaceAllIsOneUndoableEditAndFiresOnChange() { + let (controller, _, _) = makeEditor("saved text\nline two") + var changes = 0 + controller.onChange = { changes += 1 } + controller.textView.setSelectedRange(NSRange(location: 5, length: 0)) + userAction(controller) { controller.replaceAll(with: "disk\r\nversion") } + XCTAssertEqual(controller.text, "disk\nversion") + XCTAssertEqual(changes, 1) + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 5, length: 0)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "saved text\nline two") + controller.undoManager.redo() + XCTAssertEqual(controller.text, "disk\nversion") + } + + func testReplaceAllWithShorterTextClampsTheCaret() { + let (controller, _, _) = makeEditor("a long line of text") + controller.textView.setSelectedRange(NSRange(location: 15, length: 0)) + controller.replaceAll(with: "ab") + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 2, length: 0)) + } + + // MARK: - CR normalisation + + func testInsertedCarriageReturnsAreNormalizedAndUndoIsCoherent() { + let (controller, _, _) = makeEditor("hello\n") + controller.textView.setSelectedRange(NSRange(location: 6, length: 0)) + type(controller, "a\r\nb\rc") + XCTAssertEqual(controller.text, "hello\na\nb\nc") + XCTAssertFalse(controller.text.unicodeScalars.contains("\r")) + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 11, length: 0)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "hello\n") + controller.undoManager.redo() + XCTAssertEqual(controller.text, "hello\na\nb\nc") + } + + func testReplacingASelectionWithCRLFTextNormalizes() { + let (controller, _, _) = makeEditor("one TWO three") + controller.textView.setSelectedRange(NSRange(location: 4, length: 3)) + type(controller, "2\r\n2") + XCTAssertEqual(controller.text, "one 2\n2 three") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "one TWO three") + } + + func testPastedCRLFTextIsNormalized() { + let (controller, _, _) = makeEditor("start ") + let pasteboard = NSPasteboard(name: NSPasteboard.Name("QuickMDTests.SourceEditor.\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + pasteboard.setString("x\r\ny\rz", forType: .string) + controller.textView.setSelectedRange(NSRange(location: 6, length: 0)) + userAction(controller) { + _ = controller.textView.readSelection(from: pasteboard, type: .string) + } + XCTAssertEqual(controller.text, "start x\ny\nz") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "start ") + } + + // MARK: - Typing comforts (S-D5) + + func testReturnKeepsTheLinesIndentationAsOneUndo() { + let (controller, _, _) = makeEditor(" foo") + controller.textView.setSelectedRange(NSRange(location: 7, length: 0)) + command(controller, #selector(NSResponder.insertNewline(_:))) + XCTAssertEqual(controller.text, " foo\n ") + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 12, length: 0)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, " foo") + } + + func testReturnAfterTypingUndoesSeparately() { + let (controller, _, _) = makeEditor("\tx") + controller.textView.setSelectedRange(NSRange(location: 2, length: 0)) + type(controller, "yz") + command(controller, #selector(NSResponder.insertNewline(_:))) + XCTAssertEqual(controller.text, "\txyz\n\t") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "\txyz", "the Return is its own undo step") + } + + func testTabOnACaretInsertsTheDocumentsIndentUnit() { + let (controller, _, _) = makeEditor("a") + controller.textView.setSelectedRange(NSRange(location: 0, length: 0)) + command(controller, #selector(NSResponder.insertTab(_:))) + XCTAssertEqual(controller.text, " a") + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 4, length: 0)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "a") + + controller.load("\tx\ny") + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + command(controller, #selector(NSResponder.insertTab(_:))) + XCTAssertEqual(controller.text, "\tx\n\ty", "a tab-indented document indents with tabs") + } + + func testIndentUnitFollowsEditsToTheBuffer() { + let (controller, _, _) = makeEditor("x\ny") + controller.textView.setSelectedRange(NSRange(location: 1, length: 0)) + command(controller, #selector(NSResponder.insertTab(_:))) + XCTAssertEqual(controller.text, "x \ny") + // A tab-indented line appears: the cached unit must not survive it. + controller.textView.setSelectedRange(NSRange(location: 6, length: 0)) + type(controller, "\t") + controller.textView.setSelectedRange(NSRange(location: 0, length: 0)) + command(controller, #selector(NSResponder.insertTab(_:))) + XCTAssertEqual(controller.text, "\tx \n\ty") + } + + func testTabAndShiftTabOnAMultiLineSelectionEachUndoAsOne() { + let (controller, _, _) = makeEditor("a\nb\nc") + controller.textView.setSelectedRange(NSRange(location: 0, length: 3)) + command(controller, #selector(NSResponder.insertTab(_:))) + XCTAssertEqual(controller.text, " a\n b\nc") + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 0, length: 11)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "a\nb\nc") + controller.undoManager.redo() + XCTAssertEqual(controller.text, " a\n b\nc") + + controller.textView.setSelectedRange(NSRange(location: 0, length: 11)) + command(controller, #selector(NSResponder.insertBacktab(_:))) + XCTAssertEqual(controller.text, "a\nb\nc") + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 0, length: 3)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, " a\n b\nc") + } + + func testShiftTabOnACaretOutdentsItsLine() { + let (controller, _, _) = makeEditor("x\n y") + controller.textView.setSelectedRange(NSRange(location: 8, length: 0)) + command(controller, #selector(NSResponder.insertBacktab(_:))) + XCTAssertEqual(controller.text, "x\n y") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "x\n y") + } + + func testShiftTabWithNothingToOutdentChangesNothing() { + let (controller, _, _) = makeEditor("plain") + var changes = 0 + controller.onChange = { changes += 1 } + controller.textView.setSelectedRange(NSRange(location: 2, length: 0)) + controller.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) + XCTAssertEqual(controller.text, "plain") + XCTAssertEqual(changes, 0) + XCTAssertFalse(controller.undoManager.canUndo) + } + + // MARK: - Escape + + func testEscapeCallsOnEscapeAndInsertsNothing() { + let (controller, _, _) = makeEditor("text") + var escapes = 0, changes = 0 + controller.onEscape = { escapes += 1 } + controller.onChange = { changes += 1 } + controller.textView.setSelectedRange(NSRange(location: 2, length: 0)) + controller.textView.doCommand(by: #selector(NSResponder.cancelOperation(_:))) + XCTAssertEqual(escapes, 1) + XCTAssertEqual(changes, 0) + XCTAssertEqual(controller.text, "text") + XCTAssertFalse(controller.undoManager.canUndo) + } + + // MARK: - onChange + + func testOnChangeFiresForEditsUndoAndRedoButNotForLoadOrStyle() { + let (controller, _, _) = makeEditor("abc") + var changes = 0 + controller.onChange = { changes += 1 } + controller.load("fresh") + controller.apply(style: style(scale: 1.5)) + controller.apply(style: style(dark, scale: 1.5, reading: true)) + XCTAssertEqual(changes, 0) + controller.textView.setSelectedRange(NSRange(location: 5, length: 0)) + type(controller, "!") + XCTAssertEqual(changes, 1) + controller.undoManager.undo() + XCTAssertEqual(changes, 2) + controller.undoManager.redo() + XCTAssertEqual(changes, 3) + } + + // MARK: - Caret and scrolling (S-D3, S-D13) + + func testPlaceCaretPutsTheLineAtTheTopOfTheVisibleArea() { + let (controller, scrollView, _) = makeEditor(numberedLines(2_000)) + let storage = controller.textView.textStorage!.mutableString + controller.placeCaret(atLine: 1_000) + let offset = SourceEditSupport.lineStart(1_000, in: storage) + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: offset, length: 0)) + XCTAssertEqual(controller.caretLine, 1_000) + let top = scrollView.contentView.bounds.minY + XCTAssertEqual(top, exactLineTop(controller, offset: offset), accuracy: 0.01) + // The line itself sits one vertical inset below the visible top — + // the gap a `.top` jump leaves in the rendered list. + let lineInView = exactLineTop(controller, offset: offset) + controller.textView.textContainerOrigin.y + XCTAssertEqual(lineInView - controller.textView.visibleRect.minY, + BlockLayout.Document.contentVerticalPadding, accuracy: 0.01) + } + + func testPlaceCaretOnLineZeroScrollsToTheTop() { + let (controller, scrollView, _) = makeEditor(numberedLines(500)) + controller.scroll(toLine: 300) + XCTAssertGreaterThan(scrollView.contentView.bounds.minY, 0) + controller.placeCaret(atLine: 0) + XCTAssertEqual(scrollView.contentView.bounds.minY, 0) + XCTAssertEqual(controller.caretLine, 0) + XCTAssertEqual(controller.textView.selectedRange(), NSRange(location: 0, length: 0)) + } + + func testScrollToLineLeavesTheCaretAlone() { + let (controller, scrollView, _) = makeEditor(numberedLines(1_000)) + controller.placeCaret(atLine: 10) + let caret = controller.textView.selectedRange() + controller.scroll(toLine: 700) + XCTAssertEqual(controller.textView.selectedRange(), caret) + let offset = SourceEditSupport.lineStart(700, in: controller.textView.textStorage!.mutableString) + XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: offset), accuracy: 0.01) + } + + func testCaretLineAndSelect() { + let (controller, scrollView, _) = makeEditor(numberedLines(1_000)) + let storage = controller.textView.textStorage!.mutableString + let range = NSRange(location: SourceEditSupport.lineStart(900, in: storage) + 2, length: 3) + controller.select(range) + XCTAssertEqual(controller.textView.selectedRange(), range) + XCTAssertEqual(controller.caretLine, 900) + let lineTop = exactLineTop(controller, offset: range.location) + controller.textView.textContainerOrigin.y + XCTAssertTrue(scrollView.contentView.bounds.contains(NSPoint(x: 40, y: lineTop + 1)), + "the selection is scrolled into view") + } + + func testPlaceCaretBeforeTheFirstLayoutScrollsOnceTheViewHasAWidth() { + let controller = SourceEditorController() + controller.load(numberedLines(1_000)) + let scrollView = controller.makeScrollView(style: style()) + controller.placeCaret(atLine: 600) + XCTAssertEqual(controller.caretLine, 600) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + windows.append(window) + scrollView.frame = window.contentView!.bounds + window.contentView!.addSubview(scrollView) + window.contentView!.layoutSubtreeIfNeeded() + let offset = SourceEditSupport.lineStart(600, in: controller.textView.textStorage!.mutableString) + XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: offset), accuracy: 0.01) + } + + /// ~5 MB / 200 000 lines — the provisional size cap. A line deep inside + /// lands EXACTLY at the top (layout is ensured up to it, not estimated); + /// the last line cannot reach the top, so the document's end is at the + /// bottom with the line visible. Prints the `placeCaret` time. + func testPlaceCaretInALargeDocumentIsExact() { + let text = numberedLines(200_000, width: 25) + XCTAssertGreaterThan(text.utf16.count, 5_000_000) + let (controller, scrollView, _) = makeEditor(text) + let storage = controller.textView.textStorage!.mutableString + + let started = Date() + controller.placeCaret(atLine: 199_999) + let lastLineTime = Date().timeIntervalSince(started) + let started2 = Date() + controller.placeCaret(atLine: 150_000) + let deepLineTime = Date().timeIntervalSince(started2) + print("PERF SourceEditor placeCaret: last line of 200k (cold) \(Int(lastLineTime * 1000)) ms, line 150k (warm) \(Int(deepLineTime * 1000)) ms") + + let deepOffset = SourceEditSupport.lineStart(150_000, in: storage) + XCTAssertEqual(controller.caretLine, 150_000) + XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: deepOffset), + accuracy: 0.01) + + controller.placeCaret(atLine: 199_999) + let lastOffset = SourceEditSupport.lineStart(199_999, in: storage) + XCTAssertEqual(controller.caretLine, 199_999) + let clip = scrollView.contentView.bounds + let lastTop = exactLineTop(controller, offset: lastOffset) + controller.textView.textContainerOrigin.y + let farthest = scrollView.contentView.constrainBoundsRect( + NSRect(x: 0, y: controller.textView.frame.maxY, width: clip.width, height: clip.height)) + XCTAssertEqual(clip.minY, farthest.minY, accuracy: 0.5, "scrolled to the end") + XCTAssertTrue(clip.minY <= lastTop && lastTop < clip.maxY, "the last line is visible") + } + + // MARK: - Style (zoom, theme, layout) + + func testApplyStyleChangesTheFontWithoutUndoOrChange() { + let (controller, scrollView, _) = makeEditor(numberedLines(3_000)) + var changes = 0 + controller.onChange = { changes += 1 } + let font = controller.textView.textStorage!.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + XCTAssertEqual(font?.pointSize, BlockLayout.Code.codeFontSize) + XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false) + controller.placeCaret(atLine: 1_200) + let caret = controller.textView.selectedRange() + + controller.apply(style: style(scale: 1.5)) + let zoomed = controller.textView.textStorage!.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + XCTAssertEqual(zoomed?.pointSize, BlockLayout.Code.codeFontSize * 1.5) + let last = controller.textView.textStorage!.length - 1 + let zoomedEnd = controller.textView.textStorage!.attribute(.font, at: last, effectiveRange: nil) as? NSFont + XCTAssertEqual(zoomedEnd?.pointSize, BlockLayout.Code.codeFontSize * 1.5, "the whole buffer") + XCTAssertEqual((controller.textView.typingAttributes[.font] as? NSFont)?.pointSize, + BlockLayout.Code.codeFontSize * 1.5) + XCTAssertFalse(controller.undoManager.canUndo) + XCTAssertEqual(changes, 0) + XCTAssertEqual(controller.textView.selectedRange(), caret) + // The top line stays the top line: at once within a line or two (the + // layout manager's estimate), exactly once the relayout has settled. + let storage = controller.textView.textStorage!.mutableString + let estimatedLine = SourceEditSupport.line(containing: topCharacter(controller, scrollView), in: storage) + XCTAssertLessThanOrEqual(abs(estimatedLine - 1_200), 3) + settle() + XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: caret.location), + accuracy: 0.5) + XCTAssertFalse(controller.undoManager.canUndo) + XCTAssertEqual(changes, 0) + } + + func testApplyStyleKeepsAnExistingUndoStack() { + let (controller, _, _) = makeEditor("abc") + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + type(controller, "d") + controller.apply(style: style(dark, scale: 2, reading: true)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "abc") + XCTAssertFalse(controller.undoManager.canUndo, "styling registered nothing") + } + + /// The list's arithmetic (`VirtualBlockList.currentContentWidth()` and its + /// centred cell column), spelled out independently. + private func listColumn(clip: CGFloat, reading: Bool) -> (left: CGFloat, width: CGFloat) { + let available = floor(clip) + let inner = available - 2 * 32 + let width = reading ? max(0, floor(min(inner, 720))) : max(0, inner) + return (32 + (inner - width) / 2, width) + } + + func testColumnGeometryMatchesTheRenderedList() { + for clip in [300, 640, 783.5, 800, 1000.6, 1600] as [CGFloat] { + for reading in [false, true] { + let geometry = SourceEditorController.columnGeometry(clipWidth: clip, isReadingLayout: reading) + let list = listColumn(clip: clip, reading: reading) + XCTAssertEqual(geometry.columnWidth, list.width, "clip \(clip) reading \(reading)") + XCTAssertEqual(geometry.horizontalInset, list.left, "clip \(clip) reading \(reading)") + XCTAssertGreaterThanOrEqual(geometry.horizontalInset, 32) + XCTAssertEqual(geometry.verticalInset, reading ? 48 : 24) + } + } + let standard = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: false) + XCTAssertEqual(standard, .init(horizontalInset: 32, columnWidth: 936, verticalInset: 24)) + let reading = SourceEditorController.columnGeometry(clipWidth: 1000, isReadingLayout: true) + XCTAssertEqual(reading, .init(horizontalInset: 140, columnWidth: 720, verticalInset: 48)) + } + + func testTextContainerFollowsTheViewWidthAndTheLayoutStyle() { + let (controller, scrollView, window) = makeEditor("text", width: 1000) + let view = controller.textView + func check(reading: Bool, line: UInt = #line) { + let clip = scrollView.contentView.bounds.width + let list = listColumn(clip: clip, reading: reading) + XCTAssertEqual(view.textContainer!.size.width, list.width, line: line) + XCTAssertEqual(view.textContainerInset.width, list.left, line: line) + XCTAssertEqual(view.textContainerInset.height, reading ? 48 : 24, line: line) + XCTAssertEqual(view.frame.width, clip, line: line) + } + check(reading: false) + controller.apply(style: style(reading: true)) + check(reading: true) + window.setContentSize(NSSize(width: 1400, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + check(reading: true) + controller.apply(style: style(reading: false)) + check(reading: false) + window.setContentSize(NSSize(width: 501, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + check(reading: false) + } + + /// The character at the top of the visible area (container coordinates). + private func topCharacter(_ controller: SourceEditorController, _ scrollView: NSScrollView) -> Int { + let layoutManager = controller.textView.layoutManager! + let glyph = layoutManager.glyphIndex(for: NSPoint(x: 0, y: scrollView.contentView.bounds.minY), + in: controller.textView.textContainer!) + return layoutManager.characterIndexForGlyph(at: glyph) + } + + func testResizingKeepsTheTopLineDeepInALongDocument() { + var rng = SystemRandomNumberGenerator() + let text = (0..<20_000).map { index in + String(repeating: "word ", count: Int.random(in: 0...40, using: &rng)) + "\(index)" + }.joined(separator: "\n") + let (controller, scrollView, window) = makeEditor(text, width: 900) + controller.scroll(toLine: 15_000) + let storage = controller.textView.textStorage!.mutableString + let target = SourceEditSupport.lineStart(15_000, in: storage) + XCTAssertEqual(topCharacter(controller, scrollView), target) + /// Immediately after a relayout: the estimate — the same text, give + /// or take a line or two (of 20 000, after the whole layout moved). + func checkTopLine(_ label: String, line: UInt = #line) { + let top = SourceEditSupport.line(containing: topCharacter(controller, scrollView), in: storage) + XCTAssertLessThanOrEqual(abs(top - 15_000), 3, label, line: line) + } + func checkExact(_ label: String, line: UInt = #line) { + settle() + XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: target), + accuracy: 0.5, label, line: line) + } + // Several widths in a row (a sidebar animating), then the exact landing. + for width in [700, 1100, 640] as [CGFloat] { + window.setContentSize(NSSize(width: width, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + checkTopLine("width \(width)") + } + checkExact("after resizing") + controller.apply(style: style(reading: true)) + checkTopLine("reading layout") + checkExact("reading layout") + controller.apply(style: style(scale: 1.3, reading: true)) + checkTopLine("zoom") + checkExact("zoom") + } + + func testAScrollAfterARelayoutIsNotUndoneByTheExactRestore() { + let (controller, scrollView, window) = makeEditor(numberedLines(5_000), width: 900) + controller.scroll(toLine: 3_000) + window.setContentSize(NSSize(width: 400, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + controller.scroll(toLine: 100) + settle() + let offset = SourceEditSupport.lineStart(100, in: controller.textView.textStorage!.mutableString) + XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: offset), accuracy: 0.5) + } + + func testColoursAndAppearanceFollowADarkAndALightTheme() { + let (controller, scrollView, _) = makeEditor("text", style: style(dark)) + XCTAssertEqual(scrollView.appearance?.name, .darkAqua) + assertSameColor(controller.textView.backgroundColor, dark.backgroundColor, appearance: scrollView.appearance) + assertSameColor(scrollView.backgroundColor, dark.backgroundColor, appearance: scrollView.appearance) + assertSameColor(controller.textView.insertionPointColor, dark.textColor, appearance: scrollView.appearance) + assertSameColor(controller.textView.textStorage!.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor, + dark.textColor, appearance: scrollView.appearance) + XCTAssertTrue(scrollView.drawsBackground) + + controller.apply(style: style(light)) + XCTAssertEqual(scrollView.appearance?.name, .aqua) + assertSameColor(controller.textView.backgroundColor, light.backgroundColor, appearance: scrollView.appearance) + assertSameColor(controller.textView.insertionPointColor, light.textColor, appearance: scrollView.appearance) + assertSameColor(controller.textView.textStorage!.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor, + light.textColor, appearance: scrollView.appearance) + XCTAssertFalse(controller.undoManager.canUndo) + } + + func testEqualStylesCompareEqual() { + XCTAssertEqual(style(dark, scale: 1.25, reading: true), style(dark, scale: 1.25, reading: true)) + XCTAssertNotEqual(style(dark), style(light)) + XCTAssertNotEqual(style(scale: 1), style(scale: 1.1)) + XCTAssertNotEqual(style(reading: false), style(reading: true)) + } + + // MARK: - Find (S-D6) + + func testFindBarShowsAndHides() { + let (controller, _, window) = makeEditor("find me, find me") + window.makeFirstResponder(controller.textView) + XCTAssertFalse(controller.isFindBarVisible) + controller.showFind() + XCTAssertTrue(controller.isFindBarVisible) + controller.hideFind() + XCTAssertFalse(controller.isFindBarVisible) + } + + func testFocusMakesTheTextViewFirstResponder() { + let (controller, _, window) = makeEditor("x") + controller.focus() + XCTAssertTrue(window.firstResponder === controller.textView) + } +} From d7ee1d5f3022d2d2e4d81bdef250628e7402a0f5 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 14:53:55 +0200 Subject: [PATCH 05/17] fix(source-edit): editor review fixes - undo grouping, own undo manager on the view, bounded relayout restore Paste normalization is its own undo step; SourceTextView returns the editor's UndoManager even without its delegate; restored text takes the current style; the exact top-line restore after a relayout is skipped past 400k UTF-16 units (keeps the estimate instead of a multi-hundred-ms layout); focus requests wait for a window; Shift/Option-Return insert a plain newline. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD/SourceEditorController.swift | 194 +++++++++--- .../SourceEditorControllerTests.swift | 282 +++++++++++++++--- 2 files changed, 405 insertions(+), 71 deletions(-) diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift index a6037df..d9b5263 100644 --- a/QuickMD/QuickMD/SourceEditorController.swift +++ b/QuickMD/QuickMD/SourceEditorController.swift @@ -18,23 +18,58 @@ import SwiftUI // callbacks that create/drive this are nonisolated on the older SDK the CI // runner builds with (constraints "CI builds on an OLDER SDK"). -/// The editor's text view. A subclass only so it has its own type in the view -/// hierarchy (accessibility, debugging); all behaviour lives in the -/// controller, which is its delegate. -final class SourceTextView: NSTextView {} +/// The editor's text view. Behaviour lives in the controller (its delegate); +/// the subclass holds the two things that must not depend on that delegate. +final class SourceTextView: NSTextView { + /// The editor's own undo stack, held STRONGLY here. `delegate` is weak: + /// were the controller gone while the view is still in a window, the + /// delegate's `undoManager(for:)` would no longer be asked and AppKit + /// would fall back to `window.undoManager` — the viewer NSDocument's, + /// which autosaves in place (spec S-D1). The delegate method stays as a + /// second layer. + var editorUndoManager: UndoManager? + /// Called once the view is in a window (a deferred `focus()`). + var onMoveToWindow: (() -> Void)? + + override var undoManager: UndoManager? { + editorUndoManager ?? super.undoManager + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { onMoveToWindow?() } + } +} /// The editor's scroll view. Reports `tile()` — the one place AppKit lays out /// the clip view after a resize, a scroller change or the find bar appearing — /// so the column geometry is recomputed from the clip width it just set; the /// end of a live resize, when the deferred exact scroll position lands; and -/// the wheel, which cancels that deferred position (the user moved on). +/// the wheel and the start of a live scroll (scroller drag, trackpad), which +/// cancel that deferred position: the user moved on. final class SourceEditorScrollView: NSScrollView { var onTile: (() -> Void)? var onEndLiveResize: (() -> Void)? - var onScrollWheel: (() -> Void)? + var onUserScroll: (() -> Void)? + private var liveScrollObserver: NSObjectProtocol? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + liveScrollObserver = NotificationCenter.default.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, object: self, queue: nil + ) { [weak self] _ in self?.onUserScroll?() } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + deinit { + if let liveScrollObserver { NotificationCenter.default.removeObserver(liveScrollObserver) } + } override func scrollWheel(with event: NSEvent) { - onScrollWheel?() + onUserScroll?() super.scrollWheel(with: event) } @@ -52,8 +87,8 @@ final class SourceEditorScrollView: NSScrollView { /// Owns the editor's text view and everything about it the session needs: /// the buffer, the caret, scrolling, find, style and its own undo stack. /// -/// An `NSObject` because it is the text view's delegate. -final class SourceEditorController: NSObject, NSTextViewDelegate { +/// An `NSObject` because it is the text view's and the storage's delegate. +final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageDelegate { /// Everything the editor's look depends on. Equal styles are a no-op in /// `apply(style:)`, so SwiftUI may hand the same one on every update. @@ -121,6 +156,22 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { /// the rendered list's width debounce (`VirtualBlockList`), for the same /// reason: sidebars animate over 0.2 s and every frame changes the width. private static let exactRestoreDelay: TimeInterval = 0.1 + /// The exact restore re-lays out everything above the anchor, so its cost + /// grows with the anchor's offset. Measured headlessly (Debug app; the + /// cost is all AppKit layout), 2026-10-05: an anchor 400 000 UTF-16 units + /// in costs ~160 ms with short 25-character lines (the worst case: one + /// line fragment per 25 units) and ~85 ms with long wrapping lines; at the + /// end of a 2 MB buffer it would be ~700 / ~400 ms — per zoom step or + /// sidebar toggle. Below the limit the pause passes for part of the + /// resize / zoom; beyond it the estimate is kept (the right text stays on + /// screen within a line or two, only the scroller is approximate). The + /// explicit jumps (`placeCaret`, `scroll(toLine:)`) are exact at any offset. + static let exactRestoreCharacterLimit = 400_000 + /// `focus()` asked for before the view had a window (the session mounts + /// and focuses in one pass); honoured when it gets one. + private var focusRequested = false + /// Re-entrancy guard for the attribute fix-up in `didProcessEditing`. + private var isFixingAttributes = false override init() { // An explicit TextKit 1 stack: an NSTextView created with a container @@ -141,6 +192,9 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { layoutManager.addTextContainer(textContainer) textView = SourceTextView(frame: .zero, textContainer: textContainer) super.init() + textView.editorUndoManager = undoManager + textView.onMoveToWindow = { [weak self] in self?.focusIfRequested() } + textStorage.delegate = self configureTextView() } @@ -189,12 +243,18 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { /// Builds the scroll view once and returns it on every later call (the /// representable's `makeNSView`). The text view exists from `init`, so /// `load` / `text` work before this is ever called. + /// + /// ONE live `SourceEditorView` per controller: there is one view, and an + /// NSView has one superview. If SwiftUI briefly keeps two representables + /// (a removal transition, an identity change), the view is detached from + /// the old host before the new one gets it, never shared between them. func makeScrollView(style: Style) -> NSScrollView { if let scrollView { + scrollView.removeFromSuperview() apply(style: style) return scrollView } - let scrollView = SourceEditorScrollView() + let scrollView = SourceEditorScrollView(frame: .zero) scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false // Same as the rendered list: a scroller that autohides changes the @@ -207,7 +267,7 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { scrollView.documentView = textView scrollView.onTile = { [weak self] in self?.updateGeometry() } scrollView.onEndLiveResize = { [weak self] in self?.finishRelayout() } - scrollView.onScrollWheel = { [weak self] in self?.dropPendingAnchor() } + scrollView.onUserScroll = { [weak self] in self?.dropPendingAnchor() } self.scrollView = scrollView apply(style: style) return scrollView @@ -351,12 +411,12 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { } let glyph = layoutManager.glyphIndexForCharacter(at: min(anchor.character, textStorage.length - 1)) let estimatedTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY - scrollLaidOut(toY: estimatedTop - anchor.offset) + scrollLaidOut(toY: clipY(forLineTop: estimatedTop, anchor)) // Laying out the screen around it can refine the line's estimate; // follow it once so the same text is really at the top. let refinedTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil, withoutAdditionalLayout: true).minY - if refinedTop != estimatedTop { scrollClip(toY: refinedTop - anchor.offset) } + if refinedTop != estimatedTop { scrollClip(toY: clipY(forLineTop: refinedTop, anchor)) } pendingAnchor = anchor pendingAnchorClipY = scrollView?.contentView.bounds.minY ?? 0 exactRestoreWork?.cancel() @@ -375,16 +435,20 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { exactRestoreWork = nil guard let anchor = pendingAnchor else { return } pendingAnchor = nil + guard anchor.character < Self.exactRestoreCharacterLimit else { return } // AppKit itself nudges the clip by a line or two while background // layout refines the estimate; more than half a screen is a scroll - // by the user (scroller drag, keyboard paging — the wheel already - // dropped the anchor), and their position wins. + // that slipped past the explicit cancels (wheel, live scroll, + // keyboard commands), and the user's position wins. guard let scrollView else { return } let clip = scrollView.contentView.bounds guard abs(clip.minY - pendingAnchorClipY) <= clip.height / 2 else { return } restore(anchor) } + /// A relayout's exact top-line restore is still to come (tests wait on it). + var isRestorePending: Bool { pendingAnchor != nil } + /// An explicit scroll supersedes a top line still being kept. private func dropPendingAnchor() { exactRestoreWork?.cancel() @@ -408,6 +472,7 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { textStorage.endEditing() cachedIndentUnit = nil undoManager.removeAllActions() + textView.breakUndoCoalescing() textView.setSelectedRange(NSRange(location: 0, length: 0)) pendingTopOffset = nil dropPendingAnchor() @@ -460,8 +525,20 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { scrollToTop(characterOffset: SourceEditSupport.lineStart(line, in: textStorage.mutableString)) } + /// Makes the text view first responder — now, or as soon as it is in a + /// window (the session mounts the editor and focuses it in one pass). func focus() { - textView.window?.makeFirstResponder(textView) + guard let window = textView.window else { + focusRequested = true + return + } + focusRequested = false + window.makeFirstResponder(textView) + } + + private func focusIfRequested() { + guard focusRequested else { return } + focus() } /// Scrolls so the line containing `offset` starts `verticalInset` below @@ -477,13 +554,14 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { scrollLaidOut(toY: lineTop(atCharacter: offset)) } - /// Scrolls the clip to container-y `y` after laying out the screenful it - /// will show: near the end of the buffer the rest of the text decides how - /// far the clip may scroll, and an estimated remainder would leave the - /// clip past the view's real end once layout catches up. + /// Scrolls the clip to `y` (view coordinates) after laying out the + /// screenful it will show: near the end of the buffer the rest of the + /// text decides how far the clip may scroll, and an estimated remainder + /// would leave the clip past the view's real end once layout catches up. private func scrollLaidOut(toY y: CGFloat) { guard let scrollView else { return } - let visible = NSRect(x: 0, y: max(y, 0), width: textContainer.size.width, + let visible = NSRect(x: 0, y: max(y - textView.textContainerOrigin.y, 0), + width: textContainer.size.width, height: scrollView.contentView.bounds.height) layoutManager.ensureLayout(forBoundingRect: visible, in: textContainer) textView.sizeToFit() @@ -527,18 +605,25 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { scrollView.reflectScrolledClipView(clip) } - /// The character at the top of the visible area and how far its line - /// starts above that top — what `apply(style:)` puts back. + /// The line at the top edge of the visible area and how far its top is + /// from that edge — what a relayout puts back. + /// + /// Measured in CONTAINER coordinates on both sides (the clip's origin + /// minus `textContainerOrigin.y`), so a change of the vertical inset — + /// Reading Mode, 24 ⇄ 48 pt — leaves the line exactly where it was on + /// screen instead of shifting it by the inset delta. private struct TopAnchor { let character: Int + /// The line's top minus the visible top, container coordinates. let offset: CGFloat } private func topVisibleAnchor() -> TopAnchor? { guard let scrollView, textStorage.length > 0, textContainer.size.width > 0 else { return nil } - let top = scrollView.contentView.bounds.minY - guard top > 0 else { return nil } - let glyph = layoutManager.glyphIndex(for: NSPoint(x: 0, y: top), in: textContainer) + let clipTop = scrollView.contentView.bounds.minY + guard clipTop > 0 else { return nil } + let top = clipTop - textView.textContainerOrigin.y + let glyph = layoutManager.glyphIndex(for: NSPoint(x: 0, y: max(top, 0)), in: textContainer) var line = NSRange() let lineTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: &line).minY // The fragment's FIRST character: after a rewrap that is the @@ -547,9 +632,15 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { return TopAnchor(character: character, offset: lineTop - top) } + /// The clip origin that puts `anchor`'s line, now at container-y + /// `lineTop`, back at its distance from the visible top. + private func clipY(forLineTop lineTop: CGFloat, _ anchor: TopAnchor) -> CGFloat { + lineTop - anchor.offset + textView.textContainerOrigin.y + } + private func restore(_ anchor: TopAnchor) { guard textContainer.size.width > 0 else { return } - scrollLaidOut(toY: lineTop(atCharacter: anchor.character) - anchor.offset) + scrollLaidOut(toY: clipY(forLineTop: lineTop(atCharacter: anchor.character), anchor)) } // MARK: - Find (S-D6) @@ -606,25 +697,37 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { } /// The buffer never contains U+000D. A change carrying a CR (paste, drop, - /// Services, an input method) is refused and re-inserted LF-normalised - /// through the normal insertion path, so undo records exactly what landed - /// in the buffer. Scalar check (constraint 14): Swift hides "\r" inside - /// "\r\n" from Character-based `contains`. Per keystroke this looks at the - /// typed characters only. + /// Services, an input method) is refused and re-applied LF-normalised as + /// an edit of its own (`perform`: own undo group, coalescing broken on + /// both sides) — through `insertText` it would count as typing and merge + /// with the keystrokes around it into one undo step. Scalar check + /// (constraint 14): Swift hides "\r" inside "\r\n" from Character-based + /// `contains`. Per keystroke this looks at the typed characters only. func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard let replacementString, replacementString.unicodeScalars.contains("\r") else { return true } - textView.insertText(MarkdownDocument.normalizeLineEndings(replacementString), - replacementRange: affectedCharRange) + let normalized = MarkdownDocument.normalizeLineEndings(replacementString) + let end = affectedCharRange.location + (normalized as NSString).length + perform(range: affectedCharRange, replacement: normalized, + selection: NSRange(location: end, length: 0)) return false } /// Return keeps the line's indentation, Tab / Shift-Tab indent and outdent /// (S-D5); Escape goes to the session and never opens completion. + /// Shift-Return (`insertLineBreak:`, which would insert U+2028 — a line + /// separator the parser does not split on) and Option-Return + /// (`insertNewlineIgnoringFieldEditor:`) are plain Returns here: the + /// buffer's only line break is "\n". + /// + /// Any other command (arrows, paging, Home / End) is the user moving: + /// it cancels a pending exact top-line restore, then runs as usual. func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { let selection = textView.selectedRange() switch commandSelector { - case #selector(NSResponder.insertNewline(_:)): + case #selector(NSResponder.insertNewline(_:)), + #selector(NSResponder.insertLineBreak(_:)), + #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)): perform(SourceEditSupport.newlineEdit(in: textStorage.mutableString, selection: selection)) return true case #selector(NSResponder.insertTab(_:)): @@ -643,10 +746,31 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { onEscape?() return true default: + dropPendingAnchor() return false } } + // MARK: - NSTextStorageDelegate + + /// Plain text has ONE style. Restyling (`apply(style:)`) rewrites the + /// storage's attributes, but undo operations keep the text they removed + /// with the attributes it had — undoing a deletion after a zoom would + /// bring back a run in the old font. Every character edit is therefore + /// stamped with the current base attributes; attribute changes are left + /// alone (they are ours). Changing attributes, not characters, is what + /// `didProcessEditing` allows. Per keystroke this touches the typed range. + func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, changeInLength delta: Int) { + guard editedMask.contains(.editedCharacters), !isFixingAttributes, + editedRange.length > 0, NSMaxRange(editedRange) <= textStorage.length else { return } + let attributes = baseAttributes + guard !attributes.isEmpty else { return } + isFixingAttributes = true + defer { isFixingAttributes = false } + textStorage.setAttributes(attributes, range: editedRange) + } + func textDidChange(_ notification: Notification) { cachedIndentUnit = nil // A kept top line is a character offset; an edit may have moved it. diff --git a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift index 079e778..2316bf9 100644 --- a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift +++ b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift @@ -58,10 +58,67 @@ final class SourceEditorControllerTests: XCTestCase { return (controller, scrollView, window) } - /// Lets the deferred exact top-line restore (100 ms after the last - /// relayout) run. - private func settle() { - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + /// Runs the main run loop until `condition` holds (or a generous timeout). + @discardableResult + private func waitUntil(timeout: TimeInterval = 10, _ condition: () -> Bool, + file: StaticString = #filePath, line: UInt = #line) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + guard Date() < deadline else { + XCTFail("condition not met within \(timeout) s", file: file, line: line) + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + return true + } + + /// Waits for the deferred exact top-line restore (100 ms after the last + /// relayout) to have run. + private func settle(_ controller: SourceEditorController, file: StaticString = #filePath, line: UInt = #line) { + waitUntil({ !controller.isRestorePending }, file: file, line: line) + } + + /// Deterministic pseudo-random numbers (SplitMix64), so a failing + /// layout test fails the same way every run. + private struct SeededGenerator: RandomNumberGenerator { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } + + /// Lines of 0–40 words (they wrap differently at every width). + private func wrappingLines(_ count: Int, seed: UInt64 = 42) -> String { + var rng = SeededGenerator(state: seed) + return (0.. (character: Int, offset: CGFloat) { + let layoutManager = controller.textView.layoutManager! + let top = scrollView.contentView.bounds.minY - controller.textView.textContainerOrigin.y + let glyph = layoutManager.glyphIndex(for: NSPoint(x: 0, y: max(top, 0)), + in: controller.textView.textContainer!) + var line = NSRange() + let lineTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: &line).minY + return (layoutManager.characterIndexForGlyph(at: line.location), lineTop - top) + } + + /// `topLine`'s offset for `character`'s line, from a FULL layout. + private func exactOffset(_ controller: SourceEditorController, _ scrollView: NSScrollView, + character: Int) -> CGFloat { + exactLineTop(controller, offset: character) + - (scrollView.contentView.bounds.minY - controller.textView.textContainerOrigin.y) } private func userAction(_ controller: SourceEditorController, _ body: () -> Void) { @@ -481,6 +538,7 @@ final class SourceEditorControllerTests: XCTestCase { XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false) controller.placeCaret(atLine: 1_200) let caret = controller.textView.selectedRange() + let before = topLine(controller, scrollView) controller.apply(style: style(scale: 1.5)) let zoomed = controller.textView.textStorage!.attribute(.font, at: 0, effectiveRange: nil) as? NSFont @@ -496,11 +554,10 @@ final class SourceEditorControllerTests: XCTestCase { // The top line stays the top line: at once within a line or two (the // layout manager's estimate), exactly once the relayout has settled. let storage = controller.textView.textStorage!.mutableString - let estimatedLine = SourceEditSupport.line(containing: topCharacter(controller, scrollView), in: storage) - XCTAssertLessThanOrEqual(abs(estimatedLine - 1_200), 3) - settle() - XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: caret.location), - accuracy: 0.5) + let estimatedLine = SourceEditSupport.line(containing: topLine(controller, scrollView).character, in: storage) + XCTAssertLessThanOrEqual(abs(estimatedLine - SourceEditSupport.line(containing: before.character, in: storage)), 3) + settle(controller) + XCTAssertEqual(exactOffset(controller, scrollView, character: before.character), before.offset, accuracy: 0.5) XCTAssertFalse(controller.undoManager.canUndo) XCTAssertEqual(changes, 0) } @@ -565,61 +622,121 @@ final class SourceEditorControllerTests: XCTestCase { check(reading: false) } - /// The character at the top of the visible area (container coordinates). - private func topCharacter(_ controller: SourceEditorController, _ scrollView: NSScrollView) -> Int { - let layoutManager = controller.textView.layoutManager! - let glyph = layoutManager.glyphIndex(for: NSPoint(x: 0, y: scrollView.contentView.bounds.minY), - in: controller.textView.textContainer!) - return layoutManager.characterIndexForGlyph(at: glyph) - } - - func testResizingKeepsTheTopLineDeepInALongDocument() { - var rng = SystemRandomNumberGenerator() - let text = (0..<20_000).map { index in - String(repeating: "word ", count: Int.random(in: 0...40, using: &rng)) + "\(index)" - }.joined(separator: "\n") - let (controller, scrollView, window) = makeEditor(text, width: 900) - controller.scroll(toLine: 15_000) + func testRelayoutsKeepTheTopLineDeepInAWrappingDocument() { + // ~300 K UTF-16 units: the anchor stays below the exact-restore limit. + let (controller, scrollView, window) = makeEditor(wrappingLines(3_000), width: 900) let storage = controller.textView.textStorage!.mutableString - let target = SourceEditSupport.lineStart(15_000, in: storage) - XCTAssertEqual(topCharacter(controller, scrollView), target) + XCTAssertLessThan(storage.length, SourceEditorController.exactRestoreCharacterLimit) + controller.scroll(toLine: 2_000) + let anchor = topLine(controller, scrollView) + let anchorLine = SourceEditSupport.line(containing: anchor.character, in: storage) /// Immediately after a relayout: the estimate — the same text, give - /// or take a line or two (of 20 000, after the whole layout moved). - func checkTopLine(_ label: String, line: UInt = #line) { - let top = SourceEditSupport.line(containing: topCharacter(controller, scrollView), in: storage) - XCTAssertLessThanOrEqual(abs(top - 15_000), 3, label, line: line) + /// or take a line or two. + func checkEstimate(_ label: String, line: UInt = #line) { + let top = SourceEditSupport.line(containing: topLine(controller, scrollView).character, in: storage) + XCTAssertLessThanOrEqual(abs(top - anchorLine), 3, label, line: line) } + /// Once settled: the anchor's line exactly where it was on screen. func checkExact(_ label: String, line: UInt = #line) { - settle() - XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: target), + settle(controller, line: line) + XCTAssertEqual(exactOffset(controller, scrollView, character: anchor.character), anchor.offset, accuracy: 0.5, label, line: line) } // Several widths in a row (a sidebar animating), then the exact landing. for width in [700, 1100, 640] as [CGFloat] { window.setContentSize(NSSize(width: width, height: 600)) window.contentView!.layoutSubtreeIfNeeded() - checkTopLine("width \(width)") + checkEstimate("width \(width)") } checkExact("after resizing") controller.apply(style: style(reading: true)) - checkTopLine("reading layout") + checkEstimate("reading layout") checkExact("reading layout") controller.apply(style: style(scale: 1.3, reading: true)) - checkTopLine("zoom") + checkEstimate("zoom") checkExact("zoom") } + /// Reading Mode changes the vertical inset (24 -> 48 pt). In a window + /// narrower than the 720 pt cap the column does not change, so the line + /// at the top must not move by a single point — at once and after settling. + func testReadingModeInsetChangeDoesNotShiftTheText() { + let (controller, scrollView, _) = makeEditor(wrappingLines(2_000), width: 700) + XCTAssertEqual(SourceEditorController.columnGeometry(clipWidth: scrollView.contentView.bounds.width, + isReadingLayout: true).columnWidth, + controller.textView.textContainer!.size.width, "same column in both layouts") + controller.scroll(toLine: 1_200) + let storage = controller.textView.textStorage!.mutableString + let target = SourceEditSupport.lineStart(1_200, in: storage) + func screenY() -> CGFloat { + exactLineTop(controller, offset: target) + controller.textView.textContainerOrigin.y + - scrollView.contentView.bounds.minY + } + let before = screenY() + controller.apply(style: style(reading: true)) + XCTAssertEqual(controller.textView.textContainerInset.height, 48) + XCTAssertEqual(screenY(), before, accuracy: 0.5, "at once") + settle(controller) + XCTAssertEqual(screenY(), before, accuracy: 0.5, "after settling") + controller.apply(style: style(reading: false)) + settle(controller) + XCTAssertEqual(screenY(), before, accuracy: 0.5, "and back") + } + + /// Beyond `exactRestoreCharacterLimit` the estimate is kept: the exact + /// pass — a layout of everything above the anchor — does not run. Seen + /// directly: after settling, the text above the anchor is still unlaid + /// (background layout off here, so only the controller could lay it out). + func testExactRestoreIsSkippedBeyondTheCharacterLimit() { + let (controller, scrollView, window) = makeEditor(numberedLines(40_000), width: 900) + let layoutManager = controller.textView.layoutManager! + layoutManager.backgroundLayoutEnabled = false + controller.scroll(toLine: 30_000) + let anchor = topLine(controller, scrollView) + XCTAssertGreaterThan(anchor.character, SourceEditorController.exactRestoreCharacterLimit) + window.setContentSize(NSSize(width: 700, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + XCTAssertTrue(controller.isRestorePending) + settle(controller) + XCTAssertLessThan(layoutManager.firstUnlaidCharacterIndex(), anchor.character) + + // Control: below the limit the same sequence does lay out up to it. + controller.scroll(toLine: 5_000) + let near = topLine(controller, scrollView) + XCTAssertLessThan(near.character, SourceEditorController.exactRestoreCharacterLimit) + window.setContentSize(NSSize(width: 900, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + settle(controller) + XCTAssertGreaterThanOrEqual(layoutManager.firstUnlaidCharacterIndex(), near.character) + } + func testAScrollAfterARelayoutIsNotUndoneByTheExactRestore() { let (controller, scrollView, window) = makeEditor(numberedLines(5_000), width: 900) controller.scroll(toLine: 3_000) window.setContentSize(NSSize(width: 400, height: 600)) window.contentView!.layoutSubtreeIfNeeded() controller.scroll(toLine: 100) - settle() + settle(controller) let offset = SourceEditSupport.lineStart(100, in: controller.textView.textStorage!.mutableString) XCTAssertEqual(scrollView.contentView.bounds.minY, exactLineTop(controller, offset: offset), accuracy: 0.5) } + func testLiveScrollAndKeyboardCommandsCancelTheExactRestore() { + let (controller, scrollView, window) = makeEditor(numberedLines(5_000), width: 900) + controller.scroll(toLine: 3_000) + window.setContentSize(NSSize(width: 600, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + XCTAssertTrue(controller.isRestorePending) + NotificationCenter.default.post(name: NSScrollView.willStartLiveScrollNotification, object: scrollView) + XCTAssertFalse(controller.isRestorePending, "a scroller drag / trackpad scroll") + + window.setContentSize(NSSize(width: 800, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + XCTAssertTrue(controller.isRestorePending) + controller.textView.doCommand(by: #selector(NSResponder.pageDown(_:))) + XCTAssertFalse(controller.isRestorePending, "keyboard paging") + } + func testColoursAndAppearanceFollowADarkAndALightTheme() { let (controller, scrollView, _) = makeEditor("text", style: style(dark)) XCTAssertEqual(scrollView.appearance?.name, .darkAqua) @@ -663,4 +780,97 @@ final class SourceEditorControllerTests: XCTestCase { controller.focus() XCTAssertTrue(window.firstResponder === controller.textView) } + + func testFocusBeforeTheViewHasAWindowIsHonouredWhenItGetsOne() { + let controller = SourceEditorController() + let scrollView = controller.makeScrollView(style: style()) + controller.focus() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + windows.append(window) + window.contentView!.addSubview(scrollView) + XCTAssertTrue(window.firstResponder === controller.textView) + } + + func testMakeScrollViewDetachesTheViewFromAnOldHost() { + let (controller, scrollView, window) = makeEditor("x") + XCTAssertTrue(scrollView.superview === window.contentView) + let again = controller.makeScrollView(style: style()) + XCTAssertTrue(again === scrollView) + XCTAssertNil(again.superview, "never in two hosts at once") + } + + // MARK: - Review fixes (undo, attributes, line breaks) + + func testUndoManagerDoesNotDependOnTheDelegate() { + let (controller, _, window) = makeEditor("abc") + controller.textView.delegate = nil + XCTAssertTrue(controller.textView.undoManager === controller.undoManager) + XCTAssertFalse(controller.textView.undoManager === window.undoManager) + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + type(controller, "d") + XCTAssertTrue(controller.undoManager.canUndo) + XCTAssertFalse(window.undoManager?.canUndo ?? false) + } + + func testPastedCRLFTextIsItsOwnUndoStepBetweenTyping() { + let (controller, _, _) = makeEditor("") + let pasteboard = NSPasteboard(name: NSPasteboard.Name("QuickMDTests.SourceEditor.\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + pasteboard.setString("x\r\ny", forType: .string) + type(controller, "abc") + userAction(controller) { _ = controller.textView.readSelection(from: pasteboard, type: .string) } + type(controller, "def") + XCTAssertEqual(controller.text, "abcx\nydef") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "abcx\ny", "only the last typing goes") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "abc", "then the paste") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "") + } + + func testUndoAfterARestyleRestoresTextInTheCurrentStyle() { + let (controller, _, _) = makeEditor("hello world") + controller.textView.setSelectedRange(NSRange(location: 6, length: 5)) + userAction(controller) { controller.textView.delete(nil) } + XCTAssertEqual(controller.text, "hello ") + controller.apply(style: style(dark, scale: 2)) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "hello world") + let storage = controller.textView.textStorage! + var run = NSRange() + let font = storage.attribute(.font, at: 6, longestEffectiveRange: &run, + in: NSRange(location: 0, length: storage.length)) as? NSFont + XCTAssertEqual(font?.pointSize, BlockLayout.Code.codeFontSize * 2) + XCTAssertEqual(run, NSRange(location: 0, length: storage.length), "one run: a plain buffer") + assertSameColor(storage.attribute(.foregroundColor, at: 8, effectiveRange: nil) as? NSColor, + dark.textColor, appearance: NSAppearance(named: .darkAqua)) + } + + func testShiftReturnAndOptionReturnInsertAPlainNewline() { + for selector in [#selector(NSResponder.insertLineBreak(_:)), + #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:))] { + let (controller, _, _) = makeEditor(" item") + controller.textView.setSelectedRange(NSRange(location: 6, length: 0)) + command(controller, selector) + XCTAssertEqual(controller.text, " item\n ", "\(selector)") + XCTAssertFalse(controller.text.unicodeScalars.contains("\u{2028}")) + controller.undoManager.undo() + XCTAssertEqual(controller.text, " item") + } + } + + func testLoadBreaksTypingCoalescing() { + let (controller, _, _) = makeEditor("") + type(controller, "old") + controller.load("new") + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + type(controller, "er") + XCTAssertEqual(controller.text, "newer") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "new") + } } From 76020a34fc312ccd93e0e5a09ca549c662f88114 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 14:54:33 +0200 Subject: [PATCH 06/17] feat(source-edit): edit session - enter, save, conflicts, leave, close confirmation SourceEditSession owns one window's edit flows: fresh read with entry checks (writable, size, byte-exact round trip), in-place save in the file's own encoding and line endings with a conflict check, external-change handling (silent adopt when clean, banner when dirty), leave / discard, and the EditCloseGuard handlers. Prompts are injectable; the rendered text always equals what is on disk. SourceEditorController gains reload(_:). Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 10 + QuickMD/QuickMD/SourceEditSession.swift | 685 ++++++++++++++ QuickMD/QuickMD/SourceEditorController.swift | 46 + .../QuickMDTests/SourceEditSessionTests.swift | 860 ++++++++++++++++++ .../SourceEditorControllerTests.swift | 82 ++ 5 files changed, 1683 insertions(+) create mode 100644 QuickMD/QuickMD/SourceEditSession.swift create mode 100644 QuickMD/QuickMDTests/SourceEditSessionTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index cd689ee..e367106 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -137,6 +137,9 @@ A1B2C3D401000140 /* EditCloseGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; }; A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; }; A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000141 /* EditCloseGuardTests.swift */; }; + A1B2C3D401000160 /* SourceEditSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; }; + A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; }; + A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000161 /* SourceEditSessionTests.swift */; }; A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000F0 /* HeadingLayoutTests.swift */; }; /* End PBXBuildFile section */ @@ -169,6 +172,8 @@ A1B2C3D400000152 /* SourceEditorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorControllerTests.swift; sourceTree = ""; }; A1B2C3D400000140 /* EditCloseGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuard.swift; sourceTree = ""; }; A1B2C3D400000141 /* EditCloseGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuardTests.swift; sourceTree = ""; }; + A1B2C3D400000160 /* SourceEditSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSession.swift; sourceTree = ""; }; + A1B2C3D400000161 /* SourceEditSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSessionTests.swift; sourceTree = ""; }; A1B2C3D4000000F0 /* HeadingLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingLayoutTests.swift; sourceTree = ""; }; A1B2C3D400000001 /* QuickMDApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMDApp.swift; sourceTree = ""; }; A1B2C3D400000002 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; @@ -302,6 +307,7 @@ A1B2C3D400000131 /* SourceEditSupportTests.swift */, A1B2C3D400000152 /* SourceEditorControllerTests.swift */, A1B2C3D400000141 /* EditCloseGuardTests.swift */, + A1B2C3D400000161 /* SourceEditSessionTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -327,6 +333,7 @@ A1B2C3D400000130 /* SourceEditSupport.swift */, A1B2C3D400000150 /* SourceEditorController.swift */, A1B2C3D400000140 /* EditCloseGuard.swift */, + A1B2C3D400000160 /* SourceEditSession.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -617,6 +624,7 @@ A1B2C3D401000150 /* SourceEditorController.swift in Sources */, A1B2C3D401000152 /* SourceEditorView.swift in Sources */, A1B2C3D401000140 /* EditCloseGuard.swift in Sources */, + A1B2C3D401000160 /* SourceEditSession.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -668,6 +676,8 @@ A1B2C3D401000153 /* SourceEditorControllerTests.swift in Sources */, A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */, A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */, + A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */, + A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QuickMD/QuickMD/SourceEditSession.swift b/QuickMD/QuickMD/SourceEditSession.swift new file mode 100644 index 0000000..5accaf5 --- /dev/null +++ b/QuickMD/QuickMD/SourceEditSession.swift @@ -0,0 +1,685 @@ +import AppKit +import Combine + +// MARK: - Source Edit session (v1.12 S-D2, S-D3, S-D7–S-D11) +// +// One per document window: entering the editor, saving, external changes, +// leaving, and the answers the close guard needs. The view (T5b) owns it as a +// @StateObject, mounts `editor`, and feeds it an `Environment`. +// +// The invariant everything here protects (S-D2): the RENDERED text always +// equals what is on disk; unsaved text exists only in the editor's buffer. +// The session therefore never binds the buffer to the rendered text — it +// hands the view text through `commitText` only when that text is on disk. +// +// S-D1: nothing here calls `updateChangeCount`, touches `window.undoManager` +// or uses `performClose`. The window's NSDocument is SwiftUI's viewer +// document, which autosaves in place: if it ever became edited AppKit would +// write the ORIGINAL text over the file. +// +// Main-thread by convention, deliberately NOT @MainActor — the SwiftUI view +// code that creates and drives it is nonisolated on the older SDK the CI +// runner builds with (constraints "CI builds on an OLDER SDK"; same reasoning +// as `FileWatcher` and `EditCloseGuard`). + +final class SourceEditSession: ObservableObject { + + /// Above this the editor refuses to open: entering at the end of a 5 MB + /// buffer measured ~1.8 s of TextKit layout (the caret's exact line top + /// needs everything above it laid out), and typing comforts scan lines. + /// A file that big is not a "fix the typo you are reading" edit. + static let maximumEditableBytes = 2 * 1024 * 1024 + + // MARK: Published state + + /// The editor is showing (the view mounts it over the rendered list). + @Published private(set) var isActive = false + /// The buffer holds text that is not on disk. Set on the first edit and + /// re-checked exactly (`buffer == savedText`) before every prompt or + /// close decision, so "typed and undid" never asks. + @Published private(set) var isDirty = false + /// The file changed on disk while the buffer was dirty — drives the + /// "changed on disk" banner (S-D9). + @Published private(set) var externalChange = false + /// Published once per leave: what the view needs for the landing scroll. + @Published private(set) var lastLeave: LeaveInfo? + + /// The editing surface. Its text storage IS the buffer. + let editor = SourceEditorController() + + // MARK: Environment + + /// What the view supplies. Closures, so the session holds neither the + /// window nor the view: window → close guard → handlers → session must not + /// lead back to the window (every closed document would stay alive). + struct Environment { + var documentURL: URL? + /// The document window (weakly held by whoever provides it). + var window: () -> NSWindow? = { nil } + /// The text the rendered view currently shows, so entering only + /// re-parses when the disk moved on. Nil = unknown: always commit. + var renderedText: () -> String? = { nil } + /// Make the rendered document show this LF text. Called only with + /// text that is on disk. + var commitText: (String) -> Void = { _ in } + var toast: (String) -> Void = { _ in } + } + + var environment = Environment() + + // MARK: Prompts + + enum ConflictChoice: Equatable { case saveAnyway, loadDiskVersion, cancel } + enum SaveFailedChoice: Equatable { case saveCopy, ok } + + /// Every question the session asks. Injectable so the tests can answer; + /// `.standard` shows real sheets on the window (NSAlert / NSSavePanel + /// `beginSheetModal`), begun SYNCHRONOUSLY when called — the close guard + /// treats "a sheet is attached" as "confirmation in progress", so each + /// follow-up sheet must be up before the previous handler returns. + /// A nil window (the view not in a window yet) falls back to app-modal. + struct Prompts { + var unsavedChanges: (_ window: NSWindow?, _ fileName: String, + _ completion: @escaping (UnsavedChangesAlert.Choice) -> Void) -> Void + /// No Cancel: the window is already closing. `.save` or `.discard`. + var unsavedChangesModal: (_ fileName: String) -> UnsavedChangesAlert.Choice + var saveConflict: (_ window: NSWindow?, _ fileName: String, + _ completion: @escaping (ConflictChoice) -> Void) -> Void + /// True = save as UTF-8. + var encodingFallback: (_ window: NSWindow?, _ fileName: String, _ encodingName: String, + _ completion: @escaping (Bool) -> Void) -> Void + var saveFailed: (_ window: NSWindow?, _ error: Error, + _ completion: @escaping (SaveFailedChoice) -> Void) -> Void + /// Where to put "Save a Copy…"; nil = cancelled. + var copyDestination: (_ window: NSWindow?, _ suggestedName: String, _ directory: URL?, + _ completion: @escaping (URL?) -> Void) -> Void + /// App-modal variant for the closing-window path. + var copyDestinationModal: (_ suggestedName: String, _ directory: URL?) -> URL? + } + + var prompts = Prompts.standard + + // MARK: Leaving + + /// What leaving tells the view (S-D10). + struct LeaveInfo: Equatable { + /// The caret's 0-based line in the saved text. + let caretLine: Int + /// Scroll the rendered view to the block holding `caretLine`: the + /// session saved at least once or the caret left the entry line. + /// Otherwise the list stays exactly where the reader left it. + let shouldLand: Bool + /// Distinguishes two leaves with the same values (`onChange`). + let sequence: Int + } + + // MARK: Entering + + /// Why `enter` did not enter. `message` is what the view toasts. + enum EnterRefusal: Equatable { + case alreadyEditing + case noFile + case missing(fileName: String) + case unreadable(fileName: String) + case tooLarge(fileName: String) + case readOnly(fileName: String) + /// Re-encoding the unchanged text would not reproduce the file's + /// bytes (the decoder repaired something): a save would rewrite bytes + /// the user never touched (S-D8 entry check). + case unsafe(fileName: String) + + var message: String { + switch self { + case .alreadyEditing: + return "Already editing the source." + case .noFile: + return "This document has no file to edit." + case .missing(let name): + return "“\(name)” no longer exists." + case .unreadable(let name): + return "“\(name)” could not be read." + case .tooLarge(let name): + return "“\(name)” is too large to edit here (the limit is 2 MB)." + case .readOnly(let name): + return "“\(name)” is read-only." + case .unsafe(let name): + return "“\(name)” can’t be edited safely: saving would change parts of the file you did not edit." + } + } + } + + // MARK: Internal state + + /// How the file is stored; a save writes the buffer back in it. + private(set) var format = DocumentFileFormat(encoding: .utf8, hasBOM: false, lineEnding: .lf) + /// The file bytes the buffer is based on: as read on entry, as written by + /// our last save, or as acknowledged by the user (Keep My Version / Save + /// Anyway). A disk that differs from these was changed by someone else. + private(set) var baseBytes = Data() + /// The LF text matching `baseBytes` — the clean state of the buffer. + private(set) var savedText = "" + /// The caret's line right after entering (0-based). + private var entryLine = 0 + private var hasSavedOnce = false + /// The file had mixed line endings; the first save unifies them and says so. + private var hasMixedLineEndings = false + private var leaveSequence = 0 + /// Set while the session itself replaces the buffer (`replaceAll`), so + /// the editor's `onChange` does not flip the dirty flag on and off. + private var isApplyingOwnEdit = false + private weak var closeGuard: EditCloseGuard? + + init() { + editor.onChange = { [weak self] in self?.bufferDidChange() } + editor.onEscape = { [weak self] in self?.requestLeave() } + } + + private var fileName: String { + environment.documentURL?.lastPathComponent ?? "Untitled" + } + + private var window: NSWindow? { + environment.window() ?? editor.textView.window + } + + /// Enters the editor with the caret at the start of 0-based `line` (nil: + /// the top), scrolled to the top of the visible area — the view converts + /// the reading position's 1-based `editorLine()`. Returns nil when it + /// entered; otherwise nothing changed and the refusal says why. + /// The view requests focus after mounting the editor (`focusEditor`). + @discardableResult + func enter(atLine line: Int?) -> EnterRefusal? { + guard !isActive else { return .alreadyEditing } + guard let url = environment.documentURL else { return .noFile } + let name = url.lastPathComponent + let path = url.path + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: path) else { return .missing(fileName: name) } + if let size = (try? fileManager.attributesOfItem(atPath: path))?[.size] as? NSNumber, + size.intValue > Self.maximumEditableBytes { + return .tooLarge(fileName: name) + } + guard fileManager.isWritableFile(atPath: path) else { return .readOnly(fileName: name) } + // Fresh from disk, not the rendered text: the watcher's debounce may lag. + guard let data = try? Data(contentsOf: url) else { return .unreadable(fileName: name) } + guard data.count <= Self.maximumEditableBytes else { return .tooLarge(fileName: name) } + guard let decoded = DocumentFileFormat.decode(data) else { return .unreadable(fileName: name) } + guard decoded.isByteExact || decoded.hasMixedLineEndings else { return .unsafe(fileName: name) } + + adopt(decoded, bytes: data) + hasSavedOnce = false + externalChange = false + isDirty = false + if environment.renderedText() != decoded.text { + environment.commitText(decoded.text) + } + editor.load(decoded.text) + installGuardIfNeeded() + closeGuard?.setEdited(false) + isActive = true + editor.placeCaret(atLine: max(line ?? 0, 0)) + entryLine = editor.caretLine + return nil + } + + func focusEditor() { + editor.focus() + } + + private func adopt(_ decoded: DocumentFileFormat.Decoded, bytes: Data) { + format = decoded.format + baseBytes = bytes + savedText = decoded.text + hasMixedLineEndings = decoded.hasMixedLineEndings + } + + /// The guard is installed once per window and stays; while the session + /// is inactive or clean it answers "not dirty" and forwards everything. + private func installGuardIfNeeded() { + guard let window else { return } + if let closeGuard, closeGuard.window === window, !closeGuard.isClosed { return } + closeGuard = EditCloseGuard.install(on: window, handlers: EditCloseGuard.Handlers( + isDirty: { [weak self] in self?.checkDirty() ?? false }, + confirm: { [weak self] window, completion in + guard let self else { return completion(true) } + self.confirmClose(on: window, completion: completion) + }, + resolveSynchronously: { [weak self] _ in self?.resolveSynchronously() } + )) + } + + // MARK: - Dirty state + + private func bufferDidChange() { + guard !isApplyingOwnEdit, isActive, !isDirty else { return } + isDirty = true + installGuardIfNeeded() + closeGuard?.setEdited(true) + } + + /// The exact check: the flag only says "something was typed". Clears the + /// flag (and the close button's dot) when the buffer is back to the saved + /// text. Cheap while clean — the comparison runs only once flagged. + @discardableResult + func checkDirty() -> Bool { + guard isActive, isDirty else { return false } + if editor.text == savedText { setClean() } + return isDirty + } + + private func setClean() { + if isDirty { isDirty = false } + closeGuard?.setEdited(false) + } + + /// Runs `body` (which replaces the buffer through the editor) without the + /// edit counting as the user's, then ends clean. + private func replaceBufferAsOwnEdit(_ body: () -> Void) { + isApplyingOwnEdit = true + body() + isApplyingOwnEdit = false + } + + // MARK: - Saving (S-D7) + + /// ⌘S. Synchronous except for prompts; `then` gets true once the buffer + /// is on disk (or nothing needed saving) and false on cancel or failure — + /// called after the last prompt is answered. + func save(then completion: ((Bool) -> Void)? = nil) { + let finish: (Bool) -> Void = { completion?($0) } + guard isActive, environment.documentURL != nil else { return finish(false) } + let text = editor.text + guard text != savedText else { + // Nothing to write: no mtime change, no watcher echo. + setClean() + return finish(true) + } + if let data = format.encode(text) { + saveCheckingDisk(text: text, data: data, format: format, finish: finish) + return + } + prompts.encodingFallback(window, fileName, Self.displayName(of: format.encoding)) { [self] useUTF8 in + guard useUTF8 else { return finish(false) } + let fallback = format.utf8Fallback + // UTF-8 represents every String. + guard let data = fallback.encode(text) else { return finish(false) } + saveCheckingDisk(text: text, data: data, format: fallback, finish: finish) + } + } + + /// Step 2: the disk must still hold `baseBytes`, or the user decides. + private func saveCheckingDisk(text: String, data: Data, format target: DocumentFileFormat, + finish: @escaping (Bool) -> Void) { + guard let url = environment.documentURL else { return finish(false) } + // Unreadable / missing: nothing to conflict with — the write reports it. + guard let disk = try? Data(contentsOf: url), disk != baseBytes else { + return write(text: text, data: data, format: target, restoring: baseBytes, finish: finish) + } + prompts.saveConflict(window, fileName) { [self] choice in + switch choice { + case .saveAnyway: + // The user saw the conflict and chose their text: what is on + // disk now is what a failed write puts back. + baseBytes = disk + write(text: text, data: data, format: target, restoring: disk, finish: finish) + case .loadDiskVersion: + loadDiskVersion() + finish(false) + case .cancel: + finish(false) + } + } + } + + /// Steps 3 and 4. + private func write(text: String, data: Data, format target: DocumentFileFormat, restoring: Data, + finish: @escaping (Bool) -> Void) { + guard let url = environment.documentURL else { return finish(false) } + do { + try DocumentFileWriter.write(data, to: url, restoring: restoring) + } catch { + // The buffer stays dirty; the user can keep a copy of it. + prompts.saveFailed(window, error) { [self] choice in + guard choice == .saveCopy else { return finish(false) } + prompts.copyDestination(window, Self.copyName(for: url), url.deletingLastPathComponent()) { [self] destination in + if let destination { writeCopy(data, to: destination) } + finish(false) + } + } + return + } + didSave(text: text, data: data, format: target) + finish(true) + } + + private func didSave(text: String, data: Data, format target: DocumentFileFormat) { + format = target + baseBytes = data + savedText = text + hasSavedOnce = true + externalChange = false + let unified = hasMixedLineEndings + hasMixedLineEndings = false + environment.commitText(text) + if editor.text == text { setClean() } + environment.toast(unified ? "Saved · line endings unified to \(Self.displayName(of: target.lineEnding))" + : "Saved") + } + + /// A copy is a new file the save panel granted: a plain (non-atomic) + /// write. The document itself stays unsaved. + @discardableResult + private func writeCopy(_ data: Data, to destination: URL) -> Bool { + do { + try data.write(to: destination) + environment.toast("Copy saved") + return true + } catch { + environment.toast("The copy could not be saved: \(error.localizedDescription)") + return false + } + } + + static func copyName(for url: URL) -> String { + let base = url.deletingPathExtension().lastPathComponent + let ext = url.pathExtension + return ext.isEmpty ? "\(base) copy" : "\(base) copy.\(ext)" + } + + static func displayName(of encoding: DocumentFileFormat.Encoding) -> String { + switch encoding { + case .utf8: return "UTF-8" + case .utf16LittleEndian, .utf16BigEndian: return "UTF-16" + case .isoLatin1: return "ISO Latin 1" + } + } + + static func displayName(of lineEnding: DocumentFileFormat.LineEnding) -> String { + switch lineEnding { + case .lf: return "LF" + case .crlf: return "CRLF" + case .cr: return "CR" + } + } + + // MARK: - Leaving (S-D10) + + /// Esc, Done, the toggle command. Clean → leaves at once. Dirty → the + /// unsaved-changes sheet: Save leaves only if the save succeeded, Don't + /// Save leaves and drops the buffer, Cancel stays. `then(true)` = left. + func requestLeave(then completion: ((Bool) -> Void)? = nil) { + guard isActive else { completion?(false); return } + guard checkDirty() else { + leave() + completion?(true) + return + } + prompts.unsavedChanges(window, fileName) { [self] choice in + switch choice { + case .save: + save { [self] saved in + if saved { leave() } + completion?(saved) + } + case .discard: + leave() + completion?(true) + case .cancel: + completion?(false) + } + } + } + + private func leave() { + let line = editor.caretLine + leaveSequence += 1 + isActive = false + externalChange = false + if isDirty { isDirty = false } + closeGuard?.setEdited(false) + lastLeave = LeaveInfo(caretLine: line, shouldLand: hasSavedOnce || line != entryLine, + sequence: leaveSequence) + } + + /// "Discard Changes": back to the saved text as ONE undoable edit (⌘Z + /// brings the changes back, and with them the dirty state); stays in the mode. + func discardChanges() { + guard isActive else { return } + if editor.text != savedText { + replaceBufferAsOwnEdit { editor.replaceAll(with: savedText) } + } + setClean() + } + + // MARK: - External changes (S-D9) + + /// The view's file watcher fired while the session is active. + func diskDidChange() { + guard isActive, let url = environment.documentURL, + let data = try? Data(contentsOf: url) else { return } + guard data != baseBytes else { + // Our own save's echo, a touch — or the file went back to the + // version the buffer is based on, after a change the banner showed. + if externalChange { + externalChange = false + environment.commitText(savedText) + } + return + } + guard let decoded = DocumentFileFormat.decode(data) else { return } + // Rendered == disk, whatever the buffer holds. + environment.commitText(decoded.text) + guard !checkDirty() else { + externalChange = true + return + } + if let refusal = refusal(forDisk: decoded, bytes: data) { + // Clean, so nothing is lost; continuing would mean saving a file + // this session could not have entered. + leave() + environment.toast(refusal.message) + return + } + adopt(decoded, bytes: data) + editor.reload(decoded.text) + } + + /// The entry checks that still matter for a file already open. + private func refusal(forDisk decoded: DocumentFileFormat.Decoded, bytes: Data) -> EnterRefusal? { + if bytes.count > Self.maximumEditableBytes { return .tooLarge(fileName: fileName) } + if !decoded.isByteExact && !decoded.hasMixedLineEndings { return .unsafe(fileName: fileName) } + return nil + } + + /// Banner: "Keep My Version". The disk's current bytes become the base — + /// acknowledged, so a later save does not ask again — and the buffer is + /// dirty against THAT text (it is what is on disk now). + func keepMyVersion() { + guard isActive else { return } + externalChange = false + guard let url = environment.documentURL, let data = try? Data(contentsOf: url) else { return } + baseBytes = data + if let decoded = DocumentFileFormat.decode(data) { + savedText = decoded.text + hasMixedLineEndings = decoded.hasMixedLineEndings + } + if editor.text == savedText { + setClean() + } else if !isDirty { + isDirty = true + closeGuard?.setEdited(true) + } + } + + /// Banner (and conflict prompt): "Load Disk Version". Replaces the buffer + /// as ONE undoable edit — ⌘Z brings the user's text back, dirty again — + /// and adopts the disk as the clean state. + func loadDiskVersion() { + guard isActive, let url = environment.documentURL else { return } + guard let data = try? Data(contentsOf: url), let decoded = DocumentFileFormat.decode(data) else { + environment.toast(EnterRefusal.unreadable(fileName: fileName).message) + return + } + if let refusal = refusal(forDisk: decoded, bytes: data) { + // Keep the user's text; Keep My Version / Save still work. + environment.toast(refusal.message) + return + } + adopt(decoded, bytes: data) + externalChange = false + environment.commitText(decoded.text) + if editor.text != decoded.text { + replaceBufferAsOwnEdit { editor.replaceAll(with: decoded.text) } + } + setClean() + } + + // MARK: - Close guard (S-D11) + + /// The guard's `confirm`: the unsaved-changes sheet on that window. + /// `completion(true)` only once clean — saved, or Don't Save reverted the + /// buffer (also when another tab then cancels the quit and this window + /// stays open). + private func confirmClose(on window: NSWindow, completion: @escaping (Bool) -> Void) { + guard checkDirty() else { return completion(true) } + prompts.unsavedChanges(window, fileName) { [self] choice in + switch choice { + case .save: + save { completion($0) } + case .discard: + revertToSaved() + completion(true) + case .cancel: + completion(false) + } + } + } + + /// Don't Save on close: the buffer really goes back to the saved text + /// (not undoable — the window is closing), the caret stays put. + private func revertToSaved() { + editor.reload(savedText) + setClean() + } + + /// The window is already closing and cannot be kept: app-modal Save / + /// Don't Save, and Save writes without further questions — UTF-8 if the + /// encoding cannot hold the text, over a changed disk (the user said + /// Save), and a copy through an app-modal panel if the write fails. + private func resolveSynchronously() { + guard checkDirty(), let url = environment.documentURL else { return } + guard prompts.unsavedChangesModal(fileName) == .save else { return revertToSaved() } + let text = editor.text + let target = format.encode(text) != nil ? format : format.utf8Fallback + guard let data = target.encode(text) else { return } + let onDisk = (try? Data(contentsOf: url)) ?? baseBytes + do { + try DocumentFileWriter.write(data, to: url, restoring: onDisk) + didSave(text: text, data: data, format: target) + } catch { + // Until a copy is written or the user gives up on the panel. + while let destination = prompts.copyDestinationModal(Self.copyName(for: url), + url.deletingLastPathComponent()) { + if writeCopy(data, to: destination) { break } + } + } + } +} + +// MARK: - Standard prompts + +extension SourceEditSession.Prompts { + + static let conflictSaveAnywayIdentifier = "source-conflict-save-anyway" + static let conflictLoadDiskIdentifier = "source-conflict-load-disk" + static let conflictCancelIdentifier = "source-conflict-cancel" + static let encodingUTF8Identifier = "source-encoding-utf8" + static let encodingCancelIdentifier = "source-encoding-cancel" + static let saveFailedCopyIdentifier = "source-savefail-copy" + static let saveFailedOKIdentifier = "source-savefail-ok" + static let copyPanelIdentifier = "source-copy-panel" + + /// Real sheets on the window, app-modal without one. + static let standard = SourceEditSession.Prompts( + unsavedChanges: { window, fileName, completion in + guard let window else { return completion(UnsavedChangesAlert.runModal(fileName: fileName)) } + UnsavedChangesAlert.beginSheet(on: window, fileName: fileName, completion: completion) + }, + unsavedChangesModal: { fileName in + UnsavedChangesAlert.runModal(fileName: fileName) + }, + saveConflict: { window, fileName, completion in + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "“\(fileName)” has been changed by another application." + alert.informativeText = "Save Anyway replaces the other version with yours. Load Disk Version " + + "shows the file as it is on disk; Undo brings your text back." + addButton(to: alert, "Save Anyway", identifier: conflictSaveAnywayIdentifier) + addButton(to: alert, "Load Disk Version", identifier: conflictLoadDiskIdentifier) + addButton(to: alert, "Cancel", identifier: conflictCancelIdentifier, key: "\u{1b}") + run(alert, on: window) { response in + switch response { + case .alertFirstButtonReturn: completion(.saveAnyway) + case .alertSecondButtonReturn: completion(.loadDiskVersion) + default: completion(.cancel) + } + } + }, + encodingFallback: { window, fileName, encodingName, completion in + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "“\(fileName)” uses \(encodingName), which cannot store some of the characters you typed." + alert.informativeText = "Saving as UTF-8 keeps every character. The line endings stay as they are." + addButton(to: alert, "Save as UTF-8", identifier: encodingUTF8Identifier) + addButton(to: alert, "Cancel", identifier: encodingCancelIdentifier, key: "\u{1b}") + run(alert, on: window) { completion($0 == .alertFirstButtonReturn) } + }, + saveFailed: { window, error, completion in + let alert = NSAlert() + alert.alertStyle = .critical + let localized = error as? LocalizedError + alert.messageText = localized?.errorDescription ?? error.localizedDescription + alert.informativeText = [localized?.failureReason, localized?.recoverySuggestion] + .compactMap { $0 }.joined(separator: "\n\n") + addButton(to: alert, "Save a Copy…", identifier: saveFailedCopyIdentifier) + addButton(to: alert, "OK", identifier: saveFailedOKIdentifier, key: "\u{1b}") + run(alert, on: window) { completion($0 == .alertFirstButtonReturn ? .saveCopy : .ok) } + }, + copyDestination: { window, suggestedName, directory, completion in + let panel = copyPanel(suggestedName: suggestedName, directory: directory) + guard let window else { + return completion(panel.runModal() == .OK ? panel.url : nil) + } + panel.beginSheetModal(for: window) { response in + completion(response == .OK ? panel.url : nil) + } + }, + copyDestinationModal: { suggestedName, directory in + let panel = copyPanel(suggestedName: suggestedName, directory: directory) + return panel.runModal() == .OK ? panel.url : nil + } + ) + + private static func addButton(to alert: NSAlert, _ title: String, identifier: String, key: String? = nil) { + let button = alert.addButton(withTitle: title) + if let key { button.keyEquivalent = key } + button.setAccessibilityIdentifier(identifier) + } + + private static func run(_ alert: NSAlert, on window: NSWindow?, + completion: @escaping (NSApplication.ModalResponse) -> Void) { + guard let window else { return completion(alert.runModal()) } + alert.beginSheetModal(for: window, completionHandler: completion) + } + + private static func copyPanel(suggestedName: String, directory: URL?) -> NSSavePanel { + let panel = NSSavePanel() + panel.title = "Save a Copy" + panel.nameFieldStringValue = suggestedName + panel.directoryURL = directory + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.identifier = NSUserInterfaceItemIdentifier(copyPanelIdentifier) + return panel + } +} diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift index a6037df..b353d6e 100644 --- a/QuickMD/QuickMD/SourceEditorController.swift +++ b/QuickMD/QuickMD/SourceEditorController.swift @@ -414,6 +414,52 @@ final class SourceEditorController: NSObject, NSTextViewDelegate { scrollClip(toY: 0) } + /// `load` for a buffer the user is looking at: adopting a clean external + /// change, or reverting after Don't Save on close (S-D9). Same contract — + /// no undo entry, no `onChange`, the undo stack is cleared — but the + /// caret keeps its line and column and the line at the top of the visible + /// area stays there, each clamped to the new text: an external rewrite of + /// a paragraph elsewhere must not throw the user back to line 0. + /// Positions are carried as (line, column), not as offsets — an edit + /// above the caret shifts every offset below it. + func reload(_ text: String) { + let normalized = MarkdownDocument.normalizeLineEndings(text) + let old = textStorage.mutableString + func lineAndColumn(_ offset: Int) -> (line: Int, column: Int) { + let line = SourceEditSupport.line(containing: offset, in: old) + return (line, offset - SourceEditSupport.lineStart(line, in: old)) + } + let caret = lineAndColumn(textView.selectedRange().location) + let top = topVisibleAnchor().map { (position: lineAndColumn($0.character), offset: $0.offset) } + let pendingTopLine = pendingTopOffset.map { SourceEditSupport.line(containing: $0, in: old) } + + textStorage.beginEditing() + textStorage.replaceCharacters(in: NSRange(location: 0, length: textStorage.length), + with: NSAttributedString(string: normalized, attributes: baseAttributes)) + textStorage.endEditing() + cachedIndentUnit = nil + undoManager.removeAllActions() + dropPendingAnchor() + + let new = textStorage.mutableString + func offset(line: Int, column: Int) -> Int { + // `lineRange` clamps a line past the end to the last line. + let range = SourceEditSupport.lineRange(line, in: new) + return range.location + min(column, range.length) + } + textView.setSelectedRange(NSRange(location: offset(line: caret.line, column: caret.column), length: 0)) + if let pendingTopLine { + // Not laid out yet: the entry scroll still has to happen, now in + // the new text. + pendingTopOffset = SourceEditSupport.lineStart(pendingTopLine, in: new) + } else if let top { + restore(TopAnchor(character: offset(line: top.position.line, column: top.position.column), + offset: top.offset)) + } else { + scrollClip(toY: 0) + } + } + /// Replaces everything as ONE undoable edit (Discard Changes, Load Disk /// Version — ⌘Z brings the previous text back). Fires `onChange`. The /// caret keeps its offset where the new text is long enough. diff --git a/QuickMD/QuickMDTests/SourceEditSessionTests.swift b/QuickMD/QuickMDTests/SourceEditSessionTests.swift new file mode 100644 index 0000000..05a7794 --- /dev/null +++ b/QuickMD/QuickMDTests/SourceEditSessionTests.swift @@ -0,0 +1,860 @@ +import XCTest +import AppKit +import Darwin + +/// SourceEditSession (v1.12 S-D2, S-D3, S-D7–S-D11) driven headlessly: real +/// temp files, a real SourceEditorController hosted in a window that is never +/// ordered on screen, prompts injected as recorders with scripted answers (no +/// real sheet ever appears), the close guard's process hooks stubbed. +/// +/// Undo: as in `SourceEditorControllerTests`, the editor's undo manager does +/// not group by event (a test has no events) and each simulated user action +/// is wrapped in its own group. +final class SourceEditSessionTests: XCTestCase { + + // MARK: - Fakes + + /// Scripted answers + a record of every question asked. + private final class FakePrompts { + var unsavedAnswers: [UnsavedChangesAlert.Choice] = [] + var unsavedCalls = 0 + /// Runs when the unsaved-changes prompt is asked, before it answers. + var onUnsaved: (() -> Void)? + /// `.discard` by default: tearDown closes windows, and a dirty session + /// must not write into a fixture then. + var modalAnswer: UnsavedChangesAlert.Choice = .discard + var modalCalls = 0 + var conflictAnswer: SourceEditSession.ConflictChoice = .cancel + var conflictCalls = 0 + var encodingAnswer = false + var encodingNames: [String] = [] + var saveFailedAnswer: SourceEditSession.SaveFailedChoice = .ok + var saveFailedErrors: [Error] = [] + var copyURL: URL? + var copyCalls = 0 + var copyModalURLs: [URL?] = [] + var copyModalCalls = 0 + + var prompts: SourceEditSession.Prompts { + SourceEditSession.Prompts( + unsavedChanges: { _, _, completion in + self.unsavedCalls += 1 + self.onUnsaved?() + completion(self.unsavedAnswers.isEmpty ? .cancel : self.unsavedAnswers.removeFirst()) + }, + unsavedChangesModal: { _ in + self.modalCalls += 1 + return self.modalAnswer + }, + saveConflict: { _, _, completion in + self.conflictCalls += 1 + completion(self.conflictAnswer) + }, + encodingFallback: { _, _, name, completion in + self.encodingNames.append(name) + completion(self.encodingAnswer) + }, + saveFailed: { _, error, completion in + self.saveFailedErrors.append(error) + completion(self.saveFailedAnswer) + }, + copyDestination: { _, _, _, completion in + self.copyCalls += 1 + completion(self.copyURL) + }, + copyDestinationModal: { _, _ in + self.copyModalCalls += 1 + return self.copyModalURLs.isEmpty ? nil : self.copyModalURLs.removeFirst() + } + ) + } + } + + /// What the session tells the view. + private final class ViewRecorder { + var rendered: String? + var commits: [String] = [] + var toasts: [String] = [] + } + + private var directory: URL! + private var windows: [NSWindow] = [] + private var prompts = FakePrompts() + private var view = ViewRecorder() + private var savedHasAttachedSheet: ((NSWindow) -> Bool)! + private var savedDisable: ((String) -> Void)! + private var savedEnable: ((String) -> Void)! + + override func setUpWithError() throws { + try super.setUpWithError() + _ = NSApplication.shared + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("SourceEditSessionTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + prompts = FakePrompts() + view = ViewRecorder() + savedHasAttachedSheet = EditCloseGuard.hasAttachedSheet + savedDisable = EditCloseGuard.disableAutomaticTermination + savedEnable = EditCloseGuard.enableAutomaticTermination + EditCloseGuard.hasAttachedSheet = { _ in false } + EditCloseGuard.disableAutomaticTermination = { _ in } + EditCloseGuard.enableAutomaticTermination = { _ in } + } + + override func tearDownWithError() throws { + windows.forEach { $0.close() } + windows.removeAll() + EditCloseGuard.hasAttachedSheet = savedHasAttachedSheet + EditCloseGuard.disableAutomaticTermination = savedDisable + EditCloseGuard.enableAutomaticTermination = savedEnable + if let enumerator = FileManager.default.enumerator(atPath: directory.path) { + for case let path as String in enumerator { + chmod(directory.appendingPathComponent(path).path, 0o644) + } + } + try? FileManager.default.removeItem(at: directory) + try super.tearDownWithError() + } + + // MARK: - Helpers + + private func fixture(_ bytes: Data, name: String = "doc.md") throws -> URL { + let url = directory.appendingPathComponent(name) + try bytes.write(to: url) + return url + } + + private func fixture(_ text: String, name: String = "doc.md") throws -> URL { + try fixture(Data(text.utf8), name: name) + } + + private func bytes(_ url: URL) -> Data? { try? Data(contentsOf: url) } + + /// A session whose editor is hosted in an offscreen window, wired to the + /// recorders. The environment captures the window weakly, as the view's must. + private func makeSession(_ url: URL?, rendered: String? = nil) -> (SourceEditSession, NSWindow) { + let session = SourceEditSession() + session.editor.undoManager.groupsByEvent = false + let style = SourceEditorController.Style(theme: MarkdownTheme.cached(for: .light), + fontScale: 1, isReadingLayout: false) + let scrollView = session.editor.makeScrollView(style: style) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + scrollView.frame = window.contentView!.bounds + scrollView.autoresizingMask = [.width, .height] + window.contentView!.addSubview(scrollView) + window.contentView!.layoutSubtreeIfNeeded() + windows.append(window) + view.rendered = rendered + let recorder = view + session.environment = SourceEditSession.Environment( + documentURL: url, + window: { [weak window] in window }, + renderedText: { [weak recorder] in recorder?.rendered }, + commitText: { [weak recorder] text in + recorder?.commits.append(text) + recorder?.rendered = text + }, + toast: { [weak recorder] in recorder?.toasts.append($0) } + ) + session.prompts = prompts.prompts + return (session, window) + } + + private func entered(_ url: URL, line: Int? = nil, rendered: String? = nil, + file: StaticString = #filePath, lineNumber: UInt = #line) -> (SourceEditSession, NSWindow) { + let (session, window) = makeSession(url, rendered: rendered) + XCTAssertNil(session.enter(atLine: line), file: file, line: lineNumber) + XCTAssertTrue(session.isActive, file: file, line: lineNumber) + return (session, window) + } + + private func userAction(_ session: SourceEditSession, _ body: () -> Void) { + session.editor.undoManager.beginUndoGrouping() + body() + session.editor.undoManager.endUndoGrouping() + } + + /// Types `string` at UTF-16 `offset` (default: where the caret is). + private func type(_ session: SourceEditSession, _ string: String, at offset: Int? = nil) { + let textView = session.editor.textView + if let offset { textView.setSelectedRange(NSRange(location: offset, length: 0)) } + userAction(session) { textView.insertText(string, replacementRange: textView.selectedRange()) } + } + + private func delete(_ session: SourceEditSession, _ range: NSRange) { + let textView = session.editor.textView + userAction(session) { textView.insertText("", replacementRange: range) } + } + + @discardableResult + private func save(_ session: SourceEditSession) -> Bool? { + var result: Bool? + session.save { result = $0 } + return result + } + + private func guardHandlers(_ window: NSWindow, file: StaticString = #filePath, + line: UInt = #line) throws -> EditCloseGuard.Handlers { + try XCTUnwrap(EditCloseGuard.guardFor(window), "the session installs the guard", file: file, line: line) + .handlers + } + + private func inode(_ url: URL) -> (ino: UInt64, mode: UInt16) { + var info = stat() + XCTAssertEqual(stat(url.path, &info), 0) + return (UInt64(info.st_ino), UInt16(info.st_mode & 0o7777)) + } + + // MARK: - Entering: refusals (S-D3, S-D8) + + func testEnterWithoutAFileIsRefused() { + let (session, _) = makeSession(nil) + XCTAssertEqual(session.enter(atLine: 0), .noFile) + XCTAssertFalse(session.isActive) + } + + func testEnterMissingFileIsRefused() { + let (session, _) = makeSession(directory.appendingPathComponent("gone.md")) + let refusal = session.enter(atLine: 0) + XCTAssertEqual(refusal, .missing(fileName: "gone.md")) + XCTAssertEqual(refusal?.message, "“gone.md” no longer exists.") + XCTAssertFalse(session.isActive) + } + + func testEnterUnreadableIsRefused() throws { + // A directory exists and is writable but cannot be read as data. + let folder = directory.appendingPathComponent("folder.md") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: false) + let (session, _) = makeSession(folder) + XCTAssertEqual(session.enter(atLine: 0), .unreadable(fileName: "folder.md")) + XCTAssertFalse(session.isActive) + } + + func testEnterReadOnlyFileIsRefused() throws { + let url = try fixture("text\n", name: "x.md") + chmod(url.path, 0o444) + let (session, _) = makeSession(url) + let refusal = session.enter(atLine: 0) + XCTAssertEqual(refusal, .readOnly(fileName: "x.md")) + XCTAssertEqual(refusal?.message, "“x.md” is read-only.") + XCTAssertFalse(session.isActive) + } + + func testEnterAboveTheSizeLimitIsRefusedAndAtTheLimitIsAllowed() throws { + XCTAssertEqual(SourceEditSession.maximumEditableBytes, 2 * 1024 * 1024) + let line = String(repeating: "a", count: 1023) + "\n" + let atLimit = String(repeating: line, count: 2 * 1024) + XCTAssertEqual(atLimit.utf8.count, SourceEditSession.maximumEditableBytes) + let big = try fixture(atLimit + "b", name: "big.md") + let (refused, _) = makeSession(big) + XCTAssertEqual(refused.enter(atLine: 0), .tooLarge(fileName: "big.md")) + XCTAssertFalse(refused.isActive) + + let ok = try fixture(atLimit, name: "ok.md") + _ = entered(ok) + } + + func testEnterFileTheDecoderAlteredIsRefused() throws { + let candidates = [Data([0xFF, 0xFE, 0x61, 0x00, 0x62]), + Data([0xFE, 0xFF, 0x00, 0x61, 0x00]), + Data([0xFF, 0xFE, 0x00, 0xD8, 0x61, 0x00])] + guard let unsafe = candidates.first(where: { data in + guard let decoded = DocumentFileFormat.decode(data) else { return false } + return !decoded.isByteExact && !decoded.hasMixedLineEndings + }) else { + throw XCTSkip("this system's decoder round-trips every malformed fixture") + } + let url = try fixture(unsafe, name: "odd.md") + let (session, _) = makeSession(url) + let refusal = session.enter(atLine: 0) + XCTAssertEqual(refusal, .unsafe(fileName: "odd.md")) + XCTAssertEqual(refusal?.message, + "“odd.md” can’t be edited safely: saving would change parts of the file you did not edit.") + XCTAssertFalse(session.isActive) + } + + func testEnterTwiceIsRefused() throws { + let (session, _) = entered(try fixture("a\n")) + XCTAssertEqual(session.enter(atLine: 0), .alreadyEditing) + XCTAssertTrue(session.isActive) + } + + func testMixedLineEndingsMayBeEdited() throws { + let (session, _) = entered(try fixture("a\r\nb\nc\r\n")) + XCTAssertEqual(session.editor.text, "a\nb\nc\n") + } + + // MARK: - Entering: success + + func testEnterReadsFreshDiskTextAndCommitsItWhenTheViewWasBehind() throws { + let url = try fixture("new disk text\n") + let (session, _) = entered(url, rendered: "stale rendered text\n") + XCTAssertEqual(session.editor.text, "new disk text\n") + XCTAssertEqual(session.savedText, "new disk text\n") + XCTAssertEqual(session.baseBytes, bytes(url)) + XCTAssertEqual(view.commits, ["new disk text\n"]) + XCTAssertFalse(session.isDirty) + } + + func testEnterDoesNotCommitWhenTheViewAlreadyShowsTheDiskText() throws { + let url = try fixture("same\r\ntext\r\n") + _ = entered(url, rendered: "same\ntext\n") + XCTAssertEqual(view.commits, []) + } + + func testEnterPlacesTheCaretOnTheGivenLineAndInstallsTheGuard() throws { + let url = try fixture((0..<50).map { "line \($0)" }.joined(separator: "\n")) + let (session, window) = entered(url, line: 20) + XCTAssertEqual(session.editor.caretLine, 20) + XCTAssertNotNil(EditCloseGuard.guardFor(window)) + XCTAssertTrue(window.delegate === EditCloseGuard.guardFor(window)) + XCTAssertFalse(session.editor.undoManager.canUndo) + } + + // MARK: - Dirty state + + func testDirtyFlagMirrorsToTheWindowAndClearsOnTheExactCheck() throws { + let (session, window) = entered(try fixture("abc\n")) + XCTAssertFalse(window.isDocumentEdited) + type(session, "x", at: 0) + XCTAssertTrue(session.isDirty) + XCTAssertTrue(window.isDocumentEdited) + XCTAssertTrue(try guardHandlers(window).isDirty()) + session.editor.undoManager.undo() + XCTAssertEqual(session.editor.text, "abc\n") + XCTAssertFalse(try guardHandlers(window).isDirty(), "the guard's check is exact") + XCTAssertFalse(session.isDirty) + XCTAssertFalse(window.isDocumentEdited) + } + + func testNothingLandsOnTheWindowsUndoManager() throws { + let (session, window) = entered(try fixture("abc\n")) + type(session, "x", at: 0) + XCTAssertTrue(session.editor.undoManager.canUndo) + XCTAssertFalse(window.undoManager?.canUndo ?? false, "S-D1: the window's stack stays empty") + } + + // MARK: - Saving: byte-exact (S-D7, S-D8) + + private func assertByteExactSave(_ original: Data, expected: Data, insert: String = "X", + file: StaticString = #filePath, line: UInt = #line) throws { + let url = try fixture(original, name: "fmt-\(UUID().uuidString).md") + let (session, _) = entered(url, file: file, lineNumber: line) + type(session, insert, at: 0) + XCTAssertEqual(save(session), true, file: file, line: line) + XCTAssertEqual(bytes(url), expected, "after the edit", file: file, line: line) + XCTAssertFalse(session.isDirty, file: file, line: line) + // Revert by a new edit (not undo) and save again: the original bytes. + delete(session, NSRange(location: 0, length: (insert as NSString).length)) + XCTAssertEqual(save(session), true, file: file, line: line) + XCTAssertEqual(bytes(url), original, "after reverting the edit", file: file, line: line) + } + + func testByteExactSaveLF() throws { + try assertByteExactSave(Data("a\nb\n".utf8), expected: Data("Xa\nb\n".utf8)) + } + + func testByteExactSaveCRLF() throws { + try assertByteExactSave(Data("a\r\nb\r\n".utf8), expected: Data("Xa\r\nb\r\n".utf8)) + } + + func testByteExactSaveUTF8WithBOM() throws { + let bom = Data([0xEF, 0xBB, 0xBF]) + try assertByteExactSave(bom + Data("a\nb".utf8), expected: bom + Data("Xa\nb".utf8)) + } + + func testByteExactSaveUTF16LE() throws { + let bom = Data([0xFF, 0xFE]) + try assertByteExactSave(bom + "zażółć\r\n".data(using: .utf16LittleEndian)!, + expected: bom + "Xzażółć\r\n".data(using: .utf16LittleEndian)!) + } + + func testByteExactSaveLatin1() throws { + try assertByteExactSave("café\nb\n".data(using: .isoLatin1)!, + expected: "Xcafé\nb\n".data(using: .isoLatin1)!) + } + + func testTypedAndUndoneSaveDoesNotWrite() throws { + let url = try fixture("abc\n") + let past = Date(timeIntervalSince1970: 1_000_000_000) + try FileManager.default.setAttributes([.modificationDate: past], ofItemAtPath: url.path) + let (session, _) = entered(url) + type(session, "x", at: 1) + session.editor.undoManager.undo() + XCTAssertEqual(save(session), true) + XCTAssertEqual(save(session), true, "nothing changed at all") + let mtime = try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date + XCTAssertEqual(mtime, past, "a no-op save does not touch the file") + XCTAssertEqual(view.toasts, []) + XCTAssertFalse(session.isDirty) + } + + func testSaveKeepsInodeAndPermissionsAndTheUndoStack() throws { + let url = try fixture("abc\n") + chmod(url.path, 0o640) + let before = inode(url) + let (session, window) = entered(url) + type(session, "x", at: 0) + XCTAssertEqual(save(session), true) + let after = inode(url) + XCTAssertEqual(after.ino, before.ino) + XCTAssertEqual(after.mode, 0o640) + XCTAssertEqual(bytes(url), Data("xabc\n".utf8)) + XCTAssertEqual(view.toasts, ["Saved"]) + XCTAssertEqual(view.commits.last, "xabc\n", "the rendered view shows the saved text") + XCTAssertFalse(window.isDocumentEdited) + XCTAssertTrue(session.editor.undoManager.canUndo, "the undo stack survives a save") + session.editor.undoManager.undo() + XCTAssertTrue(session.isDirty, "undoing past the save is an edit") + } + + func testMixedLineEndingsToastOnlyOnTheFirstSave() throws { + let url = try fixture("a\r\nb\nc\r\n") + let (session, _) = entered(url) + type(session, "1", at: 0) + XCTAssertEqual(save(session), true) + XCTAssertEqual(bytes(url), Data("1a\r\nb\r\nc\r\n".utf8)) + type(session, "2", at: 0) + XCTAssertEqual(save(session), true) + XCTAssertEqual(view.toasts, ["Saved · line endings unified to CRLF", "Saved"]) + } + + // MARK: - Saving: conflicts (S-D9 step 2) + + private func conflictSetup() throws -> (SourceEditSession, URL) { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other app\n".utf8).write(to: url) + return (session, url) + } + + func testConflictCancelLeavesEverything() throws { + let (session, url) = try conflictSetup() + prompts.conflictAnswer = .cancel + XCTAssertEqual(save(session), false) + XCTAssertEqual(prompts.conflictCalls, 1) + XCTAssertEqual(bytes(url), Data("other app\n".utf8)) + XCTAssertEqual(session.editor.text, "mine base\n") + XCTAssertTrue(session.isDirty) + } + + func testConflictSaveAnywayWrites() throws { + let (session, url) = try conflictSetup() + prompts.conflictAnswer = .saveAnyway + XCTAssertEqual(save(session), true) + XCTAssertEqual(bytes(url), Data("mine base\n".utf8)) + XCTAssertFalse(session.isDirty) + type(session, "!", at: 0) + XCTAssertEqual(save(session), true) + XCTAssertEqual(prompts.conflictCalls, 1, "our own write is the new base") + } + + func testConflictLoadDiskVersionReplacesTheBufferUndoably() throws { + let (session, url) = try conflictSetup() + prompts.conflictAnswer = .loadDiskVersion + XCTAssertEqual(save(session), false) + XCTAssertEqual(bytes(url), Data("other app\n".utf8)) + XCTAssertEqual(session.editor.text, "other app\n") + XCTAssertFalse(session.isDirty) + session.editor.undoManager.undo() + XCTAssertEqual(session.editor.text, "mine base\n") + XCTAssertTrue(session.isDirty) + } + + // MARK: - Saving: encoding fallback, failure + + func testLatin1WithAnUnrepresentableCharacterDeclined() throws { + let original = "caf\u{E9}\n".data(using: .isoLatin1)! + let url = try fixture(original) + let (session, _) = entered(url) + type(session, "ł", at: 0) + prompts.encodingAnswer = false + XCTAssertEqual(save(session), false) + XCTAssertEqual(prompts.encodingNames, ["ISO Latin 1"]) + XCTAssertEqual(bytes(url), original) + XCTAssertTrue(session.isDirty) + XCTAssertEqual(session.format.encoding, .isoLatin1) + } + + func testLatin1WithAnUnrepresentableCharacterSavedAsUTF8() throws { + let url = try fixture("caf\u{E9}\r\n".data(using: .isoLatin1)!) + let (session, _) = entered(url) + type(session, "ł", at: 0) + prompts.encodingAnswer = true + XCTAssertEqual(save(session), true) + XCTAssertEqual(bytes(url), Data("łcafé\r\n".utf8), "UTF-8, no BOM, line ending kept") + XCTAssertEqual(session.format, DocumentFileFormat(encoding: .utf8, hasBOM: false, lineEnding: .crlf)) + XCTAssertFalse(session.isDirty) + } + + func testWriteFailureOffersACopyAndStaysDirty() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + chmod(url.path, 0o444) + let copy = directory.appendingPathComponent("copy.md") + prompts.saveFailedAnswer = .saveCopy + prompts.copyURL = copy + XCTAssertEqual(save(session), false) + XCTAssertEqual(prompts.saveFailedErrors.count, 1) + let error = try XCTUnwrap(prompts.saveFailedErrors.first as? LocalizedError) + XCTAssertEqual(error.errorDescription, "“doc.md” is read-only.") + XCTAssertEqual(prompts.copyCalls, 1) + XCTAssertEqual(bytes(copy), Data("xabc\n".utf8)) + XCTAssertEqual(bytes(url), Data("abc\n".utf8)) + XCTAssertEqual(view.toasts, ["Copy saved"]) + XCTAssertTrue(session.isDirty) + XCTAssertTrue(session.isActive) + } + + func testWriteFailureOKWritesNothing() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + try FileManager.default.removeItem(at: url) + prompts.saveFailedAnswer = .ok + XCTAssertEqual(save(session), false) + XCTAssertEqual(prompts.copyCalls, 0) + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path), "never recreated") + XCTAssertTrue(session.isDirty) + } + + // MARK: - External changes (S-D9) + + func testOurOwnSaveEchoIsIgnored() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + save(session) + let commits = view.commits.count + session.diskDidChange() + XCTAssertEqual(view.commits.count, commits) + XCTAssertEqual(session.editor.text, "xabc\n") + XCTAssertFalse(session.externalChange) + } + + func testCleanBufferAdoptsAnExternalChange() throws { + let url = try fixture("one\ntwo\nthree\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + session.editor.undoManager.undo() + session.editor.textView.setSelectedRange(NSRange(location: 5, length: 0)) // line 1, column 1 + try Data("ONE\r\ntwo!\r\nthree\r\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertEqual(session.editor.text, "ONE\ntwo!\nthree\n") + XCTAssertEqual(session.savedText, "ONE\ntwo!\nthree\n") + XCTAssertEqual(session.baseBytes, bytes(url)) + XCTAssertEqual(session.format.lineEnding, .crlf) + XCTAssertEqual(view.commits.last, "ONE\ntwo!\nthree\n") + XCTAssertFalse(session.editor.undoManager.canUndo, "the stack described the old text") + XCTAssertEqual(session.editor.textView.selectedRange().location, 5, "same line and column") + XCTAssertFalse(session.isDirty) + XCTAssertFalse(session.externalChange) + } + + func testDirtyBufferShowsTheBannerAndKeepsTheText() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertTrue(session.externalChange) + XCTAssertEqual(view.commits.last, "other\n", "rendered == disk") + XCTAssertEqual(session.editor.text, "mine base\n") + XCTAssertTrue(session.isDirty) + } + + func testKeepMyVersionThenSaveDoesNotAsk() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + session.keepMyVersion() + XCTAssertFalse(session.externalChange) + XCTAssertTrue(session.isDirty) + XCTAssertEqual(save(session), true) + XCTAssertEqual(prompts.conflictCalls, 0) + XCTAssertEqual(bytes(url), Data("mine base\n".utf8)) + } + + func testLoadDiskVersionIsOneUndoableReplacement() throws { + let url = try fixture("base\n") + let (session, window) = entered(url) + type(session, "mine ", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + session.loadDiskVersion() + XCTAssertEqual(session.editor.text, "other\n") + XCTAssertFalse(session.isDirty) + XCTAssertFalse(window.isDocumentEdited) + XCTAssertFalse(session.externalChange) + XCTAssertEqual(session.baseBytes, Data("other\n".utf8)) + session.editor.undoManager.undo() + XCTAssertEqual(session.editor.text, "mine base\n") + XCTAssertTrue(session.isDirty) + XCTAssertTrue(window.isDocumentEdited) + } + + func testDiskBackToTheBaseClearsTheBanner() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + try Data("base\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertFalse(session.externalChange) + XCTAssertEqual(view.commits.last, "base\n") + XCTAssertEqual(session.editor.text, "mine base\n") + } + + func testMissingFileLeavesEverythingAsIs() throws { + let url = try fixture("base\n") + let (session, _) = entered(url, rendered: "base\n") + type(session, "x", at: 0) + try FileManager.default.removeItem(at: url) + session.diskDidChange() + XCTAssertFalse(session.externalChange) + XCTAssertEqual(session.editor.text, "xbase\n") + XCTAssertTrue(session.isActive) + XCTAssertEqual(view.commits, []) + } + + // MARK: - Leaving (S-D10) + + func testLeaveWhenCleanDoesNotAsk() throws { + let (session, _) = entered(try fixture("abc\n")) + type(session, "x", at: 0) + session.editor.undoManager.undo() + var left: Bool? + session.requestLeave { left = $0 } + XCTAssertEqual(left, true) + XCTAssertEqual(prompts.unsavedCalls, 0, "typed and undone never prompts") + XCTAssertFalse(session.isActive) + } + + func testLeaveSave() throws { + let url = try fixture("abc\n") + let (session, window) = entered(url) + type(session, "x", at: 0) + prompts.unsavedAnswers = [.save] + var left: Bool? + session.requestLeave { left = $0 } + XCTAssertEqual(left, true) + XCTAssertEqual(bytes(url), Data("xabc\n".utf8)) + XCTAssertFalse(session.isActive) + XCTAssertFalse(window.isDocumentEdited) + } + + func testLeaveDontSave() throws { + let url = try fixture("abc\n") + let (session, window) = entered(url) + type(session, "x", at: 0) + prompts.unsavedAnswers = [.discard] + var left: Bool? + session.requestLeave { left = $0 } + XCTAssertEqual(left, true) + XCTAssertEqual(bytes(url), Data("abc\n".utf8)) + XCTAssertFalse(session.isActive) + XCTAssertFalse(session.isDirty) + XCTAssertFalse(window.isDocumentEdited) + XCTAssertFalse(try guardHandlers(window).isDirty()) + } + + func testLeaveCancelStays() throws { + let (session, _) = entered(try fixture("abc\n")) + type(session, "x", at: 0) + prompts.unsavedAnswers = [.cancel] + var left: Bool? + session.requestLeave { left = $0 } + XCTAssertEqual(left, false) + XCTAssertTrue(session.isActive) + XCTAssertTrue(session.isDirty) + XCTAssertNil(session.lastLeave) + } + + func testLeaveWithAFailingSaveStays() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + chmod(url.path, 0o444) + prompts.unsavedAnswers = [.save] + var left: Bool? + session.requestLeave { left = $0 } + XCTAssertEqual(left, false) + XCTAssertEqual(prompts.saveFailedErrors.count, 1) + XCTAssertTrue(session.isActive) + XCTAssertTrue(session.isDirty) + } + + func testEscapeRequestsLeave() throws { + let (session, _) = entered(try fixture("abc\n")) + session.editor.textView.doCommand(by: #selector(NSResponder.cancelOperation(_:))) + XCTAssertFalse(session.isActive) + } + + private func numbered(_ count: Int) -> String { + (0.. Date: Mon, 5 Oct 2026 15:15:14 +0200 Subject: [PATCH 07/17] fix(source-edit): session and editor review fixes Session: Save Anyway keeps the base until the write succeeded and re-checks the disk after the prompt; Keep My Version adopts the disk's format; one prompt chain at a time; literal (UTF-16) text comparison; lossless-decode entry check that also covers mixed line endings; banner-aware save and discard; delayed adopt of a momentarily empty file. Editor: base attributes are added, not set (marked text keeps its attributes); CR paste is named Paste; multi-range replacements are normalized; exact restore also bounded by lines; reload uses the relayout primitives. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD/DocumentFileFormat.swift | 14 +- QuickMD/QuickMD/SourceEditSession.swift | 403 ++++++++++++----- QuickMD/QuickMD/SourceEditorController.swift | 106 ++++- QuickMD/QuickMD/Views/SourceEditorView.swift | 7 + .../DocumentFileFormatTests.swift | 55 +++ .../QuickMDTests/SourceEditSessionTests.swift | 414 +++++++++++++++++- .../SourceEditorControllerTests.swift | 149 ++++++- 7 files changed, 1011 insertions(+), 137 deletions(-) diff --git a/QuickMD/QuickMD/DocumentFileFormat.swift b/QuickMD/QuickMD/DocumentFileFormat.swift index d3ed402..04e12d8 100644 --- a/QuickMD/QuickMD/DocumentFileFormat.swift +++ b/QuickMD/QuickMD/DocumentFileFormat.swift @@ -62,6 +62,13 @@ struct DocumentFileFormat: Equatable, Sendable { /// are mixed; the edit session refuses to edit in the first case /// rather than silently rewrite bytes the user never saw. let isByteExact: Bool + /// The decoded text, BEFORE line-ending normalization, re-encodes to + /// exactly the file's bytes (with the BOM): nothing was repaired or + /// dropped. Unlike `isByteExact` this ignores line endings, so it + /// tells "mixed line endings" (true — saving only unifies them) apart + /// from "mixed AND repaired by the decoder" (false). The edit session + /// refuses exactly when this is false. + let isLosslessDecode: Bool } var encoding: Encoding @@ -100,10 +107,13 @@ struct DocumentFileFormat: Equatable, Sendable { let counts = LineEndingCounts(raw) let format = DocumentFileFormat(encoding: encoding, hasBOM: hasBOM, lineEnding: counts.dominant) let text = MarkdownDocument.normalizeLineEndings(raw) - // One extra encode per edit session (≤ 5 MB) — cheap insurance. + // Two extra encodes per edit session (≤ 2 MB) — cheap insurance. + // `.lf` passes the raw text through, line endings and all. + let asRead = DocumentFileFormat(encoding: encoding, hasBOM: hasBOM, lineEnding: .lf) return Decoded(format: format, text: text, hasMixedLineEndings: counts.isMixed, - isByteExact: format.encode(text) == data) + isByteExact: format.encode(text) == data, + isLosslessDecode: asRead.encode(raw) == data) } /// Mixed files get the dominant style; ties and break-free text get LF diff --git a/QuickMD/QuickMD/SourceEditSession.swift b/QuickMD/QuickMD/SourceEditSession.swift index 5accaf5..d947c61 100644 --- a/QuickMD/QuickMD/SourceEditSession.swift +++ b/QuickMD/QuickMD/SourceEditSession.swift @@ -153,11 +153,18 @@ final class SourceEditSession: ObservableObject { /// How the file is stored; a save writes the buffer back in it. private(set) var format = DocumentFileFormat(encoding: .utf8, hasBOM: false, lineEnding: .lf) /// The file bytes the buffer is based on: as read on entry, as written by - /// our last save, or as acknowledged by the user (Keep My Version / Save - /// Anyway). A disk that differs from these was changed by someone else. + /// our last save, or as acknowledged by the user (Keep My Version). A disk + /// that differs from these was changed by someone else. Moves only + /// together with `savedText` and `format` — never ahead of a write. private(set) var baseBytes = Data() /// The LF text matching `baseBytes` — the clean state of the buffer. private(set) var savedText = "" + /// A chain of prompts (unsaved changes → conflict → encoding → save + /// failed → save panel) is on screen. While it is, nothing else may start + /// a sheet or change the buffer: `save` / `requestLeave` / the guard's + /// `confirm` report false at once, the banner and menu actions do nothing, + /// and a disk change only keeps the rendered text in step with the disk. + private(set) var isPrompting = false /// The caret's line right after entering (0-based). private var entryLine = 0 private var hasSavedOnce = false @@ -168,12 +175,28 @@ final class SourceEditSession: ObservableObject { /// the editor's `onChange` does not flip the dirty flag on and off. private var isApplyingOwnEdit = false private weak var closeGuard: EditCloseGuard? + /// The second look at a file that became empty (see `handleDiskChange`). + private var emptyFileRecheck: DispatchWorkItem? + + /// A file that suddenly reads EMPTY may be another program half-way + /// through a non-atomic save (truncate, then write): adopting it at once + /// would show a clean, empty buffer for a moment and lose the caret. + /// Looked at again after this long. + static let emptyFileRecheckDelay: TimeInterval = 0.3 + + /// The beep for a close request that arrives while a prompt is up. + /// Replaced in tests (no sound from a test run). + static var beep: () -> Void = { NSSound.beep() } init() { editor.onChange = { [weak self] in self?.bufferDidChange() } editor.onEscape = { [weak self] in self?.requestLeave() } } + deinit { + emptyFileRecheck?.cancel() + } + private var fileName: String { environment.documentURL?.lastPathComponent ?? "Untitled" } @@ -182,35 +205,48 @@ final class SourceEditSession: ObservableObject { environment.window() ?? editor.textView.window } + /// "The buffer equals the saved text", LITERALLY: UTF-16 code units, not + /// Swift's `==` (canonical equivalence — "é" precomposed and decomposed + /// compare equal, and a buffer differing only in normalization would + /// count as clean and never be saved). + static func isSameText(_ lhs: String, _ rhs: String) -> Bool { + lhs.utf16.count == rhs.utf16.count && lhs.utf16.elementsEqual(rhs.utf16) + } + + private var bufferMatchesSaved: Bool { Self.isSameText(editor.text, savedText) } + /// Enters the editor with the caret at the start of 0-based `line` (nil: /// the top), scrolled to the top of the visible area — the view converts /// the reading position's 1-based `editorLine()`. Returns nil when it - /// entered; otherwise nothing changed and the refusal says why. - /// The view requests focus after mounting the editor (`focusEditor`). + /// entered; otherwise nothing changed and the refusal says why. Checks in + /// the spec's order: no file, missing, unreadable, read-only, too large, + /// unsafe. The view requests focus after mounting the editor (`focusEditor`). @discardableResult func enter(atLine line: Int?) -> EnterRefusal? { - guard !isActive else { return .alreadyEditing } + guard !isActive, !isPrompting else { return .alreadyEditing } guard let url = environment.documentURL else { return .noFile } let name = url.lastPathComponent let path = url.path let fileManager = FileManager.default guard fileManager.fileExists(atPath: path) else { return .missing(fileName: name) } + guard fileManager.isReadableFile(atPath: path) else { return .unreadable(fileName: name) } + guard fileManager.isWritableFile(atPath: path) else { return .readOnly(fileName: name) } + // Size from the attributes first: a huge file is refused unread. if let size = (try? fileManager.attributesOfItem(atPath: path))?[.size] as? NSNumber, size.intValue > Self.maximumEditableBytes { return .tooLarge(fileName: name) } - guard fileManager.isWritableFile(atPath: path) else { return .readOnly(fileName: name) } // Fresh from disk, not the rendered text: the watcher's debounce may lag. guard let data = try? Data(contentsOf: url) else { return .unreadable(fileName: name) } guard data.count <= Self.maximumEditableBytes else { return .tooLarge(fileName: name) } guard let decoded = DocumentFileFormat.decode(data) else { return .unreadable(fileName: name) } - guard decoded.isByteExact || decoded.hasMixedLineEndings else { return .unsafe(fileName: name) } + guard decoded.isLosslessDecode else { return .unsafe(fileName: name) } adopt(decoded, bytes: data) hasSavedOnce = false externalChange = false isDirty = false - if environment.renderedText() != decoded.text { + if !(environment.renderedText().map { Self.isSameText($0, decoded.text) } ?? false) { environment.commitText(decoded.text) } editor.load(decoded.text) @@ -226,6 +262,8 @@ final class SourceEditSession: ObservableObject { editor.focus() } + /// Format, base bytes and saved text always move TOGETHER — from one read + /// of the disk, or from one successful write. private func adopt(_ decoded: DocumentFileFormat.Decoded, bytes: Data) { format = decoded.format baseBytes = bytes @@ -251,10 +289,8 @@ final class SourceEditSession: ObservableObject { // MARK: - Dirty state private func bufferDidChange() { - guard !isApplyingOwnEdit, isActive, !isDirty else { return } - isDirty = true - installGuardIfNeeded() - closeGuard?.setEdited(true) + guard !isApplyingOwnEdit, isActive else { return } + markDirty() } /// The exact check: the flag only says "something was typed". Clears the @@ -263,39 +299,84 @@ final class SourceEditSession: ObservableObject { @discardableResult func checkDirty() -> Bool { guard isActive, isDirty else { return false } - if editor.text == savedText { setClean() } + if bufferMatchesSaved { markClean() } return isDirty } - private func setClean() { + private func markDirty() { + guard !isDirty else { return } + isDirty = true + installGuardIfNeeded() + closeGuard?.setEdited(true) + } + + private func markClean() { if isDirty { isDirty = false } closeGuard?.setEdited(false) } + /// After the session changed the buffer or the saved text: the flag + /// follows what the buffer really holds. Never "clean" on faith — a + /// refused `replaceAll` leaves the user's text in place, still unsaved. + private func syncDirtyWithBuffer() { + if bufferMatchesSaved { markClean() } else { markDirty() } + } + /// Runs `body` (which replaces the buffer through the editor) without the - /// edit counting as the user's, then ends clean. + /// edit counting as the user's. private func replaceBufferAsOwnEdit(_ body: () -> Void) { isApplyingOwnEdit = true body() isApplyingOwnEdit = false } + // MARK: - Prompt chains + + /// Runs one prompt chain unless one is already running (then `completion` + /// gets false at once). `body` receives the chain's ONLY exit: it ends the + /// chain and reports the result, once — every path of `body` must call it. + private func runChain(_ completion: ((Bool) -> Void)?, _ body: (@escaping (Bool) -> Void) -> Void) { + guard !isPrompting else { + completion?(false) + return + } + isPrompting = true + var finished = false + body { [weak self] result in + guard !finished else { return } + finished = true + self?.isPrompting = false + completion?(result) + } + } + // MARK: - Saving (S-D7) /// ⌘S. Synchronous except for prompts; `then` gets true once the buffer - /// is on disk (or nothing needed saving) and false on cancel or failure — - /// called after the last prompt is answered. + /// is on disk (or nothing needed saving) and false on cancel, failure, or + /// when another prompt is already up — called after the last prompt is + /// answered. func save(then completion: ((Bool) -> Void)? = nil) { - let finish: (Bool) -> Void = { completion?($0) } + guard isActive else { completion?(false); return } + runChain(completion) { finish in + performSave(window: window, finish: finish) + } + } + + /// The save steps, inside a chain. `window` is where every follow-up sheet + /// goes — the one the chain started on (the guard's `confirm` hands its + /// own: a sheet elsewhere, or none, would look like a lost confirmation). + private func performSave(window: NSWindow?, finish: @escaping (Bool) -> Void) { guard isActive, environment.documentURL != nil else { return finish(false) } let text = editor.text - guard text != savedText else { - // Nothing to write: no mtime change, no watcher echo. - setClean() + // Nothing to write — unless the banner is up: then the disk is not + // what the buffer holds, and the conflict flow decides. + if !externalChange && Self.isSameText(text, savedText) { + markClean() return finish(true) } if let data = format.encode(text) { - saveCheckingDisk(text: text, data: data, format: format, finish: finish) + saveCheckingDisk(text: text, data: data, format: format, window: window, finish: finish) return } prompts.encodingFallback(window, fileName, Self.displayName(of: format.encoding)) { [self] useUTF8 in @@ -303,27 +384,36 @@ final class SourceEditSession: ObservableObject { let fallback = format.utf8Fallback // UTF-8 represents every String. guard let data = fallback.encode(text) else { return finish(false) } - saveCheckingDisk(text: text, data: data, format: fallback, finish: finish) + saveCheckingDisk(text: text, data: data, format: fallback, window: window, finish: finish) } } - /// Step 2: the disk must still hold `baseBytes`, or the user decides. + /// Step 2: the disk must still hold `baseBytes`, or the user decides — + /// about the version they were shown. Save Anyway re-reads the disk: if + /// it moved while the prompt was up, the user is asked again about the + /// NEW version; nothing is ever overwritten (or "restored" over) unseen. private func saveCheckingDisk(text: String, data: Data, format target: DocumentFileFormat, - finish: @escaping (Bool) -> Void) { + window: NSWindow?, finish: @escaping (Bool) -> Void) { guard let url = environment.documentURL else { return finish(false) } // Unreadable / missing: nothing to conflict with — the write reports it. guard let disk = try? Data(contentsOf: url), disk != baseBytes else { - return write(text: text, data: data, format: target, restoring: baseBytes, finish: finish) + return write(text: text, data: data, format: target, restoring: baseBytes, + window: window, finish: finish) } + // The disk moved on: the rendered view shows it, the banner says so. + noteExternalChange(disk) prompts.saveConflict(window, fileName) { [self] choice in switch choice { case .saveAnyway: - // The user saw the conflict and chose their text: what is on - // disk now is what a failed write puts back. - baseBytes = disk - write(text: text, data: data, format: target, restoring: disk, finish: finish) + if let now = try? Data(contentsOf: url), now != disk { + saveCheckingDisk(text: text, data: data, format: target, window: window, finish: finish) + return + } + // `baseBytes` stays until the write succeeded; a failed write + // puts back exactly the version the user agreed to replace. + write(text: text, data: data, format: target, restoring: disk, window: window, finish: finish) case .loadDiskVersion: - loadDiskVersion() + applyDiskVersion() finish(false) case .cancel: finish(false) @@ -333,7 +423,7 @@ final class SourceEditSession: ObservableObject { /// Steps 3 and 4. private func write(text: String, data: Data, format target: DocumentFileFormat, restoring: Data, - finish: @escaping (Bool) -> Void) { + window: NSWindow?, finish: @escaping (Bool) -> Void) { guard let url = environment.documentURL else { return finish(false) } do { try DocumentFileWriter.write(data, to: url, restoring: restoring) @@ -361,11 +451,19 @@ final class SourceEditSession: ObservableObject { let unified = hasMixedLineEndings hasMixedLineEndings = false environment.commitText(text) - if editor.text == text { setClean() } + syncDirtyWithBuffer() environment.toast(unified ? "Saved · line endings unified to \(Self.displayName(of: target.lineEnding))" : "Saved") } + /// The disk differs from `baseBytes` (seen by the save check): keep the + /// rendered text equal to it and show the banner. Base and saved text stay. + private func noteExternalChange(_ data: Data) { + guard let decoded = DocumentFileFormat.decode(data) else { return } + environment.commitText(decoded.text) + externalChange = true + } + /// A copy is a new file the save panel granted: a plain (non-atomic) /// write. The document itself stays unsaved. @discardableResult @@ -406,26 +504,29 @@ final class SourceEditSession: ObservableObject { /// Esc, Done, the toggle command. Clean → leaves at once. Dirty → the /// unsaved-changes sheet: Save leaves only if the save succeeded, Don't - /// Save leaves and drops the buffer, Cancel stays. `then(true)` = left. + /// Save leaves and drops the buffer, Cancel stays. `then(true)` = left; + /// false also when another prompt is already up. func requestLeave(then completion: ((Bool) -> Void)? = nil) { guard isActive else { completion?(false); return } - guard checkDirty() else { - leave() - completion?(true) - return - } - prompts.unsavedChanges(window, fileName) { [self] choice in - switch choice { - case .save: - save { [self] saved in - if saved { leave() } - completion?(saved) - } - case .discard: + runChain(completion) { finish in + guard checkDirty() else { leave() - completion?(true) - case .cancel: - completion?(false) + return finish(true) + } + let window = self.window + prompts.unsavedChanges(window, fileName) { [self] choice in + switch choice { + case .save: + performSave(window: window) { [self] saved in + if saved { leave() } + finish(saved) + } + case .discard: + leave() + finish(true) + case .cancel: + finish(false) + } } } } @@ -433,6 +534,14 @@ final class SourceEditSession: ObservableObject { private func leave() { let line = editor.caretLine leaveSequence += 1 + emptyFileRecheck?.cancel() + emptyFileRecheck = nil + // A finished session keeps nothing alive: a focus request that never + // reached a window must not fire on a later mount, and the undo stack + // (operations holding removed text) goes — the next `enter` loads + // fresh from disk anyway. + editor.cancelPendingFocus() + editor.undoManager.removeAllActions() isActive = false externalChange = false if isDirty { isDirty = false } @@ -442,19 +551,28 @@ final class SourceEditSession: ObservableObject { } /// "Discard Changes": back to the saved text as ONE undoable edit (⌘Z - /// brings the changes back, and with them the dirty state); stays in the mode. + /// brings the changes back, and with them the dirty state); stays in the + /// mode. With the banner up the saved text is no longer what is on disk — + /// "drop my edits" then means the disk version (`loadDiskVersion`). func discardChanges() { - guard isActive else { return } - if editor.text != savedText { + guard isActive, !isPrompting else { return } + if externalChange { return applyDiskVersion() } + if !bufferMatchesSaved { replaceBufferAsOwnEdit { editor.replaceAll(with: savedText) } } - setClean() + syncDirtyWithBuffer() } // MARK: - External changes (S-D9) /// The view's file watcher fired while the session is active. func diskDidChange() { + emptyFileRecheck?.cancel() + emptyFileRecheck = nil + handleDiskChange(adoptingEmpty: false) + } + + private func handleDiskChange(adoptingEmpty: Bool) { guard isActive, let url = environment.documentURL, let data = try? Data(contentsOf: url) else { return } guard data != baseBytes else { @@ -469,7 +587,9 @@ final class SourceEditSession: ObservableObject { guard let decoded = DocumentFileFormat.decode(data) else { return } // Rendered == disk, whatever the buffer holds. environment.commitText(decoded.text) - guard !checkDirty() else { + // A prompt is up (its chain is deciding about the buffer), or the + // buffer holds unsaved text: the banner, never a replaced buffer. + if isPrompting || checkDirty() { externalChange = true return } @@ -480,58 +600,80 @@ final class SourceEditSession: ObservableObject { environment.toast(refusal.message) return } + if data.isEmpty && !baseBytes.isEmpty && !adoptingEmpty { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.emptyFileRecheck = nil + self.handleDiskChange(adoptingEmpty: true) + } + emptyFileRecheck = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.emptyFileRecheckDelay, execute: work) + return + } adopt(decoded, bytes: data) + externalChange = false editor.reload(decoded.text) } /// The entry checks that still matter for a file already open. private func refusal(forDisk decoded: DocumentFileFormat.Decoded, bytes: Data) -> EnterRefusal? { if bytes.count > Self.maximumEditableBytes { return .tooLarge(fileName: fileName) } - if !decoded.isByteExact && !decoded.hasMixedLineEndings { return .unsafe(fileName: fileName) } + if !decoded.isLosslessDecode { return .unsafe(fileName: fileName) } return nil } - /// Banner: "Keep My Version". The disk's current bytes become the base — - /// acknowledged, so a later save does not ask again — and the buffer is - /// dirty against THAT text (it is what is on disk now). - func keepMyVersion() { - guard isActive else { return } - externalChange = false - guard let url = environment.documentURL, let data = try? Data(contentsOf: url) else { return } - baseBytes = data - if let decoded = DocumentFileFormat.decode(data) { - savedText = decoded.text - hasMixedLineEndings = decoded.hasMixedLineEndings - } - if editor.text == savedText { - setClean() - } else if !isDirty { - isDirty = true - closeGuard?.setEdited(true) + /// Reads the disk for a banner action. Nil (after a toast saying why) when + /// it cannot be read or this session could not have entered it. + private func readDiskForAdoption() -> (decoded: DocumentFileFormat.Decoded, bytes: Data)? { + guard let url = environment.documentURL else { return nil } + guard FileManager.default.fileExists(atPath: url.path) else { + environment.toast(EnterRefusal.missing(fileName: fileName).message) + return nil } - } - - /// Banner (and conflict prompt): "Load Disk Version". Replaces the buffer - /// as ONE undoable edit — ⌘Z brings the user's text back, dirty again — - /// and adopts the disk as the clean state. - func loadDiskVersion() { - guard isActive, let url = environment.documentURL else { return } guard let data = try? Data(contentsOf: url), let decoded = DocumentFileFormat.decode(data) else { environment.toast(EnterRefusal.unreadable(fileName: fileName).message) - return + return nil } if let refusal = refusal(forDisk: decoded, bytes: data) { - // Keep the user's text; Keep My Version / Save still work. environment.toast(refusal.message) - return + return nil } - adopt(decoded, bytes: data) + return (decoded, data) + } + + /// Banner: "Keep My Version". The disk's current content — bytes, text + /// AND format — becomes the base: acknowledged, so a later save does not + /// ask again, and written back in the file's NEW format (a CRLF file git + /// rewrote as LF stays LF). The buffer is dirty against that text. A disk + /// that cannot be read or entered keeps the banner (base and saved text + /// never get out of step). + func keepMyVersion() { + guard isActive, !isPrompting else { return } + guard let disk = readDiskForAdoption() else { return } + adopt(disk.decoded, bytes: disk.bytes) externalChange = false - environment.commitText(decoded.text) - if editor.text != decoded.text { - replaceBufferAsOwnEdit { editor.replaceAll(with: decoded.text) } + syncDirtyWithBuffer() + } + + /// Banner: "Load Disk Version". Replaces the buffer as ONE undoable edit + /// — ⌘Z brings the user's text back, dirty again — and adopts the disk as + /// the clean state. + func loadDiskVersion() { + guard isActive, !isPrompting else { return } + applyDiskVersion() + } + + /// `loadDiskVersion` without the prompt check — also the conflict + /// prompt's answer, from inside its chain. + private func applyDiskVersion() { + guard isActive, let disk = readDiskForAdoption() else { return } + adopt(disk.decoded, bytes: disk.bytes) + externalChange = false + environment.commitText(disk.decoded.text) + if !bufferMatchesSaved { + replaceBufferAsOwnEdit { editor.replaceAll(with: disk.decoded.text) } } - setClean() + syncDirtyWithBuffer() } // MARK: - Close guard (S-D11) @@ -539,18 +681,25 @@ final class SourceEditSession: ObservableObject { /// The guard's `confirm`: the unsaved-changes sheet on that window. /// `completion(true)` only once clean — saved, or Don't Save reverted the /// buffer (also when another tab then cancels the quit and this window - /// stays open). + /// stays open). A close or quit that arrives while one of our prompts is + /// up (Esc's sheet, a save's) is cancelled with a beep, not queued. private func confirmClose(on window: NSWindow, completion: @escaping (Bool) -> Void) { + guard !isPrompting else { + Self.beep() + return completion(false) + } guard checkDirty() else { return completion(true) } - prompts.unsavedChanges(window, fileName) { [self] choice in - switch choice { - case .save: - save { completion($0) } - case .discard: - revertToSaved() - completion(true) - case .cancel: - completion(false) + runChain(completion) { finish in + prompts.unsavedChanges(window, fileName) { [self] choice in + switch choice { + case .save: + performSave(window: window, finish: finish) + case .discard: + revertToSaved() + finish(true) + case .cancel: + finish(false) + } } } } @@ -559,7 +708,7 @@ final class SourceEditSession: ObservableObject { /// (not undoable — the window is closing), the caret stays put. private func revertToSaved() { editor.reload(savedText) - setClean() + syncDirtyWithBuffer() } /// The window is already closing and cannot be kept: app-modal Save / @@ -599,25 +748,21 @@ extension SourceEditSession.Prompts { static let saveFailedOKIdentifier = "source-savefail-ok" static let copyPanelIdentifier = "source-copy-panel" - /// Real sheets on the window, app-modal without one. + /// Real sheets on the window, app-modal without one — with the same + /// buttons either way (the unsaved-changes prompt keeps its Cancel). static let standard = SourceEditSession.Prompts( unsavedChanges: { window, fileName, completion in - guard let window else { return completion(UnsavedChangesAlert.runModal(fileName: fileName)) } + guard let window else { + let alert = UnsavedChangesAlert.makeAlert(fileName: fileName, allowsCancel: true) + return completion(UnsavedChangesAlert.choice(for: alert.runModal())) + } UnsavedChangesAlert.beginSheet(on: window, fileName: fileName, completion: completion) }, unsavedChangesModal: { fileName in UnsavedChangesAlert.runModal(fileName: fileName) }, saveConflict: { window, fileName, completion in - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "“\(fileName)” has been changed by another application." - alert.informativeText = "Save Anyway replaces the other version with yours. Load Disk Version " - + "shows the file as it is on disk; Undo brings your text back." - addButton(to: alert, "Save Anyway", identifier: conflictSaveAnywayIdentifier) - addButton(to: alert, "Load Disk Version", identifier: conflictLoadDiskIdentifier) - addButton(to: alert, "Cancel", identifier: conflictCancelIdentifier, key: "\u{1b}") - run(alert, on: window) { response in + run(makeConflictAlert(fileName: fileName), on: window) { response in switch response { case .alertFirstButtonReturn: completion(.saveAnyway) case .alertSecondButtonReturn: completion(.loadDiskVersion) @@ -626,13 +771,9 @@ extension SourceEditSession.Prompts { } }, encodingFallback: { window, fileName, encodingName, completion in - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "“\(fileName)” uses \(encodingName), which cannot store some of the characters you typed." - alert.informativeText = "Saving as UTF-8 keeps every character. The line endings stay as they are." - addButton(to: alert, "Save as UTF-8", identifier: encodingUTF8Identifier) - addButton(to: alert, "Cancel", identifier: encodingCancelIdentifier, key: "\u{1b}") - run(alert, on: window) { completion($0 == .alertFirstButtonReturn) } + run(makeEncodingAlert(fileName: fileName, encodingName: encodingName), on: window) { + completion($0 == .alertFirstButtonReturn) + } }, saveFailed: { window, error, completion in let alert = NSAlert() @@ -660,6 +801,34 @@ extension SourceEditSession.Prompts { } ) + /// Built separately so the tests can check the buttons without a sheet. + static func makeConflictAlert(fileName: String) -> NSAlert { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "“\(fileName)” has been changed by another application." + alert.informativeText = "Save Anyway replaces the other version with yours. Load Disk Version " + + "shows the file as it is on disk; Undo brings your text back." + // No default button: Return must not overwrite another application's + // changes on reflex. Escape cancels. + addButton(to: alert, "Save Anyway", identifier: conflictSaveAnywayIdentifier, key: "") + addButton(to: alert, "Load Disk Version", identifier: conflictLoadDiskIdentifier) + addButton(to: alert, "Cancel", identifier: conflictCancelIdentifier, key: "\u{1b}") + return alert + } + + /// Says nothing about line endings: a mixed file has them unified by the + /// same save, a uniform one keeps them — one sentence true for both is + /// the one about the characters. + static func makeEncodingAlert(fileName: String, encodingName: String) -> NSAlert { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "“\(fileName)” uses \(encodingName), which cannot store some of the characters you typed." + alert.informativeText = "Saving as UTF-8 keeps every character you typed." + addButton(to: alert, "Save as UTF-8", identifier: encodingUTF8Identifier) + addButton(to: alert, "Cancel", identifier: encodingCancelIdentifier, key: "\u{1b}") + return alert + } + private static func addButton(to alert: NSAlert, _ title: String, identifier: String, key: String? = nil) { let button = alert.addButton(withTitle: title) if let key { button.keyEquivalent = key } diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift index d6ffeb7..462fd51 100644 --- a/QuickMD/QuickMD/SourceEditorController.swift +++ b/QuickMD/QuickMD/SourceEditorController.swift @@ -30,6 +30,15 @@ final class SourceTextView: NSTextView { var editorUndoManager: UndoManager? /// Called once the view is in a window (a deferred `focus()`). var onMoveToWindow: (() -> Void)? + /// True while text from a pasteboard is being inserted (paste, Services, + /// a drop) — the CR-normalising re-insert names its undo step after it. + private(set) var isReadingPasteboard = false + + override func readSelection(from pboard: NSPasteboard, type: NSPasteboard.PasteboardType) -> Bool { + isReadingPasteboard = true + defer { isReadingPasteboard = false } + return super.readSelection(from: pboard, type: type) + } override var undoManager: UndoManager? { editorUndoManager ?? super.undoManager @@ -167,6 +176,11 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// screen within a line or two, only the scroller is approximate). The /// explicit jumps (`placeCaret`, `scroll(toLine:)`) are exact at any offset. static let exactRestoreCharacterLimit = 400_000 + /// The same budget in lines: layout cost follows line fragments, not + /// UTF-16 units, so a file of very short lines would get several times + /// the work under the character limit alone. 400 000 units at the + /// measured worst case of 25 characters per line = 16 000 lines. + static let exactRestoreLineLimit = 16_000 /// `focus()` asked for before the view had a window (the session mounts /// and focuses in one pass); honoured when it gets one. private var focusRequested = false @@ -247,7 +261,11 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// ONE live `SourceEditorView` per controller: there is one view, and an /// NSView has one superview. If SwiftUI briefly keeps two representables /// (a removal transition, an identity change), the view is detached from - /// the old host before the new one gets it, never shared between them. + /// the old host before the new one gets it, never shared between them — + /// and the old host is left EMPTY. So the integrator must not put a + /// `.transition` on the editor overlay, and must keep its identity stable + /// across Reading Mode / zoom / theme changes (style flows through + /// `apply(style:)`, never through a new view identity). func makeScrollView(style: Style) -> NSScrollView { if let scrollView { scrollView.removeFromSuperview() @@ -435,7 +453,7 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD exactRestoreWork = nil guard let anchor = pendingAnchor else { return } pendingAnchor = nil - guard anchor.character < Self.exactRestoreCharacterLimit else { return } + guard isExactRestoreAffordable(at: anchor.character) else { return } // AppKit itself nudges the clip by a line or two while background // layout refines the estimate; more than half a screen is a scroll // that slipped past the explicit cancels (wheel, live scroll, @@ -446,6 +464,15 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD restore(anchor) } + /// Whether an exact top-line restore at `character` stays within both + /// budgets. The line count scans up to the anchor — only once the + /// character limit already holds, so the scan is bounded too. + private func isExactRestoreAffordable(at character: Int) -> Bool { + guard character < Self.exactRestoreCharacterLimit else { return false } + return SourceEditSupport.line(containing: character, in: textStorage.mutableString) + < Self.exactRestoreLineLimit + } + /// A relayout's exact top-line restore is still to come (tests wait on it). var isRestorePending: Bool { pendingAnchor != nil } @@ -486,7 +513,12 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// area stays there, each clamped to the new text: an external rewrite of /// a paragraph elsewhere must not throw the user back to line 0. /// Positions are carried as (line, column), not as offsets — an edit - /// above the caret shifts every offset below it. + /// above the caret shifts every offset below it. Nothing pending survives + /// pointing into the old text: a relayout's kept top line becomes the + /// line kept here (it is the truer top while its exact pass is due), and + /// an entry scroll still waiting for a width is re-targeted. The top line + /// lands exactly within the exact-restore budgets (characters and lines), + /// by the estimate beyond them — the same trade-off as a relayout. func reload(_ text: String) { let normalized = MarkdownDocument.normalizeLineEndings(text) let old = textStorage.mutableString @@ -495,7 +527,9 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD return (line, offset - SourceEditSupport.lineStart(line, in: old)) } let caret = lineAndColumn(textView.selectedRange().location) - let top = topVisibleAnchor().map { (position: lineAndColumn($0.character), offset: $0.offset) } + let top = (pendingAnchor ?? topVisibleAnchor()).map { + (position: lineAndColumn($0.character), offset: $0.offset) + } let pendingTopLine = pendingTopOffset.map { SourceEditSupport.line(containing: $0, in: old) } textStorage.beginEditing() @@ -504,7 +538,9 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD textStorage.endEditing() cachedIndentUnit = nil undoManager.removeAllActions() + textView.breakUndoCoalescing() dropPendingAnchor() + pendingTopOffset = nil let new = textStorage.mutableString func offset(line: Int, column: Int) -> Int { @@ -518,8 +554,15 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD // the new text. pendingTopOffset = SourceEditSupport.lineStart(pendingTopLine, in: new) } else if let top { - restore(TopAnchor(character: offset(line: top.position.line, column: top.position.column), - offset: top.offset)) + let anchor = TopAnchor(character: offset(line: top.position.line, column: top.position.column), + offset: top.offset) + if isExactRestoreAffordable(at: anchor.character) { + restore(anchor) + } else if textContainer.size.width > 0, textStorage.length > 0 { + let glyph = layoutManager.glyphIndexForCharacter(at: min(anchor.character, textStorage.length - 1)) + let estimatedTop = layoutManager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY + scrollLaidOut(toY: clipY(forLineTop: estimatedTop, anchor)) + } } else { scrollClip(toY: 0) } @@ -582,6 +625,12 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD window.makeFirstResponder(textView) } + /// Drops a deferred `focus()`: the session left the mode before the view + /// ever reached a window, and a later mount must not take the focus. + func cancelPendingFocus() { + focusRequested = false + } + private func focusIfRequested() { guard focusRequested else { return } focus() @@ -756,6 +805,40 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD let end = affectedCharRange.location + (normalized as NSString).length perform(range: affectedCharRange, replacement: normalized, selection: NSRange(location: end, length: 0)) + // The refused change was a paste, so the step replacing it is one. + // AppKit exposes no localized name for it; the app's menus are English. + if self.textView.isReadingPasteboard { undoManager.setActionName("Paste") } + return false + } + + /// The multi-range variant (the find bar's Replace All, multi-selection + /// edits). NSTextView asks this one instead of the single-range method + /// when the delegate implements it, so it must close the same CR route. + /// One range → the single-range path above; several → every replacement + /// LF-normalised and applied back to front (earlier ranges stay valid) + /// as ONE undo step. + func textView(_ textView: NSTextView, shouldChangeTextInRanges affectedRanges: [NSValue], + replacementStrings: [String]?) -> Bool { + guard let replacementStrings, replacementStrings.count == affectedRanges.count, + replacementStrings.contains(where: { $0.unicodeScalars.contains("\r") }) else { return true } + if affectedRanges.count == 1 { + return self.textView(textView, shouldChangeTextIn: affectedRanges[0].rangeValue, + replacementString: replacementStrings[0]) + } + let normalized = replacementStrings.map(MarkdownDocument.normalizeLineEndings) + undoManager.beginUndoGrouping() + defer { undoManager.endUndoGrouping() } + textView.breakUndoCoalescing() + guard textView.shouldChangeText(inRanges: affectedRanges, replacementStrings: normalized) else { + return false + } + let edits = zip(affectedRanges.map(\.rangeValue), normalized).sorted { $0.0.location > $1.0.location } + for (range, replacement) in edits { + textStorage.replaceCharacters(in: range, + with: NSAttributedString(string: replacement, attributes: baseAttributes)) + } + textView.didChangeText() + textView.breakUndoCoalescing() return false } @@ -803,9 +886,12 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// storage's attributes, but undo operations keep the text they removed /// with the attributes it had — undoing a deletion after a zoom would /// bring back a run in the old font. Every character edit is therefore - /// stamped with the current base attributes; attribute changes are left - /// alone (they are ours). Changing attributes, not characters, is what - /// `didProcessEditing` allows. Per keystroke this touches the typed range. + /// stamped with the current base font and colour; attribute changes are + /// left alone (they are ours). Changing attributes, not characters, is + /// what `didProcessEditing` allows. Per keystroke this touches the typed + /// range. ADDED, not replaced: `setAttributes` would also strip what + /// NSTextView itself keeps on the range — the marked-text underline of an + /// input method or a dead key composing there. func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { guard editedMask.contains(.editedCharacters), !isFixingAttributes, @@ -814,7 +900,7 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD guard !attributes.isEmpty else { return } isFixingAttributes = true defer { isFixingAttributes = false } - textStorage.setAttributes(attributes, range: editedRange) + textStorage.addAttributes(attributes, range: editedRange) } func textDidChange(_ notification: Notification) { diff --git a/QuickMD/QuickMD/Views/SourceEditorView.swift b/QuickMD/QuickMD/Views/SourceEditorView.swift index b9c5e70..43def94 100644 --- a/QuickMD/QuickMD/Views/SourceEditorView.swift +++ b/QuickMD/QuickMD/Views/SourceEditorView.swift @@ -6,6 +6,13 @@ import SwiftUI /// hierarchy once and owns all behaviour (`SourceEditorController.swift`), so /// the session keeps talking to the same text view — and the same undo stack — /// however often SwiftUI re-creates this struct. +/// +/// Exactly ONE live `SourceEditorView` per controller: the controller has one +/// scroll view, and a second host takes it from the first, which stays empty. +/// The integrator must not put a `.transition` on the editor overlay (the +/// outgoing copy would briefly be a second host), and must keep the view's +/// identity stable across Reading Mode / zoom / theme changes — those reach +/// the editor through `style` (`apply(style:)`), never through a new identity. struct SourceEditorView: NSViewRepresentable { let controller: SourceEditorController let style: SourceEditorController.Style diff --git a/QuickMD/QuickMDTests/DocumentFileFormatTests.swift b/QuickMD/QuickMDTests/DocumentFileFormatTests.swift index 7e8138e..2f4830c 100644 --- a/QuickMD/QuickMDTests/DocumentFileFormatTests.swift +++ b/QuickMD/QuickMDTests/DocumentFileFormatTests.swift @@ -30,6 +30,7 @@ final class DocumentFileFormatTests: XCTestCase { XCTAssertEqual(decoded.format.encode(decoded.text), bytes, "round trip changed the bytes", file: file, line: line) XCTAssertTrue(decoded.isByteExact, file: file, line: line) + XCTAssertTrue(decoded.isLosslessDecode, file: file, line: line) XCTAssertFalse(decoded.hasMixedLineEndings, file: file, line: line) } @@ -178,6 +179,60 @@ final class DocumentFileFormatTests: XCTestCase { } } + // MARK: - Lossless decode (the edit session's entry check) + + func testMixedLineEndingFilesDecodeLosslessly() throws { + for (name, bytes) in [("CRLF+LF", utf8("a\r\nb\r\nc\n")), + ("CR+CRLF", utf8("a\r\r\nb")), + ("LF+CR UTF-16 LE", Data([0xFF, 0xFE]) + "a\nb\rc".data(using: .utf16LittleEndian)!), + ("CRLF+LF Latin-1", "caf\u{E9}\r\nb\n".data(using: .isoLatin1)!), + ("CRLF+LF UTF-8 BOM", Data([0xEF, 0xBB, 0xBF]) + utf8("a\r\nb\n"))] { + let decoded = try XCTUnwrap(DocumentFileFormat.decode(bytes), name) + XCTAssertTrue(decoded.hasMixedLineEndings, name) + XCTAssertFalse(decoded.isByteExact, name) + XCTAssertTrue(decoded.isLosslessDecode, name) + } + } + + /// An odd number of UTF-16 bytes cannot be reproduced by any UTF-16 + /// encode: if the decoder took it as UTF-16 it repaired something, and the + /// flag must say so — with or without mixed line endings. (Should + /// Foundation refuse it and fall back to Latin-1, that decode IS + /// lossless: every byte is one character.) + func testOddLengthUTF16IsNotLossless() throws { + let cases: [(String, Data)] = [ + ("odd LE", Data([0xFF, 0xFE, 0x61, 0x00, 0x62])), + ("odd BE", Data([0xFE, 0xFF, 0x00, 0x61, 0x00])), + ("odd LE mixed", Data([0xFF, 0xFE]) + "a\r\nb\nc".data(using: .utf16LittleEndian)! + Data([0x64])), + ("odd BE mixed", Data([0xFE, 0xFF]) + "a\r\nb\nc".data(using: .utf16BigEndian)! + Data([0x00])), + ] + for (name, bytes) in cases { + let decoded = try XCTUnwrap(DocumentFileFormat.decode(bytes), name) + switch decoded.format.encoding { + case .utf16LittleEndian, .utf16BigEndian: + XCTAssertFalse(decoded.isLosslessDecode, name) + case .isoLatin1: + XCTAssertTrue(decoded.isLosslessDecode, name) + case .utf8: + XCTFail("\(name) cannot be UTF-8") + } + XCTAssertFalse(decoded.isByteExact && !decoded.isLosslessDecode, name) + } + } + + func testLosslessDecodeMatchesARawReencode() throws { + let cases = [Data([0xFF, 0xFE, 0x00, 0xD8, 0x61, 0x00]), Data([0xFE, 0xFF, 0xDC, 0x00, 0x00, 0x61]), + utf8(""), utf8("x"), Data([0xEF, 0xBB, 0xBF])] + for bytes in cases { + let decoded = try XCTUnwrap(DocumentFileFormat.decode(bytes)) + let raw = try XCTUnwrap(MarkdownDocument.decodeDetectingEncoding(bytes)).text + let asRead = DocumentFileFormat(encoding: decoded.format.encoding, hasBOM: decoded.format.hasBOM, + lineEnding: .lf) + XCTAssertEqual(decoded.isLosslessDecode, asRead.encode(raw) == bytes) + if decoded.isByteExact { XCTAssertTrue(decoded.isLosslessDecode) } + } + } + // MARK: - Line-ending policy func testMixedLineEndingsPickDominant() { diff --git a/QuickMD/QuickMDTests/SourceEditSessionTests.swift b/QuickMD/QuickMDTests/SourceEditSessionTests.swift index 05a7794..f054d14 100644 --- a/QuickMD/QuickMDTests/SourceEditSessionTests.swift +++ b/QuickMD/QuickMDTests/SourceEditSessionTests.swift @@ -34,32 +34,53 @@ final class SourceEditSessionTests: XCTestCase { var copyCalls = 0 var copyModalURLs: [URL?] = [] var copyModalCalls = 0 + /// The unsaved-changes prompt stays "on screen": its completion is + /// kept in `heldUnsaved` instead of being called. + var holdUnsaved = false + var heldUnsaved: ((UnsavedChangesAlert.Choice) -> Void)? + /// Runs before each conflict answer, with the call's 1-based number + /// (e.g. another app writes the file while the prompt is up). + var beforeConflictAnswer: ((Int) -> Void)? + /// Per-call conflict answers; `conflictAnswer` once they run out. + var conflictAnswers: [SourceEditSession.ConflictChoice] = [] + /// The window every sheet-capable prompt was given, in call order. + var windows: [NSWindow?] = [] var prompts: SourceEditSession.Prompts { SourceEditSession.Prompts( - unsavedChanges: { _, _, completion in + unsavedChanges: { window, _, completion in self.unsavedCalls += 1 + self.windows.append(window) self.onUnsaved?() + if self.holdUnsaved { + self.heldUnsaved = completion + return + } completion(self.unsavedAnswers.isEmpty ? .cancel : self.unsavedAnswers.removeFirst()) }, unsavedChangesModal: { _ in self.modalCalls += 1 return self.modalAnswer }, - saveConflict: { _, _, completion in + saveConflict: { window, _, completion in self.conflictCalls += 1 - completion(self.conflictAnswer) + self.windows.append(window) + self.beforeConflictAnswer?(self.conflictCalls) + completion(self.conflictAnswers.isEmpty ? self.conflictAnswer : self.conflictAnswers.removeFirst()) }, - encodingFallback: { _, _, name, completion in + encodingFallback: { window, _, name, completion in self.encodingNames.append(name) + self.windows.append(window) completion(self.encodingAnswer) }, - saveFailed: { _, error, completion in + saveFailed: { window, error, completion in self.saveFailedErrors.append(error) + self.windows.append(window) completion(self.saveFailedAnswer) }, - copyDestination: { _, _, _, completion in + copyDestination: { window, _, _, completion in self.copyCalls += 1 + self.windows.append(window) completion(self.copyURL) }, copyDestinationModal: { _, _ in @@ -84,6 +105,8 @@ final class SourceEditSessionTests: XCTestCase { private var savedHasAttachedSheet: ((NSWindow) -> Bool)! private var savedDisable: ((String) -> Void)! private var savedEnable: ((String) -> Void)! + private var savedBeep: (() -> Void)! + private var beeps = 0 override func setUpWithError() throws { try super.setUpWithError() @@ -99,6 +122,9 @@ final class SourceEditSessionTests: XCTestCase { EditCloseGuard.hasAttachedSheet = { _ in false } EditCloseGuard.disableAutomaticTermination = { _ in } EditCloseGuard.enableAutomaticTermination = { _ in } + savedBeep = SourceEditSession.beep + beeps = 0 + SourceEditSession.beep = { [weak self] in self?.beeps += 1 } } override func tearDownWithError() throws { @@ -107,6 +133,7 @@ final class SourceEditSessionTests: XCTestCase { EditCloseGuard.hasAttachedSheet = savedHasAttachedSheet EditCloseGuard.disableAutomaticTermination = savedDisable EditCloseGuard.enableAutomaticTermination = savedEnable + SourceEditSession.beep = savedBeep if let enumerator = FileManager.default.enumerator(atPath: directory.path) { for case let path as String in enumerator { chmod(directory.appendingPathComponent(path).path, 0o644) @@ -262,7 +289,7 @@ final class SourceEditSessionTests: XCTestCase { Data([0xFF, 0xFE, 0x00, 0xD8, 0x61, 0x00])] guard let unsafe = candidates.first(where: { data in guard let decoded = DocumentFileFormat.decode(data) else { return false } - return !decoded.isByteExact && !decoded.hasMixedLineEndings + return !decoded.isLosslessDecode }) else { throw XCTSkip("this system's decoder round-trips every malformed fixture") } @@ -691,6 +718,22 @@ final class SourceEditSessionTests: XCTestCase { XCTAssertTrue(session.isDirty) } + func testLeavingDropsTheUndoStackAndAPendingFocus() throws { + let (session, window) = entered(try fixture("abc\n")) + type(session, "x", at: 0) + XCTAssertEqual(save(session), true) + XCTAssertTrue(session.editor.undoManager.canUndo) + // The editor is not mounted yet when focus is asked for. + let scrollView = try XCTUnwrap(session.editor.textView.enclosingScrollView) + scrollView.removeFromSuperview() + session.focusEditor() + session.requestLeave() + XCTAssertFalse(session.isActive) + XCTAssertFalse(session.editor.undoManager.canUndo) + window.contentView!.addSubview(scrollView) + XCTAssertFalse(window.firstResponder === session.editor.textView, "no focus after the mode ended") + } + func testEscapeRequestsLeave() throws { let (session, _) = entered(try fixture("abc\n")) session.editor.textView.doCommand(by: #selector(NSResponder.cancelOperation(_:))) @@ -839,6 +882,363 @@ final class SourceEditSessionTests: XCTestCase { XCTAssertTrue(window.delegate === EditCloseGuard.guardFor(window), "installed once, stays") } + // MARK: - Review round (2026-10-05) + + private func spin(_ seconds: TimeInterval) { + RunLoop.current.run(until: Date().addingTimeInterval(seconds)) + } + + // 1. Save Anyway never advances the base ahead of the write. + func testSaveAnywayThenAFailedWriteKeepsEverythingConsistent() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other app\n".utf8).write(to: url) + prompts.conflictAnswer = .saveAnyway + prompts.beforeConflictAnswer = { _ in chmod(url.path, 0o444) } + prompts.saveFailedAnswer = .ok + XCTAssertEqual(save(session), false) + XCTAssertEqual(prompts.saveFailedErrors.count, 1) + XCTAssertEqual(bytes(url), Data("other app\n".utf8), "the other version is still on disk") + XCTAssertEqual(session.baseBytes, Data("base\n".utf8), "base untouched by a failed write") + XCTAssertEqual(session.savedText, "base\n") + XCTAssertEqual(view.rendered, "other app\n", "rendered == disk") + XCTAssertTrue(session.externalChange, "the banner says the disk moved") + XCTAssertTrue(session.isDirty) + // The watcher's echo of nothing changes nothing; the next save asks again. + session.diskDidChange() + XCTAssertEqual(view.rendered, "other app\n") + chmod(url.path, 0o644) + prompts.beforeConflictAnswer = nil + XCTAssertEqual(save(session), true) + XCTAssertEqual(prompts.conflictCalls, 2) + XCTAssertEqual(bytes(url), Data("mine base\n".utf8)) + XCTAssertFalse(session.externalChange) + } + + // 2. The disk moved while the conflict prompt was up: ask about the new version. + func testDiskChangeWhileTheConflictPromptIsUpAsksAgain() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other 1\n".utf8).write(to: url) + prompts.conflictAnswer = .saveAnyway + prompts.beforeConflictAnswer = { call in + if call == 1 { try? Data("other 2\n".utf8).write(to: url) } + } + XCTAssertEqual(save(session), true) + XCTAssertEqual(prompts.conflictCalls, 2, "the second version was shown before overwriting it") + XCTAssertEqual(bytes(url), Data("mine base\n".utf8)) + } + + func testDiskChangeWhileTheConflictPromptIsUpThenCancelOverwritesNothing() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other 1\n".utf8).write(to: url) + prompts.conflictAnswers = [.saveAnyway, .cancel] + prompts.beforeConflictAnswer = { call in + if call == 1 { try? Data("other 2\n".utf8).write(to: url) } + } + XCTAssertEqual(save(session), false) + XCTAssertEqual(bytes(url), Data("other 2\n".utf8)) + XCTAssertEqual(view.rendered, "other 2\n") + } + + // 3. Keep My Version adopts the disk's NEW format. + func testKeepMyVersionAfterAFormatChangeSavesInTheNewFormat() throws { + let url = try fixture("a\r\nb\r\n") + let (session, _) = entered(url) + type(session, "X", at: 0) + try Data("a\nb\n".utf8).write(to: url) // git rewrote it as LF + session.diskDidChange() + session.keepMyVersion() + XCTAssertEqual(session.format.lineEnding, .lf) + XCTAssertEqual(session.savedText, "a\nb\n") + XCTAssertEqual(save(session), true) + XCTAssertEqual(prompts.conflictCalls, 0) + XCTAssertEqual(bytes(url), Data("Xa\nb\n".utf8), "LF, only the user's edit") + } + + func testKeepMyVersionOfAFileThatCannotBeEditedKeepsTheBanner() throws { + let unsafe = [Data([0xFF, 0xFE, 0x61, 0x00, 0x62]), Data([0xFE, 0xFF, 0x00, 0x61, 0x00]), + Data([0xFF, 0xFE, 0x00, 0xD8, 0x61, 0x00])] + .first { DocumentFileFormat.decode($0).map { !$0.isLosslessDecode } ?? false } + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try XCTUnwrap(unsafe).write(to: url) + session.diskDidChange() + XCTAssertTrue(session.externalChange) + session.keepMyVersion() + XCTAssertTrue(session.externalChange, "banner stays") + XCTAssertEqual(session.baseBytes, Data("base\n".utf8)) + XCTAssertEqual(session.savedText, "base\n") + XCTAssertEqual(view.toasts.last?.contains("can’t be edited safely"), true) + try FileManager.default.removeItem(at: url) + session.keepMyVersion() + XCTAssertTrue(session.externalChange) + XCTAssertEqual(view.toasts.last, "“doc.md” no longer exists.") + } + + // 4. Re-entrancy while a prompt is up. + func testNothingStartsWhileAPromptIsUp() throws { + let url = try fixture("abc\n") + let (session, window) = entered(url) + type(session, "x", at: 0) + prompts.holdUnsaved = true + var left: Bool? + session.requestLeave { left = $0 } + XCTAssertNil(left) + XCTAssertTrue(session.isPrompting) + + var saved: Bool? + session.save { saved = $0 } + XCTAssertEqual(saved, false) + var leftAgain: Bool? + session.requestLeave { leftAgain = $0 } + XCTAssertEqual(leftAgain, false) + var confirmed: Bool? + try guardHandlers(window).confirm(window) { confirmed = $0 } + XCTAssertEqual(confirmed, false, "a ⌘W / ⌘Q during the sheet is cancelled, not queued") + XCTAssertEqual(beeps, 1) + session.discardChanges() + session.keepMyVersion() + session.loadDiskVersion() + XCTAssertEqual(session.editor.text, "xabc\n", "no banner / menu action ran") + XCTAssertEqual(prompts.unsavedCalls, 1, "never a second sheet") + XCTAssertEqual(bytes(url), Data("abc\n".utf8)) + + // A disk change keeps rendered == disk and raises the banner, buffer untouched. + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertEqual(view.rendered, "other\n") + XCTAssertTrue(session.externalChange) + XCTAssertEqual(session.editor.text, "xabc\n") + + let held = try XCTUnwrap(prompts.heldUnsaved) + held(.cancel) + XCTAssertEqual(left, false) + XCTAssertFalse(session.isPrompting) + XCTAssertEqual(save(session), false, "a new chain starts (and meets the conflict, cancelled)") + XCTAssertEqual(prompts.conflictCalls, 1) + } + + func testAChainEndsOnEveryExit() throws { + let url = try fixture("abc\n") + let (session, window) = entered(url) + type(session, "x", at: 0) + prompts.unsavedAnswers = [.cancel] + session.requestLeave() + XCTAssertFalse(session.isPrompting) + chmod(url.path, 0o444) + prompts.saveFailedAnswer = .saveCopy + prompts.copyURL = nil + save(session) + XCTAssertFalse(session.isPrompting, "save failed, copy panel cancelled") + chmod(url.path, 0o644) + prompts.unsavedAnswers = [.cancel] + try guardHandlers(window).confirm(window) { _ in } + XCTAssertFalse(session.isPrompting) + XCTAssertEqual(save(session), true) + XCTAssertFalse(session.isPrompting) + } + + // 5. Never clean on faith. + func testRefusedDiscardKeepsTheBufferDirty() throws { + let (session, window) = entered(try fixture("abc\n")) + type(session, "x", at: 0) + session.editor.textView.isEditable = false + session.discardChanges() + XCTAssertEqual(session.editor.text, "xabc\n") + XCTAssertTrue(session.isDirty) + XCTAssertTrue(window.isDocumentEdited) + XCTAssertTrue(try guardHandlers(window).isDirty()) + } + + func testRefusedLoadDiskVersionKeepsTheBufferDirty() throws { + let url = try fixture("abc\n") + let (session, window) = entered(url) + type(session, "x", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + session.editor.textView.isEditable = false + session.loadDiskVersion() + XCTAssertEqual(session.editor.text, "xabc\n") + XCTAssertTrue(session.isDirty, "dirty against the adopted disk text") + XCTAssertTrue(window.isDocumentEdited) + } + + // 6. Mixed AND repaired is refused; mixed alone is allowed (above). + func testEntryRefusesExactlyWhenTheDecodeIsLossy() throws { + let mixedRepaired = Data([0xFF, 0xFE]) + "a\r\nb\nc".data(using: .utf16LittleEndian)! + Data([0x64]) + let decoded = try XCTUnwrap(DocumentFileFormat.decode(mixedRepaired)) + let url = try fixture(mixedRepaired, name: "odd.md") + let (session, _) = makeSession(url) + let refusal = session.enter(atLine: 0) + XCTAssertEqual(refusal == .unsafe(fileName: "odd.md"), !decoded.isLosslessDecode) + } + + // 7. Follow-up prompts use the window confirm was given. + func testConfirmFollowUpsUseTheConfirmWindow() throws { + let url = try fixture("abc\n") + let (session, window) = entered(url) + type(session, "x", at: 0) + // No window from the environment, and the editor unmounted, so the + // session's own fallback finds none either. + session.environment.window = { nil } + session.editor.textView.enclosingScrollView?.removeFromSuperview() + try Data("other\n".utf8).write(to: url) + prompts.unsavedAnswers = [.save] + prompts.conflictAnswer = .saveAnyway + chmod(url.path, 0o444) + prompts.saveFailedAnswer = .saveCopy + prompts.copyURL = nil + var result: Bool? + try guardHandlers(window).confirm(window) { result = $0 } + XCTAssertEqual(result, false) + XCTAssertEqual(prompts.windows.count, 4, "unsaved, conflict, save failed, copy panel") + XCTAssertTrue(prompts.windows.allSatisfy { $0 === window }) + } + + // 8. Literal comparison: NFC vs NFD is a change. + func testNormalizationOnlyChangeIsDirtyAndSaved() throws { + let url = try fixture("caf\u{E9}\n") + let (session, window) = entered(url) + let length = (session.editor.text as NSString).length + userAction(session) { + session.editor.textView.insertText("cafe\u{301}\n", replacementRange: NSRange(location: 0, length: length)) + } + XCTAssertEqual(session.editor.text, session.savedText, "Swift == says equal…") + XCTAssertTrue(session.checkDirty(), "…the session does not") + XCTAssertTrue(window.isDocumentEdited) + XCTAssertEqual(save(session), true) + XCTAssertEqual(bytes(url), Data("cafe\u{301}\n".utf8)) + XCTAssertFalse(SourceEditSession.isSameText("\u{E9}", "e\u{301}")) + XCTAssertTrue(SourceEditSession.isSameText("e\u{301}", "e\u{301}")) + } + + // 9. With the banner up, ⌘S on an unchanged buffer is not a silent no-op… + func testSaveWithTheBannerUpAndAnUnchangedBufferGoesThroughTheConflict() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + session.editor.undoManager.undo() + XCTAssertEqual(session.editor.text, "base\n") + prompts.conflictAnswer = .saveAnyway + XCTAssertEqual(save(session), true) + XCTAssertEqual(prompts.conflictCalls, 1) + XCTAssertEqual(bytes(url), Data("base\n".utf8)) + XCTAssertFalse(session.externalChange) + } + + // …and Discard Changes means the disk version. + func testDiscardWithTheBannerUpLoadsTheDiskVersion() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "mine ", at: 0) + try Data("other\n".utf8).write(to: url) + session.diskDidChange() + session.discardChanges() + XCTAssertEqual(session.editor.text, "other\n") + XCTAssertFalse(session.isDirty) + XCTAssertFalse(session.externalChange) + session.editor.undoManager.undo() + XCTAssertEqual(session.editor.text, "mine base\n") + } + + // 10. A clean adopt clears the banner. + func testCleanAdoptClearsTheBanner() throws { + let url = try fixture("base\n") + let (session, _) = entered(url) + type(session, "x", at: 0) + try Data("other 1\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertTrue(session.externalChange) + session.editor.undoManager.undo() + try Data("other 2\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertEqual(session.editor.text, "other 2\n") + XCTAssertFalse(session.externalChange) + } + + // 11. An empty file is adopted only if it is still empty a moment later. + func testEmptyFileIsAdoptedAfterTheRecheck() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + try Data().write(to: url) + session.diskDidChange() + XCTAssertEqual(view.rendered, "", "rendered == disk at once") + XCTAssertEqual(session.editor.text, "abc\n", "not adopted yet") + spin(SourceEditSession.emptyFileRecheckDelay + 0.3) + XCTAssertEqual(session.editor.text, "") + XCTAssertEqual(session.savedText, "") + } + + func testEmptyFileFollowedByContentAdoptsTheContent() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + try Data().write(to: url) + session.diskDidChange() + try Data("rewritten\n".utf8).write(to: url) + session.diskDidChange() + XCTAssertEqual(session.editor.text, "rewritten\n") + spin(SourceEditSession.emptyFileRecheckDelay + 0.3) + XCTAssertEqual(session.editor.text, "rewritten\n") + XCTAssertEqual(session.savedText, "rewritten\n") + } + + func testLeavingCancelsTheEmptyFileRecheck() throws { + let url = try fixture("abc\n") + let (session, _) = entered(url) + try Data().write(to: url) + session.diskDidChange() + session.requestLeave() + let commits = view.commits.count + spin(SourceEditSession.emptyFileRecheckDelay + 0.3) + XCTAssertEqual(view.commits.count, commits) + XCTAssertEqual(session.savedText, "abc\n") + } + + // 12. The standard alerts, built but never shown. + func testConflictAlertHasNoDefaultButton() { + let alert = SourceEditSession.Prompts.makeConflictAlert(fileName: "x.md") + XCTAssertEqual(alert.messageText, "“x.md” has been changed by another application.") + XCTAssertEqual(alert.buttons.map(\.title), ["Save Anyway", "Load Disk Version", "Cancel"]) + XCTAssertFalse(alert.buttons.contains { $0.keyEquivalent == "\r" }, "Return triggers nothing") + XCTAssertEqual(alert.buttons[2].keyEquivalent, "\u{1b}") + XCTAssertEqual(alert.buttons.map { $0.accessibilityIdentifier() }, + ["source-conflict-save-anyway", "source-conflict-load-disk", "source-conflict-cancel"]) + } + + func testEncodingAlertMakesNoClaimAboutLineEndings() { + let alert = SourceEditSession.Prompts.makeEncodingAlert(fileName: "x.md", encodingName: "ISO Latin 1") + XCTAssertEqual(alert.messageText, + "“x.md” uses ISO Latin 1, which cannot store some of the characters you typed.") + XCTAssertFalse(alert.informativeText.lowercased().contains("line ending")) + XCTAssertEqual(alert.buttons.map { $0.accessibilityIdentifier() }, + ["source-encoding-utf8", "source-encoding-cancel"]) + } + + // 13. Entry-check order. + func testEntryCheckOrder() throws { + let big = String(repeating: "a", count: SourceEditSession.maximumEditableBytes + 1) + let readOnlyAndBig = try fixture(big, name: "ro-big.md") + chmod(readOnlyAndBig.path, 0o444) + XCTAssertEqual(makeSession(readOnlyAndBig).0.enter(atLine: 0), .readOnly(fileName: "ro-big.md")) + + let writeOnly = try fixture("abc\n", name: "wo.md") + chmod(writeOnly.path, 0o222) + XCTAssertEqual(makeSession(writeOnly).0.enter(atLine: 0), .unreadable(fileName: "wo.md"), + "unreadable before read-only") + + let noAccess = try fixture(big, name: "none-big.md") + chmod(noAccess.path, 0o000) + XCTAssertEqual(makeSession(noAccess).0.enter(atLine: 0), .unreadable(fileName: "none-big.md")) + } + // MARK: - Lifetime func testSessionDeallocatesWithAGuardInstalled() throws { diff --git a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift index 893230f..189cc34 100644 --- a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift +++ b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift @@ -638,6 +638,8 @@ final class SourceEditorControllerTests: XCTestCase { } /// Once settled: the anchor's line exactly where it was on screen. func checkExact(_ label: String, line: UInt = #line) { + // A relayout that did nothing would pass the check below vacuously. + XCTAssertTrue(controller.isRestorePending, "\(label): a restore is due", line: line) settle(controller, line: line) XCTAssertEqual(exactOffset(controller, scrollView, character: anchor.character), anchor.offset, accuracy: 0.5, label, line: line) @@ -794,6 +796,12 @@ final class SourceEditorControllerTests: XCTestCase { XCTAssertEqual(controller.text, "ONE\nTWO\n") XCTAssertFalse(controller.undoManager.canUndo, "reload clears the stack") XCTAssertEqual(changes, 1, "reload never fires onChange") + // Typing after a reload never merges with typing before it. + controller.textView.setSelectedRange(NSRange(location: 3, length: 0)) + type(controller, "?") + controller.undoManager.undo() + XCTAssertEqual(controller.text, "ONE\nTWO\n") + XCTAssertFalse(controller.undoManager.canUndo) } func testReloadKeepsTheCaretsLineAndColumn() { @@ -823,12 +831,56 @@ final class SourceEditorControllerTests: XCTestCase { controller.scroll(toLine: 1_200) let before = scrollView.contentView.bounds.minY XCTAssertGreaterThan(before, 0) + let storageBefore = controller.textView.textStorage!.mutableString + let lineBefore = SourceEditSupport.line(containing: topLine(controller, scrollView).character, + in: storageBefore) // Same line count, one line above the top rewritten. let edited = numberedLines(2_000).replacingOccurrences(of: "line 5 ", with: "LINE 5 ") controller.reload(edited) let storage = controller.textView.textStorage!.mutableString - XCTAssertEqual(SourceEditSupport.line(containing: topCharacter(controller, scrollView), in: storage), 1_200) + XCTAssertEqual(SourceEditSupport.line(containing: topLine(controller, scrollView).character, in: storage), + lineBefore) XCTAssertEqual(scrollView.contentView.bounds.minY, before, accuracy: 0.5) + XCTAssertFalse(controller.isRestorePending) + } + + /// A reload while a relayout's exact restore is due keeps THAT top line + /// (exactly) and leaves nothing pending that points into the old text. + func testReloadDuringAPendingRestoreKeepsItsLineAndCancelsIt() { + let (controller, scrollView, window) = makeEditor(wrappingLines(2_000), width: 900) + let storage = controller.textView.textStorage!.mutableString + controller.scroll(toLine: 1_200) + let anchor = topLine(controller, scrollView) + let anchorLine = SourceEditSupport.line(containing: anchor.character, in: storage) + window.setContentSize(NSSize(width: 700, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + XCTAssertTrue(controller.isRestorePending) + // A line far above the top changes; the line count does not. + controller.reload("changed\n" + wrappingLines(2_000).split(separator: "\n", omittingEmptySubsequences: false) + .dropFirst().joined(separator: "\n")) + XCTAssertFalse(controller.isRestorePending, "nothing stale left to run") + let line = SourceEditSupport.line(containing: topLine(controller, scrollView).character, in: storage) + XCTAssertEqual(line, anchorLine) + let character = SourceEditSupport.lineStart(anchorLine, in: storage) + anchor.character + - SourceEditSupport.lineStart(anchorLine, in: wrappingLines(2_000) as NSString) + XCTAssertEqual(exactOffset(controller, scrollView, character: character), anchor.offset, accuracy: 0.5) + } + + /// Beyond the exact-restore limit a reload keeps the top line by the + /// estimate and does not lay out everything above it. + func testReloadBeyondTheExactLimitUsesTheEstimate() { + let (controller, scrollView, _) = makeEditor(numberedLines(40_000), width: 900) + let layoutManager = controller.textView.layoutManager! + layoutManager.backgroundLayoutEnabled = false + let storage = controller.textView.textStorage!.mutableString + controller.scroll(toLine: 30_000) + let anchor = topLine(controller, scrollView) + XCTAssertGreaterThan(anchor.character, SourceEditorController.exactRestoreCharacterLimit) + controller.reload(numberedLines(40_000).replacingOccurrences(of: "line 39999 ", with: "LINE 39999 ")) + XCTAssertLessThan(layoutManager.firstUnlaidCharacterIndex(), anchor.character) + let top = SourceEditSupport.line(containing: topLine(controller, scrollView).character, in: storage) + XCTAssertLessThanOrEqual(abs(top - 30_000), 3) + XCTAssertFalse(controller.isRestorePending) } func testReloadWithShorterTextScrollsAsFarAsItCan() { @@ -955,4 +1007,99 @@ final class SourceEditorControllerTests: XCTestCase { controller.undoManager.undo() XCTAssertEqual(controller.text, "new") } + + // MARK: - Second review round + + /// The base font and colour are ADDED to an edited range: another + /// attribute placed in the same edit (as NSTextView does for marked text + /// while an input method composes) survives. + func testEditStampsBaseAttributesWithoutStrippingOthers() { + let (controller, _, _) = makeEditor("ab", style: style(dark, scale: 2)) + let storage = controller.textView.textStorage! + let foreign = NSAttributedString.Key("QuickMDTests.foreign") + storage.replaceCharacters(in: NSRange(location: 1, length: 0), with: NSAttributedString( + string: "XY", + attributes: [.font: NSFont.systemFont(ofSize: 40), .foregroundColor: NSColor.red, + .underlineStyle: NSUnderlineStyle.single.rawValue, foreign: true])) + XCTAssertEqual(controller.text, "aXYb") + for index in [1, 2] { + XCTAssertEqual(storage.attribute(foreign, at: index, effectiveRange: nil) as? Bool, true) + XCTAssertEqual(storage.attribute(.underlineStyle, at: index, effectiveRange: nil) as? Int, + NSUnderlineStyle.single.rawValue) + let font = storage.attribute(.font, at: index, effectiveRange: nil) as? NSFont + XCTAssertEqual(font?.pointSize, BlockLayout.Code.codeFontSize * 2) + assertSameColor(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor, + dark.textColor, appearance: NSAppearance(named: .darkAqua)) + } + } + + func testPastedCRTextUndoIsNamedPaste() { + let (controller, _, _) = makeEditor("start ") + let pasteboard = NSPasteboard(name: NSPasteboard.Name("QuickMDTests.SourceEditor.\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + pasteboard.clearContents() + pasteboard.setString("x\r\ny", forType: .string) + controller.textView.setSelectedRange(NSRange(location: 6, length: 0)) + userAction(controller) { _ = controller.textView.readSelection(from: pasteboard, type: .string) } + XCTAssertEqual(controller.text, "start x\ny") + XCTAssertEqual(controller.undoManager.undoActionName, "Paste") + } + + /// Replace All with a replacement containing CR (multi-range change): + /// every replacement is normalised, and the whole change undoes as one. + func testMultiRangeReplacementWithCRIsNormalizedAsOneUndo() { + let (controller, _, _) = makeEditor("a-b-c") + let ranges = [NSRange(location: 1, length: 1), NSRange(location: 3, length: 1)].map { NSValue(range: $0) } + var allowed = true + userAction(controller) { + allowed = controller.textView.shouldChangeText(inRanges: ranges, replacementStrings: ["\r\n", "\r"]) + } + XCTAssertFalse(allowed, "the CR version is refused and replaced") + XCTAssertEqual(controller.text, "a\nb\nc") + XCTAssertFalse(controller.text.unicodeScalars.contains("\r")) + controller.undoManager.undo() + XCTAssertEqual(controller.text, "a-b-c") + controller.undoManager.redo() + XCTAssertEqual(controller.text, "a\nb\nc") + } + + func testMultiRangeReplacementWithoutCRIsLeftToAppKit() { + let (controller, _, _) = makeEditor("a-b-c") + let ranges = [NSRange(location: 1, length: 1), NSRange(location: 3, length: 1)].map { NSValue(range: $0) } + XCTAssertTrue(controller.textView.delegate!.textView!(controller.textView, shouldChangeTextInRanges: ranges, + replacementStrings: ["+", "+"])) + XCTAssertEqual(controller.text, "a-b-c", "nothing done by the delegate itself") + } + + /// Short lines: under the character limit but beyond the line limit, the + /// exact pass does not run (layout above the anchor stays undone). + func testExactRestoreIsSkippedBeyondTheLineLimit() { + let (controller, scrollView, window) = makeEditor(numberedLines(20_000, width: 12), width: 900) + let layoutManager = controller.textView.layoutManager! + layoutManager.backgroundLayoutEnabled = false + let storage = controller.textView.textStorage!.mutableString + controller.scroll(toLine: 18_000) + let anchor = topLine(controller, scrollView) + XCTAssertLessThan(anchor.character, SourceEditorController.exactRestoreCharacterLimit) + XCTAssertGreaterThan(SourceEditSupport.line(containing: anchor.character, in: storage), + SourceEditorController.exactRestoreLineLimit) + window.setContentSize(NSSize(width: 700, height: 600)) + window.contentView!.layoutSubtreeIfNeeded() + XCTAssertTrue(controller.isRestorePending) + settle(controller) + XCTAssertLessThan(layoutManager.firstUnlaidCharacterIndex(), anchor.character) + } + + func testCancelPendingFocus() { + let controller = SourceEditorController() + let scrollView = controller.makeScrollView(style: style()) + controller.focus() + controller.cancelPendingFocus() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + windows.append(window) + window.contentView!.addSubview(scrollView) + XCTAssertFalse(window.firstResponder === controller.textView) + } } From 15e83df43be8e0623ecacb512bffcbac7dfae11d Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 15:23:20 +0200 Subject: [PATCH 08/17] fix(source-edit): rendered text recovers when an emptied file returns with the same bytes The session records the last text it committed to the rendered view and re-commits the saved text whenever the disk is back at the base and the two differ. Busy banner/menu actions beep; the empty-file recheck delay is injectable for tests. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD/SourceEditSession.swift | 66 +++++++++++----- .../QuickMDTests/SourceEditSessionTests.swift | 75 ++++++++++++++++--- 2 files changed, 113 insertions(+), 28 deletions(-) diff --git a/QuickMD/QuickMD/SourceEditSession.swift b/QuickMD/QuickMD/SourceEditSession.swift index d947c61..434a154 100644 --- a/QuickMD/QuickMD/SourceEditSession.swift +++ b/QuickMD/QuickMD/SourceEditSession.swift @@ -182,10 +182,19 @@ final class SourceEditSession: ObservableObject { /// through a non-atomic save (truncate, then write): adopting it at once /// would show a clean, empty buffer for a moment and lose the caret. /// Looked at again after this long. - static let emptyFileRecheckDelay: TimeInterval = 0.3 - - /// The beep for a close request that arrives while a prompt is up. - /// Replaced in tests (no sound from a test run). + static let defaultEmptyFileRecheckDelay: TimeInterval = 0.3 + /// `defaultEmptyFileRecheckDelay`; tests set 0 so they do not depend on + /// wall-clock time. + var emptyFileRecheckDelay = SourceEditSession.defaultEmptyFileRecheckDelay + + /// What the session last asked the view to show (`commit`) — nil before + /// the first entry. Lets a disk event that brings back the base bytes + /// repair a rendered text that was left showing something else. + private var lastCommittedText: String? + + /// The beep for a request that arrives while a prompt is up (a close or + /// quit, a banner or menu action). Replaced in tests (no sound from a + /// test run). static var beep: () -> Void = { NSSound.beep() } init() { @@ -215,6 +224,13 @@ final class SourceEditSession: ObservableObject { private var bufferMatchesSaved: Bool { Self.isSameText(editor.text, savedText) } + /// Every "make the rendered view show this" goes through here, so the + /// session knows what the view shows. + private func commit(_ text: String) { + lastCommittedText = text + environment.commitText(text) + } + /// Enters the editor with the caret at the start of 0-based `line` (nil: /// the top), scrolled to the top of the visible area — the view converts /// the reading position's 1-based `editorLine()`. Returns nil when it @@ -246,8 +262,10 @@ final class SourceEditSession: ObservableObject { hasSavedOnce = false externalChange = false isDirty = false - if !(environment.renderedText().map { Self.isSameText($0, decoded.text) } ?? false) { - environment.commitText(decoded.text) + if environment.renderedText().map({ Self.isSameText($0, decoded.text) }) ?? false { + lastCommittedText = decoded.text + } else { + commit(decoded.text) } editor.load(decoded.text) installGuardIfNeeded() @@ -296,6 +314,11 @@ final class SourceEditSession: ObservableObject { /// The exact check: the flag only says "something was typed". Clears the /// flag (and the close button's dot) when the buffer is back to the saved /// text. Cheap while clean — the comparison runs only once flagged. + /// + /// Once flagged it compares the whole buffer: up to tens of ms on a 2 MB + /// buffer. For a user action (save, leave, close) — anything evaluated + /// often (menu enabling, a view's `body`) must read the published + /// `isDirty` and never call this. @discardableResult func checkDirty() -> Bool { guard isActive, isDirty else { return false } @@ -450,7 +473,7 @@ final class SourceEditSession: ObservableObject { externalChange = false let unified = hasMixedLineEndings hasMixedLineEndings = false - environment.commitText(text) + commit(text) syncDirtyWithBuffer() environment.toast(unified ? "Saved · line endings unified to \(Self.displayName(of: target.lineEnding))" : "Saved") @@ -460,7 +483,7 @@ final class SourceEditSession: ObservableObject { /// rendered text equal to it and show the banner. Base and saved text stay. private func noteExternalChange(_ data: Data) { guard let decoded = DocumentFileFormat.decode(data) else { return } - environment.commitText(decoded.text) + commit(decoded.text) externalChange = true } @@ -555,7 +578,8 @@ final class SourceEditSession: ObservableObject { /// mode. With the banner up the saved text is no longer what is on disk — /// "drop my edits" then means the disk version (`loadDiskVersion`). func discardChanges() { - guard isActive, !isPrompting else { return } + guard isActive else { return } + guard !isPrompting else { return Self.beep() } if externalChange { return applyDiskVersion() } if !bufferMatchesSaved { replaceBufferAsOwnEdit { editor.replaceAll(with: savedText) } @@ -577,16 +601,20 @@ final class SourceEditSession: ObservableObject { let data = try? Data(contentsOf: url) else { return } guard data != baseBytes else { // Our own save's echo, a touch — or the file went back to the - // version the buffer is based on, after a change the banner showed. - if externalChange { - externalChange = false - environment.commitText(savedText) + // version the buffer is based on: after a change the banner + // showed, or after a moment of emptiness (a non-atomic writer + // re-saving the same content between truncate and write — the "" + // was committed, the recheck is now cancelled). Either way the + // rendered text must be the saved text again. + externalChange = false + if !(lastCommittedText.map { Self.isSameText($0, savedText) } ?? true) { + commit(savedText) } return } guard let decoded = DocumentFileFormat.decode(data) else { return } // Rendered == disk, whatever the buffer holds. - environment.commitText(decoded.text) + commit(decoded.text) // A prompt is up (its chain is deciding about the buffer), or the // buffer holds unsaved text: the banner, never a replaced buffer. if isPrompting || checkDirty() { @@ -607,7 +635,7 @@ final class SourceEditSession: ObservableObject { self.handleDiskChange(adoptingEmpty: true) } emptyFileRecheck = work - DispatchQueue.main.asyncAfter(deadline: .now() + Self.emptyFileRecheckDelay, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + emptyFileRecheckDelay, execute: work) return } adopt(decoded, bytes: data) @@ -648,7 +676,8 @@ final class SourceEditSession: ObservableObject { /// that cannot be read or entered keeps the banner (base and saved text /// never get out of step). func keepMyVersion() { - guard isActive, !isPrompting else { return } + guard isActive else { return } + guard !isPrompting else { return Self.beep() } guard let disk = readDiskForAdoption() else { return } adopt(disk.decoded, bytes: disk.bytes) externalChange = false @@ -659,7 +688,8 @@ final class SourceEditSession: ObservableObject { /// — ⌘Z brings the user's text back, dirty again — and adopts the disk as /// the clean state. func loadDiskVersion() { - guard isActive, !isPrompting else { return } + guard isActive else { return } + guard !isPrompting else { return Self.beep() } applyDiskVersion() } @@ -669,7 +699,7 @@ final class SourceEditSession: ObservableObject { guard isActive, let disk = readDiskForAdoption() else { return } adopt(disk.decoded, bytes: disk.bytes) externalChange = false - environment.commitText(disk.decoded.text) + commit(disk.decoded.text) if !bufferMatchesSaved { replaceBufferAsOwnEdit { editor.replaceAll(with: disk.decoded.text) } } diff --git a/QuickMD/QuickMDTests/SourceEditSessionTests.swift b/QuickMD/QuickMDTests/SourceEditSessionTests.swift index f054d14..84aa331 100644 --- a/QuickMD/QuickMDTests/SourceEditSessionTests.swift +++ b/QuickMD/QuickMDTests/SourceEditSessionTests.swift @@ -884,10 +884,6 @@ final class SourceEditSessionTests: XCTestCase { // MARK: - Review round (2026-10-05) - private func spin(_ seconds: TimeInterval) { - RunLoop.current.run(until: Date().addingTimeInterval(seconds)) - } - // 1. Save Anyway never advances the base ahead of the write. func testSaveAnywayThenAFailedWriteKeepsEverythingConsistent() throws { let url = try fixture("base\n") @@ -1165,43 +1161,102 @@ final class SourceEditSessionTests: XCTestCase { } // 11. An empty file is adopted only if it is still empty a moment later. + /// Runs everything already queued on the main queue: the recheck is + /// queued with delay 0 before this marker, and timers fire in deadline + /// order — no wall-clock waiting. + private func drainMainQueue() { + var drained = false + DispatchQueue.main.asyncAfter(deadline: .now()) { drained = true } + while !drained { RunLoop.current.run(until: Date().addingTimeInterval(0.01)) } + } + + private func enteredWithoutRecheckDelay(_ url: URL) -> SourceEditSession { + let (session, _) = entered(url) + session.emptyFileRecheckDelay = 0 + return session + } + func testEmptyFileIsAdoptedAfterTheRecheck() throws { + XCTAssertEqual(SourceEditSession.defaultEmptyFileRecheckDelay, 0.3) let url = try fixture("abc\n") - let (session, _) = entered(url) + let session = enteredWithoutRecheckDelay(url) try Data().write(to: url) session.diskDidChange() XCTAssertEqual(view.rendered, "", "rendered == disk at once") XCTAssertEqual(session.editor.text, "abc\n", "not adopted yet") - spin(SourceEditSession.emptyFileRecheckDelay + 0.3) + drainMainQueue() XCTAssertEqual(session.editor.text, "") XCTAssertEqual(session.savedText, "") } func testEmptyFileFollowedByContentAdoptsTheContent() throws { let url = try fixture("abc\n") - let (session, _) = entered(url) + let session = enteredWithoutRecheckDelay(url) try Data().write(to: url) session.diskDidChange() try Data("rewritten\n".utf8).write(to: url) session.diskDidChange() XCTAssertEqual(session.editor.text, "rewritten\n") - spin(SourceEditSession.emptyFileRecheckDelay + 0.3) + drainMainQueue() XCTAssertEqual(session.editor.text, "rewritten\n") XCTAssertEqual(session.savedText, "rewritten\n") } + /// A slow non-atomic writer re-saving the SAME content: the watcher sees + /// the empty file, then the base bytes again. The rendered text must end + /// as the saved text; nothing else may change. + func testEmptyFileThenTheSameBytesRestoresTheRenderedText() throws { + let url = try fixture("one\ntwo\n") + let session = enteredWithoutRecheckDelay(url) + type(session, "x", at: 0) + session.editor.undoManager.undo() + session.editor.textView.setSelectedRange(NSRange(location: 5, length: 0)) + let commitsBefore = view.commits.count + try Data().write(to: url) + session.diskDidChange() + XCTAssertEqual(view.rendered, "") + try Data("one\ntwo\n".utf8).write(to: url) + session.diskDidChange() + drainMainQueue() + XCTAssertEqual(view.commits.count, commitsBefore + 2, "\"\", then the saved text") + XCTAssertEqual(view.commits.last, "one\ntwo\n") + XCTAssertEqual(view.rendered, session.savedText) + XCTAssertEqual(session.editor.text, "one\ntwo\n") + XCTAssertEqual(session.baseBytes, Data("one\ntwo\n".utf8)) + XCTAssertTrue(session.editor.undoManager.canRedo, "undo stack untouched (no reload)") + XCTAssertEqual(session.editor.textView.selectedRange(), NSRange(location: 5, length: 0)) + XCTAssertFalse(session.externalChange) + XCTAssertFalse(session.isDirty) + // A plain echo afterwards commits nothing more. + session.diskDidChange() + XCTAssertEqual(view.commits.count, commitsBefore + 2) + } + func testLeavingCancelsTheEmptyFileRecheck() throws { let url = try fixture("abc\n") - let (session, _) = entered(url) + let session = enteredWithoutRecheckDelay(url) try Data().write(to: url) session.diskDidChange() session.requestLeave() let commits = view.commits.count - spin(SourceEditSession.emptyFileRecheckDelay + 0.3) + drainMainQueue() XCTAssertEqual(view.commits.count, commits) XCTAssertEqual(session.savedText, "abc\n") } + func testBannerAndMenuActionsBeepWhileAPromptIsUp() throws { + let (session, _) = entered(try fixture("abc\n")) + type(session, "x", at: 0) + prompts.holdUnsaved = true + session.requestLeave() + session.discardChanges() + session.keepMyVersion() + session.loadDiskVersion() + XCTAssertEqual(beeps, 3) + XCTAssertEqual(session.editor.text, "xabc\n") + prompts.heldUnsaved?(.cancel) + } + // 12. The standard alerts, built but never shown. func testConflictAlertHasNoDefaultButton() { let alert = SourceEditSession.Prompts.makeConflictAlert(fileName: "x.md") From 6ae9b22157bd48f4d177251e49809c2d94322bca Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Mon, 5 Oct 2026 15:29:33 +0200 Subject: [PATCH 09/17] feat(source-edit): wire Source Edit into the document window Edit Source / Done Editing (Option-Cmd-E), Save (Cmd-S) and Discard Changes in the File menu via focused values; the editor mounted over the rendered list; Save / Done pills while editing, the pencil pill starts Source Edit and the external editor gets its own pill; changed-on-disk banner; Cmd-F opens the editor's find bar while editing; leaving lands the rendered view on the block that holds the caret, resolved against the parse of the saved text. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 10 + QuickMD/QuickMD/MarkdownExport.swift | 55 ++- QuickMD/QuickMD/MarkdownView.swift | 358 +++++++++++++++--- QuickMD/QuickMD/QuickMDApp.swift | 43 ++- QuickMD/QuickMD/SourceEditLanding.swift | 49 +++ QuickMD/QuickMD/SourceEditSession.swift | 12 +- QuickMD/QuickMD/SourceEditorController.swift | 23 +- QuickMD/QuickMD/Views/ChromeButtons.swift | 68 +++- .../QuickMDTests/SourceEditLandingTests.swift | 73 ++++ .../QuickMDTests/SourceEditSessionTests.swift | 14 + 10 files changed, 641 insertions(+), 64 deletions(-) create mode 100644 QuickMD/QuickMD/SourceEditLanding.swift create mode 100644 QuickMD/QuickMDTests/SourceEditLandingTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index e367106..8978d6a 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -140,6 +140,9 @@ A1B2C3D401000160 /* SourceEditSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; }; A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; }; A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000161 /* SourceEditSessionTests.swift */; }; + A1B2C3D401000180 /* SourceEditLanding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000180 /* SourceEditLanding.swift */; }; + A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000180 /* SourceEditLanding.swift */; }; + A1B2C3D401000182 /* SourceEditLandingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000181 /* SourceEditLandingTests.swift */; }; A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000F0 /* HeadingLayoutTests.swift */; }; /* End PBXBuildFile section */ @@ -174,6 +177,8 @@ A1B2C3D400000141 /* EditCloseGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuardTests.swift; sourceTree = ""; }; A1B2C3D400000160 /* SourceEditSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSession.swift; sourceTree = ""; }; A1B2C3D400000161 /* SourceEditSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSessionTests.swift; sourceTree = ""; }; + A1B2C3D400000180 /* SourceEditLanding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditLanding.swift; sourceTree = ""; }; + A1B2C3D400000181 /* SourceEditLandingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditLandingTests.swift; sourceTree = ""; }; A1B2C3D4000000F0 /* HeadingLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingLayoutTests.swift; sourceTree = ""; }; A1B2C3D400000001 /* QuickMDApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMDApp.swift; sourceTree = ""; }; A1B2C3D400000002 /* MarkdownDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocument.swift; sourceTree = ""; }; @@ -308,6 +313,7 @@ A1B2C3D400000152 /* SourceEditorControllerTests.swift */, A1B2C3D400000141 /* EditCloseGuardTests.swift */, A1B2C3D400000161 /* SourceEditSessionTests.swift */, + A1B2C3D400000181 /* SourceEditLandingTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -334,6 +340,7 @@ A1B2C3D400000150 /* SourceEditorController.swift */, A1B2C3D400000140 /* EditCloseGuard.swift */, A1B2C3D400000160 /* SourceEditSession.swift */, + A1B2C3D400000180 /* SourceEditLanding.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -625,6 +632,7 @@ A1B2C3D401000152 /* SourceEditorView.swift in Sources */, A1B2C3D401000140 /* EditCloseGuard.swift in Sources */, A1B2C3D401000160 /* SourceEditSession.swift in Sources */, + A1B2C3D401000180 /* SourceEditLanding.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -678,6 +686,8 @@ A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */, A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */, A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */, + A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */, + A1B2C3D401000182 /* SourceEditLandingTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QuickMD/QuickMD/MarkdownExport.swift b/QuickMD/QuickMD/MarkdownExport.swift index c79121c..c874d0c 100644 --- a/QuickMD/QuickMD/MarkdownExport.swift +++ b/QuickMD/QuickMD/MarkdownExport.swift @@ -66,7 +66,55 @@ struct FocusedExportDocumentLocationKey: FocusedValueKey { typealias Value = ExportDocumentLocation } +/// Source Edit (v1.12 S-D12) as the File menu sees the focused document: the +/// title of "Edit Source" / "Done Editing", whether Save and Discard Changes +/// are enabled, and the commands that must be off while the editor shows +/// (Export, Print, Copy Markdown, Open in External Editor act on the SAVED +/// text, not the one the user is looking at). Non-optional for the same +/// reason as `FocusedExportNameKey`. +struct SourceEditMenuState: Equatable { + let isEditing: Bool + let isDirty: Bool + + var canSave: Bool { isEditing && isDirty } +} + +struct FocusedSourceEditStateKey: FocusedValueKey { + typealias Value = SourceEditMenuState +} + +/// ⌥⌘E — enter or leave Source Edit in the FOCUSED document only. +struct FocusedToggleSourceEditKey: FocusedValueKey { + typealias Value = () -> Void +} + +/// ⌘S while editing. +struct FocusedSaveSourceKey: FocusedValueKey { + typealias Value = () -> Void +} + +/// File ▸ Discard Changes while editing. +struct FocusedDiscardSourceChangesKey: FocusedValueKey { + typealias Value = () -> Void +} + extension FocusedValues { + var sourceEditState: SourceEditMenuState? { + get { self[FocusedSourceEditStateKey.self] } + set { self[FocusedSourceEditStateKey.self] = newValue } + } + var toggleSourceEditAction: (() -> Void)? { + get { self[FocusedToggleSourceEditKey.self] } + set { self[FocusedToggleSourceEditKey.self] = newValue } + } + var saveSourceAction: (() -> Void)? { + get { self[FocusedSaveSourceKey.self] } + set { self[FocusedSaveSourceKey.self] = newValue } + } + var discardSourceChangesAction: (() -> Void)? { + get { self[FocusedDiscardSourceChangesKey.self] } + set { self[FocusedDiscardSourceChangesKey.self] = newValue } + } var documentText: String? { get { self[FocusedDocumentTextKey.self] } set { self[FocusedDocumentTextKey.self] = newValue } @@ -913,6 +961,7 @@ struct ExportPDFCommand: View { @FocusedValue(\.documentText) private var documentText @FocusedValue(\.exportName) private var exportName @FocusedValue(\.exportDocumentLocation) private var documentLocation + @FocusedValue(\.sourceEditState) private var sourceEditState var body: some View { Button("Export as PDF\u{2026}") { @@ -921,7 +970,8 @@ struct ExportPDFCommand: View { documentURL: documentLocation?.url) } } - .disabled(documentText?.isEmpty ?? true) + // While editing the export would be of the saved text, not the screen. + .disabled((documentText?.isEmpty ?? true) || sourceEditState?.isEditing == true) .keyboardShortcut("e", modifiers: [.command, .shift]) } } @@ -929,6 +979,7 @@ struct ExportPDFCommand: View { struct PrintCommand: View { @FocusedValue(\.documentText) private var documentText @FocusedValue(\.exportDocumentLocation) private var documentLocation + @FocusedValue(\.sourceEditState) private var sourceEditState var body: some View { Button("Print\u{2026}") { @@ -936,7 +987,7 @@ struct PrintCommand: View { PrintManager.printDocument(documentText: text, documentURL: documentLocation?.url) } } - .disabled(documentText?.isEmpty ?? true) + .disabled((documentText?.isEmpty ?? true) || sourceEditState?.isEditing == true) .keyboardShortcut("p", modifiers: .command) } } diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index e11940b..09bba5d 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -114,6 +114,20 @@ struct MarkdownView: View { /// on demand. A reference in @State on purpose: the coordinator fills it, /// nothing observes it, so scrolling never re-evaluates this body (E-D1). @State private var readingPosition = DocumentReadingPosition() + /// Source Edit (v1.12): the raw-Markdown editor over the rendered list, its + /// buffer, saving and the close guard's answers. One per window, lives with + /// the tab. Publishes only rare events (enter / leave, dirty, banner) — the + /// body never reads anything per keystroke from it. + @StateObject private var editSession = SourceEditSession() + /// The editor's find bar height (0 = hidden): the bar sits where the pills + /// and banners float, so they move below it (`topChromeInset`). + @State private var editorFindBarHeight: CGFloat = 0 + /// The text `cachedBlocks` were parsed from. Block ids are positional, so a + /// scroll by id is only meaningful against the blocks of the text it was + /// computed for — the landing after Source Edit checks this (S-D10). + @State private var installedText: String? + /// A landing waiting for the parse of the just-saved text. + @State private var landing = SourceEditLanding() /// File name suggested by the PDF export save panel (`ExportPDFCommand`). private var exportName: String { @@ -188,7 +202,7 @@ struct MarkdownView: View { // Table of Contents sidebar if isToCVisible && !headings.isEmpty && !isReadingMode { TableOfContentsView(headings: headings, onSelect: { targetId in - requestScroll(to: targetId, anchor: .top, animated: true) + selectHeading(targetId) }, onCopy: { entry in if let section = SectionExtractor.extractSection(from: currentText, entry: entry, headings: headings) { copyToClipboard(section) @@ -236,13 +250,34 @@ struct MarkdownView: View { }, onCopySelection: { output in copySelectionToClipboard(output) }, focusRequest: documentFocusRequest, - isCovered: graphicPreview != nil, + // Stays mounted under the editor (its anchor and offset + // survive; a remount would park at the top) but never takes + // the focus back while covered. + isCovered: graphicPreview != nil || editSession.isActive, readingPosition: readingPosition ) + .accessibilityHidden(editSession.isActive) .onChange(of: scrollTrigger) { _ in scrollFocusedMatchIntoView() } } + .overlay { + // Source Edit (S-D4). The FIRST overlay, so the pills, banners + // and toast below stay above the editor. No transition and no + // changing identity: one live `SourceEditorView` per controller + // (an outgoing copy would take the scroll view from the new + // one). Theme, zoom and Reading Mode reach it through `style`. + Group { + if editSession.isActive { + SourceEditorView(controller: editSession.editor, + style: .init(theme: theme, fontScale: fontScale, + isReadingLayout: isReadingMode)) + } + } + // An animated transaction from elsewhere (a toast, a sidebar) + // must not turn the mount or unmount into a fade. + .transaction { $0.animation = nil } + } .overlay(alignment: .topLeading) { // Reveal sidebar when hidden (sits at top-leading; out of the way // of content). Reading mode is the one state where "hidden" was @@ -265,62 +300,47 @@ struct MarkdownView: View { .focusable(false) .opacity(0.5) .help("Show recent documents (⇧⌘D)") - .padding(.top, isSearchVisible ? 44 : 8) + .padding(.top, topChromeInset) .padding(.leading, 8) } } .overlay(alignment: .topTrailing) { - // The zoom / Edit / Copy cluster. Gone in reading mode — its - // shortcuts (⌘0, ⌘E, ⌘⇧C) all still work, so nothing is lost - // except the thing hovering over the text. - if !isReadingMode { - HStack(spacing: 8) { - if fontScale != 1.0 { - ZoomResetButton(theme: theme, fontScale: fontScale) { - applyZoom(.actualSize) - } - .transition(.opacity) - } - if documentURL != nil { - EditInEditorButton(theme: theme) { - openInExternalEditor() - } - } - CopySourceButton(theme: theme) { - copyToClipboard(currentText) - } - } - .animation(.easeInOut(duration: 0.15), value: fontScale != 1.0) - .chromeHoverCluster() - .padding(.top, isSearchVisible ? 44 : 8) - .padding(.trailing, 24) - .transition(.opacity) - } + chromeCluster } .overlay(alignment: .top) { - if fileMissing { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.yellow) - Text("File no longer exists at \(documentURL?.path ?? "this location")") - .font(.system(size: 12)) - .lineLimit(1) - .truncationMode(.middle) - Button("Close Tab") { - EditCloseGuard.requestClose(NSApp.keyWindow) + // The missing-file and changed-on-disk banners can be up at + // once (a change the buffer has not acknowledged, then the file + // goes): stacked, never on top of each other. + VStack(spacing: 8) { + if fileMissing { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + Text("File no longer exists at \(documentURL?.path ?? "this location")") + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Button("Close Tab") { + EditCloseGuard.requestClose(NSApp.keyWindow) + } + .controlSize(.small) } - .controlSize(.small) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.regularMaterial) + .clipShape(Capsule()) + .transition(.move(edge: .top).combined(with: .opacity)) + } + if editSession.externalChange { + changedOnDiskBanner } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.regularMaterial) - .clipShape(Capsule()) - .padding(.top, isSearchVisible ? 44 : 8) - .transition(.move(edge: .top).combined(with: .opacity)) } + .padding(.top, topChromeInset) } .overlay(alignment: .bottomTrailing) { - if !isReadingMode { + // Not over the editor either: it would sit on the text being + // edited, and there is nothing to read past it. + if !isReadingMode && !editSession.isActive { Group { #if APPSTORE TipJarButton(theme: theme) @@ -333,7 +353,9 @@ struct MarkdownView: View { } } .overlay(alignment: .center) { - if isRenderPending { + // The hidden list re-parses after every save; the editor is + // what is on screen, and it is not waiting for anything. + if isRenderPending && !editSession.isActive { HStack(spacing: 8) { ProgressView() .controlSize(.small) @@ -369,6 +391,94 @@ struct MarkdownView: View { } } + /// Where the top chrome (pills, banners, the sidebar button) floats: below + /// the search bar, or below the editor's find bar while that is showing — + /// the find bar's own buttons sit exactly under the Save / Done pills. + private var topChromeInset: CGFloat { + if isSearchVisible { return 44 } + if editSession.isActive && editorFindBarHeight > 0 { return editorFindBarHeight + 8 } + return 8 + } + + /// The top-right pill cluster. + /// + /// Reading: zoom reset (while zoomed), Edit (Source Edit, ⌥⌘E), Open in + /// editor (⌘E), Copy source — gone in Reading Mode, whose shortcuts (⌘0, + /// ⌥⌘E, ⌘E, ⌘⇧C) all still work, so nothing is lost except the thing + /// hovering over the text. + /// + /// Editing: ONLY Save and Done, also in Reading Mode — a mode you can only + /// leave by shortcut is not acceptable (S-D12). + @ViewBuilder + private var chromeCluster: some View { + if editSession.isActive { + HStack(spacing: 8) { + SourceSaveButton(theme: theme, isEnabled: editSession.isDirty) { + editSession.save() + } + SourceDoneButton(theme: theme) { + editSession.requestLeave() + } + } + .chromeHoverCluster() + .padding(.top, topChromeInset) + .padding(.trailing, 24) + } else if !isReadingMode { + HStack(spacing: 8) { + if fontScale != 1.0 { + ZoomResetButton(theme: theme, fontScale: fontScale) { + applyZoom(.actualSize) + } + .transition(.opacity) + } + if documentURL != nil { + EditSourceButton(theme: theme) { + toggleSourceEdit() + } + OpenInEditorButton(theme: theme) { + openInExternalEditor() + } + } + CopySourceButton(theme: theme) { + copyToClipboard(currentText) + } + } + .animation(.easeInOut(duration: 0.15), value: fontScale != 1.0) + .chromeHoverCluster() + .padding(.top, topChromeInset) + .padding(.trailing, 24) + .transition(.opacity) + } + } + + /// S-D9: the file changed on disk while the buffer holds unsaved text. The + /// rendered view (hidden) already shows the disk; the user decides about + /// the buffer. Same look and place as the missing-file banner. + private var changedOnDiskBanner: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + Text("This file changed on disk while you were editing.") + .font(.system(size: 12)) + .lineLimit(1) + Button("Keep My Version") { + editSession.keepMyVersion() + } + .controlSize(.small) + .accessibilityIdentifier("source-keep-mine") + Button("Load Disk Version") { + editSession.loadDiskVersion() + } + .controlSize(.small) + .accessibilityIdentifier("source-load-disk") + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.regularMaterial) + .clipShape(Capsule()) + .transition(.move(edge: .top).combined(with: .opacity)) + } + /// `documentStack` plus the window configuration and every value the app's /// menu commands read from the focused document. /// @@ -408,7 +518,7 @@ struct MarkdownView: View { .focusedSceneValue(\.exportName, exportName) // Print / PDF resolve relative image paths against the document folder. .focusedSceneValue(\.exportDocumentLocation, ExportDocumentLocation(url: documentURL)) - .focusedSceneValue(\.searchAction, { toggleSearch() }) + .focusedSceneValue(\.searchAction, { findCommand() }) .focusedSceneValue(\.toggleToCAction, { // No-op in reading mode: the sidebars are hidden and must come back // exactly as they were, so their flags are not touched meanwhile. @@ -428,8 +538,22 @@ struct MarkdownView: View { .focusedSceneValue(\.isReadingMode, isReadingMode) } - var body: some View { + /// `configuredDocumentStack` plus Source Edit's menu values and its leave + /// handling — one more split for the type-checker's budget. + private var sourceEditStack: some View { configuredDocumentStack + .focusedSceneValue(\.sourceEditState, + SourceEditMenuState(isEditing: editSession.isActive, isDirty: editSession.isDirty)) + .focusedSceneValue(\.toggleSourceEditAction, { toggleSourceEdit() }) + .focusedSceneValue(\.saveSourceAction, { editSession.save() }) + .focusedSceneValue(\.discardSourceChangesAction, { editSession.discardChanges() }) + .onChange(of: editSession.lastLeave) { leave in + if let leave { didLeaveSourceEdit(leave) } + } + } + + var body: some View { + sourceEditStack .environment(\.openURL, OpenURLAction { url in handleLinkActivation(url) return .handled @@ -489,6 +613,7 @@ struct MarkdownView: View { renderedFontScale = Double(scale) contentVersion += 1 textBlockMeta = parsed.textMeta + installedText = text // Inline `$…$` paragraphs and display-math rows were deferred by the // off-main pass; finish them here, on the main actor, before the // table is used for layout. @@ -504,6 +629,13 @@ struct MarkdownView: View { } return nil } + // A landing that waited for THIS parse (Source Edit saved, then + // left before the re-parse arrived): requested in the transaction + // that installs the blocks, so it wins over the list's own anchor + // restore and resolves against the new ids. + if let target = landing.blocksInstalled(parsed.blocks) { + requestScroll(to: target, anchor: .top, animated: false) + } isParsing = false } .task(id: HeightsIdentity(contentVersion: contentVersion, contentWidth: contentWidth)) { @@ -554,6 +686,7 @@ struct MarkdownView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work) } .onAppear { + configureEditSession() if let url = documentURL { RecentDocumentsStore.shared.register(url) // Auto-reload: watch this document's file and refresh on save. @@ -574,9 +707,10 @@ struct MarkdownView: View { // in QuickMDApp.swift, which correctly targets the active tab. // // Only handle the search-bar-specific keys here (⌘G next match, - // ⇧⌘G previous, Escape close) and Escape for reading mode. These - // already gate on per-view state (`isSearchVisible`, `isReadingMode`), - // so inactive tabs return the event unchanged and the active tab + // ⇧⌘G previous, Escape close), Escape for reading mode and ⌘G / ⇧⌘G + // for the Source Edit find bar. These already gate on per-view state + // (`isSearchVisible`, `isReadingMode`, `editSession.isActive`), so + // inactive tabs return the event unchanged and the active tab // consumes it. ⌘⇧R itself is a menu shortcut, routed through // `@FocusedValue` like ⌘F/⌘⇧T/⌘⇧D — a monitor would toggle every // open tab at once. @@ -598,6 +732,22 @@ struct MarkdownView: View { } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if editSession.isActive { + // ⌘G / ⇧⌘G go to the editor's text finder (S-D6). Nothing + // else is touched: Escape must reach the text view (it + // closes the find bar, then leaves the mode) or the find + // bar's field — never Reading Mode or the search first. + if flags.contains(.command), event.charactersIgnoringModifiers?.lowercased() == "g" { + if flags.contains(.shift) { + editSession.editor.findPrevious() + } else { + editSession.editor.findNext() + } + return nil + } + return event + } + if flags.contains(.command) && event.charactersIgnoringModifiers == "g" { if isSearchVisible { if flags.contains(.shift) { @@ -632,6 +782,7 @@ struct MarkdownView: View { } fileWatcher?.stop() fileWatcher = nil + releaseEditSession() } } @@ -641,6 +792,17 @@ struct MarkdownView: View { /// Same decode + line-ending normalization as the initial document load. private func reloadFromDisk() { guard let url = documentURL else { return } + if editSession.isActive { + // The session reads the disk itself and keeps the rendered text + // equal to it through `commitText` (S-D9) — or shows the banner + // when the buffer holds unsaved text. Touching `currentText` here + // would race it. + editSession.diskDidChange() + if fileMissing && FileManager.default.fileExists(atPath: url.path) { + withAnimation(.easeInOut(duration: 0.2)) { fileMissing = false } + } + return + } guard let data = try? Data(contentsOf: url), let decoded = MarkdownDocument.decode(data) else { return } let text = MarkdownDocument.normalizeLineEndings(decoded) @@ -655,13 +817,99 @@ struct MarkdownView: View { /// ⌘E — hand the document off to the user's configured editor, at the line /// the reader is at when that editor documents a line link (v1.11 E-D1). private func openInExternalEditor() { - guard let url = documentURL else { return } + // Two editors on one file: not while Source Edit is showing. + guard !editSession.isActive, let url = documentURL else { return } let line = readingPosition.editorLine() if let result = ExternalEditorManager.openInEditor(url, line: line) { showToast(ExternalEditorManager.toastText(for: result)) } } + // MARK: - Source Edit (v1.12) + + /// Hands the session what it needs from this view. The closures capture + /// the view — and with it the @StateObject's storage, a cycle — so they + /// are dropped in `onDisappear` (`releaseEditSession`) and set again here. + /// Nothing that saves depends on them: a save uses `documentURL` and the + /// window the close guard hands in (or the text view's own window). + private func configureEditSession() { + editSession.environment = SourceEditSession.Environment( + documentURL: documentURL, + window: { hostWindow }, + renderedText: { currentText }, + commitText: { text in + if !SourceEditSession.isSameText(text, currentText) { currentText = text } + }, + toast: { showToast($0) } + ) + editSession.editor.onFindBarHeightChange = { height in + // Reported from the scroll view's `tile()`, i.e. inside an AppKit + // layout pass: published on the next turn, not during it. + DispatchQueue.main.async { editorFindBarHeight = height } + } + } + + private func releaseEditSession() { + editSession.environment = SourceEditSession.Environment(documentURL: documentURL) + editSession.editor.onFindBarHeightChange = nil + } + + /// ⌥⌘E, File ▸ Edit Source / Done Editing, the Edit pill. Entering starts + /// at the source line being read — the selection's first row, else the + /// top visible row (`editorLine()` is 1-based, the session 0-based). + private func toggleSourceEdit() { + if editSession.isActive { + editSession.requestLeave() + return + } + guard graphicPreview == nil else { return } + if let refusal = editSession.enter(atLine: readingPosition.editorLine().map { $0 - 1 }) { + showToast(refusal.message) + return + } + // A landing still waiting for a parse belongs to the previous session. + landing.cancel() + isSearchVisible = false + searchText = "" + editSession.focusEditor() + } + + /// The session left (Done, Esc, the command; after Save or Don't Save). + /// The list gets the keyboard back; if the session saved or the caret + /// moved off the entry line, the list lands on the block holding the + /// caret's line — against the blocks of the CURRENT text (S-D10). + private func didLeaveSourceEdit(_ leave: SourceEditSession.LeaveInfo) { + documentFocusRequest += 1 + // `==`, as `DocumentIdentity` compares: text it calls equal is never + // re-parsed, so waiting for a parse of it would wait forever. + let current = installedText.map { $0 == currentText } ?? false + if let target = landing.leave(leave, installedBlocksAreCurrent: current, blocks: cachedBlocks) { + requestScroll(to: target, anchor: .top, animated: false) + } + } + + /// ⌘F: the editor's find bar while editing (S-D6), the search bar otherwise. + private func findCommand() { + if editSession.isActive { + editSession.editor.showFind() + } else { + toggleSearch() + } + } + + /// A ToC click. While editing, the EDITOR scrolls to the heading's source + /// line (S-D13) — best effort until the next save, since the headings come + /// from the last saved parse; the hidden list stays where the reader was. + private func selectHeading(_ targetId: String) { + if editSession.isActive { + if let entry = headings.first(where: { $0.id == targetId }) { + editSession.editor.scroll(toLine: entry.sourceLine) + } + return + } + requestScroll(to: targetId, anchor: .top, animated: true) + } + // MARK: - Block Rendering /// `blockView(for:)` plus the environment the block views would otherwise @@ -799,6 +1047,8 @@ struct MarkdownView: View { // MARK: - Search Helpers private func toggleSearch() { + // The document search cannot open over the editor (S-D6). + guard !editSession.isActive else { return } isSearchVisible.toggle() if !isSearchVisible { searchText = "" } } diff --git a/QuickMD/QuickMD/QuickMDApp.swift b/QuickMD/QuickMD/QuickMDApp.swift index 837d0ca..076917d 100644 --- a/QuickMD/QuickMD/QuickMDApp.swift +++ b/QuickMD/QuickMD/QuickMDApp.swift @@ -35,6 +35,8 @@ struct QuickMDApp: App { } .keyboardShortcut("w", modifiers: .command) Divider() + SourceEditCommands() + Divider() OpenInExternalEditorCommand() Divider() ExportPDFCommand() @@ -162,30 +164,67 @@ struct ToggleDocumentListCommand: View { } } +/// File ▸ Edit Source / Done Editing (⌥⌘E), Save (⌘S), Discard Changes — +/// Source Edit for the frontmost document only, via @FocusedValue like every +/// other per-document command (an NSEvent monitor would act on every tab). +/// ⌘S belongs to Save only while there is something to save: the viewer has +/// no other Save, so a disabled item costs nothing. +struct SourceEditCommands: View { + @FocusedValue(\.toggleSourceEditAction) var toggleSourceEditAction + @FocusedValue(\.saveSourceAction) var saveSourceAction + @FocusedValue(\.discardSourceChangesAction) var discardSourceChangesAction + @FocusedValue(\.sourceEditState) var sourceEditState + + var body: some View { + Button(sourceEditState?.isEditing == true ? "Done Editing" : "Edit Source") { + toggleSourceEditAction?() + } + .keyboardShortcut("e", modifiers: [.command, .option]) + .disabled(toggleSourceEditAction == nil) + + Button("Save") { + saveSourceAction?() + } + .keyboardShortcut("s", modifiers: .command) + .disabled(saveSourceAction == nil || sourceEditState?.canSave != true) + + Button("Discard Changes") { + discardSourceChangesAction?() + } + .disabled(discardSourceChangesAction == nil || sourceEditState?.canSave != true) + } +} + struct CopyMarkdownCommand: View { @FocusedValue(\.copyDocumentAction) var copyDocumentAction + @FocusedValue(\.sourceEditState) var sourceEditState var body: some View { Button("Copy Markdown") { copyDocumentAction?() } .keyboardShortcut("c", modifiers: [.command, .shift]) - .disabled(copyDocumentAction == nil) + // While editing it would copy the saved text, not the one on screen. + .disabled(copyDocumentAction == nil || sourceEditState?.isEditing == true) } } struct OpenInExternalEditorCommand: View { @FocusedValue(\.openInExternalEditorAction) var openInExternalEditorAction + @FocusedValue(\.sourceEditState) var sourceEditState var body: some View { Button("Open in External Editor") { openInExternalEditorAction?() } .keyboardShortcut("e", modifiers: .command) - .disabled(openInExternalEditorAction == nil) + // Two editors on one file while editing here: off until Done. + .disabled(openInExternalEditorAction == nil || sourceEditState?.isEditing == true) } } +/// ⌘F: the document search — or, while Source Edit is showing, the editor's +/// own find bar (the focused view decides; `MarkdownView.findCommand`). struct FindMenuCommand: View { @FocusedValue(\.searchAction) var searchAction diff --git a/QuickMD/QuickMD/SourceEditLanding.swift b/QuickMD/QuickMD/SourceEditLanding.swift new file mode 100644 index 0000000..7f02bd5 --- /dev/null +++ b/QuickMD/QuickMD/SourceEditLanding.swift @@ -0,0 +1,49 @@ +import Foundation + +// MARK: - Landing after Source Edit (v1.12 S-D10) +// +// Where the rendered list scrolls when the editor goes away. Pure, so the one +// timing rule that matters is tested without a window: the target block must +// be looked up in the blocks parsed from the CURRENT text. Block ids are +// positional (`text-3` exists in every parse) — a save followed at once by +// leaving would otherwise resolve against the previous parse's blocks and send +// the list to whatever `text-3` used to be. + +struct SourceEditLanding: Equatable { + + /// A 0-based source line still waiting for the parse of the current text. + private(set) var pendingLine: Int? + + /// The session left. Returns the block id to scroll to NOW, or nil — then + /// either nothing needs to move (`shouldLand` false) or the line waits for + /// `blocksInstalled` because the installed blocks are not the current + /// text's yet. Every leave replaces what an earlier one left pending. + mutating func leave(_ info: SourceEditSession.LeaveInfo, installedBlocksAreCurrent: Bool, + blocks: [MarkdownBlock]) -> String? { + pendingLine = nil + guard info.shouldLand else { return nil } + guard installedBlocksAreCurrent else { + pendingLine = info.caretLine + return nil + } + return Self.target(line: info.caretLine, in: blocks) + } + + /// A parse of the current text is being installed: the pending line, if + /// any, resolved against those blocks — to be requested in the SAME + /// transaction, where it wins over the list's own anchor restore. + mutating func blocksInstalled(_ blocks: [MarkdownBlock]) -> String? { + guard let line = pendingLine else { return nil } + pendingLine = nil + return Self.target(line: line, in: blocks) + } + + /// Editing again before the parse arrived: that landing is stale. + mutating func cancel() { + pendingLine = nil + } + + private static func target(line: Int, in blocks: [MarkdownBlock]) -> String? { + SourceEditSupport.blockIndex(containingLine: line, in: blocks).map { blocks[$0].id } + } +} diff --git a/QuickMD/QuickMD/SourceEditSession.swift b/QuickMD/QuickMD/SourceEditSession.swift index d947c61..5ee6835 100644 --- a/QuickMD/QuickMD/SourceEditSession.swift +++ b/QuickMD/QuickMD/SourceEditSession.swift @@ -190,7 +190,17 @@ final class SourceEditSession: ObservableObject { init() { editor.onChange = { [weak self] in self?.bufferDidChange() } - editor.onEscape = { [weak self] in self?.requestLeave() } + // Esc closes the find bar first, then leaves (S-D10). With the focus + // in the bar's own field the bar handles Esc itself; this is Esc in + // the text view while the bar is still showing. + editor.onEscape = { [weak self] in + guard let self else { return } + if self.editor.isFindBarVisible { + self.editor.hideFind() + } else { + self.requestLeave() + } + } } deinit { diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift index 462fd51..869ceb5 100644 --- a/QuickMD/QuickMD/SourceEditorController.swift +++ b/QuickMD/QuickMD/SourceEditorController.swift @@ -126,6 +126,12 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD /// Escape reached the text view. Consumed here so it never opens /// NSTextView's completion list; the session decides what Esc means. var onEscape: (() -> Void)? + /// The find bar's height while it is showing, 0 once it is hidden — + /// reported when it changes. The bar sits at the top of the scroll view, + /// exactly where the window's chrome pills and banners float, so the host + /// moves those below it (otherwise the Save / Done pills cover the bar's + /// own buttons). + var onFindBarHeightChange: ((CGFloat) -> Void)? /// The editor's OWN undo stack, handed to the text view through /// `undoManager(for:)`. Never `window.undoManager`: that one belongs to @@ -186,6 +192,8 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD private var focusRequested = false /// Re-entrancy guard for the attribute fix-up in `didProcessEditing`. private var isFixingAttributes = false + /// The last height handed to `onFindBarHeightChange`. + private var reportedFindBarHeight: CGFloat = 0 override init() { // An explicit TextKit 1 stack: an NSTextView created with a container @@ -283,7 +291,10 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = NSEdgeInsets() scrollView.documentView = textView - scrollView.onTile = { [weak self] in self?.updateGeometry() } + scrollView.onTile = { [weak self] in + self?.updateGeometry() + self?.reportFindBarHeight() + } scrollView.onEndLiveResize = { [weak self] in self?.finishRelayout() } scrollView.onUserScroll = { [weak self] in self?.dropPendingAnchor() } self.scrollView = scrollView @@ -747,6 +758,16 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD func findPrevious() { performFinder(.previousMatch) } func hideFind() { performFinder(.hideFindInterface) } + /// Showing or hiding the bar re-tiles the scroll view; that is where the + /// change is seen. + private func reportFindBarHeight() { + guard let scrollView else { return } + let height = scrollView.isFindBarVisible ? (scrollView.findBarView?.frame.height ?? 0) : 0 + guard height != reportedFindBarHeight else { return } + reportedFindBarHeight = height + onFindBarHeightChange?(height) + } + /// `performTextFinderAction(_:)` reads the action from the sender's `tag`. private func performFinder(_ action: NSTextFinder.Action) { let sender = NSMenuItem() diff --git a/QuickMD/QuickMD/Views/ChromeButtons.swift b/QuickMD/QuickMD/Views/ChromeButtons.swift index ab0f65c..f3d2fa5 100644 --- a/QuickMD/QuickMD/Views/ChromeButtons.swift +++ b/QuickMD/QuickMD/Views/ChromeButtons.swift @@ -319,10 +319,13 @@ struct CopySourceButton: View { } } -// MARK: - Edit In External Editor Button +// MARK: - Edit Source Button -/// Top-right pencil button — one-click handoff to the user's editor (⌘E). -struct EditInEditorButton: View { +/// Top-right pencil button — fixes the source in place (Source Edit, ⌥⌘E). +/// Since v1.12 (U2) the pencil means "edit HERE"; the handoff to another +/// application has its own pill (`OpenInEditorButton`). One of the three ways +/// into Source Edit, with File ▸ Edit Source and the shortcut. +struct EditSourceButton: View { let theme: MarkdownTheme let action: () -> Void @@ -330,12 +333,69 @@ struct EditInEditorButton: View { ChromePill( theme: theme, title: "Edit", - help: "Open in external editor (⌘E)", + help: "Edit source (⌥⌘E)", action: action ) { Image(systemName: "pencil") .font(.system(size: 11)) } + .accessibilityIdentifier("source-edit") + } +} + +// MARK: - Open In External Editor Button + +/// Top-right handoff to the user's configured editor (⌘E), at the line being +/// read when that editor supports line links. Was the pencil until v1.12. +struct OpenInEditorButton: View { + let theme: MarkdownTheme + let action: () -> Void + + var body: some View { + ChromePill( + theme: theme, + title: "Open in editor", + help: "Open in external editor (⌘E)", + action: action + ) { + Image(systemName: "arrow.up.forward.square") + .font(.system(size: 11)) + } + } +} + +// MARK: - Source Edit Pills (Save / Done) + +/// The whole cluster while Source Edit shows (S-D12) — also in Reading Mode, +/// where the cluster is otherwise hidden: a mode you could only leave by +/// shortcut is not acceptable. Save is dimmed and inert while there is +/// nothing to save, but stays in place so Done never shifts under the pointer. +struct SourceSaveButton: View { + let theme: MarkdownTheme + let isEnabled: Bool + let action: () -> Void + + var body: some View { + ChromePill(theme: theme, title: "Save", help: "Save (⌘S)", action: action) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 11)) + } + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.5) + .accessibilityIdentifier("source-save") + } +} + +struct SourceDoneButton: View { + let theme: MarkdownTheme + let action: () -> Void + + var body: some View { + ChromePill(theme: theme, title: "Done", help: "Done editing (Esc)", action: action) { + Image(systemName: "checkmark") + .font(.system(size: 11)) + } + .accessibilityIdentifier("source-done") } } diff --git a/QuickMD/QuickMDTests/SourceEditLandingTests.swift b/QuickMD/QuickMDTests/SourceEditLandingTests.swift new file mode 100644 index 0000000..91abc00 --- /dev/null +++ b/QuickMD/QuickMDTests/SourceEditLandingTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import QuickMD + +/// The landing scroll after leaving Source Edit (S-D10): resolved against the +/// blocks of the CURRENT text, never against a previous parse. +final class SourceEditLandingTests: XCTestCase { + + private func parse(_ text: String) -> [MarkdownBlock] { + MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light), fontScale: 1).parse(text) + } + + private func leave(line: Int, land: Bool = true) -> SourceEditSession.LeaveInfo { + SourceEditSession.LeaveInfo(caretLine: line, shouldLand: land, sequence: 1) + } + + func testLandsAtOnceWhenTheInstalledBlocksAreCurrent() { + let blocks = parse("# One\n\npara one\n\n# Two\n\npara two\n") + var landing = SourceEditLanding() + let target = landing.leave(leave(line: 6), installedBlocksAreCurrent: true, blocks: blocks) + XCTAssertEqual(target, blocks[SourceEditSupport.blockIndex(containingLine: 6, in: blocks)!].id) + XCTAssertNil(landing.pendingLine) + } + + func testNoLandingLeavesTheListAlone() { + var landing = SourceEditLanding() + XCTAssertNil(landing.leave(leave(line: 3, land: false), installedBlocksAreCurrent: true, + blocks: parse("a\n\nb\n\nc\n\nd\n"))) + XCTAssertNil(landing.leave(leave(line: 3, land: false), installedBlocksAreCurrent: false, + blocks: parse("a\n"))) + XCTAssertNil(landing.pendingLine) + XCTAssertNil(landing.blocksInstalled(parse("a\n"))) + } + + /// Save, then leave at once: the list still shows the OLD parse, where the + /// same positional id names a different block. + func testWaitsForTheParseOfTheSavedText() { + let old = parse("# Title\n\nintro\n") + let saved = parse("# Title\n\nnew first\n\nnew second\n\n# Next\n\nend\n") + var landing = SourceEditLanding() + XCTAssertNil(landing.leave(leave(line: 6), installedBlocksAreCurrent: false, blocks: old)) + XCTAssertEqual(landing.pendingLine, 6) + let target = landing.blocksInstalled(saved) + XCTAssertEqual(target, saved[SourceEditSupport.blockIndex(containingLine: 6, in: saved)!].id) + guard case .heading(_, let title, _) = saved.first(where: { $0.id == target })?.content else { + return XCTFail("the target is the heading on line 6 of the saved text") + } + XCTAssertEqual(title, "Next") + XCTAssertNil(landing.pendingLine, "landed once") + XCTAssertNil(landing.blocksInstalled(saved), "a later parse (zoom, theme) does not land again") + } + + func testEnteringAgainDropsThePendingLanding() { + var landing = SourceEditLanding() + _ = landing.leave(leave(line: 4), installedBlocksAreCurrent: false, blocks: []) + landing.cancel() + XCTAssertNil(landing.blocksInstalled(parse("a\n\nb\n\nc\n"))) + } + + func testANewLeaveReplacesThePendingOne() { + let blocks = parse("a\n\nb\n\nc\n\nd\n") + var landing = SourceEditLanding() + _ = landing.leave(leave(line: 6), installedBlocksAreCurrent: false, blocks: blocks) + XCTAssertNil(landing.leave(leave(line: 2, land: false), installedBlocksAreCurrent: false, blocks: blocks)) + XCTAssertNil(landing.blocksInstalled(blocks)) + } + + func testEmptyDocumentHasNoTarget() { + var landing = SourceEditLanding() + XCTAssertNil(landing.leave(leave(line: 0), installedBlocksAreCurrent: true, blocks: [])) + _ = landing.leave(leave(line: 0), installedBlocksAreCurrent: false, blocks: []) + XCTAssertNil(landing.blocksInstalled([])) + } +} diff --git a/QuickMD/QuickMDTests/SourceEditSessionTests.swift b/QuickMD/QuickMDTests/SourceEditSessionTests.swift index f054d14..655e23b 100644 --- a/QuickMD/QuickMDTests/SourceEditSessionTests.swift +++ b/QuickMD/QuickMDTests/SourceEditSessionTests.swift @@ -740,6 +740,20 @@ final class SourceEditSessionTests: XCTestCase { XCTAssertFalse(session.isActive) } + /// S-D10: with the find bar showing, Esc in the text view closes the bar + /// and stays in the mode; the next Esc leaves. + func testEscapeClosesTheFindBarFirst() throws { + let (session, window) = entered(try fixture("find me\n")) + window.makeFirstResponder(session.editor.textView) + session.editor.showFind() + XCTAssertTrue(session.editor.isFindBarVisible) + session.editor.textView.doCommand(by: #selector(NSResponder.cancelOperation(_:))) + XCTAssertFalse(session.editor.isFindBarVisible) + XCTAssertTrue(session.isActive) + session.editor.textView.doCommand(by: #selector(NSResponder.cancelOperation(_:))) + XCTAssertFalse(session.isActive) + } + private func numbered(_ count: Int) -> String { (0.. Date: Mon, 5 Oct 2026 15:51:45 +0200 Subject: [PATCH 10/17] fix(source-edit): wiring review and E2E fixes; selection carries into the editor Escape leaves edit mode (or closes the find bar) whatever has focus; Don't Save no longer scrolls the rendered view to a line of the discarded buffer; Cmd-G is matched exactly; the session follows a changed document URL; the covered list leaves the accessibility tree (AppKit level); a selection in the rendered view is selected in the source when it occurs there literally. Co-Authored-By: Claude Fable 5.1 --- QuickMD/QuickMD/DocumentReadingPosition.swift | 22 ++++ QuickMD/QuickMD/MarkdownView.swift | 49 ++++++--- QuickMD/QuickMD/SourceEditSession.swift | 68 ++++++++---- QuickMD/QuickMD/Views/VirtualBlockList.swift | 9 ++ .../QuickMDTests/SourceEditSessionTests.swift | 100 ++++++++++++++++++ 5 files changed, 217 insertions(+), 31 deletions(-) diff --git a/QuickMD/QuickMD/DocumentReadingPosition.swift b/QuickMD/QuickMD/DocumentReadingPosition.swift index 6ed7bd8..5541a57 100644 --- a/QuickMD/QuickMD/DocumentReadingPosition.swift +++ b/QuickMD/QuickMD/DocumentReadingPosition.swift @@ -24,12 +24,18 @@ final class DocumentReadingPosition { /// Answers `editorLine()`. Nil until the list has been wired up. var provider: (() -> Int?)? + /// Answers `selectionHint()`. Installed next to `provider`. + var selectionProvider: (() -> SourceEditSession.SelectionHint?)? /// The 1-based editor line the reader is at, or nil when there is no answer /// (empty document, list not laid out yet) — the caller then opens the file /// plainly. func editorLine() -> Int? { provider?() } + /// The rendered selection as Source Edit's entry hint (S-D14b), or nil + /// when nothing is selected. + func selectionHint() -> SourceEditSession.SelectionHint? { selectionProvider?() } + // MARK: Pure rule /// The row ⌘E should land on: the FIRST row of a non-empty selection (the @@ -55,4 +61,20 @@ final class DocumentReadingPosition { blocks.indices.contains(row) else { return nil } return blocks[row].sourceLine + 1 } + + /// The selection's plain text plus the source lines to look for it in: + /// from the first selected block's `sourceLine` up to (not including) the + /// `sourceLine` of the block after the last selected one — to the end of + /// the text when there is none. Nil for empty text or rows outside + /// `blocks` (a stale selection racing a re-install). + static func selectionHint(text: String, rows: ClosedRange, + blocks: [MarkdownBlock]) -> SourceEditSession.SelectionHint? { + guard !text.isEmpty, blocks.indices.contains(rows.lowerBound), + blocks.indices.contains(rows.upperBound) else { return nil } + let start = blocks[rows.lowerBound].sourceLine + let next = rows.upperBound + 1 + let end = next < blocks.count ? blocks[next].sourceLine : Int.max + guard end > start else { return nil } + return SourceEditSession.SelectionHint(text: text, lines: start..