Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

highlight search query in 'Open Quickly' results #1790

Merged
merged 17 commits into from
Jul 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
58 changes: 35 additions & 23 deletions CodeEdit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

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

/// The id of the ``CEWorkspaceFile``.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by Alex on 25.05.2022.
//

import Foundation
import SwiftUI

/// Simple state class for command palette view. Contains currently selected command,
/// query text and list of filtered commands
Expand Down Expand Up @@ -35,4 +35,17 @@ final class QuickActionsViewModel: ObservableObject {
self.filteredCommands = CommandManager.shared.commands.filter { $0.title.localizedCaseInsensitiveContains(val) }
self.selected = self.filteredCommands.first
}

func highlight(_ commandTitle: String) -> NSAttributedString {
let attribText = NSMutableAttributedString(string: commandTitle)
let range: NSRange = attribText.mutableString.range(
of: self.commandQuery,
options: NSString.CompareOptions.caseInsensitive
)
attribText.addAttribute(.foregroundColor, value: NSColor(Color(.labelColor)), range: range)
attribText.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), range: range)

return attribText
}

}
52 changes: 6 additions & 46 deletions CodeEdit/Features/Commands/Views/QuickActionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,19 @@ struct QuickActionsView: View {
}

var body: some View {
SearchPanelView<SearchResultLabel, EmptyView, Command>(
SearchPanelView<QuickSearchResultLabel, EmptyView, Command>(
title: "Commands",
image: Image(systemName: "magnifyingglass"),
options: $state.filteredCommands,
text: $state.commandQuery,
alwaysShowOptions: true,
optionRowHeight: 30
) { command in
SearchResultLabel(labelName: command.title, textToMatch: state.commandQuery)
QuickSearchResultLabel(
labelName: command.title,
charactersToHighlight: [],
nsLabelName: state.highlight(command.title)
)
} onRowClick: { command in
callHandler(command: command)
} onClose: {
Expand All @@ -62,47 +66,3 @@ struct QuickActionsView: View {
}
}
}

/// Implementation of command palette entity. While swiftui does not allow to use NSMutableAttributeStrings,
/// the only way to fallback to UIKit and have NSViewRepresentable to be a bridge between UIKit and SwiftUI.
/// Highlights currently entered text query

struct SearchResultLabel: NSViewRepresentable {

var labelName: String
var textToMatch: String

public func makeNSView(context: Context) -> some NSTextField {
let label = NSTextField(wrappingLabelWithString: labelName)
label.translatesAutoresizingMaskIntoConstraints = false
label.drawsBackground = false
label.textColor = .labelColor
label.isEditable = false
label.isSelectable = false
label.font = .labelFont(ofSize: 13)
label.allowsDefaultTighteningForTruncation = false
label.cell?.truncatesLastVisibleLine = true
label.cell?.wraps = true
label.maximumNumberOfLines = 1
label.attributedStringValue = highlight()
return label
}

func highlight() -> NSAttributedString {
let attribText = NSMutableAttributedString(string: self.labelName)
let range: NSRange = attribText.mutableString.range(
of: self.textToMatch,
options: NSString.CompareOptions.caseInsensitive
)
attribText.addAttribute(.foregroundColor, value: NSColor(Color(.labelColor)), range: range)
attribText.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize), range: range)

return attribText
}

func updateNSView(_ nsView: NSViewType, context: Context) {
nsView.textColor = textToMatch.isEmpty ? .labelColor : .secondaryLabelColor
nsView.attributedStringValue = highlight()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs
}

@IBAction func openQuickly(_ sender: Any) {
if let workspace, let state = workspace.quickOpenViewModel {
if let workspace, let state = workspace.openQuicklyViewModel {
if let quickOpenPanel {
if quickOpenPanel.isKeyWindow {
quickOpenPanel.close()
Expand All @@ -139,7 +139,7 @@ final class CodeEditWindowController: NSWindowController, NSToolbarDelegate, Obs
let panel = SearchPanel()
self.quickOpenPanel = panel

let contentView = QuickOpenView(state: state) {
let contentView = OpenQuicklyView(state: state) {
panel.close()
} openFile: { file in
workspace.editorManager.openTab(item: file)
Expand Down
4 changes: 2 additions & 2 deletions CodeEdit/Features/Documents/WorkspaceDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
var statusBarViewModel = StatusBarViewModel()
var utilityAreaModel = UtilityAreaViewModel()
var searchState: SearchState?
var quickOpenViewModel: QuickOpenViewModel?
var openQuicklyViewModel: OpenQuicklyViewModel?
var commandsPaletteState: QuickActionsViewModel?
var listenerModel: WorkspaceNotificationModel = .init()
var sourceControlManager: SourceControlManager?
Expand Down Expand Up @@ -123,7 +123,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
self.sourceControlManager = sourceControlManager
sourceControlManager.fileManager = workspaceFileManager
self.searchState = .init(self)
self.quickOpenViewModel = .init(fileURL: url)
self.openQuicklyViewModel = .init(fileURL: url)
self.commandsPaletteState = .init()

editorManager.restoreFromState(self)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// OpenQuicklyViewModel.swift
// CodeEditModules/QuickOpen
//
// Created by Marco Carnevali on 05/04/22.
//

import Combine
import Foundation
import CollectionConcurrencyKit

final class OpenQuicklyViewModel: ObservableObject {
@Published var query: String = ""
@Published var searchResults: [SearchResult] = []

let fileURL: URL
var runningTask: Task<Void, Never>?

init(fileURL: URL) {
self.fileURL = fileURL
}

/// This is used to populate the ``OpenQuicklyListItemView`` view which shows the search results to the user.
///
/// ``OpenQuicklyPreviewView`` also uses this to load the `fileUrl` for preview.
struct SearchResult: Identifiable, Hashable {
var id: String { fileURL.id }
let fileURL: URL
let matchedCharacters: [NSRange]

// This custom Hashable implementation prevents the highlighted
// selection from flickering when searching in 'Open Quickly'.
//
// See https://github.com/CodeEditApp/CodeEdit/pull/1790#issuecomment-2206832901
// for flickering visuals.
//
// Before commit 0e28b382f59184b7ebe5a7c3295afa3655b7d4e7, only the fileURL
// was retrieved from the search results and it worked as expected.
//
static func == (lhs: Self, rhs: Self) -> Bool { lhs.fileURL == rhs.fileURL }
func hash(into hasher: inout Hasher) { hasher.combine(fileURL) }
}

func fetchResults() {
let startTime = Date()
guard query != "" else {
searchResults = []
return
}

runningTask?.cancel()
runningTask = Task.detached(priority: .userInitiated) {
let enumerator = FileManager.default.enumerator(
at: self.fileURL,
includingPropertiesForKeys: [
.isRegularFileKey
],
options: [
.skipsPackageDescendants
]
)
if let filePaths = enumerator?.allObjects as? [URL] {
guard !Task.isCancelled else { return }
/// removes all filePaths which aren't regular files
let filteredFiles = filePaths.filter { url in
do {
let values = try url.resourceValues(forKeys: [.isRegularFileKey])
return (values.isRegularFile ?? false)
} catch {
return false
}
}

let fuzzySearchResults = await filteredFiles.fuzzySearch(
query: self.query.trimmingCharacters(in: .whitespaces)
).concurrentMap {
SearchResult(
fileURL: $0.item,
matchedCharacters: $0.result.matchedParts
)
}

guard !Task.isCancelled else { return }
await MainActor.run {
self.searchResults = fuzzySearchResults
print("Duration: \(Date().timeIntervalSince(startTime))")
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
//
// QuickOpenItem.swift
// OpenQuicklyListItemView.swift
// CodeEditModules/QuickOpen
//
// Created by Pavel Kasila on 20.03.22.
//

import SwiftUI

struct QuickOpenItem: View {
struct OpenQuicklyListItemView: View {
private let baseDirectory: URL
private let fileURL: URL
private let searchResult: OpenQuicklyViewModel.SearchResult

init(
baseDirectory: URL,
fileURL: URL
searchResult: OpenQuicklyViewModel.SearchResult
) {
self.baseDirectory = baseDirectory
self.fileURL = fileURL
self.searchResult = searchResult
}

var relativePathComponents: ArraySlice<String> {
return fileURL.pathComponents.dropFirst(baseDirectory.pathComponents.count).dropLast()
return searchResult.fileURL.pathComponents.dropFirst(baseDirectory.pathComponents.count).dropLast()
}

var body: some View {
HStack(spacing: 8) {
Image(nsImage: NSWorkspace.shared.icon(forFile: fileURL.path))
Image(nsImage: NSWorkspace.shared.icon(forFile: searchResult.fileURL.path))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 0) {
Text(fileURL.lastPathComponent).font(.system(size: 13))
.lineLimit(1)
QuickSearchResultLabel(
labelName: searchResult.fileURL.lastPathComponent,
charactersToHighlight: searchResult.matchedCharacters
)
Text(relativePathComponents.joined(separator: " ▸ "))
.font(.system(size: 10.5))
.foregroundColor(.secondary)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//
// QuickOpenPreviewView.swift
// OpenQuicklyPreviewView.swift
// CodeEditModules/QuickOpen
//
// Created by Pavel Kasila on 20.03.22.
//

import SwiftUI

struct QuickOpenPreviewView: View {
struct OpenQuicklyPreviewView: View {

private let queue = DispatchQueue(label: "app.codeedit.CodeEdit.quickOpen.preview")
private let item: CEWorkspaceFile
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// QuickOpenView.swift
// OpenQuicklyView.swift
// CodeEditModules/QuickOpen
//
// Created by Pavel Kasila on 20.03.22.
Expand All @@ -13,22 +13,22 @@ extension URL: Identifiable {
}
}

struct QuickOpenView: View {
struct OpenQuicklyView: View {
@EnvironmentObject private var workspace: WorkspaceDocument

private let onClose: () -> Void
private let openFile: (CEWorkspaceFile) -> Void

@ObservedObject private var state: QuickOpenViewModel
@ObservedObject private var openQuicklyViewModel: OpenQuicklyViewModel

@State private var selectedItem: CEWorkspaceFile?

init(
state: QuickOpenViewModel,
state: OpenQuicklyViewModel,
onClose: @escaping () -> Void,
openFile: @escaping (CEWorkspaceFile) -> Void
) {
self.state = state
self.openQuicklyViewModel = state
self.onClose = onClose
self.openFile = openFile
}
Expand All @@ -37,28 +37,31 @@ struct QuickOpenView: View {
SearchPanelView(
title: "Open Quickly",
image: Image(systemName: "magnifyingglass"),
options: $state.openQuicklyFiles,
text: $state.openQuicklyQuery,
options: $openQuicklyViewModel.searchResults,
text: $openQuicklyViewModel.query,
optionRowHeight: 40
) { file in
QuickOpenItem(baseDirectory: state.fileURL, fileURL: file)
} preview: { fileURL in
QuickOpenPreviewView(item: CEWorkspaceFile(url: fileURL))
} onRowClick: { fileURL in
) { searchResult in
OpenQuicklyListItemView(
baseDirectory: openQuicklyViewModel.fileURL,
searchResult: searchResult
)
} preview: { searchResult in
OpenQuicklyPreviewView(item: CEWorkspaceFile(url: searchResult.fileURL))
} onRowClick: { searchResult in
guard let file = workspace.workspaceFileManager?.getFile(
fileURL.relativePath,
searchResult.fileURL.relativePath,
createIfNotFound: true
) else {
return
}
openFile(file)
state.openQuicklyQuery = ""
openQuicklyViewModel.query = ""
onClose()
} onClose: {
onClose()
}
.onReceive(state.$openQuicklyQuery.debounce(for: 0.2, scheduler: DispatchQueue.main)) { _ in
state.fetchOpenQuickly()
.onReceive(openQuicklyViewModel.$query.debounce(for: 0.2, scheduler: DispatchQueue.main)) { _ in
openQuicklyViewModel.fetchResults()
}
}
}
Loading
Loading