Skip to content
Draft
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
4 changes: 4 additions & 0 deletions macOS/Synapse Notes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@
FBDE6F50B234E8BEEC81D70B /* GitServiceConflictsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */; };
FD832B5AFBA2E311DEB0EF87 /* CommandPaletteScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFDA7432187B97B46732652 /* CommandPaletteScoringTests.swift */; };
FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */; };
C91E22F83BC54D67E8F00201 /* StringSearchMatchRangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82F33E94CD65E78F9F11313 /* StringSearchMatchRangesTests.swift */; };
FFB6DAEB709AE113A8039AC1 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA805991148562D94A36617 /* CommandPaletteView.swift */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -288,6 +289,7 @@
2AF93A428A55CD52A1488410 /* AppThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppThemeTests.swift; sourceTree = "<group>"; };
3642C0B4C5DF51584C3FB7C7 /* AppStateContentChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateContentChangeTests.swift; sourceTree = "<group>"; };
3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiLinkClickTests.swift; sourceTree = "<group>"; };
D82F33E94CD65E78F9F11313 /* StringSearchMatchRangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringSearchMatchRangesTests.swift; sourceTree = "<group>"; };
3B5764CC787B08BA6E1D64B4 /* MarkdownPreviewRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewRendererTests.swift; sourceTree = "<group>"; };
3DA805991148562D94A36617 /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = "<group>"; };
3DB8C6CC66C5265161E6B171 /* CalendarDayActivityCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculatorTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -668,6 +670,7 @@
2080178498715BA2E9CE7F9C /* VaultIndexTests.swift */,
46245289C81F5BC71F3DAA8D /* VaultRootResolverTests.swift */,
3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */,
D82F33E94CD65E78F9F11313 /* StringSearchMatchRangesTests.swift */,
);
path = SynapseNotesTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -1088,6 +1091,7 @@
5D395266B546904502376A52 /* VaultIndexTests.swift in Sources */,
2C5DA5CE689E982A146800B0 /* VaultRootResolverTests.swift in Sources */,
FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */,
C91E22F83BC54D67E8F00201 /* StringSearchMatchRangesTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
6 changes: 6 additions & 0 deletions macOS/SynapseNotes/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,10 @@ class AppState: ObservableObject {
// Current-file find state (shared so CMD-G works globally)
@Published var searchQuery: String = ""
@Published var searchMatchIndex: Int = 0
/// Number of matches that have highlight ranges (capped); used for ⌘G navigation wrapping.
@Published var searchMatchCount: Int = 0
/// Full-document match count for the current query (may exceed `searchMatchCount` when over highlight cap).
@Published var searchMatchTotal: Int = 0
@Published var isReplaceVisible: Bool = false
@Published var replaceText: String = ""

Expand Down Expand Up @@ -2279,6 +2282,9 @@ class AppState: ObservableObject {
isSearchPresented = false
isReplaceVisible = false
replaceText = ""
searchMatchTotal = 0
searchMatchIndex = 0
searchMatchCount = 0
}

func presentRootNoteSheet(in directory: URL? = nil) {
Expand Down
57 changes: 36 additions & 21 deletions macOS/SynapseNotes/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,12 @@ struct RawEditor: NSViewRepresentable {
// Access the binding directly from RawEditor
self.selectedEmbedID = embedID
}
textView.onMatchCountUpdate = participatesInGlobalEditorCommands ? { count in appState.searchMatchCount = count } : nil
textView.onMatchCountUpdate = participatesInGlobalEditorCommands
? { highlighted, total in
appState.searchMatchCount = highlighted
appState.searchMatchTotal = total
}
: nil
textView.participatesInGlobalSearch = participatesInGlobalEditorCommands
textView.onActivatePane = isEditable ? nil : { appState.focusPane(paneIndex) }
textView.refreshInlineImagePreviews()
Expand Down Expand Up @@ -2228,7 +2233,8 @@ class LinkAwareTextView: NSTextView {
var onOpenExternalURL: ((URL) -> Void)? // External URL opening (defaults to NSWorkspace)
var onSelectEmbed: ((String) -> Void)? // embed ID when clicking on markdown
var currentFileURL: URL?
var onMatchCountUpdate: ((Int) -> Void)?
/// `(highlightedMatchCount, totalMatchCount)` — highlights cap at 2000 ranges; total is full-document count.
var onMatchCountUpdate: ((Int, Int) -> Void)?
/// Only the editor participating in global commands (current focused note) should react to
/// find/replace notifications that mutate text. Mirrors `onMatchCountUpdate` gating.
var participatesInGlobalSearch: Bool = false
Expand Down Expand Up @@ -2661,15 +2667,7 @@ class LinkAwareTextView: NSTextView {
return
}
let content = storage.string
let needle = query.lowercased()
var matches: [NSRange] = []
var searchStart = content.startIndex
while searchStart < content.endIndex,
let range = content.range(of: needle, options: .caseInsensitive, range: searchStart..<content.endIndex) {
matches.append(NSRange(range, in: content))
searchStart = range.upperBound
if matches.count > 2000 { break }
}
let (matches, totalMatches) = content.synapseSearchMatchRanges(caseInsensitive: query, maxStored: 2000)

let dimHighlight = NSColor.yellow.withAlphaComponent(0.30)
let focusHighlight = NSColor.yellow
Expand All @@ -2694,8 +2692,8 @@ class LinkAwareTextView: NSTextView {
lastSearchHighlightRanges = matches
lastSearchFocusIndex = focusIndex

// Report match count back to SwiftUI
onMatchCountUpdate?(matches.count)
// Report navigable (highlighted) count and true total so Replace All scope is never understated.
onMatchCountUpdate?(matches.count, totalMatches)

// Scroll focused match into view (don't select — selection rendering overwrites highlight attributes)
if matches.indices.contains(focusIndex) {
Expand All @@ -2714,6 +2712,7 @@ class LinkAwareTextView: NSTextView {
storage.endEditing()
lastSearchHighlightRanges = []
lastSearchFocusIndex = -1
onMatchCountUpdate?(0, 0)
applyMarkdownStyling()
}

Expand Down Expand Up @@ -2773,14 +2772,7 @@ class LinkAwareTextView: NSTextView {
private func nextMatchIndex(forQuery query: String, after location: Int) -> Int {
guard let storage = textStorage else { return 0 }
let content = storage.string
var matches: [NSRange] = []
var searchStart = content.startIndex
while searchStart < content.endIndex,
let r = content.range(of: query, options: .caseInsensitive, range: searchStart..<content.endIndex) {
matches.append(NSRange(r, in: content))
searchStart = r.upperBound
if matches.count > 2000 { break }
}
let (matches, _) = content.synapseSearchMatchRanges(caseInsensitive: query, maxStored: 2000)
if matches.isEmpty { return 0 }
if let idx = matches.firstIndex(where: { $0.location >= location }) {
return idx
Expand Down Expand Up @@ -5542,3 +5534,26 @@ struct MarkdownPreviewView: NSViewRepresentable {
"""
}
}

// MARK: - Find in note: match scan (capped highlight list + true total)

extension String {
/// Case-insensitive search: returns at most `maxStored` UTF-16 ranges for highlighting and
/// the full number of matches (used so Replace All cannot mislead when total exceeds the cap).
func synapseSearchMatchRanges(caseInsensitive query: String, maxStored: Int) -> (ranges: [NSRange], total: Int) {
guard !query.isEmpty else { return ([], 0) }
let needle = query.lowercased()
var matches: [NSRange] = []
var totalMatches = 0
var searchStart = startIndex
while searchStart < endIndex,
let range = range(of: needle, options: .caseInsensitive, range: searchStart..<endIndex) {
totalMatches += 1
if matches.count < maxStored {
matches.append(NSRange(range, in: self))
}
searchStart = range.upperBound
}
return (matches, totalMatches)
}
}
14 changes: 11 additions & 3 deletions macOS/SynapseNotes/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,18 @@ struct FileSearchResult: Identifiable {
appState.searchQuery = ""
appState.searchMatchIndex = 0
appState.searchMatchCount = 0
appState.searchMatchTotal = 0
}
}

private var matchCountLabel: String {
if appState.searchMatchCount == 0 { return "No matches" }
if appState.searchMatchTotal > appState.searchMatchCount {
return "\(appState.searchMatchIndex + 1) / \(appState.searchMatchCount) shown (\(appState.searchMatchTotal) total)"
}
return "\(appState.searchMatchIndex + 1) / \(appState.searchMatchCount)"
}

// MARK: Subviews

private var findRow: some View {
Expand All @@ -130,12 +139,11 @@ struct FileSearchResult: Identifiable {
.onSubmit { advance(by: 1) }

if !appState.searchQuery.isEmpty {
Text(appState.searchMatchCount == 0
? "No matches"
: "\(appState.searchMatchIndex + 1) / \(appState.searchMatchCount)")
Text(matchCountLabel)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(SynapseTheme.textMuted)
.animation(.none, value: appState.searchMatchIndex)
.animation(.none, value: appState.searchMatchTotal)
.fixedSize()

HStack(spacing: 2) {
Expand Down
24 changes: 24 additions & 0 deletions macOS/SynapseNotesTests/StringSearchMatchRangesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import XCTest
@testable import Synapse

final class StringSearchMatchRangesTests: XCTestCase {

func test_totalExceedsCap_countsAllStoresOnlyCap() {
let s = String(repeating: "a", count: 2500)
let (ranges, total) = s.synapseSearchMatchRanges(caseInsensitive: "a", maxStored: 2000)
XCTAssertEqual(total, 2500)
XCTAssertEqual(ranges.count, 2000)
}

func test_emptyQuery_returnsEmpty() {
let (ranges, total) = "hello".synapseSearchMatchRanges(caseInsensitive: "", maxStored: 2000)
XCTAssertEqual(total, 0)
XCTAssertTrue(ranges.isEmpty)
}

func test_caseInsensitive_matchesLiteral() {
let (ranges, total) = "AaA".synapseSearchMatchRanges(caseInsensitive: "a", maxStored: 10)
XCTAssertEqual(total, 3)
XCTAssertEqual(ranges.count, 3)
}
}
Loading