Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ xcodebuild -scheme Clearly -configuration Debug build # Build from CLI

Open in Xcode: `open Clearly.xcodeproj` (gitignored, so regenerate with xcodegen first).

**Worktree-local DerivedData (Conductor / parallel workspaces).** When working in a Conductor worktree, always pass `-derivedDataPath ./.build/DerivedData` to `xcodebuild`. Without it, `xcodebuild` writes to the shared `~/Library/Developer/Xcode/DerivedData` and silently picks up products from a different worktree or the main repo, and `open` then launches a stale or wrong-source app. The `/verify` skill enforces this; do the same for any direct `xcodebuild` invocation. The launched bundle path under `.build/DerivedData/Build/Products/Debug/Clearly Dev.app` should always match the worktree you're editing — verify with `lsappinfo info -only bundlepath "Clearly Dev"` if in doubt.

- Deployment target: macOS 15.0 (raised from 14.0 to enable SwiftUI API parity with iPad shell and ship with macOS 26 SDK); iOS 17.0
- Swift 5.9 app / Swift 5 language mode for ClearlyCore (tools 6.0 so we can declare `.macOS(.v15)`). Xcode 26+ required to compile against the macOS 26 SDK so Liquid Glass lights up automatically on stock SwiftUI chrome.
- Dependencies: `cmark-gfm` (GFM markdown → HTML), `Sparkle` (auto-updates, direct distribution only), `GRDB` (SQLite + FTS5), `MCP` (Model Context Protocol SDK) via Swift Package Manager
Expand Down Expand Up @@ -82,6 +84,10 @@ Open in Xcode: `open Clearly.xcodeproj` (gitignored, so regenerate with xcodegen

**NSViewRepresentable binding gotcha**: SwiftUI can call `updateNSView` at any time — layout passes, state changes, etc. — not just in response to binding changes. When the user types, the text view's content changes immediately but the `@Binding` update is async. If `updateNSView` fires in between, it sees a mismatch and overwrites the text view with the stale binding value, causing the cursor to jump. A simple `isUpdating` boolean set inside the async block does NOT protect against this because SwiftUI defers the actual `updateNSView` call past the flag's lifetime. The fix is `pendingBindingUpdates` — a counter incremented synchronously in `textDidChange` and decremented in the async block. `updateNSView` skips text replacement while this counter is > 0. This pattern applies to any `NSViewRepresentable` that pushes changes from the AppKit side back to SwiftUI bindings asynchronously.

**`@Published` emits in `willSet` — the property hasn't been written yet at sink-fire time.** A `.sink` on `state.$prop` runs *before* the assignment completes on the same thread. Reading `state.prop` synchronously inside the sink returns the OLD value, so any code that re-derives behavior from current state (e.g., `performFind()` reading `findState.caseSensitive`) sees stale options. Two safe patterns: (1) capture the new value from the closure parameter and pass it through, like the existing `state.$query.sink { newQuery in self.performFind(query: newQuery) }` in `EditorView.Coordinator`; or (2) `DispatchQueue.main.async` inside the sink so the property is written by the time the work runs. The CombineLatest subscription on the find option toggles uses pattern (2).

**SwiftUI `.keyboardShortcut(letter, modifiers: [.command, .option])` does not dispatch on this macOS** even though the menu item displays the shortcut. `.command` alone and `[.command, .shift]` work fine; the option modifier on a letter silently fails. For ⌥⌘-letter shortcuts, follow the `injectHideToolbarIfNeeded` pattern in `ClearlyAppDelegate.applicationWillUpdate`: build an `NSMenuItem` with `keyEquivalent: "x"` + `keyEquivalentModifierMask = [.command, .option]`, target an `@objc` selector or post a notification, and let `MacRootView` observe. Don't use `.keyboardShortcut(... [.command, .option])` from a SwiftUI `Button` and assume it dispatches.

## Dual Distribution: Sparkle + App Store

The app ships through two channels from the same codebase:
Expand Down Expand Up @@ -148,6 +154,10 @@ Use these instead:

`Clearly/iOS/ClearlyUITextView.swift` calls `super.init(frame:textContainer:)` with a manually-constructed `NSTextStorage` → `NSLayoutManager` → `NSTextContainer` chain. Passing a non-nil `textContainer` is what forces TextKit 1 on iOS 16+; the default `UITextView(frame:)` defaults to TextKit 2, where `textView.textStorage` is effectively dead. Every path that reaches into `textStorage` — `MarkdownSyntaxHighlighter.highlightAll` / `highlightAround`, typing attributes, `NSTextStorageDelegate`, future save path in Phase 6 — depends on TextKit 1. Don't "simplify" the init to `super.init(frame:)` or use `UITextView(usingTextLayoutManager: true)`; highlighting will silently stop working with no crash.

### iOS find-style highlights need an explicit background-color wipe before each paint

TextKit 1 on iOS has no `removeTemporaryAttribute` API like macOS, so transient highlights (find matches, etc.) must live on `textStorage` via `addAttribute(.backgroundColor, ...)`. Re-running the syntax highlighter to "reset" backgrounds is NOT enough — `MarkdownSyntaxHighlighter.highlightAll` only *adds* attributes, it never removes them. So old find backgrounds from previous keystrokes persist into the next paint as orphaned yellow fragments. Always do `storage.removeAttribute(.backgroundColor, range: fullRange)` first, *then* re-run the highlighter (which repaints `==highlight==` markdown backgrounds), *then* paint your new find ranges. `EditorView_iOS.Coordinator.resetBackgroundsThenRehighlight` is the canonical helper.

### Save failures on iOS must not unmount the editor

`IOSDocumentSession.errorMessage` drives the full-screen "Couldn't open this note" view in `RawTextDetailView_iOS` — it is reserved for load-blocking failures only. **Never set it on a save failure.** A transient save error (iCloud offline, disk hiccup) must keep the editor mounted so the user's in-progress text survives. `performSave` routes failures through `DiagnosticLog.log` and leaves `lastSavedText` unchanged, which keeps `isDirty` true — the nav-title `•` is the user-visible signal that something's unsaved, and the next autosave / scene-phase flush retries. The same discipline applies to any future save path (Phase 11 conflict resolver, Phase 12 multi-document tab writes).
Expand Down
163 changes: 151 additions & 12 deletions Clearly/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ struct EditorView: NSViewRepresentable {
findState?.editorNavigateToPrevious = { [weak coordinator] in
coordinator?.navigateToPreviousMatch()
}
findState?.editorPerformReplace = { [weak coordinator] in
coordinator?.performReplaceCurrent()
}
findState?.editorPerformReplaceAll = { [weak coordinator] in
coordinator?.performReplaceAll()
}

// Wire up outline scroll-to
outlineState?.scrollToRange = { [weak coordinator] range in
Expand Down Expand Up @@ -387,6 +393,7 @@ struct EditorView: NSViewRepresentable {

// Find state tracking
var matchRanges: [NSRange] = []
var lastMatches: [TextMatch] = []
private var currentMatchIdx = 0 // 0-based internal index
private var findCancellables = Set<AnyCancellable>()

Expand Down Expand Up @@ -553,6 +560,23 @@ struct EditorView: NSViewRepresentable {
}
}
.store(in: &findCancellables)

// Re-run find whenever an option toggle changes. `@Published`
// emits in `willSet`, so the property hasn't been updated yet on
// this dispatch — bounce to the next runloop tick so `performFind`
// reads the new value.
Publishers.CombineLatest(state.$caseSensitive, state.$useRegex)
.dropFirst()
.sink { [weak self] _, _ in
DispatchQueue.main.async {
guard let self,
let findState = self.findState,
findState.isVisible,
findState.activeMode == .edit else { return }
self.performFind()
}
}
.store(in: &findCancellables)
}

var lastReplacementString: String?
Expand Down Expand Up @@ -784,12 +808,27 @@ struct EditorView: NSViewRepresentable {
// MARK: - Find

func performFind(query overrideQuery: String? = nil) {
guard let textView, let findState else { return }
guard let textView else { return }
let didRecompute = recomputeMatches(query: overrideQuery)
guard didRecompute else { return }
applyFindHighlights()
if let first = matchRanges.first {
textView.scrollRangeToVisible(first)
}
}

/// Recomputes matches against the current text view and updates state,
/// but does NOT paint highlights or scroll. Returns false if the query
/// was empty / errored (in which case state was already reset).
@discardableResult
private func recomputeMatches(query overrideQuery: String? = nil) -> Bool {
guard let textView, let findState else { return false }
let query = overrideQuery ?? findState.query
clearFindHighlights()

guard !query.isEmpty else {
matchRanges = []
lastMatches = []
currentMatchIdx = 0
DispatchQueue.main.async { [weak self] in
guard let self,
Expand All @@ -798,28 +837,54 @@ struct EditorView: NSViewRepresentable {
findState.matchCount = 0
findState.currentIndex = 0
findState.resultsAreStale = false
findState.regexError = nil
findState.lastReplaceCount = nil
}
return
return false
}

let ranges = TextMatcher.ranges(of: query, in: textView.string)
matchRanges = ranges
currentMatchIdx = ranges.isEmpty ? 0 : 0
let options = TextMatchOptions(
caseSensitive: findState.caseSensitive,
useRegex: findState.useRegex
)

applyFindHighlights()
let matches: [TextMatch]
do {
matches = try TextMatcher.matches(of: query, in: textView.string, options: options)
} catch let TextMatcherError.invalidRegex(message) {
matchRanges = []
lastMatches = []
currentMatchIdx = 0
DispatchQueue.main.async { [weak self] in
guard let self,
let findState = self.findState,
findState.activeMode == .edit else { return }
findState.matchCount = 0
findState.currentIndex = 0
findState.resultsAreStale = false
findState.regexError = message
findState.lastReplaceCount = nil
}
return false
} catch {
matches = []
}

matchRanges = matches.map(\.range)
lastMatches = matches
currentMatchIdx = 0

DispatchQueue.main.async { [weak self] in
guard let self,
let findState = self.findState,
findState.activeMode == .edit else { return }
findState.matchCount = ranges.count
findState.currentIndex = ranges.isEmpty ? 0 : 1
findState.matchCount = matches.count
findState.currentIndex = matches.isEmpty ? 0 : 1
findState.resultsAreStale = false
findState.regexError = nil
findState.lastReplaceCount = nil
}

if !ranges.isEmpty {
textView.scrollRangeToVisible(ranges[0])
}
return true
}

func navigateToNextMatch() {
Expand Down Expand Up @@ -880,7 +945,81 @@ struct EditorView: NSViewRepresentable {
let fullRange = NSRange(location: 0, length: storage.length)
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: fullRange)
matchRanges = []
lastMatches = []
currentMatchIdx = 0
}

// MARK: - Replace

func performReplaceCurrent() {
guard let textView, let findState,
!lastMatches.isEmpty,
currentMatchIdx < lastMatches.count else { return }
let match = lastMatches[currentMatchIdx]
let storage = textView.textStorage!
guard match.range.upperBound <= storage.length else { return }
let replacement = ReplaceEngine.substitution(for: match,
in: textView.string,
template: findState.replacementText,
isRegex: findState.useRegex)
guard textView.shouldChangeText(in: match.range, replacementString: replacement) else { return }
let resumeLocation = match.range.location + (replacement as NSString).length
textView.replaceCharacters(in: match.range, with: replacement)
textView.didChangeText()
findState.lastReplaceCount = nil
// Silent rescan — advanceToMatchAtOrAfter handles the single
// applyFindHighlights + scroll, so we don't double-paint.
recomputeMatches()
advanceToMatchAtOrAfter(location: resumeLocation)
}

/// After a single-match replace, jump to the first remaining match at or
/// after `location`. Skips any new match the replacement string may have
/// introduced inside its own range, so consecutive Replace presses walk
/// forward instead of cycling on the same spot.
private func advanceToMatchAtOrAfter(location: Int) {
guard !lastMatches.isEmpty else {
DispatchQueue.main.async { [weak self] in
guard let self, let findState = self.findState, findState.activeMode == .edit else { return }
findState.currentIndex = 0
}
return
}
let nextIdx = lastMatches.firstIndex(where: { $0.range.location >= location }) ?? 0
currentMatchIdx = nextIdx
applyFindHighlights()
textView?.scrollRangeToVisible(matchRanges[currentMatchIdx])
let publishedIdx = currentMatchIdx + 1
DispatchQueue.main.async { [weak self] in
guard let self, let findState = self.findState, findState.activeMode == .edit else { return }
findState.currentIndex = publishedIdx
}
}

func performReplaceAll() {
guard let textView, let findState, !lastMatches.isEmpty else { return }
let oldText = textView.string
let nsOldText = oldText as NSString
let newText = ReplaceEngine.applyAll(matches: lastMatches,
in: oldText,
template: findState.replacementText,
isRegex: findState.useRegex)
let fullRange = NSRange(location: 0, length: nsOldText.length)
guard textView.shouldChangeText(in: fullRange, replacementString: newText) else { return }
let replaceCount = lastMatches.count
textView.replaceCharacters(in: fullRange, with: newText)
textView.didChangeText()
DispatchQueue.main.async { [weak findState] in
findState?.lastReplaceCount = replaceCount
}
// Clear the "Replaced N occurrences" label after a beat so it
// doesn't linger across an unrelated session. If a later action
// already changed `lastReplaceCount`, leave that one alone.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak findState] in
if findState?.lastReplaceCount == replaceCount {
findState?.lastReplaceCount = nil
}
}
}
}
}
Loading
Loading