Skip to content

Commit 5967466

Browse files
LeonardoLarranagaLeonardo Larrañagathecoolwinter
authored
Project navigator file filtering (#1896)
* Initial search, saving expanded items, added "No Filter Results" label. * Fixed SwiftLint issues. * Added bold text when searching * Fixed a possible crash. * Added primary and secondary label colors for search. * When there's no filter results, the root folder is hidden. * Update CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> * Resolved some conversations - Changed `workspace.filter` to `workspace.navigatorFilter` and added its respective comment. - Returned `StandardTableViewCell` to a weak reference of the workspace object. - Replaced the navigators own filter string with the workspace one and added throttle to the filter change. - When closing the window, if there is a search going on, all the items don't get saved expanded anymore. * Replaced the workspace filter with an optional string parameter in the cell class. --------- Co-authored-by: Leonardo Larrañaga <leonardolarranaga@icloud.com> Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
1 parent eb50bed commit 5967466

9 files changed

Lines changed: 217 additions & 24 deletions

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import LanguageServerProtocol
1414
@objc(WorkspaceDocument)
1515
final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
1616
@Published var sortFoldersOnTop: Bool = true
17+
/// A string used to filter the displayed files and folders in the project navigator area based on user input.
18+
@Published var navigatorFilter: String = ""
1719

1820
private var workspaceState: [String: Any] {
1921
get {

CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,19 @@ class FileSystemTableViewCell: StandardTableViewCell {
1515
var changeLabelSmallWidth: NSLayoutConstraint!
1616

1717
private let prefs = Settings.shared.preferences.general
18+
private var navigatorFilter: String?
1819

1920
/// Initializes the `OutlineTableViewCell` with an `icon` and `label`
2021
/// Both the icon and label will be colored, and sized based on the user's preferences.
2122
/// - Parameters:
2223
/// - frameRect: The frame of the cell.
2324
/// - item: The file item the cell represents.
2425
/// - isEditable: Set to true if the user should be able to edit the file name.
25-
init(frame frameRect: NSRect, item: CEWorkspaceFile?, isEditable: Bool = true) {
26+
/// - navigatorFilter: An optional string use to filter the navigator area.
27+
/// (Used for bolding and changing primary/secondary color).
28+
init(frame frameRect: NSRect, item: CEWorkspaceFile?, isEditable: Bool = true, navigatorFilter: String? = nil) {
2629
super.init(frame: frameRect, isEditable: isEditable)
30+
self.navigatorFilter = navigatorFilter
2731

2832
if let item = item {
2933
addIcon(item: item)
@@ -40,7 +44,57 @@ class FileSystemTableViewCell: StandardTableViewCell {
4044
fileItem = item
4145
imageView?.image = item.nsIcon
4246
imageView?.contentTintColor = color(for: item)
43-
textField?.stringValue = item.labelFileName()
47+
48+
let fileName = item.labelFileName()
49+
50+
guard let navigatorFilter else {
51+
textField?.stringValue = fileName
52+
return
53+
}
54+
55+
// Apply bold style if the filename matches the workspace filter
56+
if !navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
57+
let attributedString = NSMutableAttributedString(string: fileName)
58+
59+
// Check if the filename contains the filter text
60+
let range = NSString(string: fileName).range(of: navigatorFilter, options: .caseInsensitive)
61+
if range.location != NSNotFound {
62+
// Set the label color to secondary
63+
attributedString.addAttribute(
64+
.foregroundColor,
65+
value: NSColor.secondaryLabelColor,
66+
range: NSRange(location: 0, length: attributedString.length)
67+
)
68+
69+
// If the filter text matches, bold the matching text and set primary label color
70+
attributedString.addAttributes(
71+
[
72+
.font: NSFont.boldSystemFont(ofSize: textField?.font?.pointSize ?? 12),
73+
.foregroundColor: NSColor.labelColor
74+
],
75+
range: range
76+
)
77+
} else {
78+
// If no match, apply primary label color for parent folder,
79+
// or secondary label color for a non-matching file
80+
attributedString.addAttribute(
81+
.foregroundColor,
82+
value: item.isFolder ? NSColor.labelColor : NSColor.secondaryLabelColor,
83+
range: NSRange(location: 0, length: attributedString.length)
84+
)
85+
}
86+
87+
textField?.attributedStringValue = attributedString
88+
} else {
89+
// If no filter is applied, reset to normal font and primary label color
90+
textField?.attributedStringValue = NSAttributedString(
91+
string: fileName,
92+
attributes: [
93+
.font: NSFont.systemFont(ofSize: textField?.font?.pointSize ?? 12),
94+
.foregroundColor: NSColor.labelColor
95+
]
96+
)
97+
}
4498
}
4599

46100
func addModel() {

CodeEdit/Features/NavigatorArea/OutlineView/StandardTableViewCell.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class StandardTableViewCell: NSTableCellView {
2727
init(frame frameRect: NSRect, isEditable: Bool = true) {
2828
super.init(frame: frameRect)
2929
setupViews(frame: frameRect, isEditable: isEditable)
30+
3031
}
3132

3233
// Default init, assumes isEditable to be false

CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable {
6161
self?.controller?.updateSelection(itemID: itemID)
6262
}
6363
.store(in: &cancellables)
64+
workspace.$navigatorFilter
65+
.throttle(for: 0.1, scheduler: RunLoop.main, latest: true)
66+
.sink { [weak self] _ in self?.controller?.handleFilterChange() }
67+
.store(in: &cancellables)
6468
}
6569

6670
var cancellables: Set<AnyCancellable> = []

CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell {
2222
/// - frameRect: The frame of the cell.
2323
/// - item: The file item the cell represents.
2424
/// - isEditable: Set to true if the user should be able to edit the file name.
25+
/// - navigatorFilter: An optional string use to filter the navigator area.
26+
/// (Used for bolding and changing primary/secondary color).
2527
init(
2628
frame frameRect: NSRect,
2729
item: CEWorkspaceFile?,
2830
isEditable: Bool = true,
29-
delegate: OutlineTableViewCellDelegate? = nil
31+
delegate: OutlineTableViewCellDelegate? = nil,
32+
navigatorFilter: String? = nil
3033
) {
31-
super.init(frame: frameRect, item: item, isEditable: isEditable)
34+
super.init(frame: frameRect, item: item, isEditable: isEditable, navigatorFilter: navigatorFilter)
3235
self.textField?.setAccessibilityIdentifier("ProjectNavigatorTableViewCell-\(item?.name ?? "")")
3336
self.delegate = delegate
3437
}

CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,31 @@
88
import AppKit
99

1010
extension ProjectNavigatorViewController: NSOutlineViewDataSource {
11+
/// Retrieves the children of a given item for the outline view, applying the current filter if necessary.
12+
private func getOutlineViewItems(for item: CEWorkspaceFile) -> [CEWorkspaceFile] {
13+
if let cachedChildren = filteredContentChildren[item] {
14+
return cachedChildren
15+
}
16+
17+
if let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
18+
let filteredChildren = children.filter { fileSearchMatches(workspace?.navigatorFilter ?? "", for: $0) }
19+
filteredContentChildren[item] = filteredChildren
20+
return filteredChildren
21+
}
22+
23+
return []
24+
}
25+
1126
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
1227
if let item = item as? CEWorkspaceFile {
13-
return item.isFolder ? workspace?.workspaceFileManager?.childrenOfFile(item)?.count ?? 0 : 0
28+
return getOutlineViewItems(for: item).count
1429
}
1530
return content.count
1631
}
1732

1833
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
19-
if let item = item as? CEWorkspaceFile,
20-
let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
21-
return children[index]
34+
if let item = item as? CEWorkspaceFile {
35+
return getOutlineViewItems(for: item)[index]
2236
}
2337
return content[index]
2438
}

CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate {
2424
guard let tableColumn else { return nil }
2525

2626
let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: rowHeight)
27-
28-
return ProjectNavigatorTableViewCell(frame: frameRect, item: item as? CEWorkspaceFile, delegate: self)
27+
let cell = ProjectNavigatorTableViewCell(
28+
frame: frameRect,
29+
item: item as? CEWorkspaceFile,
30+
delegate: self,
31+
navigatorFilter: workspace?.navigatorFilter
32+
)
33+
return cell
2934
}
3035

3136
func outlineViewSelectionDidChange(_ notification: Notification) {
@@ -49,8 +54,14 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate {
4954
}
5055

5156
func outlineViewItemDidExpand(_ notification: Notification) {
52-
guard let id = workspace?.editorManager?.activeEditor.selectedTab?.file.id,
53-
let item = workspace?.workspaceFileManager?.getFile(id, createIfNotFound: true),
57+
/// Save expanded items' state to restore when finish filtering.
58+
guard let workspace else { return }
59+
if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile {
60+
expandedItems.insert(item)
61+
}
62+
63+
guard let id = workspace.editorManager?.activeEditor.selectedTab?.file.id,
64+
let item = workspace.workspaceFileManager?.getFile(id, createIfNotFound: true),
5465
/// update outline selection only if the parent of selected item match with expanded item
5566
item.parent === notification.userInfo?["NSObject"] as? CEWorkspaceFile else {
5667
return
@@ -61,7 +72,13 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate {
6172
}
6273
}
6374

64-
func outlineViewItemDidCollapse(_ notification: Notification) {}
75+
func outlineViewItemDidCollapse(_ notification: Notification) {
76+
/// Save expanded items' state to restore when finish filtering.
77+
guard let workspace else { return }
78+
if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile {
79+
expandedItems.remove(item)
80+
}
81+
}
6582

6683
func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
6784
guard let id = object as? CEWorkspaceFile.ID,

CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ final class ProjectNavigatorViewController: NSViewController {
2121

2222
var scrollView: NSScrollView!
2323
var outlineView: NSOutlineView!
24+
var noResultsLabel: NSTextField!
2425

2526
/// Gets the folder structure
2627
///
@@ -31,6 +32,9 @@ final class ProjectNavigatorViewController: NSViewController {
3132
return [root]
3233
}
3334

35+
var filteredContentChildren: [CEWorkspaceFile: [CEWorkspaceFile]] = [:]
36+
var expandedItems: Set<CEWorkspaceFile> = []
37+
3438
weak var workspace: WorkspaceDocument?
3539

3640
var iconColor: SettingsData.FileIconStyle = .color {
@@ -94,6 +98,27 @@ final class ProjectNavigatorViewController: NSViewController {
9498
scrollView.autohidesScrollers = true
9599

96100
outlineView.expandItem(outlineView.item(atRow: 0))
101+
102+
/// Get autosave expanded items.
103+
for row in 0..<outlineView.numberOfRows {
104+
if let item = outlineView.item(atRow: row) as? CEWorkspaceFile {
105+
if outlineView.isItemExpanded(item) {
106+
expandedItems.insert(item)
107+
}
108+
}
109+
}
110+
111+
/// "No Filter Results" label.
112+
noResultsLabel = NSTextField(labelWithString: "No Filter Results")
113+
noResultsLabel.isHidden = true
114+
noResultsLabel.font = NSFont.systemFont(ofSize: 16)
115+
noResultsLabel.textColor = NSColor.secondaryLabelColor
116+
outlineView.addSubview(noResultsLabel)
117+
noResultsLabel.translatesAutoresizingMaskIntoConstraints = false
118+
NSLayoutConstraint.activate([
119+
noResultsLabel.centerXAnchor.constraint(equalTo: outlineView.centerXAnchor),
120+
noResultsLabel.centerYAnchor.constraint(equalTo: outlineView.centerYAnchor)
121+
])
97122
}
98123

99124
init() {
@@ -103,6 +128,7 @@ final class ProjectNavigatorViewController: NSViewController {
103128
deinit {
104129
outlineView?.removeFromSuperview()
105130
scrollView?.removeFromSuperview()
131+
noResultsLabel?.removeFromSuperview()
106132
}
107133

108134
required init?(coder: NSCoder) {
@@ -155,5 +181,82 @@ final class ProjectNavigatorViewController: NSViewController {
155181
}
156182
}
157183

158-
// TODO: File filtering
184+
func handleFilterChange() {
185+
filteredContentChildren.removeAll()
186+
outlineView.reloadData()
187+
188+
guard let workspace else { return }
189+
190+
/// If the filter is empty, show all items and restore the expanded state.
191+
if workspace.navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
192+
restoreExpandedState()
193+
outlineView.autosaveExpandedItems = true
194+
} else {
195+
outlineView.autosaveExpandedItems = false
196+
/// Expand all items for search.
197+
outlineView.expandItem(outlineView.item(atRow: 0), expandChildren: true)
198+
}
199+
200+
if let root = content.first(where: { $0.isRoot }), let children = filteredContentChildren[root] {
201+
if children.isEmpty {
202+
noResultsLabel.isHidden = false
203+
outlineView.hideRows(at: IndexSet(integer: 0))
204+
} else {
205+
noResultsLabel.isHidden = true
206+
}
207+
}
208+
}
209+
210+
/// Checks if the given filter matches the name of the item or any of its children.
211+
func fileSearchMatches(_ filter: String, for item: CEWorkspaceFile) -> Bool {
212+
guard !filter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true }
213+
214+
if item.name.localizedLowercase.contains(filter.localizedLowercase) {
215+
saveAllContentChildren(for: item)
216+
return true
217+
}
218+
219+
if let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
220+
return children.contains { fileSearchMatches(filter, for: $0) }
221+
}
222+
223+
return false
224+
}
225+
226+
/// Saves all children of a given folder item to the filtered content cache.
227+
/// This is specially useful when the name of a folder matches the search.
228+
/// Just like in Xcode, this shows all the content of the folder.
229+
private func saveAllContentChildren(for item: CEWorkspaceFile) {
230+
guard item.isFolder, filteredContentChildren[item] == nil else { return }
231+
232+
if let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
233+
filteredContentChildren[item] = children
234+
for child in children.filter({ $0.isFolder }) {
235+
saveAllContentChildren(for: child)
236+
}
237+
}
238+
}
239+
240+
/// Restores the expanded state of items when finish searching.
241+
private func restoreExpandedState() {
242+
let copy = expandedItems
243+
outlineView.collapseItem(outlineView.item(atRow: 0), collapseChildren: true)
244+
245+
for item in copy {
246+
expandParentsRecursively(of: item)
247+
outlineView.expandItem(item)
248+
}
249+
250+
expandedItems = copy
251+
}
252+
253+
/// Recursively expands all parent items of a given item in the outline view.
254+
/// The order of the items may get lost in the `expandedItems` set.
255+
/// This means that a children item might be expanded before its parent, causing it not to really expand.
256+
private func expandParentsRecursively(of item: CEWorkspaceFile) {
257+
if let parent = item.parent {
258+
expandParentsRecursively(of: parent)
259+
outlineView.expandItem(parent)
260+
}
261+
}
159262
}

0 commit comments

Comments
 (0)