The archive of how HelloNotes was built. The other docs describe the current state; this one records the journey — the milestone sequence, the greenfield editor rewrite, the retired
swift-markdown-enginefork, the GFM full-fidelity work, and the notable fixes worth remembering. It consolidates the formerimplementation-plan.md,markdown-engine-strategy.md,editor-rewrite.md, andeditor-parity.md.
Current status: v1.0 shipped (Milestones 0–13). Builds clean on macOS + iOS; the
editor package suite (swift test --package-path Packages/NotesEditor) is 83 tests /
9 suites green, plus the app unit tests. The editor is the in-repo
Packages/NotesEditor; the markdown-engine fork is removed.
The app was built as a milestone sequence, each ending on a green xcodebuild (0 errors,
0 warnings in app sources) plus off-UI smoke tests. v0.1 = M0–9, v1.0 adds M10–13.
- M0 — Foundation.
Notemodel;@Observablevault indexer with scan +NSOpenPanel; 3-columnMacContentView;WindowGroupapp entry. - M1 — Editing MVP.
EditorModel(@Observable) with debounced atomic autosave (≤1 s), dirty tracking, flush on switch/terminate; live Markdown + code highlighting; note create/delete (to Trash) + rescan; title filter; vault persisted via a security-scoped bookmark. - M2 — Knowledge graph & math.
Core/MarkdownParsingextracts[[wiki-links]], headings (AST),#tags;LinkGraphasync backlink index off-main; backlinks panel; LaTeX math; wiki-link click→navigate via a resolver that reports existence only, so files stay byte-for-byte intact. - M3 — Search & navigation. Full-text search (titles + bodies with snippets, cached off-main); "Open Quickly" fuzzy finder; external-change detection via FSEvents; folder tree with sort;
#tagsfilter; open-note conflict handling. - M4 — Git sync.
State/GitService(@Observable) over SwiftGitX, libgit2 off-main; repo status; Initialize Repository; local Commit + opt-in debounced auto-commit (never auto-pushes); user-initiated Push/Fetch. (Pull/merge deferred — SwiftGitX has no merge.) - M5 — Native rendering polish. Image paste →
assets/PNG + relative link; front-matter summary panel; native Mermaid (no WebView). Tables/footnotes render live. - M6 — iOS shell. App builds for iOS;
iOSContentViewNavigationStack; plain-textTextEditorsharing the sameEditorModel; iPadOS adaptiveNavigationSplitView. - M7 — Writing companions. Document statistics; outline/TOC popover; export to HTML (swift-markdown) or PDF (offscreen
NSTextView, no WebView); multi-tab editing (State/EditorTabs). - M8 — Organization & navigation. Nested tags (
Core/TagTree); Git version history (browse + restore); wiki-link autocomplete; open-in-new-window. - M9 — Core KB features. Aliases;
[[Note#heading]]completion; outgoing links + unlinked mentions; nativeCanvasforce-directed graph; daily notes & templates; bookmarks; editable typed properties (Core/FrontMatter+ Properties editor). - M10 — Editor unblocking via the fork. The eight "engine wall" deferrals from M3–9 resolved by forking
swift-markdown-engineand upstreaming each fix (see §3). - M11 — Library, files & git hosting. Multi-collection Library (
State/Library+Collection) with launcher/recents and Obsidian vault import; note ops (rename with vault-wide link rewrite, duplicate, drag-move); attachments + native file viewer; smart paste (HTML→Markdown); Vision image alt-text; Git hosting (HTTPS token creds in Keychain, clone/create-remote, in-app git identity). - M12 — AI: intelligence, assistant & providers. Streaming
LLMProviderprotocol with adapters (Apple Foundation Models, MLX, OpenAI-compatible, Anthropic, Gemini); "Ask Library" RAG chat with citations; agentic Assistant (AgentRunner) with tools behindPermissionBrokerapproval, web search/fetch, skills, deep research; note intelligence (summarise/tags/links). - M13 — Exploration views, polish & hardening. Edit/Preview/Source/Split modes; Marp slide decks; directional link map + content-based Mind Map; full menu bar, windowed Graph/Mind Map/Assistant/Ask Library, appearance settings, launch splash; production hardening (FIFO-serialized
GitService, atomic chat persistence, provider timeouts, bounded web fetch, zero warnings).
Naming note: the milestone plan numbers Git sync "M4"; the editor rewrite has its own independent M0–M5 track (§2). "M4" in the rewrite/fork context means editor-M4 = fork removed.
The TextKit 2 rewrite is now the only editor; the fork was removed at editor-M4 (2026-07-17).
The fork failed the PRD's own success metrics on large notes (scroll jank, freezes, caret lag, no caret autoscroll) for structural reasons — each a design choice, not a bug:
- Full-document AST re-tokenize on every edit; parse cache keyed by
String ==→ O(document) per keystroke and per caret move. ensureLayout(for: documentRange)to place code-block overlays → O(document) layout — the freeze.- Chrome as overlay subviews reconciled per scroll via
DispatchQueue.main.async→ main-queue churn. text: Binding<String>through SwiftUI → whole-string copy + O(n) compare per keystroke.- Dual storage/display text (
[[Name|id]]vs[[Name]]) → two coordinate systems (HelloNotes never uses ids). - A custom scroll-view subclass broke standard caret autoscroll.
- Raw Markdown IS the text storage — one text, one coordinate system; byte fidelity holds by construction; presentation is attributes and drawing, never text substitution.
- Every editing-path op is O(damage), never O(document) — full-document passes happen once, at open, off-main.
- TextKit 2 as designed — viewport-lazy layout, custom
NSTextLayoutFragmentdrawing for block chrome, rendering attributes for non-metric decoration; neverensureLayout(documentRange); no overlay subviews on the scroll path. - The document is an object, not a Binding — SwiftUI holds an
EditorDocumentreference. - Core is platform-free —
MarkdownCoreis Foundation-only,Sendable, shared macOS/iOS.
MarkdownCore(Foundation-only, nonisolated, Sendable):LineIndex(line-start offsets spliced per edit);Block/BlockParser(line classifier with carry state for fences/front-matter, re-parses only damaged lines until old/new states converge);Inline/InlineParser(per-block, memoized);StyleSpec(pure →[StyleRun]with semantic colour roles).MarkdownEditor(AppKit + UIKit + SwiftUI, MainActor):EditorDocument(@Observable; ownsNSTextStorage+ parse state + undo);StyleApplier(StyleRuns → storage attributes; caret-reveal restyles ≤2 paragraphs); block-fragment factory (NSTextLayoutFragmentsubclasses for code chrome, quote/callout bars, HR, block math/mermaid/transclusion — draw, not subviews);MarkdownTextView(NSTextView/UITextView);GFMLiveStyle(cmark-driven inline styling);GFMPreview(WKWebView Preview host).GFMRender: cmark-gfm-based GitHub-identical Preview + parity tests (§4).
- Open: parse everything (3.8 MB ≈ 12 ms), install plain text, style first screens synchronously (~48 ms for 3.8 MB). Rest styles progressively via an idle walker (~250-block batches) + a scroll observer styling the viewport (± margin).
- Keystroke: splice
LineIndex→ re-parse the damaged block neighborhood → restyle only those blocks. Budget < 2 ms (measured ~6 ms full cycle on the 3.8 MB stress note). - Caret move: binary-search the block at the caret; restyle ≤2 paragraphs only if the reveal set changed.
- Save: app-side debounce asks
document.textfor one snapshot.
Markers (**, `, [[, #) stay in storage always. Concealed = same-length attribute
transform (near-zero-size font + clear colour); revealed = normal dim styling on the paragraph
containing the caret. Pure colour-state changes (find highlights) use rendering attributes
through NSTextLayoutManager.renderingAttributesValidator. Programmatic scroll uses the
doc-verified TK2 pattern: ensureLayout(for:) on the target range only → enumerateTextSegments
→ scrollToVisible.
- Code blocks: async syntax highlighting via HighlighterSwift (highlight.js/JSCore) behind a
CodeHighlightingprotocol; editor takes foreground colours only, cached per content hash → synchronous restyles, no flash. Uses GitHub'sgithub/github-darktheme to match the Preview. - Block embeds / math / mermaid / transclusion / tables: one fragment-drawn
BlockRendererpath — renders image/card in draw when the caret is outside, reveals source inside; storage stays pure Markdown; async render with content-hash LRU cache. LaTeX via in-appMathImageRenderer(direct SwiftMath), tables viaTableImageRenderer(GitHub palette + zebra), Mermaid viaMermaidDiagramRenderer. - Callouts (
> [!type]): tinted band + gutter bar + icon + coloured title;>syntax concealed outside the caret; collapse/fold via a right-aligned disclosure chevron (ephemeral state, never written to file). - Task checkboxes: real glyphs over concealed
[ ]/[x]; click toggles undoably and persists to disk. - AI-native seam: Writing Tools (
.complete,.plainTextso rewrites can't corrupt Markdown); system inline predictions;EditorProxy(undoablereplace(range:with:),performAITransform) as the AI surface.
- editor-M0 — package scaffold, MarkdownCore parser + style spec, unit + perf tests.
- editor-M1 — macOS editor view (styled open, incremental typing, caret reveal, autoscroll, link taps), behind a Settings toggle.
- editor-M2 — parity + AI: autocomplete, find/replace, format commands, image/HTML paste, code highlight, Writing Tools, inline predictions, AI rewrite-selection.
- editor-M3 — embeds (image, Mermaid, block math, transclusion cards), clickable checkboxes, callouts.
- editor-M4 — flipped the default; fork removed; toggle deleted; LaTeX ported off the fork's
SwiftMathBridgetoMathImageRenderer; Mermaid/transclusion/embed providers decoupled from the fork. - editor-M5 — iOS
UITextView(usingTextLayoutManager:)sibling on the shared kernel: live inline styling, caret concealment, and the full fragment chrome via an overlay renderer. Remaining: app-side services (code colours, embeds) on iOS.
Post-M4 polish: inline $…$ LaTeX as baseline images, tables, > [!type] concealment,
front-matter fold, callout collapse/fold, footnotes.
What it was: ChristineTham/swift-markdown-engine, branch hellonotes-patches, a fork of
nodes-app/swift-markdown-engine (Apache-2.0, macOS 14+ AppKit/TextKit 2, no iOS, pre-1.0). HelloNotes
depended on it by URL + branch through M3–M13, before the greenfield rewrite replaced it.
Why fork: every editor-layer deferral from M3–9 was blocked by a missing engine hook. Of the options — (A) host-side only [exhausted], (B) upstream PRs [best long-term], (C) fork & maintain [best short-term], (D) new editor [last resort] — the choice was B+C together: fork as the working copy, raise each fix as a focused upstream PR. (Building from scratch was rejected at the time; it became the right call later once TextKit 2's own scrolling/height quirks were understood.)
The eight patches (each resolving an M3–9 wall): (1) scroll-to-location (universal TK2 fragment
path); (2) inline Mermaid (DiagramRenderer service); (3) find & replace (replaceCurrent/replaceAll);
(4) tag autocomplete (.tag inline-selection kind); (5–7) callouts / %%comments%% / front-matter
hiding (new .calloutTint fragment attribute); (8) note transclusion (host-side VaultEmbedProvider,
no engine change).
Upstream PRs opened to nodes-app/swift-markdown-engine: #91 scroll, #92 DiagramRenderer,
#93 find & replace, #94 tag token, #95 callouts/comments/front-matter.
Removal: at editor-M4 (2026-07-17) the fork was removed from the codebase once
Packages/NotesEditor became the sole editor. Its patches remain published on hellonotes-patches
and in the upstream PRs; the local checkout was later deleted and stale references scrubbed from
code comments and docs.
Made both the Preview and the live editor provably GitHub-faithful, using GitHub's own engine.
GitHub-identical Preview (GFMRender) — renders through cmark-gfm (Apple's swift-cmark,
gfm branch, 5 GFM extensions) into HTML shown in a WKWebView styled with github-markdown-css +
highlight.js GitHub themes. Provably identical:
fullSpecConformanceruns the GFM spec's ownspec.txtcorpus: 648/648 (638 exact + 10 documented tagfilter / extended-autolink overrides GitHub also applies).identicalToGitHubMarkdownAPIasserts byte-identity to a capturedapi.github.com/markdownresponse (normalising only GitHub's display post-processing).
Live-editor cmark styling — the editor's own styling was moved onto the same cmark-gfm AST so it
matches the Preview: GFMRenderer.nodes exposes the AST with source positions; GFMLiveStyle maps
nodes → style runs; heading bottom borders, indented code blocks, and cmark inline styling inside
lists and blockquotes all landed. Conformance: 340/340 inline constructs across the corpus,
711/722 block classifications agree with cmark.
GitHub table/code theming — the editor's code blocks use GitHub's highlight theme and its tables
match github-markdown-css exactly (zebra rows, #d1d9e0/#3d444d borders, #f6f8fa/#151b23
zebra, semibold header, aligned columns) — verified pixel-faithful in both appearances.
Offscreen fidelity snapshot tests (HelloNotesTests/EditorFidelitySnapshotTests.swift) render the
editor and its components offscreen (no Screen Recording permission needed) and assert editor↔Preview
parity — the table collapses to its rendered image and code keywords carry GitHub's exact palette
(#d73a49 light / #ff7b72 dark).
Coverage: full GFM (headings ATX+setext, bold/italic, strikethrough, inline/fenced code with
~190-language highlight, blockquotes, ordered/unordered lists, task lists, aligned tables, links/images,
extended autolinks, thematic breaks, footnotes, hard line breaks) plus HelloNotes/Obsidian extensions
([[wiki-links]], ![[embeds]], nested #tags, ==highlight==, %%comments%%, callouts,
$$…$$/inline $…$ math, Mermaid, hidden front matter). Not rendered natively (shown as text, as on
raw GitHub source): emoji shortcodes and raw HTML entities.
- O(document) → O(damage) is the whole rewrite's thesis. The fork's per-keystroke and per-caret-move full-document re-tokenize/re-layout was the root freeze. Two precursor fixes attacked it even before the rewrite: stopping full-document scans on every body eval (per-caret-move lag), and dropping the in-RAM note-text corpus (~207 MB on the test vault).
- GUI apps can't read
~/.gitconfig. Commits failed silently with no signature. Fix: write a commit identity into the repo's local config (GitService.ensureCommitIdentity), falling back to the macOS account name. - Byte fidelity by construction. The wiki-link resolver reports existence only (empty
id) so[[Name]]is never rewritten to[[Name|id]]; raw Markdown is the sole storage, so the editor never touches untouched bytes. - TextKit 2 rendering-attributes trap. One-shot
setRenderingAttributessilently vanish when a fragment re-lays out; the persistent channel isNSTextLayoutManager.renderingAttributesValidator. - Same-length substitution contract.
NSTextContentStorageDelegateparagraph substitution requires equal length to the backing range, so marker elision via substitution is out of contract — hence the same-length attribute-transform concealment. setAttributedStringimport stall. Pre-styling off-main and installing once causes ~100 ms stalls on first keystroke (NSTextStorage converts attribute runs lazily). Fix: batched native-path styling that settles as it walks.scrollRangeToVisibleis unreliable in TK2 against estimated heights — alwaysensureLayout(for:)the target range, thenenumerateTextSegments. Same root cause behind the pre-fork "scroll-to-heading"/"outline jump"/"heading scroll" deferrals.- Concealed-font clobber.
NSTextView.fontset after storage attach clobbered per-run concealed fonts, breaking> [!type]concealment — root-caused and fixed by ordering font-before-attach. - O(n²) byte→UTF-16 map. The naive cmark source-position map rescanned from byte 0 per node (3 MB hung); fixed with per-line prefix arrays (O(document)).
- cmark overlay scope regression. A
- xlist item parsed in isolation reads as indented code; fixed first by restricting the overlay to paragraphs/headings, then properly with a whole-document cached-runs overlay. - Concurrency posture.
MarkdownCoreis nonisolated value types +Sendable;MarkdownEditorusesdefaultIsolation(MainActor.self).OSSignposter-gated perf tests fail CI on regression (1 MB parse < 50 ms, keystroke cycle < 5 ms). Production hardening added a FIFO-serializedGitServiceand atomic chat persistence.
A pre-release pass that resolved the go/no-go items from the production audit (the register in unimplemented.md; items are removed there as they land here).
- Privacy manifest. Added
HelloNotes/PrivacyInfo.xcprivacy(auto-bundled via the synchronized file group):NSPrivacyTracking = false, no collected data types, and required-reason API declarations for UserDefaults (CA92.1), file timestamp (C617.1), and disk space (E174.1). Verified present inContents/Resources/of the built bundle. .mdUTI association. AddedUTImportedTypeDeclarationstoInfo.plist— importsnet.daringfireball.markdownconforming topublic.plain-text, taggingmd/markdown/mdown/markdn+text/markdown. (Imported, not exported, so it can't hijack the system default handler.) Fixes the latent bug where.mdfiles wouldn't bind on a Mac where no other app declared the community UTI.- Optimized Release build. Set
SWIFT_OPTIMIZATION_LEVEL = -Oon the app Release config (it was unset →-Onone); verified via-showBuildSettings. - Acknowledgements. Added
UI/AcknowledgementsView.swift(a Preferences tab) listing the bundled open-source packages and their licenses — libgit2 (GPL-2.0-with-linking-exception), swift-cmark, SwiftGitX, HighlighterSwift, SwiftMath, mermaid/elk, MLX/transformers, OpenAI, and the Apple/transitive libs.
- Flush-on-quit. Added
UI/TerminationGuard.swift— anNSApplicationDelegatethat implements theapplicationShouldTerminate→.terminateLaterhandshake, draining every window's registeredtabs.flushAll()before the process exits. Wired via@NSApplicationDelegateAdaptor;MacContentViewregisters/unregisters its tabs. No more "lost the last ~600 ms of edits on ⌘Q". - Atomic assistant writes.
EditNoteTool/WriteNoteToolnow write with.atomic(CollectionTools.swift), so a crash mid-write can't truncate a note. - Surfaced file-operation failures.
Collectiongained alastError(observable) set by every create/rename/duplicate/delete/new-folder/move failure path (previously silentnil/try?);MacContentViewpresents it as an alert (FileOperationErrorAlert). Rename now distinguishes "name already exists" from an OS error. - Rename link-rewrite reports partial failures.
rewriteWikiLinkscollects the notes it couldn't rewrite and surfaces "links may now be broken in N notes (…)" instead of swallowing each write withtry?. - Export errors surface.
EditorExportshows an alert on a nil render or a failed write (wastry?+ silent nil), and writes atomically. - Off-main reconcile.
EditorModel.reconcileWithDiskreads the changed file off the main actor so a large external change doesn't stall the UI. - No config-wipe on encode failure.
LLMSettings.persistandGitCredentials.persistonly write whenJSONEncodersucceeds (wereset(try? encode(...)), which wroteniland wiped saved providers/accounts on any failure). - Serialized git reads.
GitService.refreshStatus/history/contentnow run through the same FIFO chain as writes (serializedRead), so a status/history walk never opens a second libgit2 handle concurrently with an in-flight commit's index write.
- SSRF protection for the agent's web tools. Added
LLM/Agent/WebGuard.swift:web_fetch/web_searchnow reject non-http(s) URLs and any host that resolves (viagetaddrinfo) to a loopback / private / link-local / unique-local / CGNAT address — covering127.0.0.1,localhost,169.254.169.254(cloud metadata),10./172.16/12/192.168., IPv6::1/fc00::/7/fe80::/10, and IPv4-mapped forms. ARedirectGuardURLSessionTaskDelegatere-validates every HTTP redirect so an allowed host can't bounce to an internal one. This closes the prompt-injection → internal-exfiltration path. - Scoped "Allow all".
AssistantModel.clear()now callsPermissionBroker.reset(), so a blanket "Allow all" grant no longer persists across conversations — injected content in a fresh thread can't drivewrite_note/delete_notewithout a new approval. - Bounded response buffers.
web_searchnow streams with the same 4 MB cap asweb_fetch(through the guarded session), and the Anthropic/Gemini SSE error paths cap the accumulated error body at 16 KB (were unbounded).
- Debounced search-aggregate rebuild.
CollectionSearchModel.updateNote(called on every autosave) now patchesentryByURLO(1) synchronously but debounces the O(collection) rebuild of tags / tag-tree / link-targets / quick-open items (250 ms), so a burst of edits coalesces into one rebuild instead of one per save — the largest remaining main-thread hotspot at the 2,000-note scale. - Bounded embed caches.
CollectionEmbedProvider.cacheandBlockRenderAdapter.cache(both keyed by mtime, so previously monotonically growing) now cap at 64 entries — matching the editor's own image caches. - Bounded, off-main chat transcript.
ChatSessionStore.saveencodes + writes off the main actor and caps the persisted JSONL at the most recent 1,000 messages, so a long-lived conversation with verbatim tool outputs can't grow the file (or block the main actor) without limit.
- Print (⌘P). Added
EditorExport.printNote(renders the note's HTML through the native text system intoNSPrintOperation, no WebView) and aCommandGroup(replacing: .printItem)wired to the current note — the standard menu item a notes app must have. - Folder-delete confirmation. Deleting a folder (which trashes all its contents) now goes through a
confirmationDialog(FolderDeleteConfirmation) instead of executing instantly. - "AI not configured" state.
LLMSettings.isActiveProviderConfigured(local providers need no key; cloud providers need a Keychain key); the Assistant's empty state now shows a "Set up AI" prompt with aSettingsLinkwhen the active provider has no key, instead of inviting input that will only error. - File-operation errors are visible. (Cross-ref §1:
Collection.lastErroralert; rename distinguishes "name taken"; export shows errors.)
- Graph is VoiceOver-navigable. The force-directed graph is drawn into a
Canvas(previously an opaque rectangle to VoiceOver); it now exposes.accessibilityChildren— a labelled, activatable list of the notes (each with its link count), so a VoiceOver user can enumerate and open notes. (The Mind Map already renders its nodes as realText/Buttonviews, so it was already navigable — only its edges are a decorative Canvas.) - Git state isn't colour-only. The outline's git dirty-state dot (orange vs grey) now carries a VoiceOver label ("Uncommitted changes" / "No uncommitted changes").
The live TextKit 2 editor now runs on iOS, not just macOS — iOS is no longer plain-text-only.
- Cross-platform port.
BlockRendering's customNSTextLayoutFragmentchrome was rewritten from AppKit primitives (NSGraphicsContext/NSBezierPath/NSImage) to platform-neutral CoreGraphics via a newPlatformDrawhelper (fills, ellipses, flipped image draw, SF-symbol→CGImage).EditorLinkTapandrenderMaxWidth/isDarkAppearancewere hoisted out of the AppKit guard. The macOS chrome (heading rules, list bullets, quote bars, callouts, checkboxes, table/code/math embeds) is unchanged — verified pixel-for-pixel by the existing macOS snapshot test. - iOS view. New
MarkdownUITextView(aUITextView(usingTextLayoutManager:)bound to the sharedEditorDocument's storage, fragment delegate, selection→reveal, tap-to-navigate links) + aMarkdownEditorViewUIViewRepresentablewith the same public surface (init/editable/onLinkTap) as the macOS one. - iOS shell.
HelloNotes/UI/iOSLiveEditor.swifthosts it (builds the document, syncs edits back for autosave, rebuilds on note/font/appearance change); the iOS view-mode picker gains an Edit mode (default) alongside Preview/Markdown/Split. - Fragment chrome via overlay.
UITextView— unlikeNSTextView— doesn't invoke a customNSTextLayoutFragment.draw, so the block chrome (unordered-list bullet glyphs, callout tint band/bar/icon, blockquote gutter bar, task checkboxes, heading bottom-rule) wouldn't paint. A transparentChromeOverlayViewsubview enumerates the laid-outRenderedBlockFragments and calls a chrome-only draw entry point (drawChromeOnly(at:in:)— the same callout/checkbox/bullet/rule/inline-image passes as macOS, minussuper.draw/block image sinceUITextViewdraws the text). Refreshed on layout / edit / selection change.blockLayoutDelegate+chromeOverlayarelazy:init(usingTextLayoutManager:)is an inherited convenience initializer that skips the subclass's stored-property synthesis, so plain defaults were left null (a weak-assign into the null overlay faultedEXC_BAD_ACCESSat 0x8). - Verified on the simulator (
iOSEditorSnapshotTests, captured vialayer.render—drawHierarchyre-enters TextKit during the render pass): live inline styling (bold, italic, inline-code with background, strikethrough, coloured links), caret-driven concealment of markers, heading sizes + bottom rules, dimmed blockquotes with gutter bars, callout tint band/bar/icon with coloured title/body, filled/hollow list bullets, coloured ordered-list numbers, and empty/checked task checkboxes all render correctly on iOS — matching the macOS chrome. - Remaining iOS gap (documented in unimplemented.md §6): app-side services — code-syntax colours and block embeds (table/math/mermaid/transclusion images) — aren't wired on iOS yet (the renderers are AppKit
NSImage/lockFocus).
A full-codebase review (cross-platform editor-services port, TextKit 2 editor, concurrency/isolation, LLM-agent security, entitlements) produced the fixes below. All landed together; macOS + iOS builds are green, the 83-test editor package suite and the app unit tests pass. Items are struck from unimplemented.md as they land here; what the pass deliberately left open stays in that register (see the end).
create_notepath traversal (the one blocker). Thefolderargument was passed straight intoappendingPathComponent(which does not resolve..), so an injected tool-call —create_note(folder: "../../../Library/LaunchAgents", …)— could write a.mdfile outside the collection root. Now the resolved target is containment-checked (standardizedFileURLmust carry the root prefix) before any write, independently of the permission broker, so it holds even under "Allow all" (CollectionTools.swift).- "Allow all" no longer auto-approves deletions.
PermissionBroker.confirmstill auto-approves ordinary edits under a blanket grant but always requires an explicit click for a deletion (diff.isDeletion) — the highest-consequence mutation, and the one most worth gating against injected tool-calls. - Write-tool symlink containment.
edit_note/write_notereject a note whose file resolves (viaresolvingSymlinksInPath) outside the collection root (ToolContext.isWithinRoot), defending against a pre-planted symlink the directory enumerator would otherwise follow. - NAT64 in the SSRF classifier.
WebGuardnow classifies the64:ff9b::/96well-known prefix (embedded IPv4), alongside the existing loopback/private/link-local/ ULA/IPv4-mapped/CGNAT coverage. - Keychain secrets are
…WhenUnlockedThisDeviceOnly. Both BYO API keys (LLMKeychain) and the Git PAT (GitCredentials) moved off…WhenUnlocked, so they're excluded from encrypted device backups / can't restore onto another device. - Git errors are credential-scrubbed. libgit2 error strings echo the remote URL, which
for HTTPS auth carries the PAT;
GitService.scrubCredentialsstrips anyuser:token@before the string reacheslastError(push/create/clone), matching the existing sanitisation on the status/display path.
- Mermaid block render off the main actor.
BlockRenderAdapter.renderMermaidwas the only block renderer that didn't hop to the main actor, yet on macOS it ranNSImage.lockFocus(the upright flip) on the actor's background executor — unsafe AppKit drawing. It nowawait MainActor.runs like its sibling renderers. - Serialized editor writes.
EditorModel.savechained a new write behind any in-flight one (awriteInFlighttask), so two atomic writes can't race at the filesystem — closing the stale-text-on-quit window where an older debounced write could land after the flush. GitServicequeue hygiene.createRepository/cloneRepositoryroute through the FIFO queue (a value-returningrunReturning) instead of settingisBusydirectly, so a bypass op can no longer clear the busy flag (or clobberlastError) mid-way through a user push;authenticateRemotereads the remote URL through the serialized read queue (off-main) instead of opening the repo synchronously on the main actor.FileWatcherteardown. The FSEvents callback ran on a shared queue with an unretainedself;stop()now uses a dedicated serial queue and drains it (queue.sync {}) afterinvalidate, so a callback already dispatched can't runonChangeafter the object is freed.- macOS editor observer leak. The
boundsDidChangeNotificationobserver registered per editor was never removed; its token is now stored and removed indeinit.
- iOS storage stays byte-pure Markdown.
MarkdownUITextViewoverridespasteto insert plain text only (never a rich-text attachment) and disables autocorrect/autocapitalization, matching the macOS view — so neither a paste nor a substitution can inject foreign attributes into the sharedEditorDocumentstorage. - iOS chrome overlay is O(visible), not O(document).
ChromeOverlayViewclips its fragment walk to the dirty rect andrefreshChromeinvalidates only the visible slice, instead of repainting the whole document on every keystroke/selection change. - iOS large-note open doesn't block.
makeUIViewonly styles the whole document up front for notes ≤ 200 KB; larger notes rely on the document's synchronous prefix + idle background pass, andensureVisibleRangeStylednow invalidates layout for the styled span (mirroring macOS) so first-seen concealed markers lay out at their true width. - macOS copy is plain-text only. The rich-text view's default ⌘C also wrote an RTF flavor
carrying the concealed 0.1 pt / clear-colour marker runs (invisible, un-round-trippable in
Mail/Pages);
copy/cutare overridden to put only the Markdown source on the pasteboard. - Checkbox toggle keeps the box rendered. Toggling a task checkbox restored the caret to
the block's
[, which revealed the block back to raw- [x]. It now restores the pre-click selection (the toggle is a 1-for-1 char swap, so offsets are unchanged). - Wide tables render un-clipped.
TableImageRendererrenders the grid at its natural width and scales the whole bitmap to fit, instead of shrinking column widths but not the font (which clipped cells and mis-positioned right/centre-aligned text). - visionOS renders the app. The
WindowGroupbody was#if os(macOS) … #elseif os(iOS), so a visionOS build (a configured platform) fell through toEmptyView. It now falls back to the iOS content view for any non-macOS platform. (visionOS compile unverified locally — the visionOS SDK isn't installed on this machine.)
- Removed the dead
revisioncounter inCollectionEmbedProvider(written, never read; the cache is mtime-keyed) and documented its@unchecked Sendable; the code-highlight cache keys on the full snippet (nothashValue) so two snippets can't collide; corrected the stale "iOS is plain-text-only / block embeds are wired on iOS" comments.
Deliberately left open (in unimplemented.md, not patched)
- SSRF DNS-rebinding (§2).
WebGuard.validateresolves + classifies the host, butURLSessionre-resolves independently for the connection, so the check isn't pinned to the fetched IP — an attacker controlling DNS with a short TTL can pass validation then connect to an internal address. A correct fix needs socket-level pinning (customURLProtocol/ Network.framework); aURLSessionmetrics check fires too late for the streamed body to be reliable, so no fragile partial mitigation was shipped. - Git PAT in
.git/config(§2) remains upstream-blocked on a SwiftGitX credential callback; iOS block-embed / inline-math consumption (§6) still needs the collapse + fragment-image path ported to the iOS overlay (the renderers and adapter are now cross-platform and wired, butEditorDocumentonly consumes them under#if canImport(AppKit)).
Two max-effort reviews (10 finder angles each) swept the recently-landed changeset and then the entire codebase. Fixes below are all landed and verified (macOS + iOS builds green; package 83/83; app AgentTool/SmartPaste/SkillStore + EditorFidelity + iOS-editor snapshot suites pass). Ranked roughly by severity.
- VisionAlt could resume its
CheckedContinuationtwice (Vision's completion handler and a thrownperform) — funneled through a lock-guardedOnceResumer. - PropertiesEditor list-property bindings crashed on removing a non-last item
(stale enumerated index) — bounds-guarded; "Add item" now persists (
onChange). - GraphView click hit-test indexed
nodes/degreesby stalepositionsduring an off-main relayout — added the count guard the draw path already had. - EditorDocument.blockEmbedKind read
character(at:)/substring(with:)without the<= storage.lengthguard its siblings use — added it.
- MentionScanner only checked for a preceding
[[, so "Link mention" nested a link inside an existing one ([[My Note]]→[[My [[Note]]]]) — now detects an open[[. - NoteWindowView (standalone note window) never registered a termination flush hook,
losing the last edit on ⌘Q — registered with
TerminationGuard. - NoteHistoryView applied an out-of-order git preview, letting "Restore" write the wrong revision — guarded on the current selection.
- Collection.noteDidSave incremental branch didn't cancel an in-flight rebuild, which
could revert a just-saved index update — cancels
deriveTask. - Bookmarks survived rename/move (paths updated); rename/move wiki-rewrites now register as self-writes (no spurious external-change reconcile); case-only rename ("todo"→"Todo") no longer blocked with a spurious "already exists".
- GFMTree depth counter drove negative on empty containers (blank table cells, empty list items), mis-styling nesting — made the EXIT decrement symmetric with the increment.
- EditorDocument.replaceText applied the old document's cached GFM inline runs to the new storage on external reload — resets the cache before styling.
[[#heading]]produced a spurious graph link; GitHubMarkdown rewrote wiki-links inside inline code spans; IntelligenceService.matchTitles trimmed digits from both ends (dropping "2026 Goals"); FrontMatter kept empty list items; DocumentStatistics mis-counted CRLF paragraphs; FuzzyMatch awarded a spurious first-char run bonus; the sibling-collection path-prefix false-match (Notes vs NotesArchive) at 4 sites.
- OpenAI-compatible token usage was dropped (early-return before the trailing
usage-only chunk) — reads to stream end. AssistantModel/AgentRunner: a turn that
hit the tool-iteration cap left a blank answer — a final tool-less turn now summarizes;
a cancelled turn no longer leaves an empty persisted bubble;
clear()no longer lets a cancelled turn resurrect the cleared transcript. ChatSessionStore writes are serialized. DeepResearch surfaces sub-agent failures (instead of laundering them into a confident empty answer) and aborts promptly on cancellation. FoundationModels no longer advertises tool support it can't honor (agent mode falls back to chat), and its streaming diff handles snapshot revisions (common-prefix, not append-only). MLX shares one in-flight model load instead of racing two multi-GB downloads. EditorTabs dedups concurrent opens of the same note (no duplicate tabs).
- CloneRepositoryView: account-switch race (stale repo list) and a leaked
security-scoped resource on clone failure. SplashScreen "About" no longer
auto-dismisses if opened during the launch splash. RewriteSelectionView force-unwrap
→ guard. OutlineView computed stats/headings once (was 4×/2× per render);
MermaidPreview renders each diagram once into
@State; MindMapView memoizes its O(N²) layout; the document-stats debounce now covers zero-word notes.
- BlockParser incremental convergence only fires at
open == .none, so editing long prose re-parses to EOF (O(document)/keystroke). Real perf regression, but the fix is deep in correctness-critical convergence logic — needs its own change + fuzz re-verification, not a--fixedit. (Related: blank-run merge dead branch; CRLF in the block classifiers — same risk profile.) - One-account-per-host Git credential model is by design (
account(forHost:)must resolve a single account for auth), so the "collision" is not a bug. - Bookmark
isStale: the primary stores (Library/LibrariesStore) already re-mint bookmark data on every persist, so stale bookmarks self-heal there.