Skip to content

Commit a220b38

Browse files
authored
highlight search query in 'Open Quickly' results (#1790)
1 parent 373d477 commit a220b38

15 files changed

Lines changed: 243 additions & 179 deletions

File tree

CodeEdit.xcodeproj/project.pbxproj

Lines changed: 35 additions & 23 deletions
Large diffs are not rendered by default.

CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ import Combine
2626
/// loading all intermediate subdirectories (from the nearest cached parent to the file) has not been done yet and doing
2727
/// so would be unnecessary.
2828
///
29-
/// An example of this is in the ``QuickOpenView``. This view finds a file URL via a search bar, and needs to display a
30-
/// quick preview of the file. There's a good chance the file is deep in some subdirectory of the workspace, so fetching
31-
/// it from the ``CEWorkspaceFileManager`` may require loading and caching multiple directories. Instead, it just
32-
/// makes a disconnected object and uses it for the preview. Then, when opening the file in the workspace it forces the
33-
/// file to be loaded and cached.
29+
/// An example of this is in the ``OpenQuicklyView``. This view finds a file URL via a search bar, and needs to display
30+
/// a quick preview of the file. There's a good chance the file is deep in some subdirectory of the workspace, so
31+
/// fetching it from the ``CEWorkspaceFileManager`` may require loading and caching multiple directories. Instead, it
32+
/// just makes a disconnected object and uses it for the preview. Then, when opening the file in the workspace it
33+
/// forces the file to be loaded and cached.
3434
final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, EditorTabRepresentable {
3535

3636
/// The id of the ``CEWorkspaceFile``.

CodeEdit/Features/Commands/ViewModels/QuickActionsViewModel.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Created by Alex on 25.05.2022.
66
//
77

8-
import Foundation
8+
import SwiftUI
99

1010
/// Simple state class for command palette view. Contains currently selected command,
1111
/// query text and list of filtered commands
@@ -35,4 +35,17 @@ final class QuickActionsViewModel: ObservableObject {
3535
self.filteredCommands = CommandManager.shared.commands.filter { $0.title.localizedCaseInsensitiveContains(val) }
3636
self.selected = self.filteredCommands.first
3737
}
38+
39+
func highlight(_ commandTitle: String) -> NSAttributedString {
40+
let attribText = NSMutableAttributedString(string: commandTitle)
41+
let range: NSRange = attribText.mutableString.range(
42+
of: self.commandQuery,
43+
options: NSString.CompareOptions.caseInsensitive
44+
)
45+
attribText.addAttribute(.foregroundColor, value: NSColor(Color(.labelColor)), range: range)
46+
attribText.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), range: range)
47+
48+
return attribText
49+
}
50+
3851
}

CodeEdit/Features/Commands/Views/QuickActionsView.swift

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,19 @@ struct QuickActionsView: View {
4343
}
4444

4545
var body: some View {
46-
SearchPanelView<SearchResultLabel, EmptyView, Command>(
46+
SearchPanelView<QuickSearchResultLabel, EmptyView, Command>(
4747
title: "Commands",
4848
image: Image(systemName: "magnifyingglass"),
4949
options: $state.filteredCommands,
5050
text: $state.commandQuery,
5151
alwaysShowOptions: true,
5252
optionRowHeight: 30
5353
) { command in
54-
SearchResultLabel(labelName: command.title, textToMatch: state.commandQuery)
54+
QuickSearchResultLabel(
55+
labelName: command.title,
56+
charactersToHighlight: [],
57+
nsLabelName: state.highlight(command.title)
58+
)
5559
} onRowClick: { command in
5660
callHandler(command: command)
5761
} onClose: {
@@ -62,47 +66,3 @@ struct QuickActionsView: View {
6266
}
6367
}
6468
}
65-
66-
/// Implementation of command palette entity. While swiftui does not allow to use NSMutableAttributeStrings,
67-
/// the only way to fallback to UIKit and have NSViewRepresentable to be a bridge between UIKit and SwiftUI.
68-
/// Highlights currently entered text query
69-
70-
struct SearchResultLabel: NSViewRepresentable {
71-
72-
var labelName: String
73-
var textToMatch: String
74-
75-
public func makeNSView(context: Context) -> some NSTextField {
76-
let label = NSTextField(wrappingLabelWithString: labelName)
77-
label.translatesAutoresizingMaskIntoConstraints = false
78-
label.drawsBackground = false
79-
label.textColor = .labelColor
80-
label.isEditable = false
81-
label.isSelectable = false
82-
label.font = .labelFont(ofSize: 13)
83-
label.allowsDefaultTighteningForTruncation = false
84-
label.cell?.truncatesLastVisibleLine = true
85-
label.cell?.wraps = true
86-
label.maximumNumberOfLines = 1
87-
label.attributedStringValue = highlight()
88-
return label
89-
}
90-
91-
func highlight() -> NSAttributedString {
92-
let attribText = NSMutableAttributedString(string: self.labelName)
93-
let range: NSRange = attribText.mutableString.range(
94-
of: self.textToMatch,
95-
options: NSString.CompareOptions.caseInsensitive
96-
)
97-
attribText.addAttribute(.foregroundColor, value: NSColor(Color(.labelColor)), range: range)
98-
attribText.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), range: range)
99-
100-
return attribText
101-
}
102-
103-
func updateNSView(_ nsView: NSViewType, context: Context) {
104-
nsView.textColor = textToMatch.isEmpty ? .labelColor : .secondaryLabelColor
105-
nsView.attributedStringValue = highlight()
106-
}
107-
108-
}

CodeEdit/Features/Documents/Controllers/CodeEditWindowController.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs
126126
}
127127

128128
@IBAction func openQuickly(_ sender: Any) {
129-
if let workspace, let state = workspace.quickOpenViewModel {
129+
if let workspace, let state = workspace.openQuicklyViewModel {
130130
if let quickOpenPanel {
131131
if quickOpenPanel.isKeyWindow {
132132
quickOpenPanel.close()
@@ -139,7 +139,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs
139139
let panel = SearchPanel()
140140
self.quickOpenPanel = panel
141141

142-
let contentView = QuickOpenView(state: state) {
142+
let contentView = OpenQuicklyView(state: state) {
143143
panel.close()
144144
} openFile: { file in
145145
workspace.editorManager.openTab(item: file)

CodeEdit/Features/Documents/WorkspaceDocument.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
3434
var statusBarViewModel = StatusBarViewModel()
3535
var utilityAreaModel = UtilityAreaViewModel()
3636
var searchState: SearchState?
37-
var quickOpenViewModel: QuickOpenViewModel?
37+
var openQuicklyViewModel: OpenQuicklyViewModel?
3838
var commandsPaletteState: QuickActionsViewModel?
3939
var listenerModel: WorkspaceNotificationModel = .init()
4040
var sourceControlManager: SourceControlManager?
@@ -123,7 +123,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
123123
self.sourceControlManager = sourceControlManager
124124
sourceControlManager.fileManager = workspaceFileManager
125125
self.searchState = .init(self)
126-
self.quickOpenViewModel = .init(fileURL: url)
126+
self.openQuicklyViewModel = .init(fileURL: url)
127127
self.commandsPaletteState = .init()
128128

129129
editorManager.restoreFromState(self)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//
2+
// OpenQuicklyViewModel.swift
3+
// CodeEditModules/QuickOpen
4+
//
5+
// Created by Marco Carnevali on 05/04/22.
6+
//
7+
8+
import Combine
9+
import Foundation
10+
import CollectionConcurrencyKit
11+
12+
final class OpenQuicklyViewModel: ObservableObject {
13+
@Published var query: String = ""
14+
@Published var searchResults: [SearchResult] = []
15+
16+
let fileURL: URL
17+
var runningTask: Task<Void, Never>?
18+
19+
init(fileURL: URL) {
20+
self.fileURL = fileURL
21+
}
22+
23+
/// This is used to populate the ``OpenQuicklyListItemView`` view which shows the search results to the user.
24+
///
25+
/// ``OpenQuicklyPreviewView`` also uses this to load the `fileUrl` for preview.
26+
struct SearchResult: Identifiable, Hashable {
27+
var id: String { fileURL.id }
28+
let fileURL: URL
29+
let matchedCharacters: [NSRange]
30+
31+
// This custom Hashable implementation prevents the highlighted
32+
// selection from flickering when searching in 'Open Quickly'.
33+
//
34+
// See https://github.com/CodeEditApp/CodeEdit/pull/1790#issuecomment-2206832901
35+
// for flickering visuals.
36+
//
37+
// Before commit 0e28b382f59184b7ebe5a7c3295afa3655b7d4e7, only the fileURL
38+
// was retrieved from the search results and it worked as expected.
39+
//
40+
static func == (lhs: Self, rhs: Self) -> Bool { lhs.fileURL == rhs.fileURL }
41+
func hash(into hasher: inout Hasher) { hasher.combine(fileURL) }
42+
}
43+
44+
func fetchResults() {
45+
let startTime = Date()
46+
guard query != "" else {
47+
searchResults = []
48+
return
49+
}
50+
51+
runningTask?.cancel()
52+
runningTask = Task.detached(priority: .userInitiated) {
53+
let enumerator = FileManager.default.enumerator(
54+
at: self.fileURL,
55+
includingPropertiesForKeys: [
56+
.isRegularFileKey
57+
],
58+
options: [
59+
.skipsPackageDescendants
60+
]
61+
)
62+
if let filePaths = enumerator?.allObjects as? [URL] {
63+
guard !Task.isCancelled else { return }
64+
/// removes all filePaths which aren't regular files
65+
let filteredFiles = filePaths.filter { url in
66+
do {
67+
let values = try url.resourceValues(forKeys: [.isRegularFileKey])
68+
return (values.isRegularFile ?? false)
69+
} catch {
70+
return false
71+
}
72+
}
73+
74+
let fuzzySearchResults = await filteredFiles.fuzzySearch(
75+
query: self.query.trimmingCharacters(in: .whitespaces)
76+
).concurrentMap {
77+
SearchResult(
78+
fileURL: $0.item,
79+
matchedCharacters: $0.result.matchedParts
80+
)
81+
}
82+
83+
guard !Task.isCancelled else { return }
84+
await MainActor.run {
85+
self.searchResults = fuzzySearchResults
86+
print("Duration: \(Date().timeIntervalSince(startTime))")
87+
}
88+
}
89+
}
90+
}
91+
}

CodeEdit/Features/QuickOpen/ViewModels/URL+FuzzySearchable.swift renamed to CodeEdit/Features/OpenQuickly/ViewModels/URL+FuzzySearchable.swift

File renamed without changes.

CodeEdit/Features/QuickOpen/Views/NSTableViewWrapper.swift renamed to CodeEdit/Features/OpenQuickly/Views/NSTableViewWrapper.swift

File renamed without changes.

CodeEdit/Features/QuickOpen/Views/QuickOpenItem.swift renamed to CodeEdit/Features/OpenQuickly/Views/OpenQuicklyListItemView.swift

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,39 @@
11
//
2-
// QuickOpenItem.swift
2+
// OpenQuicklyListItemView.swift
33
// CodeEditModules/QuickOpen
44
//
55
// Created by Pavel Kasila on 20.03.22.
66
//
77

88
import SwiftUI
99

10-
struct QuickOpenItem: View {
10+
struct OpenQuicklyListItemView: View {
1111
private let baseDirectory: URL
12-
private let fileURL: URL
12+
private let searchResult: OpenQuicklyViewModel.SearchResult
1313

1414
init(
1515
baseDirectory: URL,
16-
fileURL: URL
16+
searchResult: OpenQuicklyViewModel.SearchResult
1717
) {
1818
self.baseDirectory = baseDirectory
19-
self.fileURL = fileURL
19+
self.searchResult = searchResult
2020
}
2121

2222
var relativePathComponents: ArraySlice<String> {
23-
return fileURL.pathComponents.dropFirst(baseDirectory.pathComponents.count).dropLast()
23+
return searchResult.fileURL.pathComponents.dropFirst(baseDirectory.pathComponents.count).dropLast()
2424
}
2525

2626
var body: some View {
2727
HStack(spacing: 8) {
28-
Image(nsImage: NSWorkspace.shared.icon(forFile: fileURL.path))
28+
Image(nsImage: NSWorkspace.shared.icon(forFile: searchResult.fileURL.path))
2929
.resizable()
3030
.aspectRatio(contentMode: .fit)
3131
.frame(width: 24, height: 24)
3232
VStack(alignment: .leading, spacing: 0) {
33-
Text(fileURL.lastPathComponent).font(.system(size: 13))
34-
.lineLimit(1)
33+
QuickSearchResultLabel(
34+
labelName: searchResult.fileURL.lastPathComponent,
35+
charactersToHighlight: searchResult.matchedCharacters
36+
)
3537
Text(relativePathComponents.joined(separator: ""))
3638
.font(.system(size: 10.5))
3739
.foregroundColor(.secondary)

0 commit comments

Comments
 (0)