feat: distinguish duplicate application names by folder
This commit is contained in:
@@ -41,6 +41,8 @@ With an empty search, applications are ordered by successful launch count, then
|
||||
|
||||
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
|
||||
|
||||
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
|
||||
|
||||
## Native UI
|
||||
|
||||
The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline.
|
||||
|
||||
@@ -11,6 +11,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private let history: LaunchHistory
|
||||
private var applications: [Application] = []
|
||||
private var results: [Application] = []
|
||||
private var locationLabels: [URL: String] = [:]
|
||||
private var discoveryTask: Task<Void, Never>?
|
||||
private var isLaunching = false
|
||||
|
||||
@@ -228,11 +229,34 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}.value
|
||||
guard let self else { return }
|
||||
self.applications = applications
|
||||
self.updateLocationLabels()
|
||||
self.discoveryTask = nil
|
||||
self.updateResults(preservingSelection: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateLocationLabels() {
|
||||
var nameCounts: [String: Int] = [:]
|
||||
nameCounts.reserveCapacity(applications.count)
|
||||
for application in applications { nameCounts[application.name, default: 0] += 1 }
|
||||
var groups: [String: [(url: URL, folder: URL)]] = [:]
|
||||
for application in applications where (nameCounts[application.name] ?? 0) > 1 {
|
||||
groups[application.name, default: []].append(
|
||||
(application.url, application.url.deletingLastPathComponent())
|
||||
)
|
||||
}
|
||||
locationLabels.removeAll(keepingCapacity: true)
|
||||
for group in groups.values {
|
||||
var folderCounts: [URL: Int] = [:]
|
||||
folderCounts.reserveCapacity(group.count)
|
||||
for entry in group { folderCounts[entry.folder, default: 0] += 1 }
|
||||
for entry in group {
|
||||
let location = (folderCounts[entry.folder] ?? 0) > 1 ? entry.url : entry.folder
|
||||
locationLabels[entry.url] = (location.path as NSString).abbreviatingWithTildeInPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateResults(preservingSelection: Bool = false) {
|
||||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||||
? results[tableView.selectedRow].id : nil
|
||||
@@ -299,10 +323,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
|
||||
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView)
|
||||
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
|
||||
?? makeCell(identifier: identifier)
|
||||
let application = results[row]
|
||||
cell.textField?.stringValue = application.name
|
||||
let location = locationLabels[application.id]
|
||||
cell.locationLabel.stringValue = location ?? ""
|
||||
cell.locationLabel.isHidden = location == nil
|
||||
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
|
||||
cell.toolTip = application.url.path
|
||||
return cell
|
||||
@@ -316,8 +343,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
return view
|
||||
}
|
||||
|
||||
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||||
let cell = NSTableCellView()
|
||||
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
|
||||
let cell = ApplicationCell()
|
||||
cell.identifier = identifier
|
||||
let icon = NSImageView()
|
||||
icon.imageScaling = .scaleProportionallyDown
|
||||
@@ -326,6 +353,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
label.textColor = SunsetPalette.text
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
let location = cell.locationLabel
|
||||
location.font = .systemFont(ofSize: 12)
|
||||
location.textColor = SunsetPalette.secondaryText
|
||||
location.lineBreakMode = .byTruncatingMiddle
|
||||
location.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
location.isHidden = true
|
||||
let title = NSStackView(views: [label, location])
|
||||
title.orientation = .horizontal
|
||||
title.alignment = .centerY
|
||||
title.spacing = 8
|
||||
let kind = NSTextField(labelWithString: "Application")
|
||||
kind.font = .systemFont(ofSize: 14)
|
||||
kind.textColor = SunsetPalette.secondaryText
|
||||
@@ -333,7 +370,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
kind.setAccessibilityElement(false)
|
||||
cell.imageView = icon
|
||||
cell.textField = label
|
||||
for view in [icon, label, kind] {
|
||||
for view in [icon, title, kind] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.addSubview(view)
|
||||
}
|
||||
@@ -342,9 +379,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
icon.widthAnchor.constraint(equalToConstant: 22),
|
||||
icon.heightAnchor.constraint(equalToConstant: 22),
|
||||
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
|
||||
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
label.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
|
||||
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
|
||||
title.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
|
||||
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
|
||||
])
|
||||
@@ -374,6 +411,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ApplicationCell: NSTableCellView {
|
||||
let locationLabel = NSTextField(labelWithString: "")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ApplicationTableView: NSTableView {
|
||||
var openSelection: (() -> Void)?
|
||||
|
||||
Reference in New Issue
Block a user