diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a990f6624a5..00231e07b32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -631,6 +631,17 @@ jobs: - name: Validate Swift warning budget run: python3 scripts/swift_warning_budget.py --log /tmp/cmux-build-output.txt + - name: Create virtual display + run: | + set -euo pipefail + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + kill -0 "$VDISPLAY_PID" + - name: Run CoreAnimation main-thread startup regression run: | set -euo pipefail @@ -650,17 +661,6 @@ jobs: CMUX_TAG="ci-ca-main-thread" \ ./scripts/verify-main-thread-ca-transactions.sh "$APP" - - name: Create virtual display - run: | - set -euo pipefail - clang -framework Foundation -framework CoreGraphics \ - -o /tmp/create-virtual-display scripts/create-virtual-display.m - /tmp/create-virtual-display & - VDISPLAY_PID=$! - echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" - sleep 3 - kill -0 "$VDISPLAY_PID" - - name: Run workspace churn typing-lag regression run: | set -euo pipefail diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 5a6a3e32618..25a0ce24a5f 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -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) diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 9d8fe70ad3d..ea58558cc11 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -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 @@ -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, @@ -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: @@ -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" diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index bf9bed33659..ad985691d17 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -295,8 +295,7 @@ 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] { @@ -304,6 +303,7 @@ public struct KeyboardShortcutsSection: View { let colocated: [ShortcutAction] = [ .focusRightSidebar, .toggleRightSidebar, + .openFileExplorerSelection, .findInDirectory, ].filter(base.contains) let colocatedSet = Set(colocated) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c6b05c69f19..a78989b7b8e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1927,6 +1927,131 @@ } } }, + "shortcut.openFileExplorerSelection.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مستكشف الملفات: فتح التحديد" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Istraživač datoteka: otvori odabrano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Filoversigt: Åbn markering" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateiexplorer: Auswahl öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "File Explorer: Open Selection" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Explorador de archivos: abrir selección" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Explorateur de fichiers : ouvrir la sélection" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Esplora file: apri selezione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルエクスプローラ: 選択項目を開く" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កម្មវិធីរុករកឯកសារ៖ បើកជម្រើស" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 탐색기: 선택 항목 열기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Filutforsker: Åpne markering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Eksplorator plików: otwórz zaznaczenie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Explorador de arquivos: abrir seleção" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проводник файлов: открыть выбранное" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตัวสำรวจไฟล์: เปิดรายการที่เลือก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dosya Gezgini: Seçimi Aç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Провідник файлів: відкрити вибране" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件浏览器:打开所选项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檔案瀏覽器:開啟所選項" + } + } + } + }, "command.markdownZoomIn.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/FileExplorerOutlineView.swift b/Sources/FileExplorerOutlineView.swift new file mode 100644 index 00000000000..aee46378dff --- /dev/null +++ b/Sources/FileExplorerOutlineView.swift @@ -0,0 +1,213 @@ +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) || matchesConfiguredOpenSelectionShortcut( + event, + shortcutForAction: shortcutForAction + ) + } + + static func matchesConfiguredOpenSelectionShortcut( + _ event: NSEvent, + shortcutForAction: (KeyboardShortcutSettings.Action) -> StoredShortcut = KeyboardShortcutSettings.shortcut(for:) + ) -> Bool { + 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.. Bool { + let isDefaultOpenEvent = FileExplorerKeyboardActivation.isDefaultOpenEvent(event) + guard isDefaultOpenEvent || FileExplorerKeyboardActivation.matchesConfiguredOpenSelectionShortcut(event) else { + return false + } + guard isDefaultOpenEvent || + (AppDelegate.shared?.shortcutWhenClauseAllows(action: .openFileExplorerSelection, event: event) ?? true) 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 + } +} diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 8e52031d41e..f37e0eccceb 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -516,17 +516,28 @@ struct FileExplorerPanelView: NSViewRepresentable { } private func resolvedSelectionRow(in outlineView: NSOutlineView) -> Int? { + if let selected = selectedOutlineRow(in: outlineView) { + if store.selectedPath != selected.node.path { + store.select(node: selected.node) + } + return selected.row + } + if let selectedPath = store.selectedPath, let resolution = selectionResolution(for: selectedPath, in: outlineView) { return resolution.row } + + return nil + } + + private func selectedOutlineRow(in outlineView: NSOutlineView) -> (row: Int, node: FileExplorerNode)? { guard outlineView.selectedRow >= 0, outlineView.selectedRow < outlineView.numberOfRows, let node = outlineView.item(atRow: outlineView.selectedRow) as? FileExplorerNode else { return nil } - store.select(node: node) - return outlineView.selectedRow + return (outlineView.selectedRow, node) } private struct SelectionResolution { @@ -604,20 +615,34 @@ struct FileExplorerPanelView: NSViewRepresentable { @objc func handleDoubleClick(_ sender: NSOutlineView) { let row = sender.clickedRow >= 0 ? sender.clickedRow : sender.selectedRow + _ = activateNode(at: row, in: sender) + } + + @discardableResult + func openSelectedItem(in outlineView: NSOutlineView) -> Bool { + guard let row = resolvedSelectionRow(in: outlineView) else { return false } + return activateNode(at: row, in: outlineView) + } + + @discardableResult + private func activateNode(at row: Int, in outlineView: NSOutlineView) -> Bool { guard row >= 0, - let node = sender.item(atRow: row) as? FileExplorerNode else { return } + let node = outlineView.item(atRow: row) as? FileExplorerNode else { return false } + + selectRow(row, in: outlineView, scroll: false) if node.isDirectory { - if sender.isItemExpanded(node) { - sender.collapseItem(node) - } else if sender.isExpandable(node) { - sender.expandItem(node) + if outlineView.isItemExpanded(node) { + outlineView.collapseItem(node) + } else if outlineView.isExpandable(node) { + outlineView.expandItem(node) } - return + return true } - guard store.provider is LocalFileExplorerProvider else { return } + guard store.provider is LocalFileExplorerProvider else { return true } onOpenFilePreview(node.path) + return true } // MARK: - Context Menu (NSMenuDelegate) @@ -2102,170 +2127,6 @@ final class FileExplorerCellView: NSTableCellView { } } -// MARK: - Non-Animating Outline View - -/// 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 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 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.. 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 - } -} - // MARK: - Row View final class FileExplorerRowView: NSTableRowView { diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index 35d936a340d..de93e525221 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -125,7 +125,8 @@ extension KeyboardShortcutSettings.Action { .diffViewerScrollToTop, .diffViewerOpenFileSearch: return .browserPanel - case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock: + case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock, + .openFileExplorerSelection: return .rightSidebarFocus case .renameTab, .renameWorkspace, .sendCtrlFToTerminal: return .nonBrowserPanel diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index c0c7cb1ae73..a8451e8f1b0 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -31,6 +31,7 @@ enum KeyboardShortcutSettings { let colocatedSidebarActions = [ .focusRightSidebar, .toggleRightSidebar, + .openFileExplorerSelection, .findInDirectory, ].filter(actions.contains) let actionSet = Set(colocatedSidebarActions) @@ -130,6 +131,7 @@ enum KeyboardShortcutSettings { // File Explorer case toggleRightSidebar = "toggleFileExplorer" + case openFileExplorerSelection // Panels case saveFilePreview @@ -228,6 +230,7 @@ enum KeyboardShortcutSettings { case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right") case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down") case .toggleRightSidebar: return String(localized: "shortcut.toggleRightSidebar.label", defaultValue: "Toggle Right Sidebar") + case .openFileExplorerSelection: return String(localized: "shortcut.openFileExplorerSelection.label", defaultValue: "File Explorer: Open Selection") case .saveFilePreview: return String(localized: "shortcut.saveFilePreview.label", defaultValue: "Save File Preview") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") @@ -409,16 +412,15 @@ enum KeyboardShortcutSettings { case .attachTextBoxFile: return StoredShortcut(key: "a", command: true, shift: true, option: true, control: false) case .sendCtrlFToTerminal: - // Unbound by default: this is a deliberate escape hatch for forwarding a - // control chord (e.g. Claude Code's Ctrl-F force-stop) to the focused - // terminal. Binding it to plain Ctrl-F would be self-referential, so users - // opt in via Settings; it stays reachable through the command palette and - // the `send_key ctrl-f` socket command. + // Unbound by default: users opt in via Settings when they need + // Ctrl-F forwarded to terminal apps such as Claude Code. return .unbound case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .toggleRightSidebar: return StoredShortcut(key: "b", command: true, shift: false, option: true, control: false) + case .openFileExplorerSelection: + return StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false) case .saveFilePreview: return StoredShortcut(key: "s", command: true, shift: false, option: false, control: false) case .openBrowser: @@ -438,8 +440,6 @@ enum KeyboardShortcutSettings { case .browserZoomReset: return StoredShortcut(key: "0", command: true, shift: false, option: false, control: false) case .markdownZoomIn: - // Same chord as browser zoom, but scoped to the markdown panel - // context so the two never collide. return StoredShortcut(key: "=", command: true, shift: false, option: false, control: false) case .markdownZoomOut: return StoredShortcut(key: "-", command: true, shift: false, option: false, control: false) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 026dc12e411..bc6322e23a9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -324,6 +324,8 @@ D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */; }; D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; + FE002104 /* FileExplorerKeyboardActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002004 /* FileExplorerKeyboardActivationTests.swift */; }; + FE001105 /* FileExplorerOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001005 /* FileExplorerOutlineView.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; 9F8A6669D6F54F0EA8E8BEB8 /* FileExplorerRootSyncPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120A1EF846214C938416846E /* FileExplorerRootSyncPolicyTests.swift */; }; FE001103 /* FileExplorerSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001003 /* FileExplorerSearchController.swift */; }; @@ -1137,6 +1139,8 @@ D0B10021A1B2C3D4E5F60001 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; D0B10023A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewHitTesting.swift; sourceTree = ""; }; D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayViewTests.swift; sourceTree = ""; }; + FE002004 /* FileExplorerKeyboardActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerKeyboardActivationTests.swift; sourceTree = ""; }; + FE001005 /* FileExplorerOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerOutlineView.swift; sourceTree = ""; }; FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; 120A1EF846214C938416846E /* FileExplorerRootSyncPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootSyncPolicyTests.swift; sourceTree = ""; }; FE001003 /* FileExplorerSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchController.swift; sourceTree = ""; }; @@ -2216,6 +2220,7 @@ B37A0000000000000000000A /* FileExplorerState.swift */, FE001003 /* FileExplorerSearchController.swift */, FE001004 /* FileExplorerTerminalPathInsertion.swift */, + FE001005 /* FileExplorerOutlineView.swift */, FE001002 /* FileExplorerView.swift */, FE003001 /* SessionIndexStore.swift */, B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */, @@ -2477,6 +2482,7 @@ C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */, E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */, FE002001 /* FileExplorerRootResolverTests.swift */, + FE002004 /* FileExplorerKeyboardActivationTests.swift */, FE002002 /* FileExplorerStoreTests.swift */, FE002003 /* FileSearchRipgrepParserTests.swift */, 9BD519CFE74530A46DF0925D /* MobileHostAuthorizationTests.swift */, @@ -3049,6 +3055,7 @@ D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */, D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */, D0B10022A1B2C3D4E5F60001 /* FileDropOverlayViewHitTesting.swift in Sources */, + FE001105 /* FileExplorerOutlineView.swift in Sources */, FE001103 /* FileExplorerSearchController.swift in Sources */, B37A00000000000000000009 /* FileExplorerState.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, @@ -3513,6 +3520,7 @@ FEED49850000000000000001 /* FeedEventClassificationTests.swift in Sources */, FEEDC1A50000000000000003 /* FeedEventClassifier.swift in Sources */, D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */, + FE002104 /* FileExplorerKeyboardActivationTests.swift in Sources */, FE002101 /* FileExplorerRootResolverTests.swift in Sources */, 9F8A6669D6F54F0EA8E8BEB8 /* FileExplorerRootSyncPolicyTests.swift in Sources */, B37A0000000000000000000B /* FileExplorerStateModePersistenceTests.swift in Sources */, diff --git a/cmuxTests/FileExplorerKeyboardActivationTests.swift b/cmuxTests/FileExplorerKeyboardActivationTests.swift new file mode 100644 index 00000000000..78200e0c75a --- /dev/null +++ b/cmuxTests/FileExplorerKeyboardActivationTests.swift @@ -0,0 +1,163 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("File explorer keyboard activation") +struct FileExplorerKeyboardActivationTests { + private final class OpenProbe { + var openedPaths: [String] = [] + } + + @Test + func selectedLocalFileOpensThroughSharedActivationPath() { + let file = FileExplorerNode(name: "README.md", path: "/tmp/project/README.md", isDirectory: false) + let probe = OpenProbe() + let (coordinator, outlineView) = makeOutline(nodes: [file], probe: probe) + + outlineView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + coordinator.store.select(node: file) + + #expect(coordinator.openSelectedItem(in: outlineView)) + #expect(probe.openedPaths == [file.path]) + } + + @Test + func selectedFolderTogglesExpansionThroughSharedActivationPath() { + let folder = FileExplorerNode(name: "Sources", path: "/tmp/project/Sources", isDirectory: true) + folder.children = [ + FileExplorerNode(name: "App.swift", path: "/tmp/project/Sources/App.swift", isDirectory: false) + ] + let probe = OpenProbe() + let (coordinator, outlineView) = makeOutline(nodes: [folder], probe: probe) + + outlineView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + coordinator.store.select(node: folder) + + #expect(coordinator.openSelectedItem(in: outlineView)) + #expect(outlineView.isItemExpanded(folder)) + #expect(coordinator.store.isExpanded(folder)) + + #expect(coordinator.openSelectedItem(in: outlineView)) + #expect(!outlineView.isItemExpanded(folder)) + #expect(!coordinator.store.isExpanded(folder)) + #expect(probe.openedPaths.isEmpty) + } + + @Test + func returnEnterAndConfiguredShortcutMatchOpenSelection() { + let returnEvent = makeKeyEvent(characters: "\r", keyCode: 36) + let keypadEnterEvent = makeKeyEvent(characters: "\r", keyCode: 76) + let downArrow = String(UnicodeScalar(NSDownArrowFunctionKey)!) + let commandDownEvent = makeKeyEvent( + modifierFlags: .command, + characters: downArrow, + keyCode: 125 + ) + let plainDownEvent = makeKeyEvent(characters: downArrow, keyCode: 125) + var requestedShortcutAction: KeyboardShortcutSettings.Action? + let configuredShortcutMatches = FileExplorerKeyboardActivation.matchesOpenSelectionShortcut( + commandDownEvent, + shortcutForAction: { action in + requestedShortcutAction = action + return action.defaultShortcut + } + ) + + #expect(FileExplorerKeyboardActivation.isDefaultOpenEvent(returnEvent)) + #expect(FileExplorerKeyboardActivation.isDefaultOpenEvent(keypadEnterEvent)) + #expect(configuredShortcutMatches) + #expect(requestedShortcutAction == .openFileExplorerSelection) + #expect(!FileExplorerKeyboardActivation.matchesOpenSelectionShortcut(plainDownEvent)) + } + + @Test + func openSelectionShortcutDefaultsAndMetadata() { + let action = KeyboardShortcutSettings.Action.openFileExplorerSelection + let shortcut = action.defaultShortcut + + #expect(action.label == String(localized: "shortcut.openFileExplorerSelection.label", defaultValue: "File Explorer: Open Selection")) + #expect(shortcut.key == "↓") + #expect(shortcut.command) + #expect(!shortcut.shift) + #expect(!shortcut.option) + #expect(!shortcut.control) + } + + @Test + func settingsVisibleActionsColocateRightSidebarFileExplorerAndFindShortcuts() { + let visibleActions = KeyboardShortcutSettings.settingsVisibleActions + let expectedActions: [KeyboardShortcutSettings.Action] = [ + .focusRightSidebar, + .toggleRightSidebar, + .openFileExplorerSelection, + .findInDirectory, + ] + + guard let startIndex = visibleActions.firstIndex(of: .focusRightSidebar) else { + Issue.record("Toggle Right Sidebar Focus should be visible in keyboard shortcut settings") + return + } + + let endIndex = startIndex + expectedActions.count + guard endIndex <= visibleActions.count else { + Issue.record("Expected shortcut settings to include the full right-sidebar shortcut run") + return + } + #expect(Array(visibleActions[startIndex.. (FileExplorerPanelView.Coordinator, FileExplorerNSOutlineView) { + let store = FileExplorerStore() + store.provider = LocalFileExplorerProvider() + store.rootPath = "/tmp/project" + store.rootNodes = nodes + + let coordinator = FileExplorerPanelView.Coordinator( + store: store, + state: FileExplorerState(), + onOpenFilePreview: { probe.openedPaths.append($0) } + ) + + let outlineView = FileExplorerNSOutlineView() + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = coordinator + outlineView.delegate = coordinator + coordinator.outlineView = outlineView + outlineView.reloadData() + + return (coordinator, outlineView) + } + + private func makeKeyEvent( + modifierFlags: NSEvent.ModifierFlags = [], + characters: String, + keyCode: UInt16 + ) -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifierFlags, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { + fatalError("Failed to construct key event") + } + return event + } +} diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5fde169a3e6..3eaf4612a91 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -425,7 +425,6 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { KeyboardShortcutSettings.Action.toggleRightSidebar.label, String(localized: "shortcut.toggleRightSidebar.label", defaultValue: "Toggle Right Sidebar") ) - let toggleRightSidebar = KeyboardShortcutSettings.Action.toggleRightSidebar.defaultShortcut XCTAssertEqual(toggleRightSidebar.key, "b") XCTAssertTrue(toggleRightSidebar.command) @@ -504,27 +503,6 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertTrue(KeyboardShortcutSettings.settingsVisibleActions.contains(.markOldestUnreadAndJumpNext)) } - func testSettingsVisibleShortcutActionsColocateRightSidebarFileExplorerAndFindShortcuts() { - let visibleActions = KeyboardShortcutSettings.settingsVisibleActions - let expectedActions: [KeyboardShortcutSettings.Action] = [ - .focusRightSidebar, - .toggleRightSidebar, - .findInDirectory, - ] - - guard let startIndex = visibleActions.firstIndex(of: .focusRightSidebar) else { - XCTFail("Toggle Right Sidebar Focus should be visible in keyboard shortcut settings") - return - } - - let endIndex = startIndex + expectedActions.count - guard endIndex <= visibleActions.count else { - XCTFail("Expected shortcut settings to include the full right-sidebar shortcut run") - return - } - XCTAssertEqual(Array(visibleActions[startIndex..`: ## Files and React Grab - `shortcuts.bindings.toggleFileExplorer` +- `shortcuts.bindings.openFileExplorerSelection` - `shortcuts.bindings.saveFilePreview` - `shortcuts.bindings.toggleReactGrab` diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 446f5d55676..b4b2773842f 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -5,7 +5,7 @@ import { Link } from "../../../../i18n/navigation"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; import settingsSchema from "../../../../data/cmux.schema.json"; -import { shortcutCategories, type LocalizedText } from "../../../../data/cmux-shortcuts"; +import { localizedText, shortcutCategories } from "../../../../data/cmux-shortcuts"; import { DocsHeading } from "../../components/docs-heading"; type SchemaProperty = { @@ -128,6 +128,7 @@ function buildSettingsFileExample(t: ConfigurationTranslation) { // "bindings": { // "toggleSidebar": "cmd+b", // "toggleFileExplorer": "cmd+opt+b", + // "openFileExplorerSelection": "cmd+down", // "newTab": ["ctrl+b", "c"], // "commandPalettePrevious": null // } @@ -145,10 +146,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s }; } -function localizedText(text: LocalizedText, locale: string) { - return locale.startsWith("ja") ? text.ja : text.en; -} - function shortcutToConfig(shortcut: { combos: string[][]; configValue?: string }) { if (shortcut.configValue) return shortcut.configValue; return shortcutComboToConfig(shortcut.combos[0] ?? []); diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 3c1d6a17064..22c18aedab3 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -14,6 +14,7 @@ const shortcutChordExample = `{ "showNotifications": ["ctrl+b", "i"], "toggleSidebar": "cmd+b", "toggleFileExplorer": "cmd+opt+b", + "openFileExplorerSelection": "cmd+down", "splitRight": "", "commandPalettePrevious": null } diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index 6072d4e037f..5a0f6d3c809 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -2,11 +2,7 @@ import { useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { shortcutCategories, type LocalizedText, type Shortcut } from "../../data/cmux-shortcuts"; - -function localizedText(text: LocalizedText, locale: string) { - return locale.startsWith("ja") ? text.ja : text.en; -} +import { localizedText, shortcutCategories, type Shortcut } from "../../data/cmux-shortcuts"; function normalize(s: string) { return s.toLowerCase().replace(/\s+/g, " ").trim(); diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 7d2be418adf..321a27acc34 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -1,7 +1,9 @@ +import type { Locale } from "../i18n/routing"; + export type LocalizedText = { en: string; ja: string; -}; +} & Partial, string>>; export type Shortcut = { id: string; @@ -18,6 +20,10 @@ export type ShortcutCategory = { shortcuts: Shortcut[]; }; +export function localizedText(text: LocalizedText, locale: string) { + return text[locale as Locale] ?? (locale.startsWith("ja") ? text.ja : text.en); +} + export const shortcutCategories: ShortcutCategory[] = [ { id: "app", @@ -116,6 +122,54 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "renameWorkspace", combos: [["⌘", "⇧", "R"]], description: { en: "Rename workspace", ja: "ワークスペース名を変更" } }, { id: "editWorkspaceDescription", combos: [["⌥", "⌘", "E"]], description: { en: "Edit workspace description", ja: "ワークスペースの説明を編集" } }, { id: "focusRightSidebar", combos: [["⌘", "⇧", "E"]], description: { en: "Toggle right-sidebar focus", ja: "右サイドバーのフォーカスを切り替え" } }, + { + id: "openFileExplorerSelection", + combos: [["⌘", "↓"]], + description: { + en: "Open selected file explorer item", + ja: "ファイルエクスプローラの選択項目を開く", + "zh-CN": "打开文件浏览器中的所选项", + "zh-TW": "開啟檔案瀏覽器中的所選項", + ko: "파일 탐색기에서 선택한 항목 열기", + de: "Ausgewähltes Dateiexplorer-Element öffnen", + es: "Abrir el elemento seleccionado del explorador de archivos", + fr: "Ouvrir l'élément sélectionné dans l'explorateur de fichiers", + it: "Apri l'elemento selezionato nell'esplora file", + da: "Åbn det valgte element i filoversigten", + pl: "Otwórz zaznaczony element eksploratora plików", + ru: "Открыть выбранный элемент проводника файлов", + bs: "Otvori odabranu stavku istraživača datoteka", + ar: "فتح العنصر المحدد في مستكشف الملفات", + no: "Åpne valgt element i filutforskeren", + "pt-BR": "Abrir o item selecionado do explorador de arquivos", + th: "เปิดรายการที่เลือกในตัวสำรวจไฟล์", + tr: "Dosya gezgininde seçili öğeyi aç", + km: "បើកធាតុដែលបានជ្រើសក្នុងកម្មវិធីរុករកឯកសារ", + uk: "Відкрити вибраний елемент провідника файлів", + }, + note: { + en: "Return and keypad Enter also open files or toggle folders while the file explorer is focused.", + ja: "ファイルエクスプローラにフォーカスがある間は、Return とテンキー Enter でもファイルを開くかフォルダを展開/折りたたみできます。", + "zh-CN": "文件浏览器获得焦点时,Return 和数字键盘 Enter 也会打开文件或切换文件夹展开状态。", + "zh-TW": "檔案瀏覽器取得焦點時,Return 和數字鍵盤 Enter 也會開啟檔案或切換資料夾展開狀態。", + ko: "파일 탐색기에 포커스가 있을 때 Return 및 숫자 키패드 Enter도 파일을 열거나 폴더 펼침 상태를 전환합니다.", + de: "Wenn der Dateiexplorer fokussiert ist, öffnen Return und Ziffernblock-Enter ebenfalls Dateien oder klappen Ordner um.", + es: "Cuando el explorador de archivos tiene el foco, Return y Enter del teclado numérico también abren archivos o alternan carpetas.", + fr: "Lorsque l'explorateur de fichiers a le focus, Return et Entrée du pavé numérique ouvrent aussi les fichiers ou replient/déplient les dossiers.", + it: "Quando l'esplora file ha il focus, Return e Invio del tastierino aprono anche i file o espandono/comprimono le cartelle.", + da: "Når filoversigten har fokus, åbner Return og Enter på det numeriske tastatur også filer eller slår mapper til/fra.", + pl: "Gdy eksplorator plików ma fokus, Return i Enter z klawiatury numerycznej także otwierają pliki lub przełączają foldery.", + ru: "Когда проводник файлов в фокусе, Return и Enter на цифровой клавиатуре также открывают файлы или переключают папки.", + bs: "Kada je istraživač datoteka u fokusu, Return i Enter na numeričkoj tastaturi također otvaraju datoteke ili prebacuju mape.", + ar: "عندما يكون مستكشف الملفات في التركيز، يفتح Return وEnter في لوحة الأرقام الملفات أيضًا أو يبدلان حالة المجلدات.", + no: "Når filutforskeren har fokus, åpner Return og Enter på talltastaturet også filer eller veksler mapper.", + "pt-BR": "Quando o explorador de arquivos está em foco, Return e Enter do teclado numérico também abrem arquivos ou alternam pastas.", + th: "เมื่อตัวสำรวจไฟล์มีโฟกัส Return และ Enter บนแป้นตัวเลขจะเปิดไฟล์หรือสลับการขยายโฟลเดอร์ได้ด้วย", + tr: "Dosya gezgini odaktayken Return ve sayısal klavye Enter da dosyaları açar veya klasörleri açıp kapatır.", + km: "នៅពេលកម្មវិធីរុករកឯកសារមានផ្តោត Return និង Enter លើក្តារលេខក៏បើកឯកសារ ឬប្តូរថតឱ្យបង្ហាញ/លាក់បានដែរ។", + uk: "Коли провідник файлів у фокусі, Return і Enter на цифровій клавіатурі також відкривають файли або перемикають папки.", + }, + }, { id: "navigateRightSidebarRows", combos: [["J / K"], ["⌃", "N / P"], ["H / L"]], diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index b4c017db991..4cc51d979ac 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1176,6 +1176,7 @@ "splitBrowserRight", "splitBrowserDown", "toggleFileExplorer", + "openFileExplorerSelection", "saveFilePreview", "openBrowser", "focusBrowserAddressBar",