@@ -25,6 +25,9 @@ struct ContentView: View {
2525 @StateObject private var sortPreferenceManager = SortPreferenceManager . shared
2626 @StateObject private var pasteHandler : PasteHandler
2727 @FocusState private var isSearchFocused : Bool
28+ @State private var isSyncing = false
29+ @State private var syncRotation : Double = 0
30+ @State private var syncStartTime : Date ?
2831
2932 @State private var windowDelegate = MainWindowDelegate ( )
3033
@@ -85,37 +88,53 @@ struct ContentView: View {
8588 if !searchQuery. isEmpty {
8689 let queryLower = searchQuery. lowercased ( )
8790 items = items. filter { item in
88- let tagIds : [ UUID ] = item. asBookmark? . tagIds ??
89- item. asImageItem? . tagIds ??
90- item. asTextItem? . tagIds ??
91- [ ]
92-
93- let tagMatch = dataStorage. tags. contains { tag in
94- tagIds. contains ( tag. id) && tag. name. lowercased ( ) . contains ( queryLower)
95- }
96-
97- if let bookmark = item. asBookmark {
98- return bookmark. title. lowercased ( ) . contains ( queryLower) ||
99- bookmark. url. lowercased ( ) . contains ( queryLower) ||
100- ( bookmark. notes? . lowercased ( ) . contains ( queryLower) ?? false ) ||
101- tagMatch
102- } else if let imageItem = item. asImageItem {
103- return imageItem. imagePath. lowercased ( ) . contains ( queryLower) ||
104- ( imageItem. notes? . lowercased ( ) . contains ( queryLower) ?? false ) ||
105- tagMatch
106- } else if let textItem = item. asTextItem {
107- return textItem. content. lowercased ( ) . contains ( queryLower) ||
108- ( textItem. notes? . lowercased ( ) . contains ( queryLower) ?? false ) ||
109- tagMatch
110- }
111- return false
91+ let searchable = searchableStrings ( for: item)
92+ return searchable. contains { $0. contains ( queryLower) }
11293 }
11394 }
11495
11596 // Apply sorting to all items uniformly
11697 return sortPreferenceManager. sortOption. sort ( items)
11798 }
11899
100+ private func tagNames( for item: AnyCollectionItem ) -> [ String ] {
101+ let tagIds : [ UUID ] = item. asBookmark? . tagIds ??
102+ item. asImageItem? . tagIds ??
103+ item. asTextItem? . tagIds ??
104+ [ ]
105+
106+ guard !tagIds. isEmpty else { return [ ] }
107+
108+ return dataStorage. tags
109+ . filter { tagIds. contains ( $0. id) }
110+ . map { $0. name. lowercased ( ) }
111+ }
112+
113+ private func searchableStrings( for item: AnyCollectionItem ) -> [ String ] {
114+ var fields : [ String ] = [ ]
115+
116+ if let bookmark = item. asBookmark {
117+ fields. append ( bookmark. title. lowercased ( ) )
118+ fields. append ( bookmark. url. lowercased ( ) )
119+ if let notes = bookmark. notes {
120+ fields. append ( notes. lowercased ( ) )
121+ }
122+ } else if let imageItem = item. asImageItem {
123+ fields. append ( imageItem. imagePath. lowercased ( ) )
124+ if let notes = imageItem. notes {
125+ fields. append ( notes. lowercased ( ) )
126+ }
127+ } else if let textItem = item. asTextItem {
128+ fields. append ( textItem. content. lowercased ( ) )
129+ if let notes = textItem. notes {
130+ fields. append ( notes. lowercased ( ) )
131+ }
132+ }
133+
134+ fields. append ( contentsOf: tagNames ( for: item) )
135+ return fields
136+ }
137+
119138 var body : some View {
120139 NavigationSplitView {
121140 SidebarView (
@@ -176,6 +195,22 @@ struct ContentView: View {
176195 . padding ( . vertical, 5 )
177196 . background ( Color ( NSColor . controlBackgroundColor) )
178197 . cornerRadius ( 6 )
198+
199+ // Sync button
200+ Button ( action: performManualSync) {
201+ Image ( systemName: " arrow.triangle.2.circlepath " )
202+ . rotationEffect ( . degrees( syncRotation) )
203+ . animation ( isSyncing
204+ ? . linear( duration: 1.0 ) . repeatForever ( autoreverses: false )
205+ : . default,
206+ value: isSyncing)
207+ . padding ( . horizontal, 10 )
208+ . padding ( . vertical, 6 )
209+ . background ( Color ( NSColor . controlBackgroundColor) )
210+ . cornerRadius ( 6 )
211+ }
212+ . buttonStyle ( . borderless)
213+ . help ( " Sync " )
179214 }
180215
181216 ToolbarItemGroup ( placement: . automatic) {
@@ -351,6 +386,12 @@ struct ContentView: View {
351386 . onReceive ( NotificationCenter . default. publisher ( for: NSNotification . Name ( " ShowAddText " ) ) ) { _ in
352387 showingAddText = true
353388 }
389+ . onReceive ( NotificationCenter . default. publisher ( for: . autoSyncStarted) ) { _ in
390+ startSyncAnimation ( )
391+ }
392+ . onReceive ( NotificationCenter . default. publisher ( for: . autoSyncEnded) ) { _ in
393+ stopSyncAnimation ( )
394+ }
354395 . onPasteCommand ( of: [ . url, . image, . plainText] ) { providers in
355396 pasteHandler. handlePaste ( providers: providers)
356397 }
@@ -375,6 +416,46 @@ struct ContentView: View {
375416 }
376417 . frame ( maxWidth: . infinity, maxHeight: . infinity)
377418 }
419+
420+ private func performManualSync( ) {
421+ guard !isSyncing else { return }
422+ startSyncAnimation ( )
423+
424+ Task { @MainActor in
425+ // Force save triggers autoSync notifications; we handle animation stop with minimum duration.
426+ DataStorage . shared. forceSaveAllData ( )
427+ stopSyncAnimation ( )
428+ }
429+ }
430+
431+ private func startSyncAnimation( ) {
432+ guard !isSyncing else { return }
433+ isSyncing = true
434+ syncRotation = 0
435+ syncStartTime = Date ( )
436+ syncRotation += 360
437+ }
438+
439+ private func stopSyncAnimation( ) {
440+ guard let start = syncStartTime else {
441+ isSyncing = false
442+ syncRotation = 0
443+ return
444+ }
445+ let elapsed = Date ( ) . timeIntervalSince ( start)
446+ let minimumDuration : TimeInterval = 1.0
447+ if elapsed >= minimumDuration {
448+ isSyncing = false
449+ syncRotation = 0
450+ } else {
451+ let remaining = minimumDuration - elapsed
452+ Task { @MainActor in
453+ try ? await Task . sleep ( nanoseconds: UInt64 ( remaining * 1_000_000_000 ) )
454+ isSyncing = false
455+ syncRotation = 0
456+ }
457+ }
458+ }
378459}
379460
380461#Preview {
0 commit comments