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

Project navigator file filtering #1896

Merged
merged 9 commits into from
Oct 7, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import LanguageServerProtocol
@objc(WorkspaceDocument)
final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate {
@Published var sortFoldersOnTop: Bool = true
/// A string used to filter the displayed files and folders in the project navigator area based on user input.
@Published var navigatorFilter: String = ""

private var workspaceState: [String: Any] {
get {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ class FileSystemTableViewCell: StandardTableViewCell {
var changeLabelSmallWidth: NSLayoutConstraint!

private let prefs = Settings.shared.preferences.general
private var navigatorFilter: String?

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

if let item = item {
addIcon(item: item)
Expand All @@ -40,7 +44,57 @@ class FileSystemTableViewCell: StandardTableViewCell {
fileItem = item
imageView?.image = item.nsIcon
imageView?.contentTintColor = color(for: item)
textField?.stringValue = item.labelFileName()

let fileName = item.labelFileName()

guard let navigatorFilter else {
textField?.stringValue = fileName
return
}

// Apply bold style if the filename matches the workspace filter
if !navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let attributedString = NSMutableAttributedString(string: fileName)

// Check if the filename contains the filter text
let range = NSString(string: fileName).range(of: navigatorFilter, options: .caseInsensitive)
if range.location != NSNotFound {
// Set the label color to secondary
attributedString.addAttribute(
.foregroundColor,
value: NSColor.secondaryLabelColor,
range: NSRange(location: 0, length: attributedString.length)
)

// If the filter text matches, bold the matching text and set primary label color
attributedString.addAttributes(
[
.font: NSFont.boldSystemFont(ofSize: textField?.font?.pointSize ?? 12),
.foregroundColor: NSColor.labelColor
],
range: range
)
} else {
// If no match, apply primary label color for parent folder,
// or secondary label color for a non-matching file
attributedString.addAttribute(
.foregroundColor,
value: item.isFolder ? NSColor.labelColor : NSColor.secondaryLabelColor,
range: NSRange(location: 0, length: attributedString.length)
)
}

textField?.attributedStringValue = attributedString
} else {
// If no filter is applied, reset to normal font and primary label color
textField?.attributedStringValue = NSAttributedString(
string: fileName,
attributes: [
.font: NSFont.systemFont(ofSize: textField?.font?.pointSize ?? 12),
.foregroundColor: NSColor.labelColor
]
)
}
}

func addModel() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class StandardTableViewCell: NSTableCellView {
init(frame frameRect: NSRect, isEditable: Bool = true) {
super.init(frame: frameRect)
setupViews(frame: frameRect, isEditable: isEditable)

}

// Default init, assumes isEditable to be false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable {
self?.controller?.updateSelection(itemID: itemID)
}
.store(in: &cancellables)
workspace.$navigatorFilter
.throttle(for: 0.1, scheduler: RunLoop.main, latest: true)
.sink { [weak self] _ in self?.controller?.handleFilterChange() }
.store(in: &cancellables)
}

var cancellables: Set<AnyCancellable> = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell {
/// - frameRect: The frame of the cell.
/// - item: The file item the cell represents.
/// - isEditable: Set to true if the user should be able to edit the file name.
/// - navigatorFilter: An optional string use to filter the navigator area.
/// (Used for bolding and changing primary/secondary color).
init(
frame frameRect: NSRect,
item: CEWorkspaceFile?,
isEditable: Bool = true,
delegate: OutlineTableViewCellDelegate? = nil
delegate: OutlineTableViewCellDelegate? = nil,
navigatorFilter: String? = nil
) {
super.init(frame: frameRect, item: item, isEditable: isEditable)
super.init(frame: frameRect, item: item, isEditable: isEditable, navigatorFilter: navigatorFilter)
self.textField?.setAccessibilityIdentifier("ProjectNavigatorTableViewCell-\(item?.name ?? "")")
self.delegate = delegate
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,31 @@
import AppKit

extension ProjectNavigatorViewController: NSOutlineViewDataSource {
/// Retrieves the children of a given item for the outline view, applying the current filter if necessary.
private func getOutlineViewItems(for item: CEWorkspaceFile) -> [CEWorkspaceFile] {
if let cachedChildren = filteredContentChildren[item] {
return cachedChildren
}

if let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
let filteredChildren = children.filter { fileSearchMatches(workspace?.navigatorFilter ?? "", for: $0) }
filteredContentChildren[item] = filteredChildren
return filteredChildren
}

return []
}

func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if let item = item as? CEWorkspaceFile {
return item.isFolder ? workspace?.workspaceFileManager?.childrenOfFile(item)?.count ?? 0 : 0
return getOutlineViewItems(for: item).count
}
return content.count
}

func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
if let item = item as? CEWorkspaceFile,
let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
return children[index]
if let item = item as? CEWorkspaceFile {
return getOutlineViewItems(for: item)[index]
}
return content[index]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate {
guard let tableColumn else { return nil }

let frameRect = NSRect(x: 0, y: 0, width: tableColumn.width, height: rowHeight)

return ProjectNavigatorTableViewCell(frame: frameRect, item: item as? CEWorkspaceFile, delegate: self)
let cell = ProjectNavigatorTableViewCell(
frame: frameRect,
item: item as? CEWorkspaceFile,
delegate: self,
navigatorFilter: workspace?.navigatorFilter
)
return cell
}

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

func outlineViewItemDidExpand(_ notification: Notification) {
guard let id = workspace?.editorManager?.activeEditor.selectedTab?.file.id,
let item = workspace?.workspaceFileManager?.getFile(id, createIfNotFound: true),
/// Save expanded items' state to restore when finish filtering.
guard let workspace else { return }
if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile {
expandedItems.insert(item)
}

guard let id = workspace.editorManager?.activeEditor.selectedTab?.file.id,
let item = workspace.workspaceFileManager?.getFile(id, createIfNotFound: true),
/// update outline selection only if the parent of selected item match with expanded item
item.parent === notification.userInfo?["NSObject"] as? CEWorkspaceFile else {
return
Expand All @@ -61,7 +72,13 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate {
}
}

func outlineViewItemDidCollapse(_ notification: Notification) {}
func outlineViewItemDidCollapse(_ notification: Notification) {
/// Save expanded items' state to restore when finish filtering.
guard let workspace else { return }
if workspace.navigatorFilter.isEmpty, let item = notification.userInfo?["NSObject"] as? CEWorkspaceFile {
expandedItems.remove(item)
}
}

func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
guard let id = object as? CEWorkspaceFile.ID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class ProjectNavigatorViewController: NSViewController {

var scrollView: NSScrollView!
var outlineView: NSOutlineView!
var noResultsLabel: NSTextField!

/// Gets the folder structure
///
Expand All @@ -31,6 +32,9 @@ final class ProjectNavigatorViewController: NSViewController {
return [root]
}

var filteredContentChildren: [CEWorkspaceFile: [CEWorkspaceFile]] = [:]
var expandedItems: Set<CEWorkspaceFile> = []

weak var workspace: WorkspaceDocument?

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

outlineView.expandItem(outlineView.item(atRow: 0))

/// Get autosave expanded items.
for row in 0..<outlineView.numberOfRows {
if let item = outlineView.item(atRow: row) as? CEWorkspaceFile {
if outlineView.isItemExpanded(item) {
expandedItems.insert(item)
}
}
}

/// "No Filter Results" label.
noResultsLabel = NSTextField(labelWithString: "No Filter Results")
noResultsLabel.isHidden = true
noResultsLabel.font = NSFont.systemFont(ofSize: 16)
noResultsLabel.textColor = NSColor.secondaryLabelColor
outlineView.addSubview(noResultsLabel)
noResultsLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
noResultsLabel.centerXAnchor.constraint(equalTo: outlineView.centerXAnchor),
noResultsLabel.centerYAnchor.constraint(equalTo: outlineView.centerYAnchor)
])
}

init() {
Expand All @@ -103,6 +128,7 @@ final class ProjectNavigatorViewController: NSViewController {
deinit {
outlineView?.removeFromSuperview()
scrollView?.removeFromSuperview()
noResultsLabel?.removeFromSuperview()
}

required init?(coder: NSCoder) {
Expand Down Expand Up @@ -155,5 +181,82 @@ final class ProjectNavigatorViewController: NSViewController {
}
}

// TODO: File filtering
func handleFilterChange() {
filteredContentChildren.removeAll()
outlineView.reloadData()

LeonardoLarranaga marked this conversation as resolved.
Show resolved Hide resolved
guard let workspace else { return }

/// If the filter is empty, show all items and restore the expanded state.
if workspace.navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
restoreExpandedState()
outlineView.autosaveExpandedItems = true
} else {
outlineView.autosaveExpandedItems = false
/// Expand all items for search.
outlineView.expandItem(outlineView.item(atRow: 0), expandChildren: true)
}

if let root = content.first(where: { $0.isRoot }), let children = filteredContentChildren[root] {
if children.isEmpty {
noResultsLabel.isHidden = false
outlineView.hideRows(at: IndexSet(integer: 0))
} else {
noResultsLabel.isHidden = true
}
}
}

/// Checks if the given filter matches the name of the item or any of its children.
func fileSearchMatches(_ filter: String, for item: CEWorkspaceFile) -> Bool {
guard !filter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true }

if item.name.localizedLowercase.contains(filter.localizedLowercase) {
saveAllContentChildren(for: item)
return true
}

if let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
return children.contains { fileSearchMatches(filter, for: $0) }
}

return false
}

/// Saves all children of a given folder item to the filtered content cache.
/// This is specially useful when the name of a folder matches the search.
/// Just like in Xcode, this shows all the content of the folder.
private func saveAllContentChildren(for item: CEWorkspaceFile) {
guard item.isFolder, filteredContentChildren[item] == nil else { return }

if let children = workspace?.workspaceFileManager?.childrenOfFile(item) {
filteredContentChildren[item] = children
for child in children.filter({ $0.isFolder }) {
saveAllContentChildren(for: child)
}
}
}

/// Restores the expanded state of items when finish searching.
private func restoreExpandedState() {
let copy = expandedItems
outlineView.collapseItem(outlineView.item(atRow: 0), collapseChildren: true)

for item in copy {
expandParentsRecursively(of: item)
outlineView.expandItem(item)
}

expandedItems = copy
}

/// Recursively expands all parent items of a given item in the outline view.
/// The order of the items may get lost in the `expandedItems` set.
/// This means that a children item might be expanded before its parent, causing it not to really expand.
private func expandParentsRecursively(of item: CEWorkspaceFile) {
if let parent = item.parent {
expandParentsRecursively(of: parent)
outlineView.expandItem(parent)
}
}
}
Loading
Loading