diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index c879dea..20a8543 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to QuickMD will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] - 2026-10-05 + +Source Edit: QuickMD stays a viewer first, but you can now fix the Markdown you are reading without leaving the window. Asked for by [@KillSwitchSIG](https://github.com/KillSwitchSIG) (#31) and [@vinaymve](https://github.com/vinaymve) (#34). + +### Added +- **Source Edit** (⌥⌘E, the pencil button, or File ▸ Edit Source): the window switches to the raw Markdown at the line you are reading; a word selected in the rendered view is already selected in the source. ⌘S saves, Esc (or Done) returns to the rendered view at the place you edited. +- Saves are byte-faithful: the file keeps its encoding (UTF-8, UTF-8 with BOM, UTF-16, ISO Latin 1), its line endings, permissions, Finder tags, creation date and symlinks — only what you typed changes. A file with mixed line endings is unified to its dominant style, and QuickMD says so. A read-only file is not edited. +- Unsaved text is never lost silently: closing the tab, the window or the app asks Save / Don't Save / Cancel, and the close button shows the edited dot. +- If another application changes the file while you edit, QuickMD does not choose for you: with no unsaved changes the editor follows the disk; otherwise a banner offers Keep My Version or Load Disk Version (undoable), and Save asks before replacing the other change. +- In the editor: undo and redo, the system find and replace bar (⌘F, ⌘G), indentation kept on Return, Tab / ⇧Tab to indent and outdent, a light syntax tint for headings, code blocks and quotes (documents up to about 100 KB), zoom and Reading Mode. Smart quotes, smart dashes and autocorrect are off. + +### Changed +- The pencil button now starts Source Edit. Open in External Editor stays on ⌘E and in the File menu. +- Documents larger than 2 MB are not edited in place (QuickMD says so; ⌘E still opens them in your editor). + +### Fixed +- Auto-reload recovers when a file that disappeared comes back at the same path (git checkout, stash pop). Before, the tab stayed on "File no longer exists" until the document was reopened. + ## [1.11.0] - 2026-10-01 Selection, images and the editor handoff, shaped by your reports: text can now be selected and copied across the whole document ([@ssstraub](https://github.com/ssstraub), #33), embedded and HTML images render ([@filmo](https://github.com/filmo), #32), and ⌘E opens your editor right where you are reading (an answer to [@KillSwitchSIG](https://github.com/KillSwitchSIG)'s #31). Thank you! diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index cbd1e67..2ed6313 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -121,6 +121,31 @@ 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 */; }; + 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 */; }; + A1B2C3D401000170 /* SourceTint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000170 /* SourceTint.swift */; }; + A1B2C3D401000171 /* SourceTint.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000170 /* SourceTint.swift */; }; + A1B2C3D401000172 /* SourceTintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000171 /* SourceTintTests.swift */; }; + A1B2C3D401000152 /* SourceEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000151 /* SourceEditorView.swift */; }; + A1B2C3D401000153 /* SourceEditorControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000152 /* SourceEditorControllerTests.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 */; }; + 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 */ @@ -142,6 +167,23 @@ 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 = ""; }; + 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 = ""; }; + A1B2C3D400000170 /* SourceTint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceTint.swift; sourceTree = ""; }; + A1B2C3D400000171 /* SourceTintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceTintTests.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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -270,6 +312,14 @@ A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, A1B2C3D400000111 /* ExternalEditorLineTests.swift */, + A1B2C3D400000122 /* DocumentFileFormatTests.swift */, + A1B2C3D400000123 /* DocumentFileWriterTests.swift */, + A1B2C3D400000131 /* SourceEditSupportTests.swift */, + A1B2C3D400000152 /* SourceEditorControllerTests.swift */, + A1B2C3D400000171 /* SourceTintTests.swift */, + A1B2C3D400000141 /* EditCloseGuardTests.swift */, + A1B2C3D400000161 /* SourceEditSessionTests.swift */, + A1B2C3D400000181 /* SourceEditLandingTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -290,6 +340,14 @@ A1B2C3D400000081 /* ImageLoader.swift */, A1B2C3D4000000C0 /* DocumentSelection.swift */, A1B2C3D400000110 /* DocumentReadingPosition.swift */, + A1B2C3D400000120 /* DocumentFileFormat.swift */, + A1B2C3D400000121 /* DocumentFileWriter.swift */, + A1B2C3D400000130 /* SourceEditSupport.swift */, + A1B2C3D400000150 /* SourceEditorController.swift */, + A1B2C3D400000170 /* SourceTint.swift */, + A1B2C3D400000140 /* EditCloseGuard.swift */, + A1B2C3D400000160 /* SourceEditSession.swift */, + A1B2C3D400000180 /* SourceEditLanding.swift */, A1B2C3D40000003D /* SectionExtractor.swift */, A1B2C3D40000003E /* DocumentSearch.swift */, A1B2C3D40000003F /* WindowTabbing.swift */, @@ -339,6 +397,7 @@ A1B2C3D400000063 /* FontPickerView.swift */, A1B2C3D40000006C /* VirtualBlockList.swift */, A1B2C3D40000006D /* SVGBlockView.swift */, + A1B2C3D400000151 /* SourceEditorView.swift */, ); path = Views; sourceTree = ""; @@ -573,6 +632,15 @@ 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 */, + A1B2C3D401000130 /* SourceEditSupport.swift in Sources */, + A1B2C3D401000150 /* SourceEditorController.swift in Sources */, + A1B2C3D401000170 /* SourceTint.swift in Sources */, + A1B2C3D401000152 /* SourceEditorView.swift in Sources */, + A1B2C3D401000140 /* EditCloseGuard.swift in Sources */, + A1B2C3D401000160 /* SourceEditSession.swift in Sources */, + A1B2C3D401000180 /* SourceEditLanding.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -614,6 +682,22 @@ 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 */, + A1B2C3D401000131 /* SourceEditSupport.swift in Sources (Tests) */, + A1B2C3D401000132 /* SourceEditSupportTests.swift in Sources */, + A1B2C3D401000151 /* SourceEditorController.swift in Sources (Tests) */, + A1B2C3D401000171 /* SourceTint.swift in Sources (Tests) */, + A1B2C3D401000153 /* SourceEditorControllerTests.swift in Sources */, + A1B2C3D401000172 /* SourceTintTests.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 */, + A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */, + A1B2C3D401000182 /* SourceEditLandingTests.swift in Sources */, A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -753,7 +837,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -778,7 +862,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -797,7 +881,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -822,7 +906,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.11.0; + MARKETING_VERSION = 1.12.0; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/QuickMD/QuickMD/DocumentFileFormat.swift b/QuickMD/QuickMD/DocumentFileFormat.swift new file mode 100644 index 0000000..04e12d8 --- /dev/null +++ b/QuickMD/QuickMD/DocumentFileFormat.swift @@ -0,0 +1,188 @@ +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 + /// 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 + /// 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) + // 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, + isLosslessDecode: asRead.encode(raw) == 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/DocumentReadingPosition.swift b/QuickMD/QuickMD/DocumentReadingPosition.swift index 6ed7bd8..8a0068f 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,21 @@ 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, before: String = "", after: 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, before: before, after: after, lines: start..