Skip to content

chore: break up EditorView.swift (5,436 lines) #238

@dep

Description

@dep

Context

Follow-up to #237 (AppState god-object refactor). EditorView.swift is the single largest file in the macOS app at 5,436 lines and contains at least 17 top-level types. Much of its size is not the SwiftUI view itself but a collection of AppKit classes, NSViewRepresentables, helper DTOs, and a ~410-line markdown styling engine that all happen to live in the same file.

Goal

Split EditorView.swift into focused, single-purpose files so that:

  1. The actual EditorView SwiftUI struct is <500 lines and reads like a view, not a framework.
  2. Markdown styling, wiki-link insertion, inline embeds, code-block buttons, and git history each live in their own testable unit.
  3. The NSTextView subclass (LinkAwareTextView, ~1,400 lines on its own) is split by responsibility.
  4. We can write unit tests against the styling engine without instantiating SwiftUI.

Non-goals: behavior changes, visual changes, or rewriting the regex-based styling. This is pure file-level extraction.

Current structure (evidence)

Top-level types in EditorView.swift:

Type Lines Purpose
EditorView (struct) 181–688 Main SwiftUI view — edit/view mode routing, git history modal, embedded notes sidebar
RawEditor (struct) 692–1073 NSViewRepresentable wrapping LinkAwareTextView
RawEditor.Coordinator (class) 946–1072 NSTextViewDelegate + NSTextStorageDelegate, debounced styling
MarkdownTheme (struct) 1157–1260 Font factory
EditorFontSignature (struct) 1278–1290 Equatable font-change detector
NSAttributedString.Key extension 1293–1304 Custom attribute keys
LinkAwareTextView extension #1 1448–2214 Preview styling, markdown styling (~410 lines at 1640–2050), search highlighting, collapsibles, task checkboxes, inline embeds
LinkAwareTextView (class) 2216–3595 NSTextView subclass — event handling, link/tag tapping, slash commands, table prettification
HTMLToMarkdownConverter (struct) 3596–3768 HTML→markdown paste converter
InlineImageMatch / InlineEmbedMatch / EmbeddedNoteInfo / SidebarEmbedType / SidebarEmbedInfo 3769–3883 DTOs for inline embeds
EmbeddedNotesPanel (struct) 3885–4022 Right-sidebar NSViewRepresentable for embedded note/image previews
FlippedNSView (class) 4025–4027 Y-flipped container
EmbeddedNoteView (class) 4029–4280 Single embedded-note preview
EmbeddedImageView (class) 4300–4780 Image preview with remote fetch via URLSession
CompletionViewController (class) 4780–4896 Wiki-link picker popover
CompletionViewController extensions 4898–4980 NSTableView + NSSearchField delegates
CodeBlockMatch (struct) 4981–4993 Code-block DTO
LinkAwareTextView extension #2 4995–5140 Code-block copy buttons
MarkdownPreviewView (struct) 5142–5436 WKWebView markdown preview

AppState coupling: 61 call sites into appState, of which:

  • appState.settings — 19 reads (font family, size, line height, hide-markdown flag)
  • Pending-restoration signals (pendingCursorPosition, pendingCursorRange, pendingScrollOffsetY, pendingSearchQuery, pendingCursorTargetPaneIndex) — consumed via consumePending*() helpers at lines 6–36 (read-and-null pattern)
  • Direct navigation calls: openFile, openFileInNewTab, openTagInNewTab, switchTab, focusPane, createNote, presentCommandPalette
  • Wiki-link picker wired through wikiLinkCompletionHandler / wikiLinkDismissHandler closures stored on AppState

Target file layout

macOS/Synapse/Editor/
 ├── EditorView.swift                  (~400 lines — just the SwiftUI struct)
 ├── RawEditor.swift                   (~250 — NSViewRepresentable only)
 ├── RawEditorCoordinator.swift        (~180 — debounced styling coordinator)
 ├── LinkAwareTextView.swift           (~700 — core NSTextView subclass, event handling)
 ├── LinkAwareTextView+Drawing.swift   (~200 — drawBackground, full-width code bg, blockquote rules)
 ├── LinkAwareTextView+Tracking.swift  (~150 — hover/tracking areas)
 ├── MarkdownStyling.swift             (~500 — applyMarkdownStyling + applyPreviewStyling + MarkdownTheme)
 ├── CollapsibleSections.swift         (~200 — collapsible state manager + toggle buttons)
 ├── CodeBlockCopyButtons.swift        (~150 — copy-button lifecycle)
 ├── WikiLinkPicker.swift              (~300 — CompletionViewController + delegates)
 ├── SlashCommands.swift               (extend existing file — expansion lives here already conceptually)
 ├── InlineEmbeds/
 │    ├── EmbeddedNotesPanel.swift     (~200)
 │    ├── EmbeddedNoteView.swift       (~250)
 │    ├── EmbeddedImageView.swift      (~400 — includes remote fetch)
 │    ├── YouTubePreviewView.swift     (~150)
 │    └── InlineEmbedMatchers.swift    (~100 — regex matching + DTOs)
 ├── GitHistoryModal.swift             (~200 — history UI + loadFileHistory + selectCommit)
 └── MarkdownPreviewView.swift         (~300 — WKWebView + JS bridge)

Approx line totals: 5,436 → ~3,800 across ~15 files (some gains from removing duplicated helpers).

Phased plan

Each phase is one PR, compiles, runs smoke tests from #237 Phase 0, and can be reverted.

Phase 1 — Leaf extractions (safe, no coupling changes)

Move types that have no other types depending on them inside the file:

  • MarkdownTheme + EditorFontSignature + NSAttributedString.Key extension → MarkdownStyling.swift
  • HTMLToMarkdownConverterHTMLToMarkdownConverter.swift
  • DTOs (InlineImageMatch, InlineEmbedMatch, CodeBlockMatch, EmbeddedNoteInfo, SidebarEmbedType, SidebarEmbedInfo) → InlineEmbeds/InlineEmbedMatchers.swift
  • FlippedNSView → co-locate with EmbeddedNotesPanel extraction in Phase 4

Phase 2 — Extract MarkdownPreviewView

  • MarkdownPreviewView (5142–5436) is a self-contained NSViewRepresentable over WKWebView.
  • Move as-is to MarkdownPreviewView.swift. Done.

Phase 3 — Extract CompletionViewController (wiki-link picker)

  • Move CompletionViewController + its extensions (4780–4980) to WikiLinkPicker.swift.
  • The wikiLinkCompletionHandler / wikiLinkDismissHandler closures on AppState remain for now — untangling those is deferred to chore: refactor god object #237.

Phase 4 — Extract inline embed views

  • EmbeddedNotesPanel, EmbeddedNoteView, EmbeddedImageView, YouTubePreviewView, FlippedNSView each to their own file under InlineEmbeds/.
  • Address the unmanaged URLSession in EmbeddedImageView (line ~4646) while it's touched: cancel the data task in removeFromSuperview().

Phase 5 — Extract markdown styling engine

  • applyMarkdownStyling() (1640–2050) and applyPreviewStyling() (1474–1630) move to MarkdownStyling.swift as free functions or a MarkdownStyler type taking NSTextStorage + settings snapshot.
  • This is the highest-value extraction: makes the styling engine unit-testable against a hand-built NSTextStorage.
  • Must keep behavior bit-exact — add golden tests (input markdown → expected attribute runs) before moving.

Phase 6 — Extract code-block copy buttons + collapsible sections

  • LinkAwareTextView extension feat: add ability to clone remote repository for notes sync #2 (4995–5140) → CodeBlockCopyButtons.swift.
  • Collapsible-section manager + toggle buttons → CollapsibleSections.swift.
  • Replace the objc_getAssociatedObject / objc_setAssociatedObject pattern (4997–5003) with a typed wrapper while touching it.

Phase 7 — Split LinkAwareTextView by responsibility

Largest phase. LinkAwareTextView (2216–3595) is ~1,400 lines; split into:

  • Core class stays in LinkAwareTextView.swift (event handling, init, properties)
  • +Drawing.swiftdrawBackground(in:) and full-width backgrounds (2276–2366)
  • +Tracking.swift — hover + tracking areas (2400–2450)
  • +Search.swift — search highlighting via notifications (2609–2700)
  • +SlashCommands.swift — slash-command expansion (3150–3300), merge with existing SlashCommands.swift if possible
  • +TablePrettifier.swift — table pretty-printing (3400–3500)

Phase 8 — Extract git history modal

  • loadFileHistory(), selectCommit(), and the history modal UI (EditorView 290–505) → GitHistoryModal.swift.
  • While there: the synchronous GitService init on main thread (464, 480) and synchronous commit content fetch (487) should be made async.

Phase 9 — Extract RawEditor.Coordinator

  • Move RawEditor + its Coordinator to RawEditor.swift + RawEditorCoordinator.swift.
  • The debounce interval (80ms, line 1048) becomes a named constant.

Phase 10 — Final cleanup

  • EditorView.swift should be <500 lines holding just the SwiftUI view.
  • Audit the consumePending*() helpers (lines 6–36) — if chore: refactor god object #237 has moved pending signals to EditorState, update these to read from the store directly. Otherwise leave as-is and delete in the chore: refactor god object #237 follow-up.

Testability milestones

  • After Phase 5: MarkdownStyler has golden tests covering headings, code blocks, wiki-links, tags, callouts, blockquotes, frontmatter — runs in <1s, no SwiftUI.
  • After Phase 4: EmbeddedImageView can be instantiated in a test window without touching the network (inject the URLSession).
  • After Phase 7: LinkAwareTextView responsibilities are file-local; reading any one concern no longer requires scrolling past 1,400 lines.

Concerns to address while touching this code

  • Synchronous file reads during styling (lines 3180, 3240, 3333, 3344): String(contentsOf:) / Data(contentsOf:) for inline image resolution. Move to background + cache.
  • Synchronous FileManager.fileExists / createFile for debug logging (2198–2205). Delete or gate behind a debug flag.
  • Silent git failures (464–467, 480–485) — surface to UI or log properly.
  • Recursive collectLinkAwareTextViews() (60–68) has no cycle detection. Unlikely to cycle in practice but worth a depth cap.

Risks

  • Bit-exact styling preservation. The markdown styling engine is regex-heavy and visually sensitive. Add golden tests before moving it.
  • Objective-C runtime interaction. objc_getAssociatedObject usage must be preserved carefully when extracting — these associations are keyed by static pointer and must stay unique across the split.
  • AppState coupling remains. This issue does not decouple EditorView from AppState — that's chore: refactor god object #237's job. The two refactors can proceed in parallel as long as we don't fight over the same file at the same time.

Out of scope

  • Decoupling from AppState (tracked in chore: refactor god object #237).
  • Rewriting the styling engine (regex → proper parser) — separate issue if desired.
  • FileTreeView and SettingsManager breakups — separate issues.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions