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
138 changes: 92 additions & 46 deletions TermiNotesAppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ class TerminalSanitizer {

// MARK: - Toggle List Model

/// A Notion-style toggle: `titleLine` stays visible, lines titleLine+1...bodyEndLine fold away.
/// Line numbers are 0-based global line indexes into the canvas; adjusted on every text edit
/// by the ledger in TermiNotesController.textStorage(didProcessEditing:).
/// A Notion-style toggle: `titleLine` stays visible; EVERYTHING below it — down to the
/// next toggle's title (or end of document) — folds away. The body end is IMPLICIT, like
/// Notion's container: no boundary to maintain, and anything typed/pasted underneath the
/// title is automatically inside. Line numbers are 0-based global line indexes, adjusted
/// on every text edit by the ledger in TermiNotesController.textStorage(didProcessEditing:).
/// (Old toggles.json entries with an explicit bodyEndLine still decode — extra keys ignored.)
struct ToggleEntry: Codable {
var titleLine: Int
var bodyEndLine: Int
var collapsed: Bool
}

Expand Down Expand Up @@ -375,7 +377,7 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
}

func textDidChange(_ notification: Notification) {
saveContent()
scheduleSave()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Toggles persist ahead of notes

High Severity

textDidChange still writes toggles.json on every edit while notes.txt is deferred by up to one second via scheduleSave. If the process exits before a flush (crash, force quit, kill), disk toggles match the live buffer but the canvas file does not, so reload pairs stale text with shifted title-line metadata.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d50547b. Configure here.

if !toggles.isEmpty || foldsRendered {
saveToggles()
renderToggles()
Expand All @@ -384,6 +386,24 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
}
}

/// Coalesce per-keystroke writes: the canvas is saved at most once per second
/// instead of on every keystroke (rewriting a 430KB+ file per keystroke was the
/// real performance cost). Flushed synchronously via flushSave() on quit.
private var saveWorkItem: DispatchWorkItem?

func scheduleSave() {
saveWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in self?.saveContent() }
saveWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work)
}

func flushSave() {
saveWorkItem?.cancel()
saveWorkItem = nil
saveContent()
}

func updateWidth() {
guard let textContainer = textView.textContainer,
let layoutManager = textView.layoutManager else { return }
Expand Down Expand Up @@ -429,11 +449,11 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
if textView.shouldChangeText(in: range, replacementString: "") {
textView.textStorage?.replaceCharacters(in: range, with: "")
textView.didChangeText()
saveContent()
flushSave()
}
}
@objc func quitApp() {
saveContent()
flushSave()
NSApplication.shared.terminate(nil)
}

Expand Down Expand Up @@ -475,9 +495,22 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
return NSRange(location: ra.location, length: rb.upperBound - ra.location)
}

/// The implicit body end (inclusive): the line before the next toggle's title,
/// or the last line of the document. `i` is the index into `toggles` (sorted).
func bodyEnd(forToggleAt i: Int) -> Int {
let s = textView.string as NSString
let lastLine = lineIndex(ofChar: s.length, in: s)
guard i >= 0, i < toggles.count else { return lastLine }
let next = i + 1 < toggles.count ? toggles[i + 1].titleLine - 1 : lastLine
return min(next, lastLine)
}

func toggle(containing line: Int) -> ToggleEntry? {
guard line >= 0 else { return nil }
return toggles.first { line >= $0.titleLine && line <= $0.bodyEndLine }
for (i, t) in toggles.enumerated() where line >= t.titleLine {
if line <= bodyEnd(forToggleAt: i) { return t }
}
return nil
}

func hasSelection() -> Bool {
Expand Down Expand Up @@ -508,10 +541,11 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
}

guard lastBodyLine > title else { return }
// v1: no nesting — a new toggle flattens any it overlaps.
toggles.removeAll { $0.titleLine <= lastBodyLine && $0.bodyEndLine >= title }
// Same-line titles replace; a new title inside another toggle's body simply
// splits it (the body above now ends at this title — implicit ends).
toggles.removeAll { $0.titleLine == title }
// Created expanded (Notion behavior): the body stays visible until the user collapses it.
toggles.append(ToggleEntry(titleLine: title, bodyEndLine: lastBodyLine, collapsed: false))
toggles.append(ToggleEntry(titleLine: title, collapsed: false))
toggles.sort { $0.titleLine < $1.titleLine }
saveToggles()
renderToggles()
Expand All @@ -523,13 +557,17 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD

@discardableResult
func flipToggle(atLine line: Int, titleOnly: Bool = false) -> Bool {
guard line >= 0, let i = toggles.firstIndex(where: {
line >= $0.titleLine && line <= $0.bodyEndLine && (!titleOnly || line == $0.titleLine)
}) else { return false }
toggles[i].collapsed.toggle()
saveToggles()
renderToggles()
return true
guard line >= 0 else { return false }
for (i, t) in toggles.enumerated() {
let inRange = titleOnly ? (line == t.titleLine) : (line >= t.titleLine && line <= bodyEnd(forToggleAt: i))
if inRange {
toggles[i].collapsed.toggle()
saveToggles()
renderToggles()
return true
}
}
return false
}

@objc func flipContextToggle(_ sender: Any?) {
Expand All @@ -538,21 +576,21 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD

@objc func removeContextToggle(_ sender: Any?) {
let line = textView.contextMenuLine
let before = toggles.count
toggles.removeAll { $0.titleLine <= line && $0.bodyEndLine >= line }
if toggles.count != before {
saveToggles()
renderToggles()
}
guard let t = toggle(containing: line) else { return }
toggles.removeAll { $0.titleLine == t.titleLine }
saveToggles()
renderToggles()
}

/// Cmd+\ — flip the toggle containing the caret, if any.
@objc func toggleFold(_ sender: Any?) {
_ = flipToggle(atLine: lineIndex(ofChar: textView.selectedRange().location))
}

/// Line-number ledger: shift toggle boundaries on every edit so folds track the text.
/// Runs in didProcessEditing (pre-save), against lastString (pre-edit snapshot).
/// Line-number ledger: shift toggle TITLE lines on every edit so folds track the text.
/// Body ends are implicit (next title / EOF), so the only state to maintain is the
/// title line itself — no per-edit boundary bookkeeping. Runs in didProcessEditing
/// (pre-save), against lastString (pre-edit snapshot).
func adjustToggles(editedRange: NSRange, changeInLength delta: Int) {
let newS = textView.string as NSString
let oldS = lastString as NSString
Expand All @@ -564,36 +602,37 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
let oldEditEndLine = lineIndex(ofChar: preRange.upperBound, in: oldS)
let newEditEndLine = lineIndex(ofChar: min(editedRange.upperBound, newS.length), in: newS)
let shift = newEditEndLine - oldEditEndLine
let lastLine = lineIndex(ofChar: newS.length, in: newS)

var out: [ToggleEntry] = []
out.reserveCapacity(toggles.count)
for var t in toggles {
if t.bodyEndLine < editStartLine {
out.append(t) // entirely before the edit
} else if isInsertion {
// Nothing destroyed: shift the toggle only if the insertion lands
// at or before the title line's first character.
if isInsertion {
// Nothing destroyed: shift the title only if the insertion lands
// at or before the title line's first character. Anything below the
// title is automatically inside the toggle (implicit end).
let titleStart = charRangeOfLine(t.titleLine)?.location ?? Int.max
if editedRange.location <= titleStart {
t.titleLine += shift
t.bodyEndLine += shift
} else {
t.bodyEndLine += shift
}
out.append(t)
} else if t.titleLine > oldEditEndLine {
t.titleLine += shift // entirely after the replaced region
t.bodyEndLine += shift
out.append(t)
} else if t.titleLine >= editStartLine {
continue // title line destroyed — drop the toggle
// Title line was inside the replaced region — re-anchor, don't drop:
// the line now at editStartLine becomes the title. (newS.length == 0
// means the whole document was deleted — then the toggle dies too.)
t.titleLine = editStartLine
if t.titleLine <= lastLine && newS.length > 0 { out.append(t) }
} else {
// Body partially eaten: clamp or shift the body end.
t.bodyEndLine = t.bodyEndLine > oldEditEndLine ? t.bodyEndLine + shift : newEditEndLine
if t.bodyEndLine > t.titleLine { out.append(t) }
out.append(t) // edit below the title: nothing to maintain
}
}
toggles = out
// Re-anchoring can collapse ordering or merge two titles onto one line.
out.sort { $0.titleLine < $1.titleLine }
var seen = Set<Int>()
toggles = out.filter { seen.insert($0.titleLine).inserted }
}

func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) {
Expand All @@ -609,8 +648,8 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
let sel = textView.selectedRange()
guard sel.length == 0 else { return }
let line = lineIndex(ofChar: sel.location)
for t in toggles where t.collapsed {
if line > t.titleLine && line <= t.bodyEndLine {
for (i, t) in toggles.enumerated() where t.collapsed {
if line > t.titleLine && line <= bodyEnd(forToggleAt: i) {
if let tr = charRangeOfLine(t.titleLine) {
let pos = max(tr.location, tr.upperBound - 1)
if pos != sel.location {
Expand All @@ -627,9 +666,10 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
let arr = try? JSONDecoder().decode([ToggleEntry].self, from: data) else { return }
let s = textView.string as NSString
let maxLine = lineIndex(ofChar: s.length, in: s)
var seen = Set<Int>()
toggles = arr.compactMap { t in
guard t.titleLine >= 0, t.bodyEndLine > t.titleLine, t.titleLine <= maxLine else { return nil }
return ToggleEntry(titleLine: t.titleLine, bodyEndLine: min(t.bodyEndLine, maxLine), collapsed: t.collapsed)
guard t.titleLine >= 0, t.titleLine <= maxLine, seen.insert(t.titleLine).inserted else { return nil }
return ToggleEntry(titleLine: t.titleLine, collapsed: t.collapsed)
}.sorted { $0.titleLine < $1.titleLine }
}

Expand All @@ -648,13 +688,15 @@ class TermiNotesController: NSViewController, NSTextViewDelegate, NSTextStorageD
lm.removeTemporaryAttribute(.font, forCharacterRange: full)
lm.removeTemporaryAttribute(.foregroundColor, forCharacterRange: full)
foldsRendered = !toggles.isEmpty
for t in toggles {
for (i, t) in toggles.enumerated() {
guard let titleRange = charRangeOfLine(t.titleLine) else { continue }
let tint = NSRange(location: titleRange.location, length: max(0, titleRange.length - 1))
lm.addTemporaryAttribute(.backgroundColor,
value: NSColor.labelColor.withAlphaComponent(t.collapsed ? 0.14 : 0.07),
forCharacterRange: tint)
if t.collapsed, let bodyRange = charRange(fromLine: t.titleLine + 1, throughLine: t.bodyEndLine), bodyRange.length > 0 {
let end = bodyEnd(forToggleAt: i)
if t.collapsed, end > t.titleLine,
let bodyRange = charRange(fromLine: t.titleLine + 1, throughLine: end), bodyRange.length > 0 {
lm.addTemporaryAttribute(.font, value: NSFont.systemFont(ofSize: 0.1), forCharacterRange: bodyRange)
lm.addTemporaryAttribute(.foregroundColor, value: NSColor.clear, forCharacterRange: bodyRange)
}
Expand Down Expand Up @@ -919,6 +961,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
}

func applicationWillTerminate(_ notification: Notification) {
controller.flushSave()
}
}

// MARK: - Screenshot sidebar views
Expand Down
4 changes: 4 additions & 0 deletions docs/FAILURE_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- Screenshot thumbnail single-click now copies the entry's **file path** (the image copy was silent and indistinguishable from failure; text copy also can't boomerang into the image watcher). Double-click still inserts a markdown image link.
- Thumbnail clicks were being swallowed: `mouseDragged` started a drag session with **zero movement threshold**, so any click with even 1-2px of hand jitter became a micro-drag and `mouseUp` never fired. Fixed with a 4px drag threshold plus explicit down/drag state; verified with synthetic wobble-click (fires) vs real-drag (doesn't) through `NSApp.sendEvent`.
- Crash vector removed: `NSApp.currentEvent?.clickCount` throws `NSInternalInconsistencyException` when the current event is not a mouse event (reproduced with a synthetic `KeyUp`). Click count is now passed from the actual `mouseUp` event.
- Pasting with a selection that covers a toggle's title line **dropped the toggle** (chevron "disappears" while the title text remains): the ledger's only drop path fired on any title-touching edit. Replaced with re-anchoring — the line at the edit position becomes the title (first pasted line on replace, first surviving body line on delete); the toggle now dies only when title *and* body are both deleted. Locked by 13 replica ledger cases.
- Notion container semantics for the body: an insertion at the toggle's tail (Enter at the end of the last body line, or a paste on the line right after the body) now **joins the toggle** instead of falling outside it; replacements/deletions below never grow it. Locked by 17 replica ledger cases.
- **Implicit body end (the actual Notion model):** the explicit `bodyEndLine` is gone — a toggle's body is now *everything under its title down to the next toggle's title (or EOF)*. Anything typed/pasted underneath is automatically inside; the ledger maintains only title lines (shift on edits above, re-anchor on title edit, dedupe on merge), which removes the entire class of boundary bugs. Old `toggles.json` entries decode transparently (unknown keys ignored). Locked by 8 new-model replica cases.
- **Performance:** the canvas was being rewritten to disk on *every keystroke* (430KB+). Saves are now debounced/coalesced to at most once per second (`DispatchWorkItem`), flushed synchronously on quit / Clear / `applicationWillTerminate`.
- The reported "app dies when clicking Toggle List" was **not a crash**: the unified log shows `termination reported by launchd (0, 0, 0)` — a clean exit via the app's own Quit path (Quit button / `Cmd+Q`). All toggle logic paths (create/flip/ledger/containment/clear) were verified headlessly against the real 7534-line document.
- Toggle title is now the non-empty line directly above the selection; if none exists, a `Toggle` line is inserted. Previously the first selected line was consumed as the title.
- Screenshots taken with `Cmd+Shift+3/4` (save-to-file) were never captured because they bypass the pasteboard; the watcher now also polls the macOS screenshot save folder (`com.apple.screencapture location`, default `~/Desktop`) and imports new image files once their size is stable across two polls.
Expand Down