Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ extension ShortcutAction {
case .attachTextBoxFile: return ShortcutStroke(key: "a", command: true, shift: true, option: true)
case .sendCtrlFToTerminal: return nil
case .toggleRightSidebar: return ShortcutStroke(key: "b", command: true, option: true)
case .openFileExplorerSelection: return ShortcutStroke(key: "↓", command: true)
case .openDiffViewer: return ShortcutStroke(key: "d", command: true, shift: true, control: true)
case .saveFilePreview: return ShortcutStroke(key: "s", command: true)
case .openBrowser: return ShortcutStroke(key: "l", command: true, shift: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
case splitBrowserRight
case splitBrowserDown
case toggleRightSidebar = "toggleFileExplorer"
case openFileExplorerSelection

// MARK: Browser & Find
case openDiffViewer
Expand Down Expand Up @@ -163,7 +164,7 @@ extension ShortcutAction {
return .navigation
case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown,
.toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown,
.toggleRightSidebar:
.toggleRightSidebar, .openFileExplorerSelection:
return .panes
case .openDiffViewer, .saveFilePreview, .openBrowser, .focusBrowserAddressBar, .browserBack,
.browserForward, .browserReload, .browserZoomIn, .browserZoomOut,
Expand Down Expand Up @@ -227,6 +228,8 @@ extension ShortcutAction {
case .switchRightSidebarToFiles, .switchRightSidebarToFind,
.switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock:
return .atom(.sidebarFocus)
case .openFileExplorerSelection:
return .atom(.sidebarFocus)
case .renameTab, .renameWorkspace:
return .and(.not(.atom(.browserFocus)), .not(.atom(.sidebarFocus)))
case .sendCtrlFToTerminal:
Expand Down Expand Up @@ -334,6 +337,8 @@ extension ShortcutAction {
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .toggleRightSidebar: return "Toggle Right Sidebar"
case .openFileExplorerSelection:
return String(localized: "shortcut.openFileExplorerSelection.label", defaultValue: "File Explorer: Open Selection")
case .openDiffViewer: return "Open Diff Viewer"
case .saveFilePreview: return "Save File Preview"
case .openBrowser: return "Open Browser"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,15 +295,15 @@ public struct KeyboardShortcutsSection: View {

/// Mirrors legacy `KeyboardShortcutSettings.settingsVisibleActions`:
/// filters out `.showHideAllWindows` (owned by Global Hotkey section)
/// then re-orders so `focusRightSidebar`, `toggleRightSidebar`, and
/// `findInDirectory` sit immediately after `markOldestUnreadAndJumpNext`
/// then re-orders the right-sidebar/find actions after `markOldestUnreadAndJumpNext`
/// or `jumpToUnread` (the unread navigation cluster), so colocated
/// sidebar/find shortcuts appear together in the settings UI.
private static var settingsVisibleActions: [ShortcutAction] {
let base = ShortcutAction.allCases.filter { $0 != .showHideAllWindows }
let colocated: [ShortcutAction] = [
.focusRightSidebar,
.toggleRightSidebar,
.openFileExplorerSelection,
.findInDirectory,
].filter(base.contains)
let colocatedSet = Set(colocated)
Expand Down
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1927,6 +1927,23 @@
}
}
},
"shortcut.openFileExplorerSelection.label": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "File Explorer: Open Selection"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ファイルエクスプローラ: 選択項目を開く"
}
}
}
},
"command.markdownZoomIn.title": {
"extractionState": "manual",
"localizations": {
Expand Down
197 changes: 197 additions & 0 deletions Sources/FileExplorerOutlineView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import AppKit

enum FileExplorerKeyboardActivation {
static func isDefaultOpenEvent(_ event: NSEvent) -> Bool {
guard event.type == .keyDown else { return false }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard flags.intersection([.command, .control, .option, .shift]).isEmpty else {
return false
}
return event.keyCode == 36 || event.keyCode == 76
}

static func matchesOpenSelectionShortcut(
_ event: NSEvent,
shortcutForAction: (KeyboardShortcutSettings.Action) -> StoredShortcut = KeyboardShortcutSettings.shortcut(for:)
) -> Bool {
isDefaultOpenEvent(event) || shortcutForAction(.openFileExplorerSelection).matches(event: event)
}
}

/// NSOutlineView subclass that disables expand/collapse animations and adds leading margin.
final class FileExplorerNSOutlineView: NSOutlineView {
/// Leading margin applied to disclosure triangles and content.
static let leadingMargin: CGFloat = 8
var onQuickSearchChanged: ((String?) -> Void)?
private var quickSearchActive = false
private var quickSearchQuery = ""

override func keyDown(with event: NSEvent) {
if let mode = AppDelegate.shared?.rightSidebarModeShortcut(for: event) {
if fileExplorerCoordinator?.handleModeShortcut(mode, in: window) == true {
return
}
}

if handleOpenSelectionShortcut(event) {
return
}

if quickSearchActive, handleQuickSearchKey(event) {
return
}

if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) {
endQuickSearch()
fileExplorerCoordinator?.moveSelection(in: self, by: delta)
return
}

if let action = RightSidebarKeyboardNavigation.disclosureAction(for: event) {
endQuickSearch()
fileExplorerCoordinator?.performDisclosureAction(action, in: self)
return
}

if RightSidebarKeyboardNavigation.isPlainSlash(event) {
beginQuickSearch()
return
}

if RightSidebarKeyboardNavigation.isPlainPrintableText(event) {
return
}
super.keyDown(with: event)
}

override func performKeyEquivalent(with event: NSEvent) -> Bool {
if handleOpenSelectionShortcut(event) {
return true
}
if quickSearchActive, handleQuickSearchKey(event) {
return true
}
if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) {
endQuickSearch()
fileExplorerCoordinator?.moveSelection(in: self, by: delta)
return true
}
if let action = RightSidebarKeyboardNavigation.disclosureAction(for: event) {
endQuickSearch()
fileExplorerCoordinator?.performDisclosureAction(action, in: self)
return true
}
return super.performKeyEquivalent(with: event)
}

override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if result {
redrawVisibleRows()
}
return result
}

override func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder()
if result {
endQuickSearch()
redrawVisibleRows()
}
return result
}

override func expandItem(_ item: Any?, expandChildren: Bool) {
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 0
super.expandItem(item, expandChildren: expandChildren)
NSAnimationContext.endGrouping()
}

override func collapseItem(_ item: Any?, collapseChildren: Bool) {
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 0
super.collapseItem(item, collapseChildren: collapseChildren)
NSAnimationContext.endGrouping()
}

override func frameOfOutlineCell(atRow row: Int) -> NSRect {
var frame = super.frameOfOutlineCell(atRow: row)
frame.origin.x += Self.leadingMargin
return frame
}

override func frameOfCell(atColumn column: Int, row: Int) -> NSRect {
var frame = super.frameOfCell(atColumn: column, row: row)
let cellShift: CGFloat = Self.leadingMargin - 6
frame.origin.x += cellShift
frame.size.width -= cellShift
return frame
}

private func redrawVisibleRows() {
setNeedsDisplay(bounds)
let visibleRows = rows(in: visibleRect)
guard visibleRows.location != NSNotFound else { return }
let upperBound = min(visibleRows.location + visibleRows.length, numberOfRows)
guard visibleRows.location < upperBound else { return }
for row in visibleRows.location..<upperBound {
rowView(atRow: row, makeIfNecessary: false)?.needsDisplay = true
}
}

private var fileExplorerCoordinator: FileExplorerPanelView.Coordinator? {
dataSource as? FileExplorerPanelView.Coordinator
}

private func handleOpenSelectionShortcut(_ event: NSEvent) -> Bool {
guard FileExplorerKeyboardActivation.matchesOpenSelectionShortcut(event) else {
return false
}
endQuickSearch()
return fileExplorerCoordinator?.openSelectedItem(in: self) ?? false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +158 to +170

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 When quick search is active and the user presses Return (or keypad Enter) but there is no selection (e.g. the quick-search query matched nothing, leaving selectedRow == -1 and store.selectedPath == nil), handleOpenSelectionShortcut calls endQuickSearch() — setting quickSearchActive = false — and then returns false because openSelectedItem found no row. The caller now sees quickSearchActive == false, so if quickSearchActive, handleQuickSearchKey(event) short-circuits and the Return event falls all the way through to super.keyDown, triggering a system alert beep. Before this PR, handleQuickSearchKey consumed Return unconditionally when quick search was active. The intent is clearly that any Return/keypad-Enter event is fully owned by this view when it matches matchesOpenSelectionShortcut. Returning true regardless of whether an item was actually opened fixes the beep while keeping the open-if-possible behaviour.

Suggested change
private func handleOpenSelectionShortcut(_ event: NSEvent) -> Bool {
guard FileExplorerKeyboardActivation.matchesOpenSelectionShortcut(event) else {
return false
}
endQuickSearch()
return fileExplorerCoordinator?.openSelectedItem(in: self) ?? false
}
private func handleOpenSelectionShortcut(_ event: NSEvent) -> Bool {
guard FileExplorerKeyboardActivation.matchesOpenSelectionShortcut(event) else {
return false
}
endQuickSearch()
_ = fileExplorerCoordinator?.openSelectedItem(in: self)
return true
}


private func beginQuickSearch() {
quickSearchActive = true
quickSearchQuery = ""
onQuickSearchChanged?(quickSearchQuery)
}

private func endQuickSearch() {
guard quickSearchActive || !quickSearchQuery.isEmpty else { return }
quickSearchActive = false
quickSearchQuery = ""
onQuickSearchChanged?(nil)
}

private func handleQuickSearchKey(_ event: NSEvent) -> Bool {
if event.keyCode == 53 {
endQuickSearch()
return true
}
if event.keyCode == 36 || event.keyCode == 76 {
endQuickSearch()
return true
}
if event.keyCode == 51 {
if !quickSearchQuery.isEmpty {
quickSearchQuery.removeLast()
onQuickSearchChanged?(quickSearchQuery)
fileExplorerCoordinator?.selectBestQuickSearchMatch(in: self, query: quickSearchQuery)
}
return true
}
guard RightSidebarKeyboardNavigation.isPlainPrintableText(event) else {
return false
}
guard let text = event.charactersIgnoringModifiers, !text.isEmpty else {
return true
}
quickSearchQuery += text
onQuickSearchChanged?(quickSearchQuery)
fileExplorerCoordinator?.selectBestQuickSearchMatch(in: self, query: quickSearchQuery)
return true
}
}
Loading
Loading