Skip to content

Add full markdown editor panel - #4239

Closed
lawrencecchen wants to merge 1 commit into
mainfrom
feat-obsidian-markdown-editor
Closed

lawrencecchen wants to merge 1 commit into
mainfrom
feat-obsidian-markdown-editor

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

  • Replace the markdown panel TextEdit mode with a native markdown editor surface.
  • Add syntax highlighting, outline navigation, formatting commands, task checkbox toggling, list continuation, link navigation, and split live preview.
  • Add localized editor strings and focused markdown editor unit coverage.

Verification:

  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination platform=macOS,arch=arm64 -derivedDataPath /tmp/cmux-obsmd-test -only-testing:cmuxTests/MarkdownPanelTests -resultBundlePath /tmp/cmux-obsmd-markdown-tests.xcresult test
  • ./scripts/reload.sh --tag obsmd

Dogfood note:

  • The tagged app built, but cmux could not materialize a non-focused helper terminal in the caller workspace for the matching web dev server. cmux read-screen --workspace workspace:94 --surface surface:283 --lines 5 returned Terminal surface not found, and the empty helper surfaces were removed.

Note

Medium Risk
Introduces a large new native editor implementation (custom NSTextView + parsing/highlighting/command handling) and wires it into panel navigation and file-opening, which could affect editing UX, performance, and focus/shortcut behaviors.

Overview
Adds a full native Markdown editor view for the Markdown panel, replacing the previous TextEdit-based mode with an in-app editor that includes a formatting toolbar, outline sidebar with heading navigation, status bar (lines/words/headings + saved/unsaved), optional line wrapping, and an optional split live preview.

Extends MarkdownPanel to support opening linked markdown files from the editor, updates the mode toggle UI/strings from TextEdit to Editor, and adds new localized strings plus unit tests covering outline extraction, list continuation/exit, checkbox toggling, formatting edits, and link detection.

Reviewed by Cursor Bugbot for commit 431f33e. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Replaces the markdown panel’s TextEdit mode with a native editor that adds syntax highlighting, outline navigation, rich edit commands, and an optional split live preview for a faster writing flow.

  • New Features

    • New MarkdownEditorView with source editor and optional live preview.
    • Syntax highlighting with light/dark palettes; font zoom and line wrap toggle.
    • Outline sidebar with jump-to-heading.
    • Toolbar and shortcuts for heading, bold, italic, code, link, task list, table.
    • Smart edits: checkbox toggle, list/number continuation on Enter, Command-click link navigation (inline, wiki, raw paths).
    • Status bar showing lines, words, headings, and save state; added localized strings.
  • Refactors

    • Replaced TextEdit UI; header action now shows “Show Editor”.
    • Added openLinkedMarkdownFile to MarkdownPanel for link navigation.
    • Added focused unit tests for outline, continuation, checkbox toggle, formatting, and link detection.

Written for commit 431f33e. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a comprehensive markdown editor with a formatting toolbar, optional outline sidebar for headings, optional live preview pane, and status bar displaying line/word counts.
    • Syntax highlighting for markdown content.
    • Link detection and opening capabilities.
    • Text formatting commands (bold, italic, code, links, tables, task lists).
    • Task list checkbox toggling and continuation editing.
  • Tests

    • Added markdown editor feature coverage tests.

Review Change Stack

@vercel

vercel Bot commented May 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 16, 2026 2:35am
cmux-staging Building Building Preview, Comment May 16, 2026 2:35am

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented May 16, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive markdown editor UI for the GhosttyTabs application. It adds a new MarkdownEditorView SwiftUI component with syntax highlighting, outline navigation, live preview, and markdown-specific text formatting commands, replacing the previous basic text editor. The implementation integrates an AppKit-backed text view with theme support and includes full test coverage.

Changes

Markdown Editor Implementation

Layer / File(s) Summary
Project configuration and localization
GhosttyTabs.xcodeproj/project.pbxproj, Resources/Localizable.xcstrings
Xcode project is updated to include MarkdownEditorView.swift in build phases and source groups. Localization strings are added for editor UI elements (toolbar buttons, status labels, mode descriptors) with English and Japanese translations.
MarkdownEditorView UI components
Sources/Panels/MarkdownEditorView.swift (lines 1–252)
MarkdownEditorView defines the SwiftUI hierarchy with conditional outline sidebar, live preview, toolbar with formatting buttons, and status bar. Layout adapts to width constraints for outline and preview visibility.
NSView-backed text editor bridge
Sources/Panels/MarkdownEditorView.swift (lines 255–638)
MarkdownSourceEditor (NSViewRepresentable) manages an AppKit text view (MarkdownSourceTextView) with theme/wrapping support and delegate-based text synchronization. MarkdownEditorHandle dispatches formatting commands, focus requests, and heading jumps.
Markdown parsing and statistics
Sources/Panels/MarkdownEditorView.swift (lines 640–1282)
Core models and parsing utilities: MarkdownEditorOutline extracts ATX/setext headings while skipping code blocks; MarkdownEditorStats counts lines/words; MarkdownEditorTextCommands generates edit operations for formatting and structural changes (lists, tasks, checkboxes); MarkdownSyntaxHighlighter applies attributed styles; MarkdownEditorLinkDetector resolves link targets from markdown, wiki, and raw paths.
MarkdownPanel integration and tests
Sources/Panels/MarkdownPanel.swift, Sources/Panels/MarkdownPanelView.swift, cmuxTests/MarkdownPanelTests.swift
MarkdownPanel adds navigation helpers to open linked markdown files. MarkdownPanelView switches .text mode to MarkdownEditorView and updates the toolbar button icon/label. Tests validate heading extraction, task/checkbox editing, formatting wrapping, and multi-format link detection.

Sequence Diagram

sequenceDiagram
  participant SwiftUI as SwiftUI View
  participant Editor as MarkdownSourceEditor
  participant TextView as MarkdownSourceTextView
  participant Panel as MarkdownPanel
  participant Highlighter as MarkdownSyntaxHighlighter

  SwiftUI->>Editor: makeNSView / updateNSView
  Editor->>TextView: create and configure
  Editor->>TextView: apply theme and wrapping
  TextView->>Panel: delegate: textDidChange
  Panel->>Panel: update textContent
  Panel->>Highlighter: re-highlight markdown
  Highlighter->>TextView: apply attributed styles
  TextView->>SwiftUI: observable changes flow back
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#4085: Both PRs add helpers to open and route markdown files to MarkdownPanel tabs, aligning workspace/socket routing logic with the new editor integration.
  • manaflow-ai/cmux#4088: Both PRs directly modify Sources/Panels/MarkdownPanelView.swift's .text display mode wiring, overlapping on the markdown editor UI surface configuration.
  • manaflow-ai/cmux#819: Both PRs integrate with app localization (xcstrings/pbxproj), though this PR focuses on markdown editor UI strings while the retrieved PR handles broader Japanese String Catalog setup.

Poem

🐰 A markdown editor hops into place,
With outlines, highlights, and preview space!
Bold, italic, links—commands galore,
Syntax shines bright, just like before.
Tasks and checkmarks dance in the glow,
Watch the editor elegantly grow! 📝✨


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (3 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error Task.sleep introduced in production code MarkdownPanelView.swift flashCopyConfirmation method, violating swift-blocking-runtime.md rules. Use SwiftUI Animation, DispatchSourceTimer, or state-based callbacks instead of Task.sleep for the 1.6-second confirmation UI timing.
Cmux Swift Concurrency ❌ Error Fire-and-forget Task in flashCopyConfirmation (MarkdownPanelView line 230) violates swift-concurrency-modernization. Task.sleep with state mutation lacks storage/cancellation/caller tie. Store Task in @State variable with cancellation on dismiss, or use TimelineView/PhaseAnimator pattern.
Cmux Swift File And Package Boundaries ❌ Error MarkdownEditorView.swift (1282 lines) exceeds 800-line limit and mixes UI, platform bridge (AppKit), and parsing. Violates swift-file-package-boundaries.md requirement. Extract ~566 lines of parsing logic (outline, stats, text commands, link detection, syntax highlighting) into separate files. Reduce file to ≤800 lines with UI and bridge code only.
Description check ⚠️ Warning The description covers the main changes (Summary and Verification sections), but is missing required template sections: Testing details, Demo Video, Review Trigger block, and Checklist items. Add Testing, Demo Video, Review Trigger, and Checklist sections from the template to ensure complete documentation of the changes and verification approach.
Docstring Coverage ⚠️ Warning Docstring coverage is 1.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (11 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add full markdown editor panel' directly summarizes the main change: introducing a complete native markdown editor to replace the TextEdit mode.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Follows FilePreviewTextEditor pattern. UI coordinators intentionally on main thread only. All @MainActor access safe. Pure value types. No isolation mistakes introduced.
Cmux No Hacky Sleeps ✅ Passed Check not applicable: PR only modifies Swift code and config files. The rule applies to TypeScript/JavaScript/shell/non-Swift runtime scripts; Swift is covered by separate rule.
Cmux Swift @Concurrent ✅ Passed No new async functions, Task creations, or @concurrent annotations in this PR. All new code is synchronous and UI-bound. Heavy operations like syntax highlighting are properly kept synchronous.
Cmux Swift Logging ✅ Passed No logging violations found. PR contains no print/debugPrint/dump/NSLog in runtime code, no ad-hoc file logging, no MainActor Logger violations, and no secret/personal data exposure.
Cmux User-Facing Error Privacy ✅ Passed All new user-facing strings are generic UI labels without sensitive data. Error message is generic: "File unavailable - The file may have been moved or deleted."
Cmux Swiftui State Layout ✅ Passed @ObservedObject is for existing legacy MarkdownPanel. AppKit bridge NSViewRepresentable owns state. GeometryReader conditionally shows views. ForEach uses value structs. State mutations in actions.
Cmux Architecture Rethink ✅ Passed No violations of swift-architectural-rethink.md rules. No symptom patches, timing repairs, locks, or observers. Clear state ownership with MarkdownPanel as single source of truth.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds markdown editor views within existing Panel infrastructure. No user-visible NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup. Only NSWindow is in test fixtures (allowed).
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-obsidian-markdown-editor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 431f33e. Configure here.

"""
let prefix = selectedRange.location > 0 && !(markdown as NSString).substring(with: NSRange(location: selectedRange.location - 1, length: 1)).contains("\n") ? "\n" : ""
let replacement = prefix + insertion
let selectedLocation = selectedRange.location + (prefix as NSString).length + 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Table insertion selects wrong characters due to off-by-one

Medium Severity

The selectedLocation in tableEdit uses + 2 offset into the insertion string, but the insertion starts with \n| Column.... Position 0 is \n, position 1 is |, position 2 is (space). With length 6, this selects " Colum" instead of the intended "Column". The offset needs to be + 3 to land on the C of "Column".

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 431f33e. Configure here.

private func toolbar(availableWidth: CGFloat) -> some View {
HStack(spacing: 7) {
toolbarButton(
systemName: showsOutline ? "sidebar.left" : "sidebar.left",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ternary always returns same icon string value

Low Severity

The outline toggle toolbar button uses showsOutline ? "sidebar.left" : "sidebar.left" — both branches produce the identical string. The icon never visually changes to reflect the toggle state, and the ternary is a no-op. Likely one branch was intended to use a different icon (e.g., "sidebar.left.fill").

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 431f33e. Configure here.

@greptile-apps

greptile-apps Bot commented May 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the plain TextEdit mode in the markdown panel with a native MarkdownEditorView adding syntax highlighting, an outline sidebar, live split preview, formatting toolbar commands, task-checkbox toggling, list continuation, and Cmd+click link navigation, along with localisations and focused unit tests.

  • MarkdownEditorView.swift (1 282 lines) ships the entire feature in one file, mixing SwiftUI views, an NSTextView subclass, a text-command engine, a syntax highlighter, an outline extractor, a link detector, a palette resolver, and stats — well past the 800-line ceiling. The pure-logic types are already exercised by the new tests without AppKit, making them strong candidates for a MarkdownEditorCore SwiftPM target.
  • The toolbar outline-toggle button uses identical SF Symbol strings in both ternary branches, so the icon never reflects collapsed state. Computed outline and stats re-run expensive regex work on every SwiftUI body pass (every keystroke), and NSRegularExpression is compiled from its literal pattern on each invocation rather than cached.

Confidence Score: 3/5

The new editor surface is functional but ships with a visible toggle-icon bug and a per-keystroke regression path through expensive main-thread regex work; the file-structure issue means none of the domain logic is independently testable without the full app target.

The toolbar outline-toggle always renders the same icon, giving users no visual state feedback. Computed outline and stats re-run full regex scans on the main thread for every keystroke, which will degrade typing performance on larger files. Both issues are in the core interactive path. The file crosses 1 200 lines mixing parsing, syntax highlighting, and UI code. Localisations, test coverage, and MarkdownPanel additions are clean.

Sources/Panels/MarkdownEditorView.swift needs the most attention: the toggle-icon literal, the computed-property render-time cost, and the overall size and responsibility split all warrant fixes before shipping.

Important Files Changed

Filename Overview
Sources/Panels/MarkdownEditorView.swift New 1282-line file mixing eight distinct responsibilities; toolbar toggle icon is a duplicate literal; computed outline/stats run expensive regex work on every body pass; NSRegularExpression compiled from literal on each keystroke.
Sources/Panels/MarkdownPanel.swift Adds openLinkedMarkdownFile/openMarkdownFile helpers and updates doc comments; minimal, well-scoped changes.
Sources/Panels/MarkdownPanelView.swift Replaces FilePreviewTextEditor with MarkdownEditorView and updates toolbar button; clean localised swap.
cmuxTests/MarkdownPanelTests.swift Adds unit tests for outline extraction, task-list continuation, checkbox toggling, bold-wrapping, and link detection without constructing AppKit UI.
Resources/Localizable.xcstrings Adds English and Japanese localisations for all new editor strings; well-formed and consistent with existing key conventions.
GhosttyTabs.xcodeproj/project.pbxproj Registers MarkdownEditorView.swift in file references and compile sources; standard bookkeeping.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[MarkdownPanelView] -->|displayMode == .text| B[MarkdownEditorView]
    B --> C[toolbar]
    B --> D[editorBody]
    B --> E[statusBar]
    D -->|availableWidth >= 720| F[outlineView - MarkdownEditorOutline.headings]
    D --> G[MarkdownSourceEditor - NSViewRepresentable]
    D -->|availableWidth >= 900| H[livePreview - MarkdownWebRenderer]
    G --> I[MarkdownSourceTextView - NSTextView subclass]
    I -->|keyDown / insertNewline / mouseDown| J[MarkdownEditorTextCommands]
    I -->|Cmd+click| K[MarkdownEditorLinkDetector]
    I -->|textDidChange| L[MarkdownSyntaxHighlighter]
    L --> M[NSTextStorage]
    B -->|computed on every body pass| F
    B -->|computed on every body pass| N[MarkdownEditorStats]
    E --> N
    E --> F
Loading

Comments Outside Diff (1)

  1. Sources/Panels/MarkdownEditorView.swift, line 1616-1629 (link)

    P2 NSRegularExpression compiled from literal on every invocation

    applyRegex compiles a new NSRegularExpression every time it is called — four times per keystroke from applyInlineHighlights. headingRangeAndLevel, listMarkerRange, atxHeading, and MarkdownEditorStats.init do the same. These are constant patterns and should be private static let properties so they compile once at first use.

Reviews (1): Last reviewed commit: "Add full markdown editor panel" | Re-trigger Greptile

Comment on lines +524 to +529

override func scrollWheel(with event: NSEvent) {
guard FilePreviewInteraction.hasZoomModifier(event) else {
super.scrollWheel(with: event)
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Outline-toggle button always renders the same icon

Both branches of the ternary return "sidebar.left", so the toolbar icon never changes when the outline is toggled. The visual affordance that the outline is hidden is never shown, leaving the user with no feedback about the current state.

Suggested change
override func scrollWheel(with event: NSEvent) {
guard FilePreviewInteraction.hasZoomModifier(event) else {
super.scrollWheel(with: event)
return
}
toolbarButton(
systemName: showsOutline ? "sidebar.left" : "sidebar.left.and.text",
label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),

Comment on lines +415 to +421
private var palette = MarkdownEditorPalette.light(foregroundColor: .labelColor)
private var wrapsLines = true

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
applyFilePreviewTextEditorInsets()
panel?.retryPendingFocus()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Expensive parse runs on every SwiftUI body pass

outline calls MarkdownEditorOutline.headings(in:) — full O(n) line scan with per-line NSRegularExpression compilation — and stats compiles and runs another regex over the entire string. Both are referenced directly in body. Because panel is @ObservedObject, each keystroke triggers a full re-render and evaluates both properties on the main thread. The fix is to sink these into @State and drive them via .onChange(of: panel.textContent).

Rule Used: Flag SwiftUI changes that can cause stale state, b... (source)

Comment on lines +1 to +1282
import AppKit
import SwiftUI

struct MarkdownEditorView: View {
@ObservedObject var panel: MarkdownPanel
let isVisibleInUI: Bool
let appearance: PanelAppearance
let onRequestPanelFocus: () -> Void

@State private var editorHandle = MarkdownEditorHandle()
@State private var renderer = MarkdownWebRendererHandle()
@State private var showsOutline = true
@State private var showsLivePreview = true
@State private var wrapsLines = true

private var outline: [MarkdownEditorHeading] {
MarkdownEditorOutline.headings(in: panel.textContent)
}

private var stats: MarkdownEditorStats {
MarkdownEditorStats(markdown: panel.textContent)
}

var body: some View {
GeometryReader { proxy in
VStack(spacing: 0) {
toolbar(availableWidth: proxy.size.width)
Divider()
editorBody(availableWidth: proxy.size.width)
Divider()
statusBar
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color(nsColor: appearance.contentBackgroundColor))
}

@ViewBuilder
private func editorBody(availableWidth: CGFloat) -> some View {
HStack(spacing: 0) {
if showsOutline, availableWidth >= 720 {
outlineView
.frame(width: min(240, max(176, availableWidth * 0.22)))
Divider()
}

MarkdownSourceEditor(
panel: panel,
handle: editorHandle,
isVisibleInUI: isVisibleInUI,
themeBackgroundColor: appearance.contentBackgroundColor,
themeForegroundColor: appearance.foregroundColor,
drawsBackground: appearance.drawsContentBackground,
wrapsLines: wrapsLines,
onRequestPanelFocus: onRequestPanelFocus
)
.frame(minWidth: 260, maxWidth: .infinity, maxHeight: .infinity)

if showsLivePreview, availableWidth >= 900 {
Divider()
livePreview
.frame(minWidth: 280, idealWidth: availableWidth * 0.42, maxWidth: .infinity)
}
}
}

private var outlineView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
if outline.isEmpty {
Text(String(localized: "markdown.editor.outline.empty", defaultValue: "No headings"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ForEach(outline) { heading in
Button {
editorHandle.jump(to: heading)
} label: {
HStack(spacing: 6) {
Text(String(repeating: " ", count: max(0, heading.level - 1)))
.font(.system(size: 11, design: .monospaced))
Text(heading.title)
.font(.system(size: 11, weight: heading.level <= 2 ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.padding(.vertical, 5)
.padding(.leading, 10 + CGFloat(max(0, heading.level - 1)) * 10)
.padding(.trailing, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(Color(nsColor: appearance.foregroundColor))
.help(heading.title)
.accessibilityLabel(heading.title)
}
}
}
.padding(.vertical, 6)
}
.background(Color(nsColor: appearance.contentBackgroundColor).opacity(0.72))
.accessibilityLabel(String(localized: "markdown.editor.outline.accessibility", defaultValue: "Markdown outline"))
}

private var livePreview: some View {
MarkdownWebRenderer(
markdown: panel.content,
theme: MarkdownWebTheme.resolve(backgroundColor: appearance.backgroundColor),
backgroundColor: appearance.contentBackgroundColor,
panelId: panel.id,
workspaceId: panel.workspaceId,
filePath: panel.filePath,
handle: renderer,
onRequestPanelFocus: onRequestPanelFocus
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func toolbar(availableWidth: CGFloat) -> some View {
HStack(spacing: 7) {
toolbarButton(
systemName: showsOutline ? "sidebar.left" : "sidebar.left",
label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),
isSelected: showsOutline,
action: { showsOutline.toggle() }
)
.disabled(availableWidth < 720)

toolbarButton(
systemName: "rectangle.split.2x1",
label: String(localized: "markdown.editor.toolbar.togglePreview", defaultValue: "Toggle Live Preview"),
isSelected: showsLivePreview,
action: { showsLivePreview.toggle() }
)
.disabled(availableWidth < 900)

toolbarButton(
systemName: "arrow.left.and.right.text.vertical",
label: String(localized: "markdown.editor.toolbar.toggleWrap", defaultValue: "Toggle Line Wrap"),
isSelected: wrapsLines,
action: { wrapsLines.toggle() }
)

toolbarDivider

toolbarButton(
systemName: "textformat.size",
label: String(localized: "markdown.editor.toolbar.heading", defaultValue: "Heading"),
action: { editorHandle.apply(.heading) }
)
toolbarButton(
systemName: "bold",
label: String(localized: "markdown.editor.toolbar.bold", defaultValue: "Bold"),
action: { editorHandle.apply(.bold) }
)
toolbarButton(
systemName: "italic",
label: String(localized: "markdown.editor.toolbar.italic", defaultValue: "Italic"),
action: { editorHandle.apply(.italic) }
)
toolbarButton(
systemName: "chevron.left.forwardslash.chevron.right",
label: String(localized: "markdown.editor.toolbar.code", defaultValue: "Code"),
action: { editorHandle.apply(.code) }
)
toolbarButton(
systemName: "link",
label: String(localized: "markdown.editor.toolbar.link", defaultValue: "Link"),
action: { editorHandle.apply(.link) }
)
toolbarButton(
systemName: "checklist",
label: String(localized: "markdown.editor.toolbar.task", defaultValue: "Task List"),
action: { editorHandle.apply(.taskList) }
)
toolbarButton(
systemName: "tablecells",
label: String(localized: "markdown.editor.toolbar.table", defaultValue: "Table"),
action: { editorHandle.apply(.table) }
)

Spacer(minLength: 8)

toolbarButton(
systemName: "text.cursor",
label: String(localized: "markdown.editor.toolbar.focus", defaultValue: "Focus Editor"),
action: { editorHandle.focus() }
)
}
.padding(.horizontal, 10)
.frame(height: 32)
.background(Color(nsColor: appearance.contentBackgroundColor).opacity(0.94))
}

private var toolbarDivider: some View {
Rectangle()
.fill(Color.secondary.opacity(0.25))
.frame(width: 1, height: 18)
.padding(.horizontal, 2)
}

private func toolbarButton(
systemName: String,
label: String,
isSelected: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.resizable()
.scaledToFit()
.frame(width: 13, height: 13)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isSelected ? cmuxAccentColor().opacity(0.18) : Color.clear)
)
}
.buttonStyle(.plain)
.foregroundStyle(isSelected ? cmuxAccentColor() : Color.secondary)
.help(label)
.accessibilityLabel(label)
}

private var statusBar: some View {
HStack(spacing: 14) {
Label("\(stats.lineCount)", systemImage: "text.alignleft")
.help(String(localized: "markdown.editor.stats.lines", defaultValue: "Lines"))
Label("\(stats.wordCount)", systemImage: "text.word.spacing")
.help(String(localized: "markdown.editor.stats.words", defaultValue: "Words"))
Label("\(outline.count)", systemImage: "list.bullet.indent")
.help(String(localized: "markdown.editor.stats.headings", defaultValue: "Headings"))
Spacer(minLength: 8)
if panel.isDirty {
Text(String(localized: "markdown.editor.status.unsaved", defaultValue: "Unsaved"))
.foregroundStyle(cmuxAccentColor())
} else {
Text(String(localized: "markdown.editor.status.saved", defaultValue: "Saved"))
.foregroundStyle(.secondary)
}
}
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.frame(height: 24)
.background(Color(nsColor: appearance.contentBackgroundColor).opacity(0.94))
}
}

@MainActor
final class MarkdownEditorHandle {
weak var textView: MarkdownSourceTextView?

func apply(_ command: MarkdownEditorCommand) {
textView?.applyMarkdownCommand(command)
}

func jump(to heading: MarkdownEditorHeading) {
guard let textView else { return }
let location = min(heading.location, (textView.string as NSString).length)
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: location, length: 0))
textView.scrollRangeToVisible(NSRange(location: location, length: 0))
}

func focus() {
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
}
}

struct MarkdownSourceEditor: NSViewRepresentable {
@ObservedObject var panel: MarkdownPanel
let handle: MarkdownEditorHandle
let isVisibleInUI: Bool
let themeBackgroundColor: NSColor
let themeForegroundColor: NSColor
let drawsBackground: Bool
let wrapsLines: Bool
let onRequestPanelFocus: () -> Void

func makeCoordinator() -> Coordinator {
Coordinator(panel: panel)
}

func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.isHidden = !isVisibleInUI
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = true
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.drawsBackground = drawsBackground

let textView = MarkdownSourceTextView()
textView.panel = panel
textView.onRequestPanelFocus = onRequestPanelFocus
textView.delegate = context.coordinator
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
textView.isRichText = false
textView.importsGraphics = false
textView.usesFindPanel = true
textView.usesFontPanel = false
textView.drawsBackground = drawsBackground
textView.minSize = NSSize(width: 0, height: 0)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = true
textView.autoresizingMask = [.width]
textView.applyFilePreviewTextEditorInsets()
textView.string = panel.textContent
textView.configure(
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground,
wrapsLines: wrapsLines
)
panel.attachTextView(textView)
handle.textView = textView

scrollView.documentView = textView
applyTheme(
scrollView,
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground
)
return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.panel = panel
scrollView.isHidden = !isVisibleInUI
applyTheme(
scrollView,
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground
)

guard let textView = scrollView.documentView as? MarkdownSourceTextView else { return }
textView.panel = panel
textView.onRequestPanelFocus = onRequestPanelFocus
textView.applyFilePreviewTextEditorInsets()
textView.configure(
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground,
wrapsLines: wrapsLines
)
panel.attachTextView(textView)
handle.textView = textView

guard textView.string != panel.textContent else {
textView.highlightMarkdown()
return
}
context.coordinator.isApplyingPanelUpdate = true
textView.string = panel.textContent
textView.highlightMarkdown()
context.coordinator.isApplyingPanelUpdate = false
}

private func applyTheme(
_ scrollView: NSScrollView,
backgroundColor: NSColor,
foregroundColor: NSColor,
drawsBackground: Bool
) {
let resolvedBackgroundColor = drawsBackground ? backgroundColor : .clear
scrollView.drawsBackground = drawsBackground
scrollView.backgroundColor = resolvedBackgroundColor
scrollView.contentView.drawsBackground = drawsBackground
scrollView.contentView.backgroundColor = resolvedBackgroundColor
if let textView = scrollView.documentView as? MarkdownSourceTextView {
textView.textColor = foregroundColor
textView.insertionPointColor = foregroundColor
}
}

final class Coordinator: NSObject, NSTextViewDelegate {
var panel: MarkdownPanel
var isApplyingPanelUpdate = false

init(panel: MarkdownPanel) {
self.panel = panel
}

func textDidChange(_ notification: Notification) {
guard !isApplyingPanelUpdate,
let textView = notification.object as? MarkdownSourceTextView else { return }
panel.updateTextContent(textView.string)
textView.highlightMarkdown()
}
}
}

final class MarkdownSourceTextView: NSTextView {
weak var panel: MarkdownPanel?
var onRequestPanelFocus: (() -> Void)?

private static let defaultEditorFontSize: CGFloat = 14
private static let minimumEditorFontSize: CGFloat = 9
private static let maximumEditorFontSize: CGFloat = 38

private var editorFontSize = defaultEditorFontSize
private var pendingSaveShortcutChordPrefix: ShortcutStroke?
private var palette = MarkdownEditorPalette.light(foregroundColor: .labelColor)
private var wrapsLines = true

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
applyFilePreviewTextEditorInsets()
panel?.retryPendingFocus()
}

override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if result {
onRequestPanelFocus?()
}
return result
}

func configure(
backgroundColor: NSColor,
foregroundColor: NSColor,
drawsBackground: Bool,
wrapsLines: Bool
) {
self.wrapsLines = wrapsLines
palette = MarkdownEditorPalette.resolve(backgroundColor: backgroundColor, foregroundColor: foregroundColor)
self.drawsBackground = drawsBackground
self.backgroundColor = drawsBackground ? backgroundColor : .clear
textColor = foregroundColor
insertionPointColor = foregroundColor
updateTextContainerSizing()
highlightMarkdown()
}

override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.type == .keyDown else {
return super.performKeyEquivalent(with: event)
}
guard let shouldSave = saveShortcutMatch(for: event) else {
return super.performKeyEquivalent(with: event)
}
if shouldSave {
panel?.saveTextContent()
}
return true
}

override func keyDown(with event: NSEvent) {
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
let characters = event.charactersIgnoringModifiers?.lowercased() {
switch characters {
case "b":
applyMarkdownCommand(.bold)
return
case "i":
applyMarkdownCommand(.italic)
return
case "k":
applyMarkdownCommand(.link)
return
default:
break
}
}
super.keyDown(with: event)
}

override func insertNewline(_ sender: Any?) {
if let edit = MarkdownEditorTextCommands.continuationEdit(
markdown: string,
selectedRange: selectedRange()
) {
apply(edit)
return
}
super.insertNewline(sender)
}

override func mouseDown(with event: NSEvent) {
onRequestPanelFocus?()

let point = convert(event.locationInWindow, from: nil)
let index = characterIndex(at: point)

if event.clickCount == 1,
let edit = MarkdownEditorTextCommands.checkboxToggleEdit(markdown: string, characterIndex: index) {
apply(edit)
return
}

let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command),
let target = MarkdownEditorLinkDetector.linkTarget(in: string, characterIndex: index),
panel?.openLinkedMarkdownFile(rawPath: target) == true {
return
}

super.mouseDown(with: event)
}

override func resetCursorRects() {
super.resetCursorRects()
addCursorRect(bounds, cursor: .iBeam)
}

override func magnify(with event: NSEvent) {
let factor = 1.0 + event.magnification
guard factor.isFinite, factor > 0 else { return }
adjustEditorFontSize(by: factor)
}

override func scrollWheel(with event: NSEvent) {
guard FilePreviewInteraction.hasZoomModifier(event) else {
super.scrollWheel(with: event)
return
}
adjustEditorFontSize(by: FilePreviewInteraction.zoomFactor(forScroll: event))
}

override func smartMagnify(with event: NSEvent) {
if editorFontSize == Self.defaultEditorFontSize {
setEditorFontSize(18)
} else {
setEditorFontSize(Self.defaultEditorFontSize)
}
}

func applyMarkdownCommand(_ command: MarkdownEditorCommand) {
guard let edit = MarkdownEditorTextCommands.edit(
command: command,
markdown: string,
selectedRange: selectedRange()
) else { return }
apply(edit)
}

func highlightMarkdown() {
MarkdownSyntaxHighlighter.highlight(textView: self, palette: palette, fontSize: editorFontSize)
}

private func apply(_ edit: MarkdownTextEdit) {
guard shouldChangeText(in: edit.replacementRange, replacementString: edit.replacement) else {
return
}
textStorage?.replaceCharacters(in: edit.replacementRange, with: edit.replacement)
didChangeText()
let documentLength = (string as NSString).length
let selected = NSRange(
location: min(edit.selectedRange.location, documentLength),
length: min(edit.selectedRange.length, max(0, documentLength - edit.selectedRange.location))
)
setSelectedRange(selected)
scrollRangeToVisible(selected)
}

private func characterIndex(at point: NSPoint) -> Int {
guard let layoutManager, let textContainer else {
return (string as NSString).length
}
let containerOrigin = textContainerOrigin
let containerPoint = NSPoint(x: point.x - containerOrigin.x, y: point.y - containerOrigin.y)
var fraction: CGFloat = 0
let index = layoutManager.characterIndex(
for: containerPoint,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: &fraction
)
return min(max(0, index), (string as NSString).length)
}

private func updateTextContainerSizing() {
guard let textContainer else { return }
if wrapsLines {
isHorizontallyResizable = false
textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(
width: max(0, enclosingScrollView?.contentSize.width ?? bounds.width),
height: CGFloat.greatestFiniteMagnitude
)
maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
} else {
isHorizontallyResizable = true
textContainer.widthTracksTextView = false
textContainer.containerSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
}
}

private func adjustEditorFontSize(by factor: CGFloat) {
setEditorFontSize(editorFontSize * factor)
}

private func setEditorFontSize(_ nextFontSize: CGFloat) {
let clamped = min(max(nextFontSize, Self.minimumEditorFontSize), Self.maximumEditorFontSize)
guard clamped.isFinite else { return }
editorFontSize = clamped
highlightMarkdown()
}

private func saveShortcutMatch(for event: NSEvent) -> Bool? {
let shortcut = KeyboardShortcutSettings.shortcut(for: .saveFilePreview)
guard shortcut.hasChord else {
pendingSaveShortcutChordPrefix = nil
return shortcut.matches(event: event) ? true : nil
}

if let pendingPrefix = pendingSaveShortcutChordPrefix {
pendingSaveShortcutChordPrefix = nil
guard pendingPrefix == shortcut.firstStroke,
let secondStroke = shortcut.secondStroke else {
return nil
}
return secondStroke.matches(event: event) ? true : nil
}

if shortcut.firstStroke.matches(event: event) {
pendingSaveShortcutChordPrefix = shortcut.firstStroke
return false
}
return nil
}
}

enum MarkdownEditorCommand {
case heading
case bold
case italic
case code
case link
case taskList
case table
}

struct MarkdownTextEdit: Equatable {
let replacementRange: NSRange
let replacement: String
let selectedRange: NSRange
}

struct MarkdownEditorHeading: Identifiable, Equatable {
let level: Int
let title: String
let location: Int

var id: String {
"\(location)-\(level)-\(title)"
}
}

enum MarkdownEditorOutline {
static func headings(in markdown: String) -> [MarkdownEditorHeading] {
let ns = markdown as NSString
let lines = markdownLineRanges(in: markdown)
var headings: [MarkdownEditorHeading] = []
var inFence = false
var previousContentLine: (range: NSRange, text: String)?

for lineRange in lines {
let line = ns.substring(with: lineRange)
let trimmedNewline = line.trimmingCharacters(in: .newlines)
let trimmed = trimmedNewline.trimmingCharacters(in: .whitespaces)

if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
inFence.toggle()
previousContentLine = nil
continue
}

guard !inFence else { continue }

if let heading = atxHeading(line: trimmedNewline, location: lineRange.location) {
headings.append(heading)
previousContentLine = nil
continue
}

if let previousHeadingCandidate = previousContentLine,
let level = setextHeadingLevel(marker: trimmed) {
let title = previousHeadingCandidate.text.trimmingCharacters(in: .whitespaces)
if !title.isEmpty {
headings.append(MarkdownEditorHeading(
level: level,
title: title,
location: previousHeadingCandidate.range.location
))
}
previousContentLine = nil
continue
}

if trimmed.isEmpty || trimmed.hasPrefix(">") || trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
previousContentLine = nil
} else {
previousContentLine = (lineRange, trimmedNewline)
}
}

return headings
}

private static func atxHeading(line: String, location: Int) -> MarkdownEditorHeading? {
let pattern = #"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)),
let markerRange = Range(match.range(at: 1), in: line),
let titleRange = Range(match.range(at: 2), in: line) else {
return nil
}
let level = line[markerRange].count
let title = String(line[titleRange]).trimmingCharacters(in: .whitespaces)
guard !title.isEmpty else { return nil }
return MarkdownEditorHeading(level: level, title: title, location: location)
}

private static func setextHeadingLevel(marker: String) -> Int? {
guard marker.count >= 2 else { return nil }
if marker.allSatisfy({ $0 == "=" }) {
return 1
}
if marker.allSatisfy({ $0 == "-" }) {
return 2
}
return nil
}
}

struct MarkdownEditorStats: Equatable {
let lineCount: Int
let wordCount: Int

init(markdown: String) {
lineCount = markdown.isEmpty ? 1 : markdown.split(separator: "\n", omittingEmptySubsequences: false).count
let regex = try? NSRegularExpression(pattern: #"[A-Za-z0-9_]+(?:[-'][A-Za-z0-9_]+)*"#)
wordCount = regex?.numberOfMatches(
in: markdown,
range: NSRange(location: 0, length: (markdown as NSString).length)
) ?? 0
}
}

enum MarkdownEditorTextCommands {
static func edit(command: MarkdownEditorCommand, markdown: String, selectedRange: NSRange) -> MarkdownTextEdit? {
switch command {
case .heading:
return prefixSelectedLines(markdown: markdown, selectedRange: selectedRange, prefix: "## ")
case .bold:
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "**", suffix: "**", placeholder: "strong text")
case .italic:
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "_", suffix: "_", placeholder: "emphasis")
case .code:
let selected = (markdown as NSString).substring(with: selectedRange)
if selected.contains("\n") {
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "```\n", suffix: "\n```", placeholder: "code")
}
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "`", suffix: "`", placeholder: "code")
case .link:
return linkEdit(markdown: markdown, selectedRange: selectedRange)
case .taskList:
return prefixSelectedLines(markdown: markdown, selectedRange: selectedRange, prefix: "- [ ] ")
case .table:
return tableEdit(markdown: markdown, selectedRange: selectedRange)
}
}

static func continuationEdit(markdown: String, selectedRange: NSRange) -> MarkdownTextEdit? {
guard selectedRange.length == 0 else { return nil }
let ns = markdown as NSString
let lineRange = ns.lineRange(for: NSRange(location: min(selectedRange.location, ns.length), length: 0))
let line = ns.substring(with: NSRange(location: lineRange.location, length: max(0, selectedRange.location - lineRange.location)))
let trimmedLine = line.trimmingCharacters(in: .whitespaces)

if let task = match(line: line, pattern: #"^(\s*)([-*+])\s+\[([ xX])\]\s*(.*)$"#) {
let body = task[4].trimmingCharacters(in: .whitespaces)
if body.isEmpty {
return MarkdownTextEdit(
replacementRange: NSRange(location: lineRange.location, length: (line as NSString).length),
replacement: "",
selectedRange: NSRange(location: lineRange.location, length: 0)
)
}
let insertion = "\n\(task[1])\(task[2]) [ ] "
return insertionEdit(insertion, at: selectedRange.location)
}

if let unordered = match(line: line, pattern: #"^(\s*)([-*+])\s+(.*)$"#) {
let body = unordered[3].trimmingCharacters(in: .whitespaces)
if body.isEmpty || trimmedLine == unordered[1] + unordered[2] {
return MarkdownTextEdit(
replacementRange: NSRange(location: lineRange.location, length: (line as NSString).length),
replacement: "",
selectedRange: NSRange(location: lineRange.location, length: 0)
)
}
let insertion = "\n\(unordered[1])\(unordered[2]) "
return insertionEdit(insertion, at: selectedRange.location)
}

if let ordered = match(line: line, pattern: #"^(\s*)(\d+)([.)])\s+(.*)$"#),
let currentNumber = Int(ordered[2]) {
let body = ordered[4].trimmingCharacters(in: .whitespaces)
if body.isEmpty {
return MarkdownTextEdit(
replacementRange: NSRange(location: lineRange.location, length: (line as NSString).length),
replacement: "",
selectedRange: NSRange(location: lineRange.location, length: 0)
)
}
let insertion = "\n\(ordered[1])\(currentNumber + 1)\(ordered[3]) "
return insertionEdit(insertion, at: selectedRange.location)
}

return nil
}

static func checkboxToggleEdit(markdown: String, characterIndex: Int) -> MarkdownTextEdit? {
let ns = markdown as NSString
guard ns.length > 0 else { return nil }
let clamped = min(max(0, characterIndex), ns.length)
let lineRange = ns.lineRange(for: NSRange(location: clamped, length: 0))
let line = ns.substring(with: lineRange)
guard let regex = try? NSRegularExpression(pattern: #"^(\s*[-*+]\s+\[)([ xX])(\])"#),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else {
return nil
}
let checkboxRange = NSRange(
location: lineRange.location + match.range(at: 1).location,
length: match.range(at: 1).length + match.range(at: 2).length + match.range(at: 3).length
)
guard characterIndex >= checkboxRange.location,
characterIndex <= checkboxRange.location + checkboxRange.length + 2 else {
return nil
}
let stateRange = NSRange(location: lineRange.location + match.range(at: 2).location, length: 1)
let current = ns.substring(with: stateRange)
let next = current == " " ? "x" : " "
return MarkdownTextEdit(
replacementRange: stateRange,
replacement: next,
selectedRange: NSRange(location: stateRange.location + 1, length: 0)
)
}

private static func wrapSelection(
markdown: String,
selectedRange: NSRange,
prefix: String,
suffix: String,
placeholder: String
) -> MarkdownTextEdit {
let ns = markdown as NSString
let selected = selectedRange.length > 0 ? ns.substring(with: selectedRange) : placeholder
let replacement = "\(prefix)\(selected)\(suffix)"
let selectionLocation = selectedRange.location + (prefix as NSString).length
let selectionLength = selectedRange.length > 0 ? selectedRange.length : (placeholder as NSString).length
return MarkdownTextEdit(
replacementRange: selectedRange,
replacement: replacement,
selectedRange: NSRange(location: selectionLocation, length: selectionLength)
)
}

private static func linkEdit(markdown: String, selectedRange: NSRange) -> MarkdownTextEdit {
let ns = markdown as NSString
let selected = selectedRange.length > 0 ? ns.substring(with: selectedRange) : "label"
let replacement = "[\(selected)]()"
let cursor = selectedRange.location + (replacement as NSString).length - 1
return MarkdownTextEdit(
replacementRange: selectedRange,
replacement: replacement,
selectedRange: NSRange(location: cursor, length: 0)
)
}

private static func tableEdit(markdown: String, selectedRange: NSRange) -> MarkdownTextEdit {
let insertion = """

| Column | Column |
| --- | --- |
| Value | Value |

"""
let prefix = selectedRange.location > 0 && !(markdown as NSString).substring(with: NSRange(location: selectedRange.location - 1, length: 1)).contains("\n") ? "\n" : ""
let replacement = prefix + insertion
let selectedLocation = selectedRange.location + (prefix as NSString).length + 2
return MarkdownTextEdit(
replacementRange: selectedRange,
replacement: replacement,
selectedRange: NSRange(location: selectedLocation, length: 6)
)
}

private static func prefixSelectedLines(markdown: String, selectedRange: NSRange, prefix: String) -> MarkdownTextEdit {
let ns = markdown as NSString
let lineRange = ns.lineRange(for: selectedRange)
let selectedText = ns.substring(with: lineRange)
let lines = selectedText.split(separator: "\n", omittingEmptySubsequences: false)
let replacement = lines.enumerated().map { index, line in
if index == lines.count - 1, line.isEmpty, selectedText.hasSuffix("\n") {
return ""
}
let text = String(line)
if text.trimmingCharacters(in: .whitespaces).isEmpty {
return text
}
return prefix + text
}.joined(separator: "\n")
return MarkdownTextEdit(
replacementRange: lineRange,
replacement: replacement,
selectedRange: NSRange(location: selectedRange.location + (prefix as NSString).length, length: selectedRange.length)
)
}

private static func insertionEdit(_ insertion: String, at location: Int) -> MarkdownTextEdit {
MarkdownTextEdit(
replacementRange: NSRange(location: location, length: 0),
replacement: insertion,
selectedRange: NSRange(location: location + (insertion as NSString).length, length: 0)
)
}

private static func match(line: String, pattern: String) -> [String]? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else {
return nil
}
var captures: [String] = []
for index in 0..<match.numberOfRanges {
let range = match.range(at: index)
guard range.location != NSNotFound else {
captures.append("")
continue
}
captures.append((line as NSString).substring(with: range))
}
return captures
}
}

enum MarkdownEditorLinkDetector {
static func linkTarget(in markdown: String, characterIndex: Int) -> String? {
if let inline = inlineLinkTarget(in: markdown, characterIndex: characterIndex) {
return inline
}
if let wiki = wikiLinkTarget(in: markdown, characterIndex: characterIndex) {
return wiki
}
return rawMarkdownPath(in: markdown, characterIndex: characterIndex)
}

private static func inlineLinkTarget(in markdown: String, characterIndex: Int) -> String? {
let pattern = #"\[[^\]\n]+\]\(([^)\n]+)\)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let ns = markdown as NSString
let fullRange = NSRange(location: 0, length: ns.length)
var result: String?
regex.enumerateMatches(in: markdown, range: fullRange) { match, _, stop in
guard let match else { return }
if NSLocationInRange(characterIndex, match.range),
match.range(at: 1).location != NSNotFound {
result = ns.substring(with: match.range(at: 1))
stop.pointee = true
}
}
return result?.trimmingCharacters(in: .whitespacesAndNewlines)
}

private static func wikiLinkTarget(in markdown: String, characterIndex: Int) -> String? {
let pattern = #"\[\[([^\]\n]+)\]\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let ns = markdown as NSString
let fullRange = NSRange(location: 0, length: ns.length)
var result: String?
regex.enumerateMatches(in: markdown, range: fullRange) { match, _, stop in
guard let match else { return }
if NSLocationInRange(characterIndex, match.range),
match.range(at: 1).location != NSNotFound {
var target = ns.substring(with: match.range(at: 1))
if let pipe = target.firstIndex(of: "|") {
target = String(target[..<pipe])
}
target = target.trimmingCharacters(in: .whitespacesAndNewlines)
if (target as NSString).pathExtension.isEmpty {
target += ".md"
}
result = target
stop.pointee = true
}
}
return result
}

private static func rawMarkdownPath(in markdown: String, characterIndex: Int) -> String? {
let ns = markdown as NSString
guard ns.length > 0 else { return nil }
let clamped = min(max(0, characterIndex), ns.length - 1)
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/:%+")
func isAllowedCharacter(at index: Int) -> Bool {
guard index >= 0, index < ns.length,
let scalar = UnicodeScalar(UInt32(ns.character(at: index))) else {
return false
}
return allowed.contains(scalar)
}
var start = clamped
while start > 0, isAllowedCharacter(at: start - 1) {
start -= 1
}
var end = clamped
while end < ns.length, isAllowedCharacter(at: end) {
end += 1
}
let candidate = ns.substring(with: NSRange(location: start, length: end - start))
.trimmingCharacters(in: .whitespacesAndNewlines)
return MarkdownPanelFileLinkResolver.isMarkdownPathLike(candidate) ? candidate : nil
}
}

struct MarkdownEditorPalette {
let foreground: NSColor
let muted: NSColor
let heading: NSColor
let emphasis: NSColor
let code: NSColor
let codeBackground: NSColor
let link: NSColor
let marker: NSColor
let quote: NSColor
let frontMatter: NSColor

static func resolve(backgroundColor: NSColor, foregroundColor: NSColor) -> MarkdownEditorPalette {
let background = backgroundColor.markdownOpaqueSRGB
if background.isLightColor {
return light(foregroundColor: foregroundColor)
}
return dark(foregroundColor: foregroundColor)
}

static func light(foregroundColor: NSColor) -> MarkdownEditorPalette {
MarkdownEditorPalette(
foreground: foregroundColor,
muted: NSColor(srgbRed: 0.36, green: 0.39, blue: 0.43, alpha: 1),
heading: NSColor(srgbRed: 0.05, green: 0.18, blue: 0.34, alpha: 1),
emphasis: NSColor(srgbRed: 0.39, green: 0.18, blue: 0.58, alpha: 1),
code: NSColor(srgbRed: 0.56, green: 0.16, blue: 0.10, alpha: 1),
codeBackground: NSColor(srgbRed: 0.94, green: 0.95, blue: 0.96, alpha: 1),
link: NSColor(srgbRed: 0.03, green: 0.34, blue: 0.69, alpha: 1),
marker: NSColor(srgbRed: 0.48, green: 0.51, blue: 0.56, alpha: 1),
quote: NSColor(srgbRed: 0.08, green: 0.42, blue: 0.32, alpha: 1),
frontMatter: NSColor(srgbRed: 0.45, green: 0.30, blue: 0.12, alpha: 1)
)
}

static func dark(foregroundColor: NSColor) -> MarkdownEditorPalette {
MarkdownEditorPalette(
foreground: foregroundColor,
muted: NSColor(srgbRed: 0.56, green: 0.60, blue: 0.66, alpha: 1),
heading: NSColor(srgbRed: 0.58, green: 0.78, blue: 1.00, alpha: 1),
emphasis: NSColor(srgbRed: 0.88, green: 0.66, blue: 1.00, alpha: 1),
code: NSColor(srgbRed: 1.00, green: 0.69, blue: 0.48, alpha: 1),
codeBackground: NSColor(srgbRed: 0.15, green: 0.17, blue: 0.20, alpha: 1),
link: NSColor(srgbRed: 0.46, green: 0.72, blue: 1.00, alpha: 1),
marker: NSColor(srgbRed: 0.50, green: 0.54, blue: 0.60, alpha: 1),
quote: NSColor(srgbRed: 0.48, green: 0.86, blue: 0.67, alpha: 1),
frontMatter: NSColor(srgbRed: 0.94, green: 0.70, blue: 0.42, alpha: 1)
)
}
}

enum MarkdownSyntaxHighlighter {
static func highlight(textView: NSTextView, palette: MarkdownEditorPalette, fontSize: CGFloat) {
guard let textStorage = textView.textStorage else { return }
let markdown = textView.string
let ns = markdown as NSString
let fullRange = NSRange(location: 0, length: ns.length)
let selectedRange = textView.selectedRange()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 2
paragraphStyle.paragraphSpacing = 4
paragraphStyle.defaultTabInterval = 24

let baseFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
let baseAttributes: [NSAttributedString.Key: Any] = [
.font: baseFont,
.foregroundColor: palette.foreground,
.paragraphStyle: paragraphStyle
]

textStorage.beginEditing()
textStorage.setAttributes(baseAttributes, range: fullRange)

applyLineHighlights(to: textStorage, markdown: markdown, palette: palette, fontSize: fontSize)
applyInlineHighlights(to: textStorage, markdown: markdown, palette: palette, fontSize: fontSize)

textStorage.endEditing()
textView.typingAttributes = baseAttributes
if selectedRange.location <= ns.length {
textView.setSelectedRange(selectedRange)
}
}

private static func applyLineHighlights(
to textStorage: NSTextStorage,
markdown: String,
palette: MarkdownEditorPalette,
fontSize: CGFloat
) {
let ns = markdown as NSString
var inFence = false
for lineRange in markdownLineRanges(in: markdown) {
let line = ns.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
addAttributes(
[.foregroundColor: palette.code, .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .semibold)],
to: textStorage,
range: lineRange
)
inFence.toggle()
continue
}

if inFence {
addAttributes(
[.foregroundColor: palette.code, .backgroundColor: palette.codeBackground],
to: textStorage,
range: lineRange
)
continue
}

if let heading = headingRangeAndLevel(in: line, lineRange: lineRange) {
let size = fontSize + CGFloat(max(0, 7 - heading.level)) * 0.8
addAttributes(
[
.foregroundColor: palette.heading,
.font: NSFont.monospacedSystemFont(ofSize: size, weight: .bold)
],
to: textStorage,
range: heading.range
)
addAttributes([.foregroundColor: palette.marker], to: textStorage, range: heading.markerRange)
continue
}

if trimmed.hasPrefix(">") {
addAttributes(
[
.foregroundColor: palette.quote,
.font: NSFontManager.shared.convert(NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), toHaveTrait: .italicFontMask)
],
to: textStorage,
range: lineRange
)
}

if trimmed.hasPrefix("---") || trimmed.hasPrefix("+++") {
addAttributes([.foregroundColor: palette.frontMatter], to: textStorage, range: lineRange)
}

if let marker = listMarkerRange(in: line, lineRange: lineRange) {
addAttributes([.foregroundColor: palette.marker], to: textStorage, range: marker)
}
}
}

private static func applyInlineHighlights(
to textStorage: NSTextStorage,
markdown: String,
palette: MarkdownEditorPalette,
fontSize: CGFloat
) {
applyRegex(#"`[^`\n]+`"#, markdown: markdown, textStorage: textStorage) { range in
[
.foregroundColor: palette.code,
.backgroundColor: palette.codeBackground,
.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
]
}
applyRegex(#"\*\*([^*\n]+)\*\*|__([^_\n]+)__"#, markdown: markdown, textStorage: textStorage) { _ in
[
.foregroundColor: palette.emphasis,
.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .bold)
]
}
applyRegex(#"(?<!\*)\*([^*\n]+)\*(?!\*)|_([^_\n]+)_"#, markdown: markdown, textStorage: textStorage) { _ in
[
.foregroundColor: palette.emphasis,
.font: NSFontManager.shared.convert(NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), toHaveTrait: .italicFontMask)
]
}
applyRegex(#"\[[^\]\n]+\]\([^)]+\)|\[\[[^\]\n]+\]\]"#, markdown: markdown, textStorage: textStorage) { _ in
[
.foregroundColor: palette.link,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
}
}

private static func applyRegex(
_ pattern: String,
markdown: String,
textStorage: NSTextStorage,
attributes: (NSRange) -> [NSAttributedString.Key: Any]
) {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return }
let range = NSRange(location: 0, length: (markdown as NSString).length)
regex.enumerateMatches(in: markdown, range: range) { match, _, _ in
guard let match else { return }
addAttributes(attributes(match.range), to: textStorage, range: match.range)
}
}

private static func headingRangeAndLevel(
in line: String,
lineRange: NSRange
) -> (range: NSRange, markerRange: NSRange, level: Int)? {
let pattern = #"^(\s{0,3})(#{1,6})(\s+.+)$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)),
match.range(at: 2).location != NSNotFound else {
return nil
}
let markerRange = NSRange(location: lineRange.location + match.range(at: 2).location, length: match.range(at: 2).length)
let range = NSRange(location: lineRange.location + match.range.location, length: match.range.length)
return (range, markerRange, match.range(at: 2).length)
}

private static func listMarkerRange(in line: String, lineRange: NSRange) -> NSRange? {
let pattern = #"^\s*(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else {
return nil
}
return NSRange(location: lineRange.location + match.range.location, length: match.range.length)
}

private static func addAttributes(
_ attributes: [NSAttributedString.Key: Any],
to textStorage: NSTextStorage,
range: NSRange
) {
guard range.location != NSNotFound,
range.length > 0,
range.location + range.length <= textStorage.length else {
return
}
textStorage.addAttributes(attributes, range: range)
}
}

func markdownLineRanges(in markdown: String) -> [NSRange] {
let ns = markdown as NSString
var ranges: [NSRange] = []
var location = 0
while location < ns.length {
let range = ns.lineRange(for: NSRange(location: location, length: 0))
ranges.append(range)
location = range.location + max(range.length, 1)
}
if ns.length == 0 {
ranges.append(NSRange(location: 0, length: 0))
}
return ranges
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 File exceeds the 800-line ceiling and mixes eight distinct responsibilities

MarkdownEditorView.swift is 1 282 lines and hosts: SwiftUI view hierarchy, an NSViewRepresentable bridge, an NSTextView subclass, a text-command engine, a syntax highlighter, an outline extractor, a link detector, a color palette resolver, and document stats. The unit tests already drive the pure-logic types without AppKit, confirming they belong in a MarkdownEditorCore SwiftPM target rather than inlined here.

Rule Used: Flag Swift changes that add too much unrelated res... (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Panels/MarkdownEditorView.swift`:
- Around line 1-1282: This file is too large and mixes UI, platform bridge, and
parsing responsibilities; split it into focused files: keep SwiftUI view
hierarchy and toolbar in MarkdownEditorView.swift (struct MarkdownEditorView,
toolbar(), editorBody(), statusBar), move NSViewRepresentable and coordinator
plus MarkdownSourceTextView and MarkdownEditorHandle into
MarkdownSourceEditor.swift (struct MarkdownSourceEditor, Coordinator, class
MarkdownSourceTextView, func applyTheme, view lifecycle methods), and move
parsing/logic into MarkdownEditorParsing.swift and
MarkdownSyntaxHighlighter.swift (enum MarkdownEditorOutline, struct
MarkdownEditorStats, enum MarkdownEditorTextCommands, enum
MarkdownEditorLinkDetector, struct MarkdownEditorPalette, enum
MarkdownSyntaxHighlighter, helper markdownLineRanges); ensure all types remain
internal/public as needed and update imports and references so callers (e.g.,
MarkdownWebRenderer, MarkdownPanel) still compile.
- Around line 717-729: The atxHeading function recreates the NSRegularExpression
on every call; create a static cached regex property (e.g., private static let
atxHeadingRegex = try! NSRegularExpression(pattern: ...)) and use it inside
atxHeading instead of instantiating a new NSRegularExpression, preserving the
same pattern, match extraction (match.range(at: 1), match.range(at: 2)), and
return behavior for MarkdownEditorHeading(level:title:location:). Apply the same
pattern of replacing ephemeral regex creation with static cached
NSRegularExpression properties in MarkdownSyntaxHighlighter.applyRegex,
MarkdownEditorTextCommands.match, and MarkdownEditorLinkDetector to avoid
repeated compilation during editing.
- Around line 125-130: The toolbarButton call uses a redundant ternary for
systemName (showsOutline ? "sidebar.left" : "sidebar.left") — replace it with a
conditional that returns distinct SF Symbols for the two states (e.g., use
"sidebar.left.fill" when showsOutline is true and "sidebar.left" when false) so
the icon reflects the selected state; update the systemName expression in the
toolbarButton invocation that references showsOutline accordingly.
- Around line 16-22: The computed properties outline and stats currently reparse
panel.textContent on every view update; change them to cached stored properties
(e.g., cachedOutline and cachedStats) and only recompute by calling
MarkdownEditorOutline.headings(in:) and MarkdownEditorStats(markdown:) when
panel.textContent actually changes—update the cache from a single change handler
(for example using SwiftUI's onChange(of: panel.textContent) or an observable
model update) so outline and stats read from the cached values instead of
parsing on every render.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4cb20d3-62f2-4c4b-a608-cf4629230573

📥 Commits

Reviewing files that changed from the base of the PR and between 54e9ede and 431f33e.

📒 Files selected for processing (6)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • Sources/Panels/MarkdownEditorView.swift
  • Sources/Panels/MarkdownPanel.swift
  • Sources/Panels/MarkdownPanelView.swift
  • cmuxTests/MarkdownPanelTests.swift

Comment on lines +1 to +1282
import AppKit
import SwiftUI

struct MarkdownEditorView: View {
@ObservedObject var panel: MarkdownPanel
let isVisibleInUI: Bool
let appearance: PanelAppearance
let onRequestPanelFocus: () -> Void

@State private var editorHandle = MarkdownEditorHandle()
@State private var renderer = MarkdownWebRendererHandle()
@State private var showsOutline = true
@State private var showsLivePreview = true
@State private var wrapsLines = true

private var outline: [MarkdownEditorHeading] {
MarkdownEditorOutline.headings(in: panel.textContent)
}

private var stats: MarkdownEditorStats {
MarkdownEditorStats(markdown: panel.textContent)
}

var body: some View {
GeometryReader { proxy in
VStack(spacing: 0) {
toolbar(availableWidth: proxy.size.width)
Divider()
editorBody(availableWidth: proxy.size.width)
Divider()
statusBar
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color(nsColor: appearance.contentBackgroundColor))
}

@ViewBuilder
private func editorBody(availableWidth: CGFloat) -> some View {
HStack(spacing: 0) {
if showsOutline, availableWidth >= 720 {
outlineView
.frame(width: min(240, max(176, availableWidth * 0.22)))
Divider()
}

MarkdownSourceEditor(
panel: panel,
handle: editorHandle,
isVisibleInUI: isVisibleInUI,
themeBackgroundColor: appearance.contentBackgroundColor,
themeForegroundColor: appearance.foregroundColor,
drawsBackground: appearance.drawsContentBackground,
wrapsLines: wrapsLines,
onRequestPanelFocus: onRequestPanelFocus
)
.frame(minWidth: 260, maxWidth: .infinity, maxHeight: .infinity)

if showsLivePreview, availableWidth >= 900 {
Divider()
livePreview
.frame(minWidth: 280, idealWidth: availableWidth * 0.42, maxWidth: .infinity)
}
}
}

private var outlineView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
if outline.isEmpty {
Text(String(localized: "markdown.editor.outline.empty", defaultValue: "No headings"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ForEach(outline) { heading in
Button {
editorHandle.jump(to: heading)
} label: {
HStack(spacing: 6) {
Text(String(repeating: " ", count: max(0, heading.level - 1)))
.font(.system(size: 11, design: .monospaced))
Text(heading.title)
.font(.system(size: 11, weight: heading.level <= 2 ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.padding(.vertical, 5)
.padding(.leading, 10 + CGFloat(max(0, heading.level - 1)) * 10)
.padding(.trailing, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(Color(nsColor: appearance.foregroundColor))
.help(heading.title)
.accessibilityLabel(heading.title)
}
}
}
.padding(.vertical, 6)
}
.background(Color(nsColor: appearance.contentBackgroundColor).opacity(0.72))
.accessibilityLabel(String(localized: "markdown.editor.outline.accessibility", defaultValue: "Markdown outline"))
}

private var livePreview: some View {
MarkdownWebRenderer(
markdown: panel.content,
theme: MarkdownWebTheme.resolve(backgroundColor: appearance.backgroundColor),
backgroundColor: appearance.contentBackgroundColor,
panelId: panel.id,
workspaceId: panel.workspaceId,
filePath: panel.filePath,
handle: renderer,
onRequestPanelFocus: onRequestPanelFocus
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func toolbar(availableWidth: CGFloat) -> some View {
HStack(spacing: 7) {
toolbarButton(
systemName: showsOutline ? "sidebar.left" : "sidebar.left",
label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),
isSelected: showsOutline,
action: { showsOutline.toggle() }
)
.disabled(availableWidth < 720)

toolbarButton(
systemName: "rectangle.split.2x1",
label: String(localized: "markdown.editor.toolbar.togglePreview", defaultValue: "Toggle Live Preview"),
isSelected: showsLivePreview,
action: { showsLivePreview.toggle() }
)
.disabled(availableWidth < 900)

toolbarButton(
systemName: "arrow.left.and.right.text.vertical",
label: String(localized: "markdown.editor.toolbar.toggleWrap", defaultValue: "Toggle Line Wrap"),
isSelected: wrapsLines,
action: { wrapsLines.toggle() }
)

toolbarDivider

toolbarButton(
systemName: "textformat.size",
label: String(localized: "markdown.editor.toolbar.heading", defaultValue: "Heading"),
action: { editorHandle.apply(.heading) }
)
toolbarButton(
systemName: "bold",
label: String(localized: "markdown.editor.toolbar.bold", defaultValue: "Bold"),
action: { editorHandle.apply(.bold) }
)
toolbarButton(
systemName: "italic",
label: String(localized: "markdown.editor.toolbar.italic", defaultValue: "Italic"),
action: { editorHandle.apply(.italic) }
)
toolbarButton(
systemName: "chevron.left.forwardslash.chevron.right",
label: String(localized: "markdown.editor.toolbar.code", defaultValue: "Code"),
action: { editorHandle.apply(.code) }
)
toolbarButton(
systemName: "link",
label: String(localized: "markdown.editor.toolbar.link", defaultValue: "Link"),
action: { editorHandle.apply(.link) }
)
toolbarButton(
systemName: "checklist",
label: String(localized: "markdown.editor.toolbar.task", defaultValue: "Task List"),
action: { editorHandle.apply(.taskList) }
)
toolbarButton(
systemName: "tablecells",
label: String(localized: "markdown.editor.toolbar.table", defaultValue: "Table"),
action: { editorHandle.apply(.table) }
)

Spacer(minLength: 8)

toolbarButton(
systemName: "text.cursor",
label: String(localized: "markdown.editor.toolbar.focus", defaultValue: "Focus Editor"),
action: { editorHandle.focus() }
)
}
.padding(.horizontal, 10)
.frame(height: 32)
.background(Color(nsColor: appearance.contentBackgroundColor).opacity(0.94))
}

private var toolbarDivider: some View {
Rectangle()
.fill(Color.secondary.opacity(0.25))
.frame(width: 1, height: 18)
.padding(.horizontal, 2)
}

private func toolbarButton(
systemName: String,
label: String,
isSelected: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.resizable()
.scaledToFit()
.frame(width: 13, height: 13)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isSelected ? cmuxAccentColor().opacity(0.18) : Color.clear)
)
}
.buttonStyle(.plain)
.foregroundStyle(isSelected ? cmuxAccentColor() : Color.secondary)
.help(label)
.accessibilityLabel(label)
}

private var statusBar: some View {
HStack(spacing: 14) {
Label("\(stats.lineCount)", systemImage: "text.alignleft")
.help(String(localized: "markdown.editor.stats.lines", defaultValue: "Lines"))
Label("\(stats.wordCount)", systemImage: "text.word.spacing")
.help(String(localized: "markdown.editor.stats.words", defaultValue: "Words"))
Label("\(outline.count)", systemImage: "list.bullet.indent")
.help(String(localized: "markdown.editor.stats.headings", defaultValue: "Headings"))
Spacer(minLength: 8)
if panel.isDirty {
Text(String(localized: "markdown.editor.status.unsaved", defaultValue: "Unsaved"))
.foregroundStyle(cmuxAccentColor())
} else {
Text(String(localized: "markdown.editor.status.saved", defaultValue: "Saved"))
.foregroundStyle(.secondary)
}
}
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.frame(height: 24)
.background(Color(nsColor: appearance.contentBackgroundColor).opacity(0.94))
}
}

@MainActor
final class MarkdownEditorHandle {
weak var textView: MarkdownSourceTextView?

func apply(_ command: MarkdownEditorCommand) {
textView?.applyMarkdownCommand(command)
}

func jump(to heading: MarkdownEditorHeading) {
guard let textView else { return }
let location = min(heading.location, (textView.string as NSString).length)
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: location, length: 0))
textView.scrollRangeToVisible(NSRange(location: location, length: 0))
}

func focus() {
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
}
}

struct MarkdownSourceEditor: NSViewRepresentable {
@ObservedObject var panel: MarkdownPanel
let handle: MarkdownEditorHandle
let isVisibleInUI: Bool
let themeBackgroundColor: NSColor
let themeForegroundColor: NSColor
let drawsBackground: Bool
let wrapsLines: Bool
let onRequestPanelFocus: () -> Void

func makeCoordinator() -> Coordinator {
Coordinator(panel: panel)
}

func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.isHidden = !isVisibleInUI
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = true
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.drawsBackground = drawsBackground

let textView = MarkdownSourceTextView()
textView.panel = panel
textView.onRequestPanelFocus = onRequestPanelFocus
textView.delegate = context.coordinator
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
textView.isRichText = false
textView.importsGraphics = false
textView.usesFindPanel = true
textView.usesFontPanel = false
textView.drawsBackground = drawsBackground
textView.minSize = NSSize(width: 0, height: 0)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = true
textView.autoresizingMask = [.width]
textView.applyFilePreviewTextEditorInsets()
textView.string = panel.textContent
textView.configure(
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground,
wrapsLines: wrapsLines
)
panel.attachTextView(textView)
handle.textView = textView

scrollView.documentView = textView
applyTheme(
scrollView,
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground
)
return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.panel = panel
scrollView.isHidden = !isVisibleInUI
applyTheme(
scrollView,
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground
)

guard let textView = scrollView.documentView as? MarkdownSourceTextView else { return }
textView.panel = panel
textView.onRequestPanelFocus = onRequestPanelFocus
textView.applyFilePreviewTextEditorInsets()
textView.configure(
backgroundColor: themeBackgroundColor,
foregroundColor: themeForegroundColor,
drawsBackground: drawsBackground,
wrapsLines: wrapsLines
)
panel.attachTextView(textView)
handle.textView = textView

guard textView.string != panel.textContent else {
textView.highlightMarkdown()
return
}
context.coordinator.isApplyingPanelUpdate = true
textView.string = panel.textContent
textView.highlightMarkdown()
context.coordinator.isApplyingPanelUpdate = false
}

private func applyTheme(
_ scrollView: NSScrollView,
backgroundColor: NSColor,
foregroundColor: NSColor,
drawsBackground: Bool
) {
let resolvedBackgroundColor = drawsBackground ? backgroundColor : .clear
scrollView.drawsBackground = drawsBackground
scrollView.backgroundColor = resolvedBackgroundColor
scrollView.contentView.drawsBackground = drawsBackground
scrollView.contentView.backgroundColor = resolvedBackgroundColor
if let textView = scrollView.documentView as? MarkdownSourceTextView {
textView.textColor = foregroundColor
textView.insertionPointColor = foregroundColor
}
}

final class Coordinator: NSObject, NSTextViewDelegate {
var panel: MarkdownPanel
var isApplyingPanelUpdate = false

init(panel: MarkdownPanel) {
self.panel = panel
}

func textDidChange(_ notification: Notification) {
guard !isApplyingPanelUpdate,
let textView = notification.object as? MarkdownSourceTextView else { return }
panel.updateTextContent(textView.string)
textView.highlightMarkdown()
}
}
}

final class MarkdownSourceTextView: NSTextView {
weak var panel: MarkdownPanel?
var onRequestPanelFocus: (() -> Void)?

private static let defaultEditorFontSize: CGFloat = 14
private static let minimumEditorFontSize: CGFloat = 9
private static let maximumEditorFontSize: CGFloat = 38

private var editorFontSize = defaultEditorFontSize
private var pendingSaveShortcutChordPrefix: ShortcutStroke?
private var palette = MarkdownEditorPalette.light(foregroundColor: .labelColor)
private var wrapsLines = true

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
applyFilePreviewTextEditorInsets()
panel?.retryPendingFocus()
}

override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if result {
onRequestPanelFocus?()
}
return result
}

func configure(
backgroundColor: NSColor,
foregroundColor: NSColor,
drawsBackground: Bool,
wrapsLines: Bool
) {
self.wrapsLines = wrapsLines
palette = MarkdownEditorPalette.resolve(backgroundColor: backgroundColor, foregroundColor: foregroundColor)
self.drawsBackground = drawsBackground
self.backgroundColor = drawsBackground ? backgroundColor : .clear
textColor = foregroundColor
insertionPointColor = foregroundColor
updateTextContainerSizing()
highlightMarkdown()
}

override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.type == .keyDown else {
return super.performKeyEquivalent(with: event)
}
guard let shouldSave = saveShortcutMatch(for: event) else {
return super.performKeyEquivalent(with: event)
}
if shouldSave {
panel?.saveTextContent()
}
return true
}

override func keyDown(with event: NSEvent) {
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
let characters = event.charactersIgnoringModifiers?.lowercased() {
switch characters {
case "b":
applyMarkdownCommand(.bold)
return
case "i":
applyMarkdownCommand(.italic)
return
case "k":
applyMarkdownCommand(.link)
return
default:
break
}
}
super.keyDown(with: event)
}

override func insertNewline(_ sender: Any?) {
if let edit = MarkdownEditorTextCommands.continuationEdit(
markdown: string,
selectedRange: selectedRange()
) {
apply(edit)
return
}
super.insertNewline(sender)
}

override func mouseDown(with event: NSEvent) {
onRequestPanelFocus?()

let point = convert(event.locationInWindow, from: nil)
let index = characterIndex(at: point)

if event.clickCount == 1,
let edit = MarkdownEditorTextCommands.checkboxToggleEdit(markdown: string, characterIndex: index) {
apply(edit)
return
}

let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command),
let target = MarkdownEditorLinkDetector.linkTarget(in: string, characterIndex: index),
panel?.openLinkedMarkdownFile(rawPath: target) == true {
return
}

super.mouseDown(with: event)
}

override func resetCursorRects() {
super.resetCursorRects()
addCursorRect(bounds, cursor: .iBeam)
}

override func magnify(with event: NSEvent) {
let factor = 1.0 + event.magnification
guard factor.isFinite, factor > 0 else { return }
adjustEditorFontSize(by: factor)
}

override func scrollWheel(with event: NSEvent) {
guard FilePreviewInteraction.hasZoomModifier(event) else {
super.scrollWheel(with: event)
return
}
adjustEditorFontSize(by: FilePreviewInteraction.zoomFactor(forScroll: event))
}

override func smartMagnify(with event: NSEvent) {
if editorFontSize == Self.defaultEditorFontSize {
setEditorFontSize(18)
} else {
setEditorFontSize(Self.defaultEditorFontSize)
}
}

func applyMarkdownCommand(_ command: MarkdownEditorCommand) {
guard let edit = MarkdownEditorTextCommands.edit(
command: command,
markdown: string,
selectedRange: selectedRange()
) else { return }
apply(edit)
}

func highlightMarkdown() {
MarkdownSyntaxHighlighter.highlight(textView: self, palette: palette, fontSize: editorFontSize)
}

private func apply(_ edit: MarkdownTextEdit) {
guard shouldChangeText(in: edit.replacementRange, replacementString: edit.replacement) else {
return
}
textStorage?.replaceCharacters(in: edit.replacementRange, with: edit.replacement)
didChangeText()
let documentLength = (string as NSString).length
let selected = NSRange(
location: min(edit.selectedRange.location, documentLength),
length: min(edit.selectedRange.length, max(0, documentLength - edit.selectedRange.location))
)
setSelectedRange(selected)
scrollRangeToVisible(selected)
}

private func characterIndex(at point: NSPoint) -> Int {
guard let layoutManager, let textContainer else {
return (string as NSString).length
}
let containerOrigin = textContainerOrigin
let containerPoint = NSPoint(x: point.x - containerOrigin.x, y: point.y - containerOrigin.y)
var fraction: CGFloat = 0
let index = layoutManager.characterIndex(
for: containerPoint,
in: textContainer,
fractionOfDistanceBetweenInsertionPoints: &fraction
)
return min(max(0, index), (string as NSString).length)
}

private func updateTextContainerSizing() {
guard let textContainer else { return }
if wrapsLines {
isHorizontallyResizable = false
textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(
width: max(0, enclosingScrollView?.contentSize.width ?? bounds.width),
height: CGFloat.greatestFiniteMagnitude
)
maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
} else {
isHorizontallyResizable = true
textContainer.widthTracksTextView = false
textContainer.containerSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
}
}

private func adjustEditorFontSize(by factor: CGFloat) {
setEditorFontSize(editorFontSize * factor)
}

private func setEditorFontSize(_ nextFontSize: CGFloat) {
let clamped = min(max(nextFontSize, Self.minimumEditorFontSize), Self.maximumEditorFontSize)
guard clamped.isFinite else { return }
editorFontSize = clamped
highlightMarkdown()
}

private func saveShortcutMatch(for event: NSEvent) -> Bool? {
let shortcut = KeyboardShortcutSettings.shortcut(for: .saveFilePreview)
guard shortcut.hasChord else {
pendingSaveShortcutChordPrefix = nil
return shortcut.matches(event: event) ? true : nil
}

if let pendingPrefix = pendingSaveShortcutChordPrefix {
pendingSaveShortcutChordPrefix = nil
guard pendingPrefix == shortcut.firstStroke,
let secondStroke = shortcut.secondStroke else {
return nil
}
return secondStroke.matches(event: event) ? true : nil
}

if shortcut.firstStroke.matches(event: event) {
pendingSaveShortcutChordPrefix = shortcut.firstStroke
return false
}
return nil
}
}

enum MarkdownEditorCommand {
case heading
case bold
case italic
case code
case link
case taskList
case table
}

struct MarkdownTextEdit: Equatable {
let replacementRange: NSRange
let replacement: String
let selectedRange: NSRange
}

struct MarkdownEditorHeading: Identifiable, Equatable {
let level: Int
let title: String
let location: Int

var id: String {
"\(location)-\(level)-\(title)"
}
}

enum MarkdownEditorOutline {
static func headings(in markdown: String) -> [MarkdownEditorHeading] {
let ns = markdown as NSString
let lines = markdownLineRanges(in: markdown)
var headings: [MarkdownEditorHeading] = []
var inFence = false
var previousContentLine: (range: NSRange, text: String)?

for lineRange in lines {
let line = ns.substring(with: lineRange)
let trimmedNewline = line.trimmingCharacters(in: .newlines)
let trimmed = trimmedNewline.trimmingCharacters(in: .whitespaces)

if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
inFence.toggle()
previousContentLine = nil
continue
}

guard !inFence else { continue }

if let heading = atxHeading(line: trimmedNewline, location: lineRange.location) {
headings.append(heading)
previousContentLine = nil
continue
}

if let previousHeadingCandidate = previousContentLine,
let level = setextHeadingLevel(marker: trimmed) {
let title = previousHeadingCandidate.text.trimmingCharacters(in: .whitespaces)
if !title.isEmpty {
headings.append(MarkdownEditorHeading(
level: level,
title: title,
location: previousHeadingCandidate.range.location
))
}
previousContentLine = nil
continue
}

if trimmed.isEmpty || trimmed.hasPrefix(">") || trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
previousContentLine = nil
} else {
previousContentLine = (lineRange, trimmedNewline)
}
}

return headings
}

private static func atxHeading(line: String, location: Int) -> MarkdownEditorHeading? {
let pattern = #"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)),
let markerRange = Range(match.range(at: 1), in: line),
let titleRange = Range(match.range(at: 2), in: line) else {
return nil
}
let level = line[markerRange].count
let title = String(line[titleRange]).trimmingCharacters(in: .whitespaces)
guard !title.isEmpty else { return nil }
return MarkdownEditorHeading(level: level, title: title, location: location)
}

private static func setextHeadingLevel(marker: String) -> Int? {
guard marker.count >= 2 else { return nil }
if marker.allSatisfy({ $0 == "=" }) {
return 1
}
if marker.allSatisfy({ $0 == "-" }) {
return 2
}
return nil
}
}

struct MarkdownEditorStats: Equatable {
let lineCount: Int
let wordCount: Int

init(markdown: String) {
lineCount = markdown.isEmpty ? 1 : markdown.split(separator: "\n", omittingEmptySubsequences: false).count
let regex = try? NSRegularExpression(pattern: #"[A-Za-z0-9_]+(?:[-'][A-Za-z0-9_]+)*"#)
wordCount = regex?.numberOfMatches(
in: markdown,
range: NSRange(location: 0, length: (markdown as NSString).length)
) ?? 0
}
}

enum MarkdownEditorTextCommands {
static func edit(command: MarkdownEditorCommand, markdown: String, selectedRange: NSRange) -> MarkdownTextEdit? {
switch command {
case .heading:
return prefixSelectedLines(markdown: markdown, selectedRange: selectedRange, prefix: "## ")
case .bold:
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "**", suffix: "**", placeholder: "strong text")
case .italic:
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "_", suffix: "_", placeholder: "emphasis")
case .code:
let selected = (markdown as NSString).substring(with: selectedRange)
if selected.contains("\n") {
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "```\n", suffix: "\n```", placeholder: "code")
}
return wrapSelection(markdown: markdown, selectedRange: selectedRange, prefix: "`", suffix: "`", placeholder: "code")
case .link:
return linkEdit(markdown: markdown, selectedRange: selectedRange)
case .taskList:
return prefixSelectedLines(markdown: markdown, selectedRange: selectedRange, prefix: "- [ ] ")
case .table:
return tableEdit(markdown: markdown, selectedRange: selectedRange)
}
}

static func continuationEdit(markdown: String, selectedRange: NSRange) -> MarkdownTextEdit? {
guard selectedRange.length == 0 else { return nil }
let ns = markdown as NSString
let lineRange = ns.lineRange(for: NSRange(location: min(selectedRange.location, ns.length), length: 0))
let line = ns.substring(with: NSRange(location: lineRange.location, length: max(0, selectedRange.location - lineRange.location)))
let trimmedLine = line.trimmingCharacters(in: .whitespaces)

if let task = match(line: line, pattern: #"^(\s*)([-*+])\s+\[([ xX])\]\s*(.*)$"#) {
let body = task[4].trimmingCharacters(in: .whitespaces)
if body.isEmpty {
return MarkdownTextEdit(
replacementRange: NSRange(location: lineRange.location, length: (line as NSString).length),
replacement: "",
selectedRange: NSRange(location: lineRange.location, length: 0)
)
}
let insertion = "\n\(task[1])\(task[2]) [ ] "
return insertionEdit(insertion, at: selectedRange.location)
}

if let unordered = match(line: line, pattern: #"^(\s*)([-*+])\s+(.*)$"#) {
let body = unordered[3].trimmingCharacters(in: .whitespaces)
if body.isEmpty || trimmedLine == unordered[1] + unordered[2] {
return MarkdownTextEdit(
replacementRange: NSRange(location: lineRange.location, length: (line as NSString).length),
replacement: "",
selectedRange: NSRange(location: lineRange.location, length: 0)
)
}
let insertion = "\n\(unordered[1])\(unordered[2]) "
return insertionEdit(insertion, at: selectedRange.location)
}

if let ordered = match(line: line, pattern: #"^(\s*)(\d+)([.)])\s+(.*)$"#),
let currentNumber = Int(ordered[2]) {
let body = ordered[4].trimmingCharacters(in: .whitespaces)
if body.isEmpty {
return MarkdownTextEdit(
replacementRange: NSRange(location: lineRange.location, length: (line as NSString).length),
replacement: "",
selectedRange: NSRange(location: lineRange.location, length: 0)
)
}
let insertion = "\n\(ordered[1])\(currentNumber + 1)\(ordered[3]) "
return insertionEdit(insertion, at: selectedRange.location)
}

return nil
}

static func checkboxToggleEdit(markdown: String, characterIndex: Int) -> MarkdownTextEdit? {
let ns = markdown as NSString
guard ns.length > 0 else { return nil }
let clamped = min(max(0, characterIndex), ns.length)
let lineRange = ns.lineRange(for: NSRange(location: clamped, length: 0))
let line = ns.substring(with: lineRange)
guard let regex = try? NSRegularExpression(pattern: #"^(\s*[-*+]\s+\[)([ xX])(\])"#),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else {
return nil
}
let checkboxRange = NSRange(
location: lineRange.location + match.range(at: 1).location,
length: match.range(at: 1).length + match.range(at: 2).length + match.range(at: 3).length
)
guard characterIndex >= checkboxRange.location,
characterIndex <= checkboxRange.location + checkboxRange.length + 2 else {
return nil
}
let stateRange = NSRange(location: lineRange.location + match.range(at: 2).location, length: 1)
let current = ns.substring(with: stateRange)
let next = current == " " ? "x" : " "
return MarkdownTextEdit(
replacementRange: stateRange,
replacement: next,
selectedRange: NSRange(location: stateRange.location + 1, length: 0)
)
}

private static func wrapSelection(
markdown: String,
selectedRange: NSRange,
prefix: String,
suffix: String,
placeholder: String
) -> MarkdownTextEdit {
let ns = markdown as NSString
let selected = selectedRange.length > 0 ? ns.substring(with: selectedRange) : placeholder
let replacement = "\(prefix)\(selected)\(suffix)"
let selectionLocation = selectedRange.location + (prefix as NSString).length
let selectionLength = selectedRange.length > 0 ? selectedRange.length : (placeholder as NSString).length
return MarkdownTextEdit(
replacementRange: selectedRange,
replacement: replacement,
selectedRange: NSRange(location: selectionLocation, length: selectionLength)
)
}

private static func linkEdit(markdown: String, selectedRange: NSRange) -> MarkdownTextEdit {
let ns = markdown as NSString
let selected = selectedRange.length > 0 ? ns.substring(with: selectedRange) : "label"
let replacement = "[\(selected)]()"
let cursor = selectedRange.location + (replacement as NSString).length - 1
return MarkdownTextEdit(
replacementRange: selectedRange,
replacement: replacement,
selectedRange: NSRange(location: cursor, length: 0)
)
}

private static func tableEdit(markdown: String, selectedRange: NSRange) -> MarkdownTextEdit {
let insertion = """

| Column | Column |
| --- | --- |
| Value | Value |

"""
let prefix = selectedRange.location > 0 && !(markdown as NSString).substring(with: NSRange(location: selectedRange.location - 1, length: 1)).contains("\n") ? "\n" : ""
let replacement = prefix + insertion
let selectedLocation = selectedRange.location + (prefix as NSString).length + 2
return MarkdownTextEdit(
replacementRange: selectedRange,
replacement: replacement,
selectedRange: NSRange(location: selectedLocation, length: 6)
)
}

private static func prefixSelectedLines(markdown: String, selectedRange: NSRange, prefix: String) -> MarkdownTextEdit {
let ns = markdown as NSString
let lineRange = ns.lineRange(for: selectedRange)
let selectedText = ns.substring(with: lineRange)
let lines = selectedText.split(separator: "\n", omittingEmptySubsequences: false)
let replacement = lines.enumerated().map { index, line in
if index == lines.count - 1, line.isEmpty, selectedText.hasSuffix("\n") {
return ""
}
let text = String(line)
if text.trimmingCharacters(in: .whitespaces).isEmpty {
return text
}
return prefix + text
}.joined(separator: "\n")
return MarkdownTextEdit(
replacementRange: lineRange,
replacement: replacement,
selectedRange: NSRange(location: selectedRange.location + (prefix as NSString).length, length: selectedRange.length)
)
}

private static func insertionEdit(_ insertion: String, at location: Int) -> MarkdownTextEdit {
MarkdownTextEdit(
replacementRange: NSRange(location: location, length: 0),
replacement: insertion,
selectedRange: NSRange(location: location + (insertion as NSString).length, length: 0)
)
}

private static func match(line: String, pattern: String) -> [String]? {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else {
return nil
}
var captures: [String] = []
for index in 0..<match.numberOfRanges {
let range = match.range(at: index)
guard range.location != NSNotFound else {
captures.append("")
continue
}
captures.append((line as NSString).substring(with: range))
}
return captures
}
}

enum MarkdownEditorLinkDetector {
static func linkTarget(in markdown: String, characterIndex: Int) -> String? {
if let inline = inlineLinkTarget(in: markdown, characterIndex: characterIndex) {
return inline
}
if let wiki = wikiLinkTarget(in: markdown, characterIndex: characterIndex) {
return wiki
}
return rawMarkdownPath(in: markdown, characterIndex: characterIndex)
}

private static func inlineLinkTarget(in markdown: String, characterIndex: Int) -> String? {
let pattern = #"\[[^\]\n]+\]\(([^)\n]+)\)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let ns = markdown as NSString
let fullRange = NSRange(location: 0, length: ns.length)
var result: String?
regex.enumerateMatches(in: markdown, range: fullRange) { match, _, stop in
guard let match else { return }
if NSLocationInRange(characterIndex, match.range),
match.range(at: 1).location != NSNotFound {
result = ns.substring(with: match.range(at: 1))
stop.pointee = true
}
}
return result?.trimmingCharacters(in: .whitespacesAndNewlines)
}

private static func wikiLinkTarget(in markdown: String, characterIndex: Int) -> String? {
let pattern = #"\[\[([^\]\n]+)\]\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let ns = markdown as NSString
let fullRange = NSRange(location: 0, length: ns.length)
var result: String?
regex.enumerateMatches(in: markdown, range: fullRange) { match, _, stop in
guard let match else { return }
if NSLocationInRange(characterIndex, match.range),
match.range(at: 1).location != NSNotFound {
var target = ns.substring(with: match.range(at: 1))
if let pipe = target.firstIndex(of: "|") {
target = String(target[..<pipe])
}
target = target.trimmingCharacters(in: .whitespacesAndNewlines)
if (target as NSString).pathExtension.isEmpty {
target += ".md"
}
result = target
stop.pointee = true
}
}
return result
}

private static func rawMarkdownPath(in markdown: String, characterIndex: Int) -> String? {
let ns = markdown as NSString
guard ns.length > 0 else { return nil }
let clamped = min(max(0, characterIndex), ns.length - 1)
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/:%+")
func isAllowedCharacter(at index: Int) -> Bool {
guard index >= 0, index < ns.length,
let scalar = UnicodeScalar(UInt32(ns.character(at: index))) else {
return false
}
return allowed.contains(scalar)
}
var start = clamped
while start > 0, isAllowedCharacter(at: start - 1) {
start -= 1
}
var end = clamped
while end < ns.length, isAllowedCharacter(at: end) {
end += 1
}
let candidate = ns.substring(with: NSRange(location: start, length: end - start))
.trimmingCharacters(in: .whitespacesAndNewlines)
return MarkdownPanelFileLinkResolver.isMarkdownPathLike(candidate) ? candidate : nil
}
}

struct MarkdownEditorPalette {
let foreground: NSColor
let muted: NSColor
let heading: NSColor
let emphasis: NSColor
let code: NSColor
let codeBackground: NSColor
let link: NSColor
let marker: NSColor
let quote: NSColor
let frontMatter: NSColor

static func resolve(backgroundColor: NSColor, foregroundColor: NSColor) -> MarkdownEditorPalette {
let background = backgroundColor.markdownOpaqueSRGB
if background.isLightColor {
return light(foregroundColor: foregroundColor)
}
return dark(foregroundColor: foregroundColor)
}

static func light(foregroundColor: NSColor) -> MarkdownEditorPalette {
MarkdownEditorPalette(
foreground: foregroundColor,
muted: NSColor(srgbRed: 0.36, green: 0.39, blue: 0.43, alpha: 1),
heading: NSColor(srgbRed: 0.05, green: 0.18, blue: 0.34, alpha: 1),
emphasis: NSColor(srgbRed: 0.39, green: 0.18, blue: 0.58, alpha: 1),
code: NSColor(srgbRed: 0.56, green: 0.16, blue: 0.10, alpha: 1),
codeBackground: NSColor(srgbRed: 0.94, green: 0.95, blue: 0.96, alpha: 1),
link: NSColor(srgbRed: 0.03, green: 0.34, blue: 0.69, alpha: 1),
marker: NSColor(srgbRed: 0.48, green: 0.51, blue: 0.56, alpha: 1),
quote: NSColor(srgbRed: 0.08, green: 0.42, blue: 0.32, alpha: 1),
frontMatter: NSColor(srgbRed: 0.45, green: 0.30, blue: 0.12, alpha: 1)
)
}

static func dark(foregroundColor: NSColor) -> MarkdownEditorPalette {
MarkdownEditorPalette(
foreground: foregroundColor,
muted: NSColor(srgbRed: 0.56, green: 0.60, blue: 0.66, alpha: 1),
heading: NSColor(srgbRed: 0.58, green: 0.78, blue: 1.00, alpha: 1),
emphasis: NSColor(srgbRed: 0.88, green: 0.66, blue: 1.00, alpha: 1),
code: NSColor(srgbRed: 1.00, green: 0.69, blue: 0.48, alpha: 1),
codeBackground: NSColor(srgbRed: 0.15, green: 0.17, blue: 0.20, alpha: 1),
link: NSColor(srgbRed: 0.46, green: 0.72, blue: 1.00, alpha: 1),
marker: NSColor(srgbRed: 0.50, green: 0.54, blue: 0.60, alpha: 1),
quote: NSColor(srgbRed: 0.48, green: 0.86, blue: 0.67, alpha: 1),
frontMatter: NSColor(srgbRed: 0.94, green: 0.70, blue: 0.42, alpha: 1)
)
}
}

enum MarkdownSyntaxHighlighter {
static func highlight(textView: NSTextView, palette: MarkdownEditorPalette, fontSize: CGFloat) {
guard let textStorage = textView.textStorage else { return }
let markdown = textView.string
let ns = markdown as NSString
let fullRange = NSRange(location: 0, length: ns.length)
let selectedRange = textView.selectedRange()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 2
paragraphStyle.paragraphSpacing = 4
paragraphStyle.defaultTabInterval = 24

let baseFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
let baseAttributes: [NSAttributedString.Key: Any] = [
.font: baseFont,
.foregroundColor: palette.foreground,
.paragraphStyle: paragraphStyle
]

textStorage.beginEditing()
textStorage.setAttributes(baseAttributes, range: fullRange)

applyLineHighlights(to: textStorage, markdown: markdown, palette: palette, fontSize: fontSize)
applyInlineHighlights(to: textStorage, markdown: markdown, palette: palette, fontSize: fontSize)

textStorage.endEditing()
textView.typingAttributes = baseAttributes
if selectedRange.location <= ns.length {
textView.setSelectedRange(selectedRange)
}
}

private static func applyLineHighlights(
to textStorage: NSTextStorage,
markdown: String,
palette: MarkdownEditorPalette,
fontSize: CGFloat
) {
let ns = markdown as NSString
var inFence = false
for lineRange in markdownLineRanges(in: markdown) {
let line = ns.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
addAttributes(
[.foregroundColor: palette.code, .font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .semibold)],
to: textStorage,
range: lineRange
)
inFence.toggle()
continue
}

if inFence {
addAttributes(
[.foregroundColor: palette.code, .backgroundColor: palette.codeBackground],
to: textStorage,
range: lineRange
)
continue
}

if let heading = headingRangeAndLevel(in: line, lineRange: lineRange) {
let size = fontSize + CGFloat(max(0, 7 - heading.level)) * 0.8
addAttributes(
[
.foregroundColor: palette.heading,
.font: NSFont.monospacedSystemFont(ofSize: size, weight: .bold)
],
to: textStorage,
range: heading.range
)
addAttributes([.foregroundColor: palette.marker], to: textStorage, range: heading.markerRange)
continue
}

if trimmed.hasPrefix(">") {
addAttributes(
[
.foregroundColor: palette.quote,
.font: NSFontManager.shared.convert(NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), toHaveTrait: .italicFontMask)
],
to: textStorage,
range: lineRange
)
}

if trimmed.hasPrefix("---") || trimmed.hasPrefix("+++") {
addAttributes([.foregroundColor: palette.frontMatter], to: textStorage, range: lineRange)
}

if let marker = listMarkerRange(in: line, lineRange: lineRange) {
addAttributes([.foregroundColor: palette.marker], to: textStorage, range: marker)
}
}
}

private static func applyInlineHighlights(
to textStorage: NSTextStorage,
markdown: String,
palette: MarkdownEditorPalette,
fontSize: CGFloat
) {
applyRegex(#"`[^`\n]+`"#, markdown: markdown, textStorage: textStorage) { range in
[
.foregroundColor: palette.code,
.backgroundColor: palette.codeBackground,
.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
]
}
applyRegex(#"\*\*([^*\n]+)\*\*|__([^_\n]+)__"#, markdown: markdown, textStorage: textStorage) { _ in
[
.foregroundColor: palette.emphasis,
.font: NSFont.monospacedSystemFont(ofSize: fontSize, weight: .bold)
]
}
applyRegex(#"(?<!\*)\*([^*\n]+)\*(?!\*)|_([^_\n]+)_"#, markdown: markdown, textStorage: textStorage) { _ in
[
.foregroundColor: palette.emphasis,
.font: NSFontManager.shared.convert(NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular), toHaveTrait: .italicFontMask)
]
}
applyRegex(#"\[[^\]\n]+\]\([^)]+\)|\[\[[^\]\n]+\]\]"#, markdown: markdown, textStorage: textStorage) { _ in
[
.foregroundColor: palette.link,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
}
}

private static func applyRegex(
_ pattern: String,
markdown: String,
textStorage: NSTextStorage,
attributes: (NSRange) -> [NSAttributedString.Key: Any]
) {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return }
let range = NSRange(location: 0, length: (markdown as NSString).length)
regex.enumerateMatches(in: markdown, range: range) { match, _, _ in
guard let match else { return }
addAttributes(attributes(match.range), to: textStorage, range: match.range)
}
}

private static func headingRangeAndLevel(
in line: String,
lineRange: NSRange
) -> (range: NSRange, markerRange: NSRange, level: Int)? {
let pattern = #"^(\s{0,3})(#{1,6})(\s+.+)$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)),
match.range(at: 2).location != NSNotFound else {
return nil
}
let markerRange = NSRange(location: lineRange.location + match.range(at: 2).location, length: match.range(at: 2).length)
let range = NSRange(location: lineRange.location + match.range.location, length: match.range.length)
return (range, markerRange, match.range(at: 2).length)
}

private static func listMarkerRange(in line: String, lineRange: NSRange) -> NSRange? {
let pattern = #"^\s*(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) else {
return nil
}
return NSRange(location: lineRange.location + match.range.location, length: match.range.length)
}

private static func addAttributes(
_ attributes: [NSAttributedString.Key: Any],
to textStorage: NSTextStorage,
range: NSRange
) {
guard range.location != NSNotFound,
range.length > 0,
range.location + range.length <= textStorage.length else {
return
}
textStorage.addAttributes(attributes, range: range)
}
}

func markdownLineRanges(in markdown: String) -> [NSRange] {
let ns = markdown as NSString
var ranges: [NSRange] = []
var location = 0
while location < ns.length {
let range = ns.lineRange(for: NSRange(location: location, length: 0))
ranges.append(range)
location = range.location + max(range.length, 1)
}
if ns.length == 0 {
ranges.append(NSRange(location: 0, length: 0))
}
return ranges
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

File exceeds length limit and mixes concerns.

This file is 1282 lines and combines UI rendering (SwiftUI views), platform bridge code (NSViewRepresentable, NSTextView subclass), and parsing logic (outline extraction, link detection, text commands, syntax highlighting). Per coding guidelines, production Swift files should not exceed 800 lines and should not mix these responsibilities.

Consider extracting into separate files:

  • MarkdownEditorView.swift — SwiftUI view hierarchy and toolbar
  • MarkdownSourceEditor.swift — NSViewRepresentable bridge and NSTextView subclass
  • MarkdownEditorParsing.swift — MarkdownEditorOutline, MarkdownEditorStats, MarkdownEditorTextCommands, MarkdownEditorLinkDetector
  • MarkdownSyntaxHighlighter.swift — syntax highlighting and palette

As per coding guidelines: "A new production Swift file must not exceed 400 lines without a clear single responsibility, or 800 lines even when the responsibility is mostly coherent" and "Do not mix UI rendering, state ownership, persistence, networking, parsing, subprocess/socket protocol, and platform bridge code in one Swift file."

🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 938-938: Prefer empty collection over optional collection

(discouraged_optional_collection)


[Warning] 256-256: Classes should have an explicit deinit method

(required_deinit)


[Warning] 388-388: Classes should have an explicit deinit method

(required_deinit)


[Warning] 405-405: Classes should have an explicit deinit method

(required_deinit)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/MarkdownEditorView.swift` around lines 1 - 1282, This file is
too large and mixes UI, platform bridge, and parsing responsibilities; split it
into focused files: keep SwiftUI view hierarchy and toolbar in
MarkdownEditorView.swift (struct MarkdownEditorView, toolbar(), editorBody(),
statusBar), move NSViewRepresentable and coordinator plus MarkdownSourceTextView
and MarkdownEditorHandle into MarkdownSourceEditor.swift (struct
MarkdownSourceEditor, Coordinator, class MarkdownSourceTextView, func
applyTheme, view lifecycle methods), and move parsing/logic into
MarkdownEditorParsing.swift and MarkdownSyntaxHighlighter.swift (enum
MarkdownEditorOutline, struct MarkdownEditorStats, enum
MarkdownEditorTextCommands, enum MarkdownEditorLinkDetector, struct
MarkdownEditorPalette, enum MarkdownSyntaxHighlighter, helper
markdownLineRanges); ensure all types remain internal/public as needed and
update imports and references so callers (e.g., MarkdownWebRenderer,
MarkdownPanel) still compile.

Comment on lines +16 to +22
private var outline: [MarkdownEditorHeading] {
MarkdownEditorOutline.headings(in: panel.textContent)
}

private var stats: MarkdownEditorStats {
MarkdownEditorStats(markdown: panel.textContent)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider memoizing outline and stats to avoid repeated parsing.

These computed properties parse the entire document on every view update. For large files, this could cause UI lag since outline extraction and word counting happen on each render.

♻️ Proposed refactor using memoization
+    `@State` private var cachedOutline: (content: String, headings: [MarkdownEditorHeading])?
+    `@State` private var cachedStats: (content: String, stats: MarkdownEditorStats)?
+
     private var outline: [MarkdownEditorHeading] {
-        MarkdownEditorOutline.headings(in: panel.textContent)
+        if cachedOutline?.content == panel.textContent {
+            return cachedOutline?.headings ?? []
+        }
+        let headings = MarkdownEditorOutline.headings(in: panel.textContent)
+        DispatchQueue.main.async {
+            cachedOutline = (panel.textContent, headings)
+        }
+        return headings
     }
 
     private var stats: MarkdownEditorStats {
-        MarkdownEditorStats(markdown: panel.textContent)
+        if cachedStats?.content == panel.textContent {
+            return cachedStats?.stats ?? MarkdownEditorStats(markdown: "")
+        }
+        let computed = MarkdownEditorStats(markdown: panel.textContent)
+        DispatchQueue.main.async {
+            cachedStats = (panel.textContent, computed)
+        }
+        return computed
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/MarkdownEditorView.swift` around lines 16 - 22, The computed
properties outline and stats currently reparse panel.textContent on every view
update; change them to cached stored properties (e.g., cachedOutline and
cachedStats) and only recompute by calling MarkdownEditorOutline.headings(in:)
and MarkdownEditorStats(markdown:) when panel.textContent actually
changes—update the cache from a single change handler (for example using
SwiftUI's onChange(of: panel.textContent) or an observable model update) so
outline and stats read from the cached values instead of parsing on every
render.

Comment on lines +125 to +130
toolbarButton(
systemName: showsOutline ? "sidebar.left" : "sidebar.left",
label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),
isSelected: showsOutline,
action: { showsOutline.toggle() }
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Redundant ternary — both branches are identical.

The condition showsOutline ? "sidebar.left" : "sidebar.left" returns the same icon regardless of state. This appears to be a copy-paste oversight.

🐛 Proposed fix
         toolbarButton(
-            systemName: showsOutline ? "sidebar.left" : "sidebar.left",
+            systemName: "sidebar.left",
             label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),
             isSelected: showsOutline,
             action: { showsOutline.toggle() }
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
toolbarButton(
systemName: showsOutline ? "sidebar.left" : "sidebar.left",
label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),
isSelected: showsOutline,
action: { showsOutline.toggle() }
)
toolbarButton(
systemName: "sidebar.left",
label: String(localized: "markdown.editor.toolbar.toggleOutline", defaultValue: "Toggle Outline"),
isSelected: showsOutline,
action: { showsOutline.toggle() }
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/MarkdownEditorView.swift` around lines 125 - 130, The
toolbarButton call uses a redundant ternary for systemName (showsOutline ?
"sidebar.left" : "sidebar.left") — replace it with a conditional that returns
distinct SF Symbols for the two states (e.g., use "sidebar.left.fill" when
showsOutline is true and "sidebar.left" when false) so the icon reflects the
selected state; update the systemName expression in the toolbarButton invocation
that references showsOutline accordingly.

Comment on lines +717 to +729
private static func atxHeading(line: String, location: Int) -> MarkdownEditorHeading? {
let pattern = #"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)),
let markerRange = Range(match.range(at: 1), in: line),
let titleRange = Range(match.range(at: 2), in: line) else {
return nil
}
let level = line[markerRange].count
let title = String(line[titleRange]).trimmingCharacters(in: .whitespaces)
guard !title.isEmpty else { return nil }
return MarkdownEditorHeading(level: level, title: title, location: location)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Cache static regex patterns instead of recreating them on each call.

NSRegularExpression objects are compiled on each invocation. Since all patterns are constant, caching them as static properties would improve performance, especially during syntax highlighting which runs on every keystroke.

♻️ Example refactor for outline parsing
 enum MarkdownEditorOutline {
+    private static let atxPattern = try? NSRegularExpression(pattern: #"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$"#)
+
     private static func atxHeading(line: String, location: Int) -> MarkdownEditorHeading? {
-        let pattern = #"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$"#
-        guard let regex = try? NSRegularExpression(pattern: pattern),
+        guard let regex = atxPattern,
               let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)),

Apply similarly to MarkdownSyntaxHighlighter.applyRegex, MarkdownEditorTextCommands.match, and MarkdownEditorLinkDetector.

Also applies to: 1217-1229

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/MarkdownEditorView.swift` around lines 717 - 729, The
atxHeading function recreates the NSRegularExpression on every call; create a
static cached regex property (e.g., private static let atxHeadingRegex = try!
NSRegularExpression(pattern: ...)) and use it inside atxHeading instead of
instantiating a new NSRegularExpression, preserving the same pattern, match
extraction (match.range(at: 1), match.range(at: 2)), and return behavior for
MarkdownEditorHeading(level:title:location:). Apply the same pattern of
replacing ephemeral regex creation with static cached NSRegularExpression
properties in MarkdownSyntaxHighlighter.applyRegex,
MarkdownEditorTextCommands.match, and MarkdownEditorLinkDetector to avoid
repeated compilation during editing.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/Panels/MarkdownEditorView.swift">

<violation number="1" location="Sources/Panels/MarkdownEditorView.swift:17">
P2: This computed property performs a full-document parse during `body` evaluation. Since edits to `@ObservedObject` state trigger frequent re-renders, derive/calculate this from `panel.textContent` changes (e.g., cached state) to avoid repeated main-thread parsing while typing.</violation>

<violation number="2" location="Sources/Panels/MarkdownEditorView.swift:1012">
P2: Command-click at end-of-document is coerced onto the last character, which can incorrectly open the trailing markdown link/path.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic

private static func rawMarkdownPath(in markdown: String, characterIndex: Int) -> String? {
let ns = markdown as NSString
guard ns.length > 0 else { return nil }
let clamped = min(max(0, characterIndex), ns.length - 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Command-click at end-of-document is coerced onto the last character, which can incorrectly open the trailing markdown link/path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/MarkdownEditorView.swift, line 1012:

<comment>Command-click at end-of-document is coerced onto the last character, which can incorrectly open the trailing markdown link/path.</comment>

<file context>
@@ -0,0 +1,1282 @@
+    private static func rawMarkdownPath(in markdown: String, characterIndex: Int) -> String? {
+        let ns = markdown as NSString
+        guard ns.length > 0 else { return nil }
+        let clamped = min(max(0, characterIndex), ns.length - 1)
+        let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/:%+")
+        func isAllowedCharacter(at index: Int) -> Bool {
</file context>

@State private var wrapsLines = true

private var outline: [MarkdownEditorHeading] {
MarkdownEditorOutline.headings(in: panel.textContent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This computed property performs a full-document parse during body evaluation. Since edits to @ObservedObject state trigger frequent re-renders, derive/calculate this from panel.textContent changes (e.g., cached state) to avoid repeated main-thread parsing while typing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/MarkdownEditorView.swift, line 17:

<comment>This computed property performs a full-document parse during `body` evaluation. Since edits to `@ObservedObject` state trigger frequent re-renders, derive/calculate this from `panel.textContent` changes (e.g., cached state) to avoid repeated main-thread parsing while typing.</comment>

<file context>
@@ -0,0 +1,1282 @@
+    @State private var wrapsLines = true
+
+    private var outline: [MarkdownEditorHeading] {
+        MarkdownEditorOutline.headings(in: panel.textContent)
+    }
+
</file context>

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 431f33e1 Deployed May 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants