diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..d1b6dba2ac34 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -24,8 +24,8 @@ 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift 4367 cmuxTests/BrowserPanelTests.swift -4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 4121 Sources/BrowserWindowPortal.swift +3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift @@ -46,7 +46,7 @@ 2126 cmuxTests/CmuxConfigTests.swift 2085 cmuxTests/ShortcutAndCommandPaletteTests.swift 2078 Sources/SessionPersistence.swift -2011 Sources/KeyboardShortcutSettingsFileStore.swift +2036 Sources/KeyboardShortcutSettingsFileStore.swift 1944 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift @@ -55,11 +55,13 @@ 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift -1656 Sources/FileExplorerView.swift +1670 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift +1608 cmuxTests/FileExplorerStoreTests.swift 1604 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift +1506 Sources/FileExplorerStore.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift 1433 Sources/RemoteTmuxControlConnection.swift @@ -69,7 +71,6 @@ 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1363 Sources/CMUXInstalledExtensionSidebarHostView.swift 1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift -1317 Sources/FileExplorerStore.swift 1295 cmuxTests/MobileHostAuthorizationTests.swift 1291 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift 1290 cmuxTests/TextBoxSubmitActionTests.swift @@ -79,7 +80,6 @@ 1240 cmuxTests/SidebarOrderingTests.swift 1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1205 Sources/RemoteTmuxController.swift -1204 cmuxTests/FileExplorerStoreTests.swift 1197 cmuxTests/CodexAppServerSessionTests.swift 1166 Sources/VaultAgentProcessScanner.swift 1147 cmuxTests/PiVaultAgentPersistenceTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4028a5ebc9f9..7aea94a5504f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -99042,6 +99042,1131 @@ } } }, + "fileExplorer.sort.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort Files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルの並び替え" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فرز الملفات" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sortiraj datoteke" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sorter filer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dateien sortieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordenar archivos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Trier les fichiers" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ordina file" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "តម្រៀបឯកសារ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일 정렬" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sorter filer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sortuj pliki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ordenar arquivos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортировать файлы" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดเรียงไฟล์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dosyaları sırala" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортувати файли" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排序文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排序檔案" + } + } + } + }, + "fileExplorer.sort.key.dateCreated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date Created" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成日" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تاريخ الإنشاء" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Datum kreiranja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Oprettelsesdato" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erstellungsdatum" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fecha de creación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Date de création" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Data di creazione" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កាលបរិច្ឆេទបង្កើត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생성일" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Opprettelsesdato" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Data utworzenia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Data de criação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дата создания" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วันที่สร้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oluşturulma tarihi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дата створення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立日期" + } + } + } + }, + "fileExplorer.sort.key.dateModified": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date Modified" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更日" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تاريخ التعديل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Datum izmjene" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ændringsdato" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Änderungsdatum" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fecha de modificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Date de modification" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Data di modifica" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កាលបរិច្ឆេទកែប្រែ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수정일" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endringsdato" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Data modyfikacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Data de modificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дата изменения" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วันที่แก้ไข" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Değiştirilme tarihi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дата зміни" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修改日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修改日期" + } + } + } + }, + "fileExplorer.sort.key.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاسم" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Naziv" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Navn" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nome" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ឈ្មោះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Navn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nazwa" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nome" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ad" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Назва" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "名稱" + } + } + } + }, + "fileExplorer.sort.menu.order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Order" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "順序" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترتيب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Redoslijed" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rækkefølge" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reihenfolge" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Orden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ordre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ordine" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "លំដាប់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "순서" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rekkefølge" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolejność" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ordem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Порядок" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลำดับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sıra" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Порядок" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "顺序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "順序" + } + } + } + }, + "fileExplorer.sort.menu.sortBy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort By" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "並び替え" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فرز حسب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sortiraj po" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sorter efter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortieren nach" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordenar por" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Trier par" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ordina per" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "តម្រៀបតាម" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정렬 기준" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sorter etter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sortuj według" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ordenar por" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортировать по" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดเรียงตาม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sıralama ölçütü" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортувати за" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排序依据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排序依據" + } + } + } + }, + "fileExplorer.sort.order.ascending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ascending" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昇順" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تصاعدي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Rastuće" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Stigende" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufsteigend" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ascendente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Croissant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Crescente" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ឡើង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오름차순" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Stigende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rosnąco" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Crescente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По возрастанию" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จากน้อยไปมาก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Artan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "За зростанням" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遞增" + } + } + } + }, + "fileExplorer.sort.order.descending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Descending" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "降順" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنازلي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Opadajuće" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Faldende" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Absteigend" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descendente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Décroissant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Decrescente" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចុះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내림차순" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Synkende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Malejąco" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Decrescente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "По убыванию" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จากมากไปน้อย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Azalan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "За спаданням" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "降序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遞減" + } + } + } + }, + "fileExplorer.sort.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort: %@, %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "並び替え: %@、%@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فرز: %@، %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sortiranje: %@, %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sorter: %@, %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortieren: %@, %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordenar: %@, %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tri : %@, %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ordina: %@, %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "តម្រៀប៖ %@, %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정렬: %@, %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sorter: %@, %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sortowanie: %@, %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ordenar: %@, %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортировка: %@, %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดเรียง: %@, %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sırala: %@, %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортування: %@, %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "排序:%@,%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排序:%@,%@" + } + } + } + }, "fileExplorer.empty": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 037bb90c18dd..38c54dc4adcc 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -461,6 +461,8 @@ extension CmuxSettingsFileStore { "canvas.snappingEnabled", "fileEditor.wordWrap", "fileExplorer.doubleClickAction", + "fileExplorer.sortBy", + "fileExplorer.sortOrder", "shortcuts.bindings", ] } diff --git a/Sources/FileExplorerHeaderView.swift b/Sources/FileExplorerHeaderView.swift index 6fc9018be6bf..09452b85d1a1 100644 --- a/Sources/FileExplorerHeaderView.swift +++ b/Sources/FileExplorerHeaderView.swift @@ -1,13 +1,17 @@ import AppKit import CmuxFoundation -/// Pure AppKit header bar with folder icon, path label, and hidden files toggle. +/// Pure AppKit header bar with folder icon, path label, and sort control. final class FileExplorerHeaderView: NSView { private let iconView = NSImageView() private let pathLabel = NSTextField(labelWithString: "") + private let sortButton = NSButton() private var heightConstraint: NSLayoutConstraint? private var displayPath = "" private var quickSearchQuery: String? + private var sortOptions = FileExplorerSortOptions.defaultValue + var onSelectSortKey: ((FileExplorerSortKey) -> Void)? + var onSelectSortOrder: ((FileExplorerSortOrder) -> Void)? override init(frame: NSRect) { super.init(frame: frame) @@ -22,6 +26,15 @@ final class FileExplorerHeaderView: NSView { iconView.translatesAutoresizingMaskIntoConstraints = false iconView.contentTintColor = .secondaryLabelColor + sortButton.translatesAutoresizingMaskIntoConstraints = false + sortButton.isBordered = false + sortButton.bezelStyle = .regularSquare + sortButton.imagePosition = .imageOnly + sortButton.contentTintColor = .secondaryLabelColor + sortButton.focusRingType = .none + sortButton.target = self + sortButton.action = #selector(showSortMenu(_:)) + pathLabel.translatesAutoresizingMaskIntoConstraints = false applyFonts() pathLabel.textColor = .secondaryLabelColor @@ -31,6 +44,7 @@ final class FileExplorerHeaderView: NSView { addSubview(iconView) addSubview(pathLabel) + addSubview(sortButton) let heightConstraint = heightAnchor.constraint(equalToConstant: RightSidebarChromeMetrics.secondaryBarHeight) self.heightConstraint = heightConstraint @@ -45,7 +59,12 @@ final class FileExplorerHeaderView: NSView { pathLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4), pathLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - pathLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + pathLabel.trailingAnchor.constraint(equalTo: sortButton.leadingAnchor, constant: -4), + + sortButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + sortButton.centerYAnchor.constraint(equalTo: centerYAnchor), + sortButton.widthAnchor.constraint(equalToConstant: 22), + sortButton.heightAnchor.constraint(equalToConstant: 22), ]) applyHeaderState() } @@ -55,9 +74,10 @@ final class FileExplorerHeaderView: NSView { heightConstraint?.constant = RightSidebarChromeMetrics.secondaryBarHeight } - func update(displayPath: String) { - guard self.displayPath != displayPath else { return } + func update(displayPath: String, sortOptions: FileExplorerSortOptions) { + guard self.displayPath != displayPath || self.sortOptions != sortOptions else { return } self.displayPath = displayPath + self.sortOptions = sortOptions applyHeaderState() } @@ -81,5 +101,67 @@ final class FileExplorerHeaderView: NSView { pathLabel.stringValue = displayPath pathLabel.toolTip = displayPath } + sortButton.image = NSImage(systemSymbolName: "arrow.up.arrow.down", accessibilityDescription: nil)? + .withSymbolConfiguration(config) + sortButton.toolTip = String.localizedStringWithFormat( + String(localized: "fileExplorer.sort.tooltip", defaultValue: "Sort: %@, %@"), + sortOptions.key.localizedTitle, + sortOptions.order.localizedTitle + ) + sortButton.setAccessibilityLabel( + String(localized: "fileExplorer.sort.accessibilityLabel", defaultValue: "Sort Files") + ) + } + + @objc private func showSortMenu(_ sender: NSButton) { + let menu = NSMenu() + + let sortByItem = NSMenuItem( + title: String(localized: "fileExplorer.sort.menu.sortBy", defaultValue: "Sort By"), + action: nil, + keyEquivalent: "" + ) + sortByItem.isEnabled = false + menu.addItem(sortByItem) + + for key in FileExplorerSortKey.allCases { + let item = NSMenuItem(title: key.localizedTitle, action: #selector(selectSortKey(_:)), keyEquivalent: "") + item.target = self + item.representedObject = key.rawValue + item.state = key == sortOptions.key ? .on : .off + menu.addItem(item) + } + + menu.addItem(.separator()) + + let orderItem = NSMenuItem( + title: String(localized: "fileExplorer.sort.menu.order", defaultValue: "Order"), + action: nil, + keyEquivalent: "" + ) + orderItem.isEnabled = false + menu.addItem(orderItem) + + for order in FileExplorerSortOrder.allCases { + let item = NSMenuItem(title: order.localizedTitle, action: #selector(selectSortOrder(_:)), keyEquivalent: "") + item.target = self + item.representedObject = order.rawValue + item.state = order == sortOptions.order ? .on : .off + menu.addItem(item) + } + + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.maxY + 2), in: sender) + } + + @objc private func selectSortKey(_ sender: NSMenuItem) { + guard let rawValue = sender.representedObject as? String, + let key = FileExplorerSortKey(rawValue: rawValue) else { return } + onSelectSortKey?(key) + } + + @objc private func selectSortOrder(_ sender: NSMenuItem) { + guard let rawValue = sender.representedObject as? String, + let order = FileExplorerSortOrder(rawValue: rawValue) else { return } + onSelectSortOrder?(order) } } diff --git a/Sources/FileExplorerNodeSorter.swift b/Sources/FileExplorerNodeSorter.swift new file mode 100644 index 000000000000..b9ac2f754826 --- /dev/null +++ b/Sources/FileExplorerNodeSorter.swift @@ -0,0 +1,76 @@ +import Foundation + +struct FileExplorerNodeSorter { + let options: FileExplorerSortOptions + + func sorted(_ nodes: [FileExplorerNode]) -> [FileExplorerNode] { + nodes.sorted { lhs, rhs in + isOrderedBefore(lhs, rhs) + } + } + + private func isOrderedBefore( + _ lhs: FileExplorerNode, + _ rhs: FileExplorerNode + ) -> Bool { + switch options.key { + case .name: + if lhs.isDirectory != rhs.isDirectory { + return lhs.isDirectory + } + return orderedByName(lhs, rhs, order: options.order) + case .dateCreated: + if let result = orderedByDate(lhs.creationDate, rhs.creationDate, order: options.order) { + return result + } + return orderedByFallback(lhs, rhs) + case .dateModified: + if let result = orderedByDate(lhs.modificationDate, rhs.modificationDate, order: options.order) { + return result + } + return orderedByFallback(lhs, rhs) + } + } + + private func orderedByDate(_ lhs: Date?, _ rhs: Date?, order: FileExplorerSortOrder) -> Bool? { + switch (lhs, rhs) { + case let (lhs?, rhs?) where lhs != rhs: + return order == .ascending ? lhs < rhs : lhs > rhs + case (.some, nil): + return true + case (nil, .some): + return false + default: + return nil + } + } + + private func orderedByFallback(_ lhs: FileExplorerNode, _ rhs: FileExplorerNode) -> Bool { + if lhs.isDirectory != rhs.isDirectory { + return lhs.isDirectory + } + return orderedByName(lhs, rhs, order: .ascending) + } + + private func orderedByName( + _ lhs: FileExplorerNode, + _ rhs: FileExplorerNode, + order: FileExplorerSortOrder + ) -> Bool { + switch lhs.name.localizedCaseInsensitiveCompare(rhs.name) { + case .orderedAscending: + return order == .ascending + case .orderedDescending: + return order == .descending + case .orderedSame: + switch lhs.path.localizedCaseInsensitiveCompare(rhs.path) { + case .orderedAscending: + return order == .ascending + case .orderedDescending: + return order == .descending + case .orderedSame: + return false + } + } + } +} diff --git a/Sources/FileExplorerSortKey.swift b/Sources/FileExplorerSortKey.swift new file mode 100644 index 000000000000..63294ec3b599 --- /dev/null +++ b/Sources/FileExplorerSortKey.swift @@ -0,0 +1,22 @@ +import Foundation + +enum FileExplorerSortKey: String, CaseIterable, Sendable { + case name + case dateCreated + case dateModified + + init(resolvingRawValue raw: String?) { + self = raw.flatMap { Self(rawValue: $0) } ?? .name + } + + var localizedTitle: String { + switch self { + case .name: + return String(localized: "fileExplorer.sort.key.name", defaultValue: "Name") + case .dateCreated: + return String(localized: "fileExplorer.sort.key.dateCreated", defaultValue: "Date Created") + case .dateModified: + return String(localized: "fileExplorer.sort.key.dateModified", defaultValue: "Date Modified") + } + } +} diff --git a/Sources/FileExplorerSortOptions.swift b/Sources/FileExplorerSortOptions.swift new file mode 100644 index 000000000000..c0685f15047d --- /dev/null +++ b/Sources/FileExplorerSortOptions.swift @@ -0,0 +1,8 @@ +import Foundation + +struct FileExplorerSortOptions: Equatable, Sendable { + let key: FileExplorerSortKey + let order: FileExplorerSortOrder + + static let defaultValue = FileExplorerSortOptions(key: .name, order: .ascending) +} diff --git a/Sources/FileExplorerSortOrder.swift b/Sources/FileExplorerSortOrder.swift new file mode 100644 index 000000000000..ecbe868651ac --- /dev/null +++ b/Sources/FileExplorerSortOrder.swift @@ -0,0 +1,19 @@ +import Foundation + +enum FileExplorerSortOrder: String, CaseIterable, Sendable { + case ascending + case descending + + init(resolvingRawValue raw: String?) { + self = raw.flatMap { Self(rawValue: $0) } ?? .ascending + } + + var localizedTitle: String { + switch self { + case .ascending: + return String(localized: "fileExplorer.sort.order.ascending", defaultValue: "Ascending") + case .descending: + return String(localized: "fileExplorer.sort.order.descending", defaultValue: "Descending") + } + } +} diff --git a/Sources/FileExplorerSortSettings.swift b/Sources/FileExplorerSortSettings.swift new file mode 100644 index 000000000000..5e13b32100c4 --- /dev/null +++ b/Sources/FileExplorerSortSettings.swift @@ -0,0 +1,32 @@ +import Foundation + +struct FileExplorerSortSettings { + static let sortKeyKey = "fileExplorer.sortBy" + static let sortOrderKey = "fileExplorer.sortOrder" + static let didChangeNotification = Notification.Name("cmux.fileExplorerSortSettingsDidChange") + + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + + init(defaults: UserDefaults, notificationCenter: NotificationCenter) { + self.defaults = defaults + self.notificationCenter = notificationCenter + } + + func resolvedOptions() -> FileExplorerSortOptions { + FileExplorerSortOptions( + key: FileExplorerSortKey(resolvingRawValue: defaults.string(forKey: Self.sortKeyKey)), + order: FileExplorerSortOrder(resolvingRawValue: defaults.string(forKey: Self.sortOrderKey)) + ) + } + + func setOptions(_ options: FileExplorerSortOptions) { + defaults.set(options.key.rawValue, forKey: Self.sortKeyKey) + defaults.set(options.order.rawValue, forKey: Self.sortOrderKey) + notifyDidChange() + } + + func notifyDidChange() { + notificationCenter.post(name: Self.didChangeNotification, object: nil) + } +} diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index ba7b80dfd8b0..6b40f2b052f3 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -210,6 +210,22 @@ struct FileExplorerEntry: Sendable { let name: String let path: String let isDirectory: Bool + let creationDate: Date? + let modificationDate: Date? + + init( + name: String, + path: String, + isDirectory: Bool, + creationDate: Date? = nil, + modificationDate: Date? = nil + ) { + self.name = name + self.path = path + self.isDirectory = isDirectory + self.creationDate = creationDate + self.modificationDate = modificationDate + } } final class FileExplorerNode: Identifiable { @@ -217,25 +233,28 @@ final class FileExplorerNode: Identifiable { let name: String let path: String let isDirectory: Bool + let creationDate: Date? + let modificationDate: Date? var children: [FileExplorerNode]? var isLoading: Bool = false var error: String? - init(name: String, path: String, isDirectory: Bool) { + init( + name: String, + path: String, + isDirectory: Bool, + creationDate: Date? = nil, + modificationDate: Date? = nil + ) { self.id = path self.name = name self.path = path self.isDirectory = isDirectory + self.creationDate = creationDate + self.modificationDate = modificationDate } var isExpandable: Bool { isDirectory } - - var sortedChildren: [FileExplorerNode]? { - children?.sorted { a, b in - if a.isDirectory != b.isDirectory { return a.isDirectory } - return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending - } - } } // MARK: - Root Resolver @@ -306,13 +325,24 @@ final class LocalFileExplorerProvider: FileExplorerProvider { func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] { let fm = FileManager.default - let contents = try fm.contentsOfDirectory(atPath: path) - return contents.compactMap { name in + let contents = try fm.contentsOfDirectory( + at: URL(fileURLWithPath: path, isDirectory: true), + includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey], + options: [] + ) + return contents.compactMap { url in + let name = url.lastPathComponent guard showHidden || !name.hasPrefix(".") else { return nil } - let fullPath = (path as NSString).appendingPathComponent(name) - var isDir: ObjCBool = false - guard fm.fileExists(atPath: fullPath, isDirectory: &isDir) else { return nil } - return FileExplorerEntry(name: name, path: fullPath, isDirectory: isDir.boolValue) + let values = try? url.resourceValues( + forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey] + ) + return FileExplorerEntry( + name: name, + path: url.path, + isDirectory: values?.isDirectory ?? false, + creationDate: values?.creationDate, + modificationDate: values?.contentModificationDate + ) } } } @@ -675,34 +705,119 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { connection: SSHFileExplorerConnection, showHidden: Bool ) async throws -> [FileExplorerEntry] { - // Escape single quotes in path for shell safety - let escapedPath = shellSingleQuote(path) - let lsFlags = showHidden ? "-1paFA" : "-1paF" let output = try await runSSHCommand( connection: connection, - command: "ls \(lsFlags) \(escapedPath) 2>/dev/null" + command: posixShellBootstrap(script: remoteListingScript(path: path, showHidden: showHidden)) ) - + return parseRemoteListing(output, path: path, showHidden: showHidden) + } + + /// POSIX `sh` script that lists `path` for the file explorer. + /// + /// It detects GNU vs BSD `stat` once, then enumerates the directory with + /// `find ... -exec stat {} +`, which batches `stat` over the entries within + /// the argument-size limit. The previous implementation spawned two or more + /// `stat` processes per entry, which could make large remote directories + /// appear to hang; a single glob of every entry would instead overflow + /// `ARG_MAX` and truncate large listings. Timestamps are always collected + /// so changing the sort key re-sorts the cached listing without a re-fetch. + /// + /// Access failures stay distinguishable from empty directories: an + /// unreadable or missing directory (`cd` fails or `.` is not readable), a + /// host without a usable `stat`, or a listing command that fails (`find` + /// lacks `-mindepth`/`-maxdepth`, an unsupported `stat` format, or a + /// per-entry error) exits non-zero via `|| exit 1` so `runSSHCommand` raises + /// `sshCommandFailed` instead of masking the failure as an empty listing. A + /// readable but genuinely empty directory makes `find` exit zero with no + /// output and reaches the trailing `exit 0`, listing as empty. + static func remoteListingScript(path: String, showHidden: Bool) -> String { + let escapedPath = shellSingleQuote(path) + // Exclude dotfiles unless hidden entries are requested. `find` never + // yields `.`/`..` because it only descends from `.`. + let nameFilter = showHidden ? "" : "! -name '.*' " + // `stat` does NOT dereference symlinks (no `-L`): a following stat omits + // dangling symlinks entirely on GNU, hiding them from the explorer. With + // plain lstat every entry is listed, and symlinks report as their own + // type — matching the previous `ls -F` behavior. + // + // Literal tabs separate the fields; GNU `stat -c` does not expand `\t`. + return """ + cd \(escapedPath) 2>/dev/null || exit 1 + [ -r . ] || exit 1 + if stat -c %Y / >/dev/null 2>&1; then + find . -mindepth 1 -maxdepth 1 \(nameFilter)-exec stat -c '%A\t%Y\t%W\t%n' {} + 2>/dev/null || exit 1 + elif stat -f %m / >/dev/null 2>&1; then + find . -mindepth 1 -maxdepth 1 \(nameFilter)-exec stat -f '%Sp\t%m\t%B\t%N' {} + 2>/dev/null || exit 1 + else + exit 1 + fi + exit 0 + """ + } + + /// Wraps a POSIX script so it runs under `/bin/sh`, independent of the + /// remote account's login shell. + /// + /// OpenSSH runs the remote command through the user's login shell, and + /// non-POSIX shells (fish, csh/tcsh) cannot parse `for`/`if`/`case` or + /// `$(...)`. The script is base64-encoded so only `/bin/sh -c` plus a + /// base64 payload — which contains no shell metacharacters — reaches the + /// login shell. `base64 -d` (GNU/coreutils) falls back to `-D` (BSD/macOS). + /// + /// If neither decode works (no/incompatible `base64`), the decoded script is + /// empty and the bootstrap exits non-zero rather than running `eval ""` and + /// reporting a silently empty directory. + static func posixShellBootstrap(script: String) -> String { + let encoded = Data(script.utf8).base64EncodedString() + return "/bin/sh -c 'b=\(encoded); s=$(printf %s \"$b\" | base64 -d 2>/dev/null || printf %s \"$b\" | base64 -D 2>/dev/null); [ -n \"$s\" ] || exit 1; eval \"$s\"'" + } + + /// Parses the tab-separated output of ``remoteListingScript(path:showHidden:)``. + /// + /// Each line is `modemtimebtimename`. The trailing `name` + /// field is split last so names containing spaces survive. The leading mode + /// string uses `stat`'s permission format (`d…`, `-…`, `l…`) so directory + /// detection does not depend on localized file-type prose. `find .` reports + /// each entry as `./name`, so only the final path component is kept. + static func parseRemoteListing( + _ output: String, + path: String, + showHidden: Bool + ) -> [FileExplorerEntry] { let normalizedPath = path.hasSuffix("/") ? path : path + "/" return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in - let entry = String(line) - // Skip . and .. entries - guard entry != "./" && entry != "../" else { return nil } - let isDir = entry.hasSuffix("/") - let name = isDir ? String(entry.dropLast()) : entry + let parts = line.split(separator: "\t", maxSplits: 3, omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let rawName = parts[3] + let name = String(rawName.split(separator: "/").last ?? rawName) + guard !name.isEmpty, name != ".", name != ".." else { return nil } guard showHidden || !name.hasPrefix(".") else { return nil } - // Strip type indicators from -F flag (*, @, =, |) for files - let cleanName: String - if !isDir, let last = name.last, "*@=|".contains(last) { - cleanName = String(name.dropLast()) - } else { - cleanName = name - } - let fullPath = normalizedPath + cleanName - return FileExplorerEntry(name: cleanName, path: fullPath, isDirectory: isDir) + let isDirectory = parts[0].first == "d" + return FileExplorerEntry( + name: name, + path: normalizedPath + name, + isDirectory: isDirectory, + creationDate: dateFromEpochString(String(parts[2]), minimumEpoch: birthTimeMinimumEpoch), + modificationDate: dateFromEpochString(String(parts[1])) + ) } } + /// Birth times below this epoch (≈1973-03-03) are treated as unknown; some + /// filesystems report `0` or other small sentinels when birth time is + /// unavailable. + private static let birthTimeMinimumEpoch: TimeInterval = 100_000_000 + + private static func dateFromEpochString( + _ value: String, + minimumEpoch: TimeInterval = 0 + ) -> Date? { + guard let seconds = TimeInterval(value), seconds > 0, seconds >= minimumEpoch else { + return nil + } + return Date(timeIntervalSince1970: seconds) + } + private static func shellSingleQuote(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" } @@ -744,6 +859,8 @@ final class FileExplorerStore: ObservableObject { @Published private(set) var isRootLoading: Bool = false @Published private(set) var gitStatusByPath: [String: GitFileStatus] = [:] @Published private(set) var contentRevision = 0 + private(set) var sortOptions: FileExplorerSortOptions + private(set) var sortRevision = 0 @Published private(set) var rootStatusMessage: String? private(set) var workspaceRootIdentity: UUID? @@ -783,11 +900,29 @@ final class FileExplorerStore: ObservableObject { private var remoteHomeResolutionTask: Task? private var remoteHomeResolutionKey: String? + private let sortSettings: FileExplorerSortSettings + private let notificationCenter: NotificationCenter + private var sortSettingsObserver: NSObjectProtocol? private let gitStatusProvider: GitStatusProvider - init(gitStatusProvider: GitStatusProvider = GitStatusProvider()) { + init( + sortDefaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default, + gitStatusProvider: GitStatusProvider = GitStatusProvider() + ) { + self.notificationCenter = notificationCenter self.gitStatusProvider = gitStatusProvider + let sortSettings = FileExplorerSortSettings(defaults: sortDefaults, notificationCenter: notificationCenter) + self.sortSettings = sortSettings + self.sortOptions = sortSettings.resolvedOptions() + self.sortSettingsObserver = notificationCenter.addObserver( + forName: FileExplorerSortSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.applySortOptionsFromDefaults() + } } var displayRootPath: String { @@ -990,6 +1125,21 @@ final class FileExplorerStore: ObservableObject { expandedPaths.contains(node.path) } + func setSortKey(_ key: FileExplorerSortKey) { + let nextOrder: FileExplorerSortOrder = sortOptions.key == .name && key != .name + ? .descending + : sortOptions.order + setSortOptions(FileExplorerSortOptions(key: key, order: nextOrder)) + } + + func setSortOrder(_ order: FileExplorerSortOrder) { + setSortOptions(FileExplorerSortOptions(key: sortOptions.key, order: order)) + } + + func setSortOptions(_ options: FileExplorerSortOptions) { + applySortOptions(options, persist: true) + } + func select(node: FileExplorerNode?) { let path = node?.path let paths = path.map { Set([$0]) } ?? [] @@ -1067,13 +1217,16 @@ final class FileExplorerStore: ObservableObject { let entries = try await provider.listDirectory(path: path, showHidden: showHiddenFiles) try Task.checkCancellation() let children = entries.map { entry in - let node = FileExplorerNode(name: entry.name, path: entry.path, isDirectory: entry.isDirectory) + let node = FileExplorerNode( + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + creationDate: entry.creationDate, + modificationDate: entry.modificationDate + ) nodesByPath[entry.path] = node return node - }.sorted { a, b in - if a.isDirectory != b.isDirectory { return a.isDirectory } - return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending - } + }.sorted(using: sortOptions) if let parentNode { parentNode.children = children @@ -1139,6 +1292,33 @@ final class FileExplorerStore: ObservableObject { isRootLoading = false } + private func applySortOptionsFromDefaults() { + applySortOptions(sortSettings.resolvedOptions(), persist: false) + } + + private func applySortOptions(_ options: FileExplorerSortOptions, persist: Bool) { + guard sortOptions != options else { return }; objectWillChange.send() + sortOptions = options + resortLoadedNodes() + sortRevision &+= 1 + if persist { + sortSettings.setOptions(options) + } + } + + private func resortLoadedNodes() { + rootNodes = sortNodes(rootNodes) + for node in nodesByPath.values { + if let children = node.children { + node.children = sortNodes(children) + } + } + } + + private func sortNodes(_ nodes: [FileExplorerNode]) -> [FileExplorerNode] { + FileExplorerNodeSorter(options: sortOptions).sorted(nodes) + } + private func applyRemoteSSHWorkspaceRoot( workspaceId: UUID, connection: SSHFileExplorerConnection, @@ -1311,7 +1491,16 @@ final class FileExplorerStore: ObservableObject { } deinit { + if let sortSettingsObserver { + notificationCenter.removeObserver(sortSettingsObserver) + } cancelRemoteHomeResolution() directoryWatchTask?.cancel() } } + +private extension Array where Element == FileExplorerNode { + func sorted(using options: FileExplorerSortOptions) -> [FileExplorerNode] { + FileExplorerNodeSorter(options: options).sorted(self) + } +} diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 0c7c017ae904..dbc32940d01b 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -93,6 +93,8 @@ struct FileExplorerPanelView: NSViewRepresentable { weak var containerView: FileExplorerContainerView? weak var outlineView: NSOutlineView? private var lastRootNodeCount: Int = -1 + private var lastContentRevision: Int = -1 + private var lastSortRevision: Int = -1 private var observationCancellable: AnyCancellable? private var styleObserver: Any? private var isUpdatingOutlineProgrammatically = false @@ -179,9 +181,15 @@ struct FileExplorerPanelView: NSViewRepresentable { ) let newCount = store.rootNodes.count + let newContentRevision = store.contentRevision + let newSortRevision = store.sortRevision withProgrammaticOutlineUpdate { - if newCount != lastRootNodeCount { + if newCount != lastRootNodeCount || + newContentRevision != lastContentRevision || + newSortRevision != lastSortRevision { lastRootNodeCount = newCount + lastContentRevision = newContentRevision + lastSortRevision = newSortRevision let expandedPaths = store.expandedPaths outlineView.reloadData() restoreExpansionState(expandedPaths, in: outlineView) @@ -193,9 +201,9 @@ struct FileExplorerPanelView: NSViewRepresentable { } private func restoreExpansionState(_ expandedPaths: Set, in outlineView: NSOutlineView) { - for row in 0.. Any { @@ -238,7 +246,7 @@ struct FileExplorerPanelView: NSViewRepresentable { return store.rootNodes[index] } guard let node = item as? FileExplorerNode, - let children = node.sortedChildren else { + let children = node.children else { return FileExplorerNode(name: "", path: "", isDirectory: false) } return children[index] @@ -697,6 +705,12 @@ final class FileExplorerContainerView: NSView { self.coordinator = coordinator super.init(frame: .zero) + headerView.onSelectSortKey = { [weak coordinator] key in + coordinator?.store.setSortKey(key) + } + headerView.onSelectSortOrder = { [weak coordinator] order in + coordinator?.store.setSortOrder(order) + } updateShortcutPlacement(coordinator.placement) configureSearchDebounce() @@ -965,7 +979,7 @@ final class FileExplorerContainerView: NSView { let searchScopeChanged = workspaceRootChanged || nextRootPath != currentRootPath || nextProviderIsLocal != currentProviderIsLocal currentRootPath = nextRootPath; currentProviderIsLocal = nextProviderIsLocal currentWorkspaceRootIdentity = nextWorkspaceRootIdentity; currentContentRevision = nextContentRevision - headerView.update(displayPath: store.displayRootPath) + headerView.update(displayPath: store.displayRootPath, sortOptions: store.sortOptions) if workspaceRootChanged { cancelPendingSearchRefresh(); pendingSearchRefreshAfterSettled = false; searchController.cancel(clear: true); searchField.stringValue = ""; applySearchSnapshot(.empty) } if searchScopeChanged { pendingSearchRefreshAfterSettled = false @@ -1653,4 +1667,4 @@ extension FileExplorerContainerView: NSSearchFieldDelegate, NSTableViewDataSourc copyRelativePathItem.representedObject = NSNumber(value: row) menu.addItem(copyRelativePathItem) } -} \ No newline at end of file +} diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 8a176038fb9e..9aad0e83a674 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -216,6 +216,8 @@ extension CmuxSettingsFileStore { [ "fileExplorer": [ "doubleClickAction": FileExplorerDoubleClickActionSettings.defaultValue.rawValue, + "sortBy": FileExplorerSortOptions.defaultValue.key.rawValue, + "sortOrder": FileExplorerSortOptions.defaultValue.order.rawValue, ], ], [ diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index bf08f3e34904..e668f0017115 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -693,6 +693,26 @@ final class CmuxSettingsFileStore { } else if section.keys.contains("doubleClickAction") { logInvalid("fileExplorer.doubleClickAction", sourcePath: sourcePath) } + + if let raw = jsonString(section["sortBy"]) { + if let key = FileExplorerSortKey(rawValue: raw) { + snapshot.managedUserDefaults[FileExplorerSortSettings.sortKeyKey] = .string(key.rawValue) + } else { + logInvalid("fileExplorer.sortBy", sourcePath: sourcePath) + } + } else if section.keys.contains("sortBy") { + logInvalid("fileExplorer.sortBy", sourcePath: sourcePath) + } + + if let raw = jsonString(section["sortOrder"]) { + if let order = FileExplorerSortOrder(rawValue: raw) { + snapshot.managedUserDefaults[FileExplorerSortSettings.sortOrderKey] = .string(order.rawValue) + } else { + logInvalid("fileExplorer.sortOrder", sourcePath: sourcePath) + } + } else if section.keys.contains("sortOrder") { + logInvalid("fileExplorer.sortOrder", sourcePath: sourcePath) + } } private func parseSidebarSection( @@ -1630,6 +1650,11 @@ final class CmuxSettingsFileStore { TerminalCopyOnSelectSettings.notifyDidChange(notificationCenter: notificationCenter) } + if change.defaultsKey == FileExplorerSortSettings.sortKeyKey || + change.defaultsKey == FileExplorerSortSettings.sortOrderKey { + notificationCenter.post(name: FileExplorerSortSettings.didChangeNotification, object: nil) + } + if change.defaultsKey == AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey { agentSessionAutoResumeDidChange = true } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1a0f2bf013e5..f69d92c0f178 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -542,6 +542,7 @@ FE002112 /* FileExplorerGitStatusProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002012 /* FileExplorerGitStatusProviderTests.swift */; }; FE5996020000000000000002 /* FileExplorerHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996020000000000000001 /* FileExplorerHeaderView.swift */; }; FE001105 /* FileExplorerKeyboardShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001005 /* FileExplorerKeyboardShortcuts.swift */; }; + FE59960B0000000000000002 /* FileExplorerNodeSorter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE59960B0000000000000001 /* FileExplorerNodeSorter.swift */; }; FE5996030000000000000002 /* FileExplorerNSOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; 9F8A6669D6F54F0EA8E8BEB8 /* FileExplorerRootSyncPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120A1EF846214C938416846E /* FileExplorerRootSyncPolicyTests.swift */; }; @@ -551,6 +552,10 @@ FE5996060000000000000002 /* FileExplorerSearchResultCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996060000000000000001 /* FileExplorerSearchResultCellView.swift */; }; FE5996070000000000000002 /* FileExplorerSearchResultsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5996070000000000000001 /* FileExplorerSearchResultsTableView.swift */; }; F5996002A1B2C3D4E5F60001 /* FileExplorerShortcutSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5996002A1B2C3D4E5F60002 /* FileExplorerShortcutSettingsTests.swift */; }; + FE59960C0000000000000002 /* FileExplorerSortKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE59960C0000000000000001 /* FileExplorerSortKey.swift */; }; + FE59960A0000000000000002 /* FileExplorerSortOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE59960A0000000000000001 /* FileExplorerSortOptions.swift */; }; + FE59960D0000000000000002 /* FileExplorerSortOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE59960D0000000000000001 /* FileExplorerSortOrder.swift */; }; + FE59960E0000000000000002 /* FileExplorerSortSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE59960E0000000000000001 /* FileExplorerSortSettings.swift */; }; B37A00000000000000000009 /* FileExplorerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000A /* FileExplorerState.swift */; }; B37A0000000000000000000B /* FileExplorerStateModePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */; }; FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; @@ -1857,6 +1862,7 @@ FE002012 /* FileExplorerGitStatusProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerGitStatusProviderTests.swift; sourceTree = ""; }; FE5996020000000000000001 /* FileExplorerHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerHeaderView.swift; sourceTree = ""; }; FE001005 /* FileExplorerKeyboardShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerKeyboardShortcuts.swift; sourceTree = ""; }; + FE59960B0000000000000001 /* FileExplorerNodeSorter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerNodeSorter.swift; sourceTree = ""; }; FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerNSOutlineView.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 = ""; }; @@ -1866,6 +1872,10 @@ FE5996060000000000000001 /* FileExplorerSearchResultCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchResultCellView.swift; sourceTree = ""; }; FE5996070000000000000001 /* FileExplorerSearchResultsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchResultsTableView.swift; sourceTree = ""; }; F5996002A1B2C3D4E5F60002 /* FileExplorerShortcutSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerShortcutSettingsTests.swift; sourceTree = ""; }; + FE59960C0000000000000001 /* FileExplorerSortKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSortKey.swift; sourceTree = ""; }; + FE59960A0000000000000001 /* FileExplorerSortOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSortOptions.swift; sourceTree = ""; }; + FE59960D0000000000000001 /* FileExplorerSortOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSortOrder.swift; sourceTree = ""; }; + FE59960E0000000000000001 /* FileExplorerSortSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSortSettings.swift; sourceTree = ""; }; B37A0000000000000000000A /* FileExplorerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerState.swift; sourceTree = ""; }; B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStateModePersistenceTests.swift; sourceTree = ""; }; FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; @@ -3539,6 +3549,11 @@ FE5996080000000000000001 /* FileExplorerExternalOpenMenuItems.swift */, FE5996090000000000000001 /* FileExplorerExternalOpenRequest.swift */, FE5996020000000000000001 /* FileExplorerHeaderView.swift */, + FE59960B0000000000000001 /* FileExplorerNodeSorter.swift */, + FE59960C0000000000000001 /* FileExplorerSortKey.swift */, + FE59960A0000000000000001 /* FileExplorerSortOptions.swift */, + FE59960D0000000000000001 /* FileExplorerSortOrder.swift */, + FE59960E0000000000000001 /* FileExplorerSortSettings.swift */, FE001005 /* FileExplorerKeyboardShortcuts.swift */, FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */, FE5996040000000000000001 /* FileExplorerRowView.swift */, @@ -4738,12 +4753,17 @@ FE5996090000000000000002 /* FileExplorerExternalOpenRequest.swift in Sources */, FE5996020000000000000002 /* FileExplorerHeaderView.swift in Sources */, FE001105 /* FileExplorerKeyboardShortcuts.swift in Sources */, + FE59960B0000000000000002 /* FileExplorerNodeSorter.swift in Sources */, FE5996030000000000000002 /* FileExplorerNSOutlineView.swift in Sources */, FE5996040000000000000002 /* FileExplorerRowView.swift in Sources */, FE001103 /* FileExplorerSearchController.swift in Sources */, FE5996050000000000000002 /* FileExplorerSearchField.swift in Sources */, FE5996060000000000000002 /* FileExplorerSearchResultCellView.swift in Sources */, FE5996070000000000000002 /* FileExplorerSearchResultsTableView.swift in Sources */, + FE59960C0000000000000002 /* FileExplorerSortKey.swift in Sources */, + FE59960A0000000000000002 /* FileExplorerSortOptions.swift in Sources */, + FE59960D0000000000000002 /* FileExplorerSortOrder.swift in Sources */, + FE59960E0000000000000002 /* FileExplorerSortSettings.swift in Sources */, B37A00000000000000000009 /* FileExplorerState.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001104 /* FileExplorerTerminalPathInsertion.swift in Sources */, diff --git a/cmuxTests/FileExplorerDoubleClickActionTests.swift b/cmuxTests/FileExplorerDoubleClickActionTests.swift index fb472b1f1b2c..fcca4361a5ea 100644 --- a/cmuxTests/FileExplorerDoubleClickActionTests.swift +++ b/cmuxTests/FileExplorerDoubleClickActionTests.swift @@ -126,3 +126,126 @@ import Testing ) } } + +@Suite struct FileExplorerSortSettingsTests { + private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" + private let importedManagedDefaultsKey = "cmux.settingsFile.importedManagedDefaults.v1" + + private func makeDefaults() -> UserDefaults { + let suiteName = "cmux-file-explorer-sort-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-file-explorer-sort-settings-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func writeSettingsFile(_ contents: String, to url: URL) throws { + try contents.data(using: .utf8)!.write(to: url, options: .atomic) + } + + private func preservingDefaults(keys: [String], _ body: () throws -> Void) throws { + let defaults = UserDefaults.standard + let saved = Dictionary(uniqueKeysWithValues: keys.map { ($0, defaults.object(forKey: $0)) }) + defer { + for (key, value) in saved { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + try body() + } + + @Test func defaultSortIsNameAscending() { + #expect(FileExplorerSortOptions.defaultValue == FileExplorerSortOptions(key: .name, order: .ascending)) + } + + @Test func parsesEachKnownSortKeyRawValue() { + #expect(FileExplorerSortKey(resolvingRawValue: "name") == .name) + #expect(FileExplorerSortKey(resolvingRawValue: "dateCreated") == .dateCreated) + #expect(FileExplorerSortKey(resolvingRawValue: "dateModified") == .dateModified) + } + + @Test func parsesEachKnownSortOrderRawValue() { + #expect(FileExplorerSortOrder(resolvingRawValue: "ascending") == .ascending) + #expect(FileExplorerSortOrder(resolvingRawValue: "descending") == .descending) + } + + @Test func unknownSortValuesFallBackToDefaults() { + for raw in [nil, "", "modified", "date-created", "DESC", "newest"] { + #expect(FileExplorerSortKey(resolvingRawValue: raw) == .name) + #expect(FileExplorerSortOrder(resolvingRawValue: raw) == .ascending) + } + } + + @Test func rawValuesMatchConfigSchema() { + #expect(FileExplorerSortKey.allCases.map(\.rawValue) == ["name", "dateCreated", "dateModified"]) + #expect(FileExplorerSortOrder.allCases.map(\.rawValue) == ["ascending", "descending"]) + } + + @Test func resolvedOptionsRoundTripThroughUserDefaults() { + let defaults = makeDefaults() + let settings = FileExplorerSortSettings(defaults: defaults, notificationCenter: NotificationCenter()) + let options = FileExplorerSortOptions(key: .dateModified, order: .descending) + + settings.setOptions(options) + + #expect(settings.resolvedOptions() == options) + } + + @Test func settingsFileStoreParsesFileExplorerSortOptions() throws { + let defaults = UserDefaults.standard + + try preservingDefaults(keys: [ + FileExplorerSortSettings.sortKeyKey, + FileExplorerSortSettings.sortOrderKey, + settingsFileBackupsDefaultsKey, + importedManagedDefaultsKey + ]) { + defaults.removeObject(forKey: FileExplorerSortSettings.sortKeyKey) + defaults.removeObject(forKey: FileExplorerSortSettings.sortOrderKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + defaults.removeObject(forKey: importedManagedDefaultsKey) + let sortSettings = FileExplorerSortSettings(defaults: defaults, notificationCenter: NotificationCenter()) + + #expect(sortSettings.resolvedOptions() == FileExplorerSortOptions.defaultValue) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "fileExplorer": { + "sortBy": "dateModified", + "sortOrder": "descending" + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + + withExtendedLifetime(store) { + #expect(defaults.string(forKey: FileExplorerSortSettings.sortKeyKey) == "dateModified") + #expect(defaults.string(forKey: FileExplorerSortSettings.sortOrderKey) == "descending") + #expect(sortSettings.resolvedOptions() == FileExplorerSortOptions(key: .dateModified, order: .descending)) + } + } + } +} diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index cfba78a3e5f1..b5ba1f55ac36 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Testing #if canImport(cmux_DEV) @@ -186,6 +187,54 @@ struct FileExplorerStoreTests { #expect(!(store.rootNodes[1].isDirectory)) } + @Test + func testNameSortPreservesFoldersFirstThenAlphabetical() { + let nodes = [ + FileExplorerNode(name: "z-file.txt", path: "/project/z-file.txt", isDirectory: false), + FileExplorerNode(name: "beta", path: "/project/beta", isDirectory: true), + FileExplorerNode(name: "alpha.txt", path: "/project/alpha.txt", isDirectory: false), + FileExplorerNode(name: "alpha", path: "/project/alpha", isDirectory: true), + ] + + let sorted = FileExplorerNodeSorter(options: FileExplorerSortOptions(key: .name, order: .ascending)) + .sorted(nodes) + + #expect(sorted.map(\.name) == ["alpha", "beta", "alpha.txt", "z-file.txt"]) + } + + @Test + func testDateModifiedDescendingSortsNewestEntriesAcrossFilesAndFolders() { + let old = Date(timeIntervalSince1970: 100) + let newer = Date(timeIntervalSince1970: 200) + let newest = Date(timeIntervalSince1970: 300) + let nodes = [ + FileExplorerNode( + name: "old-folder", + path: "/project/old-folder", + isDirectory: true, + modificationDate: old + ), + FileExplorerNode( + name: "new-report.png", + path: "/project/new-report.png", + isDirectory: false, + modificationDate: newest + ), + FileExplorerNode( + name: "middle.txt", + path: "/project/middle.txt", + isDirectory: false, + modificationDate: newer + ), + FileExplorerNode(name: "untimed.txt", path: "/project/untimed.txt", isDirectory: false), + ] + + let sorted = FileExplorerNodeSorter(options: FileExplorerSortOptions(key: .dateModified, order: .descending)) + .sorted(nodes) + + #expect(sorted.map(\.name) == ["new-report.png", "middle.txt", "old-folder", "untimed.txt"]) + } + @Test func testDisplayRootPathUsesTilde() { let provider = MockFileExplorerProvider(homePath: "/home/user") @@ -1202,3 +1251,358 @@ struct FileSearchControllerTests { } } } + +// MARK: - SSH remote listing command construction & parsing + +/// Regression coverage for the SSH date-sort listing rewrite: the remote +/// command must run under a POSIX shell (not the account's login shell, which +/// may be fish/csh), must not spawn `stat` per entry or overflow `ARG_MAX`, +/// must keep dangling symlinks visible, and must surface access failures +/// instead of reporting them as empty directories. +@Suite +struct ProcessSSHFileExplorerListingTests { + + // MARK: Behavioral coverage — execute the real generated command + + /// Builds the command the SSH transport would send and runs it locally + /// through `shell` (which stands in for the remote login shell OpenSSH hands + /// the command to), returning captured stdout and the process exit status. + private func runRemoteListing( + path: String, + showHidden: Bool, + shell: String = "/bin/sh" + ) throws -> (output: String, status: Int32) { + let command = ProcessSSHFileExplorerTransport.posixShellBootstrap( + script: ProcessSSHFileExplorerTransport.remoteListingScript( + path: path, + showHidden: showHidden + ) + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-c", command] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + try process.run() + let data = stdout.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (String(decoding: data, as: UTF8.self), process.terminationStatus) + } + + private func makeListingFixtureDirectory(name: String? = nil) throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ssh-listing-\(name ?? UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func names( + from output: String, + path: String, + showHidden: Bool + ) -> [FileExplorerEntry] { + ProcessSSHFileExplorerTransport.parseRemoteListing(output, path: path, showHidden: showHidden) + } + + @Test + func testRemoteListingExecutesAndReportsDirectoryContents() throws { + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + try "x".write(to: root.appendingPathComponent("file one.txt"), atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("sub"), + withIntermediateDirectories: true + ) + + let (output, status) = try runRemoteListing(path: root.path, showHidden: false) + #expect(status == 0) + let entries = names(from: output, path: root.path, showHidden: false) + let byName = Dictionary(entries.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + #expect(Set(entries.map(\.name)) == ["file one.txt", "sub"]) + #expect(byName["sub"]?.isDirectory == true) + #expect(byName["file one.txt"]?.isDirectory == false) + #expect(byName["file one.txt"]?.path == root.path + "/file one.txt") + // Timestamps are always collected so date sorts work without re-listing. + #expect(byName["sub"]?.modificationDate != nil) + } + + @Test + func testRemoteListingExecutionHidesDotfilesUnlessRequested() throws { + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + try "x".write(to: root.appendingPathComponent(".secret"), atomically: true, encoding: .utf8) + try "y".write(to: root.appendingPathComponent("visible.txt"), atomically: true, encoding: .utf8) + + let (visibleOut, visibleStatus) = try runRemoteListing(path: root.path, showHidden: false) + #expect(visibleStatus == 0) + #expect(names(from: visibleOut, path: root.path, showHidden: false).map(\.name) == ["visible.txt"]) + + let (allOut, allStatus) = try runRemoteListing(path: root.path, showHidden: true) + #expect(allStatus == 0) + #expect(Set(names(from: allOut, path: root.path, showHidden: true).map(\.name)) == [".secret", "visible.txt"]) + } + + @Test + func testRemoteListingExecutionKeepsDanglingAndDirectorySymlinks() throws { + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory( + at: root.appendingPathComponent("target"), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + atPath: root.appendingPathComponent("dir-link").path, + withDestinationPath: "target" + ) + try FileManager.default.createSymbolicLink( + atPath: root.appendingPathComponent("broken-link").path, + withDestinationPath: "does-not-exist" + ) + + let (output, status) = try runRemoteListing(path: root.path, showHidden: false) + #expect(status == 0) + let entries = names(from: output, path: root.path, showHidden: false) + // The dangling symlink must stay visible (the earlier `stat -L` path + // silently dropped it on GNU). Symlinks are never expandable directories. + #expect(Set(entries.map(\.name)) == ["target", "dir-link", "broken-link"]) + let byName = Dictionary(entries.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + #expect(byName["broken-link"]?.isDirectory == false) + #expect(byName["dir-link"]?.isDirectory == false) + #expect(byName["target"]?.isDirectory == true) + } + + @Test + func testRemoteListingExecutionEmptyDirectorySucceedsWithNoEntries() throws { + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let (output, status) = try runRemoteListing(path: root.path, showHidden: true) + #expect(status == 0) + #expect(names(from: output, path: root.path, showHidden: true).isEmpty) + } + + @Test + func testRemoteListingExecutionMissingDirectoryExitsNonZero() throws { + let root = try makeListingFixtureDirectory() + try FileManager.default.removeItem(at: root) + // A missing directory must surface as a non-zero exit (→ sshCommandFailed), + // never a silently empty listing. + let (output, status) = try runRemoteListing(path: root.path, showHidden: false) + #expect(status != 0) + #expect(output.isEmpty) + } + + @Test(.enabled(if: geteuid() != 0, "permission bits do not restrict the root user")) + func testRemoteListingExecutionUnreadableDirectoryExitsNonZero() throws { + let root = try makeListingFixtureDirectory() + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path) + try? FileManager.default.removeItem(at: root) + } + try "x".write(to: root.appendingPathComponent("f"), atomically: true, encoding: .utf8) + // Execute bit only (so `cd` succeeds) but no read bit, exercising `[ -r . ]`. + try FileManager.default.setAttributes([.posixPermissions: 0o111], ofItemAtPath: root.path) + let (_, status) = try runRemoteListing(path: root.path, showHidden: false) + #expect(status != 0) + } + + @Test + func testRemoteListingExecutionHandlesPathWithSingleQuote() throws { + let root = try makeListingFixtureDirectory(name: "qu'ote-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + try "x".write(to: root.appendingPathComponent("inside.txt"), atomically: true, encoding: .utf8) + // If the path were not single-quote-escaped, `cd` would fail and the + // command would error instead of listing the file. + let (output, status) = try runRemoteListing(path: root.path, showHidden: false) + #expect(status == 0) + #expect(names(from: output, path: root.path, showHidden: false).map(\.name) == ["inside.txt"]) + } + + @Test + func testRemoteListingExitsNonZeroWhenListingCommandFails() throws { + // A wholesale listing failure — e.g. a remote `find` that lacks + // `-mindepth`/`-maxdepth`, or a `stat` format the probe did not exercise — + // must surface as a non-zero exit (→ sshCommandFailed), never a silently + // empty directory. Shadow `find` with a stub that exits non-zero while the + // real `stat`/`base64` stay resolvable through the inherited PATH. + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + try "x".write(to: root.appendingPathComponent("present.txt"), atomically: true, encoding: .utf8) + + let binDir = try makeListingFixtureDirectory(name: "fakebin-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: binDir) } + let fakeFind = binDir.appendingPathComponent("find") + try "#!/bin/sh\nexit 3\n".write(to: fakeFind, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeFind.path) + + let command = ProcessSSHFileExplorerTransport.posixShellBootstrap( + script: ProcessSSHFileExplorerTransport.remoteListingScript(path: root.path, showHidden: false) + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + // Prepend the stub directory so `find` resolves to the failing stub while + // the real `stat`/`base64` remain reachable via the inherited PATH. + var environment = ProcessInfo.processInfo.environment + let inheritedPath = environment["PATH"] ?? "/usr/bin:/bin" + environment["PATH"] = "\(binDir.path):\(inheritedPath)" + process.environment = environment + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + try process.run() + let data = stdout.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + // Without `|| exit 1` on the `find`, the failure is swallowed and the + // script reaches the trailing `exit 0` with empty output — the regression. + #expect(process.terminationStatus != 0) + #expect(data.isEmpty) + } + + // MARK: POSIX shell bootstrap (login-shell independence, base64 dependency) + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"), + "zsh is required for the cross-login-shell behavior test")) + func testRemoteListingRunsIdenticallyAcrossLoginShells() throws { + // The base64 bootstrap is what makes the command independent of the + // remote login shell. Running it through /bin/sh and /bin/zsh (a common + // interactive login shell) must produce the same listing. + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + try "x".write(to: root.appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("d"), + withIntermediateDirectories: true + ) + + let (shOut, shStatus) = try runRemoteListing(path: root.path, showHidden: false, shell: "/bin/sh") + let (zshOut, zshStatus) = try runRemoteListing(path: root.path, showHidden: false, shell: "/bin/zsh") + #expect(shStatus == 0) + #expect(zshStatus == 0) + let shNames = Set(names(from: shOut, path: root.path, showHidden: false).map(\.name)) + let zshNames = Set(names(from: zshOut, path: root.path, showHidden: false).map(\.name)) + #expect(shNames == ["a.txt", "d"]) + #expect(shNames == zshNames) + } + + @Test + func testRemoteListingExitsNonZeroWhenBase64Unavailable() throws { + // With base64 missing from PATH the decode yields an empty script, which + // must error rather than run `eval ""` and report an empty listing. + let root = try makeListingFixtureDirectory() + defer { try? FileManager.default.removeItem(at: root) } + try "x".write(to: root.appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + + let command = ProcessSSHFileExplorerTransport.posixShellBootstrap( + script: ProcessSSHFileExplorerTransport.remoteListingScript(path: root.path, showHidden: false) + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + // An empty PATH removes the external `base64` (printf/[/cd are builtins), + // forcing the decode-failure path. /bin/sh is launched by absolute path. + process.environment = ["PATH": ""] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus != 0) + } + + // MARK: Parsing of the tab-separated stat output + + @Test + func testParseRemoteListingParsesModeTimesAndName() { + // Lines are `modemtimebtimename`. `find .` reports each + // entry as `./name`, so only the final component is kept. Cover directory + // and regular-file mode strings plus a name with a space. + let output = [ + "drwxr-xr-x\t1700000000\t1690000000\t./sub", + "-rw-r--r--\t1700000100\t1690000100\t./main.swift", + "-rw-r--r--\t1700000200\t1690000200\t./file one.txt", + ].joined(separator: "\n") + + let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( + output, + path: "/srv/app", + showHidden: false + ) + + #expect(entries.map(\.name) == ["sub", "main.swift", "file one.txt"]) + #expect(entries[0].isDirectory) + #expect(!entries[1].isDirectory) + #expect(!entries[2].isDirectory) + #expect(entries[0].path == "/srv/app/sub") + #expect(entries[2].path == "/srv/app/file one.txt") + #expect(entries[1].modificationDate == Date(timeIntervalSince1970: 1700000100)) + #expect(entries[1].creationDate == Date(timeIntervalSince1970: 1690000100)) + } + + @Test + func testParseRemoteListingKeepsSymlinksVisibleAsNonDirectories() { + // Dangling and directory symlinks both report with `l` mode under + // non-dereferencing stat; they must stay listed (so users can manage + // them) and never be treated as expandable directories. + let output = [ + "lrwxr-xr-x\t1700000000\t1690000000\t./broken-link", + "lrwxr-xr-x\t1700000000\t1690000000\t./dir-link", + ].joined(separator: "\n") + + let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( + output, + path: "/srv/app", + showHidden: false + ) + + #expect(entries.map(\.name) == ["broken-link", "dir-link"]) + #expect(entries.allSatisfy { !$0.isDirectory }) + } + + @Test + func testParseRemoteListingExcludesDotEntriesAndRespectsHidden() { + // `find` never emits `.`/`..`, but the parser excludes them defensively; + // the real entries arrive as `find`'s `./name` form. + let output = [ + "drwxr-xr-x\t1700000000\t1690000000\t.", + "drwxr-xr-x\t1700000000\t1690000000\t..", + "-rw-r--r--\t1700000000\t1690000000\t./.hidden", + "-rw-r--r--\t1700000000\t1690000000\t./visible.txt", + ].joined(separator: "\n") + + let visibleOnly = ProcessSSHFileExplorerTransport.parseRemoteListing( + output, + path: "/srv/app", + showHidden: false + ) + #expect(visibleOnly.map(\.name) == ["visible.txt"]) + + let withHidden = ProcessSSHFileExplorerTransport.parseRemoteListing( + output, + path: "/srv/app", + showHidden: true + ) + // `.` and `..` are always dropped; the dotfile survives when requested. + #expect(withHidden.map(\.name) == [".hidden", "visible.txt"]) + } + + @Test + func testParseRemoteListingTreatsMissingOrLowBirthTimeAsUnknown() { + let output = [ + "-rw-r--r--\t1700000000\t0\t./zero-birth.txt", + "-rw-r--r--\t1700000000\t42\t./tiny-birth.txt", + "-rw-r--r--\t1700000000\t1690000000\t./real-birth.txt", + ].joined(separator: "\n") + + let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( + output, + path: "/srv/app", + showHidden: false + ) + + #expect(entries[0].creationDate == nil) + #expect(entries[1].creationDate == nil) + #expect(entries[2].creationDate == Date(timeIntervalSince1970: 1690000000)) + // Modification time is still populated for every entry. + #expect(entries.allSatisfy { $0.modificationDate == Date(timeIntervalSince1970: 1700000000) }) + } +} diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 145b8c003027..dcd4cac53417 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1310,6 +1310,20 @@ "default": "preview", "description": "What double-clicking (or pressing Return on a search result for) a file in the file explorer does. `preview` opens the built-in cmux file preview (the default and historical behavior). `defaultEditor` opens with the macOS default app for the file type. `preferredEditor` opens with the `app.preferredEditor` command, falling back to the default app when none is set. Only applies to files; directories always expand/collapse, and non-local (remote) file explorers always open the cmux preview.", "descriptionKey": "schemaDescriptions.fileExplorer.doubleClickAction" + }, + "sortBy": { + "type": "string", + "enum": ["name", "dateCreated", "dateModified"], + "default": "name", + "description": "How the file explorer orders entries. `name` preserves the historical folders-first alphabetical order; date sorts use each entry's created or modified timestamp, with entries missing that timestamp placed last.", + "descriptionKey": "schemaDescriptions.fileExplorer.sortBy" + }, + "sortOrder": { + "type": "string", + "enum": ["ascending", "descending"], + "default": "ascending", + "description": "Sort direction for the file explorer. Date sorting with `descending` places newer entries first.", + "descriptionKey": "schemaDescriptions.fileExplorer.sortOrder" } } }, diff --git a/web/messages/ar.json b/web/messages/ar.json index 49fd5ba4e416..26c017c8021b 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "إعدادات مستكشف الملفات (شجرة الملفات) في الشريط الجانبي الأيمن.", - "doubleClickAction": "ما يفعله النقر المزدوج (أو الضغط على Return في نتيجة بحث) على ملف في مستكشف الملفات. يفتح `preview` معاينة ملفات cmux المضمنة (السلوك الافتراضي والتاريخي). يفتح `defaultEditor` الملف باستخدام تطبيق macOS الافتراضي لنوع الملف. يفتح `preferredEditor` باستخدام أمر `app.preferredEditor`، مع الرجوع إلى التطبيق الافتراضي عند عدم ضبطه. ينطبق ذلك على الملفات فقط؛ فالمجلدات تُوسَّع أو تُطوى دائمًا، ومستكشفات الملفات غير المحلية (البعيدة) تفتح دائمًا معاينة cmux." + "doubleClickAction": "ما يفعله النقر المزدوج (أو الضغط على Return في نتيجة بحث) على ملف في مستكشف الملفات. يفتح `preview` معاينة ملفات cmux المضمنة (السلوك الافتراضي والتاريخي). يفتح `defaultEditor` الملف باستخدام تطبيق macOS الافتراضي لنوع الملف. يفتح `preferredEditor` باستخدام أمر `app.preferredEditor`، مع الرجوع إلى التطبيق الافتراضي عند عدم ضبطه. ينطبق ذلك على الملفات فقط؛ فالمجلدات تُوسَّع أو تُطوى دائمًا، ومستكشفات الملفات غير المحلية (البعيدة) تفتح دائمًا معاينة cmux.", + "sortBy": "كيفية ترتيب مستكشف الملفات للعناصر. يحافظ `name` على الترتيب الأبجدي السابق مع عرض المجلدات أولاً؛ وتستخدم ترتيبات التاريخ وقت إنشاء كل عنصر أو تعديله، وتُوضع العناصر التي تفتقد ذلك الوقت في النهاية.", + "sortOrder": "اتجاه الفرز في مستكشف الملفات. عند الفرز حسب التاريخ، يضع `descending` العناصر الأحدث أولاً." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/bs.json b/web/messages/bs.json index 42854cfbceb0..d98f384cb3ad 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Postavke istraživača datoteka (stabla datoteka) u desnoj bočnoj traci.", - "doubleClickAction": "Šta se dešava kada dvaput kliknete (ili pritisnete Return na rezultatu pretrage) datoteku u istraživaču datoteka. `preview` otvara ugrađeni cmux pregled datoteke (zadano i dosadašnje ponašanje). `defaultEditor` otvara datoteku macOS zadanom aplikacijom za tu vrstu datoteke. `preferredEditor` otvara pomoću komande `app.preferredEditor`, uz povratak na zadanu aplikaciju kada nije postavljena. Primjenjuje se samo na datoteke; direktoriji se uvijek proširuju/skupljaju, a nelokalni (udaljeni) istraživači datoteka uvijek otvaraju cmux pregled." + "doubleClickAction": "Šta se dešava kada dvaput kliknete (ili pritisnete Return na rezultatu pretrage) datoteku u istraživaču datoteka. `preview` otvara ugrađeni cmux pregled datoteke (zadano i dosadašnje ponašanje). `defaultEditor` otvara datoteku macOS zadanom aplikacijom za tu vrstu datoteke. `preferredEditor` otvara pomoću komande `app.preferredEditor`, uz povratak na zadanu aplikaciju kada nije postavljena. Primjenjuje se samo na datoteke; direktoriji se uvijek proširuju/skupljaju, a nelokalni (udaljeni) istraživači datoteka uvijek otvaraju cmux pregled.", + "sortBy": "Način na koji istraživač datoteka poreda stavke. `name` zadržava dosadašnji abecedni redoslijed s mapama prvo; sortiranja po datumu koriste vrijeme kreiranja ili izmjene svake stavke, a stavke bez tog vremena stavljaju se na kraj.", + "sortOrder": "Smjer sortiranja za istraživač datoteka. Kod sortiranja po datumu, `descending` stavlja novije stavke prve." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/da.json b/web/messages/da.json index 7099fcecd776..5b19ede6ea76 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Indstillinger for filoversigten (filtræet) i højre sidepanel.", - "doubleClickAction": "Bestemmer, hvad der sker, når du dobbeltklikker (eller trykker Return på et søgeresultat for) en fil i filoversigten. `preview` åbner den indbyggede cmux-filvisning (standard og historisk adfærd). `defaultEditor` åbner med macOS-standardappen for filtypen. `preferredEditor` åbner med kommandoen `app.preferredEditor` og falder tilbage til standardappen, når den ikke er sat. Gælder kun filer; mapper udvides/skjules altid, og ikke-lokale (eksterne) filoversigter åbner altid cmux-forhåndsvisningen." + "doubleClickAction": "Bestemmer, hvad der sker, når du dobbeltklikker (eller trykker Return på et søgeresultat for) en fil i filoversigten. `preview` åbner den indbyggede cmux-filvisning (standard og historisk adfærd). `defaultEditor` åbner med macOS-standardappen for filtypen. `preferredEditor` åbner med kommandoen `app.preferredEditor` og falder tilbage til standardappen, når den ikke er sat. Gælder kun filer; mapper udvides/skjules altid, og ikke-lokale (eksterne) filoversigter åbner altid cmux-forhåndsvisningen.", + "sortBy": "Hvordan filoversigten sorterer poster. `name` bevarer den hidtidige alfabetiske sortering med mapper først; datosorteringer bruger hver posts oprettelses- eller ændringstidspunkt, og poster uden dette tidspunkt placeres sidst.", + "sortOrder": "Sorteringsretning for filoversigten. Ved datosortering placerer `descending` nyere poster først." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/de.json b/web/messages/de.json index 378ca2d07ec1..59ad37d98760 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Einstellungen für den Datei-Explorer (Dateibaum) in der rechten Seitenleiste.", - "doubleClickAction": "Legt fest, was ein Doppelklick auf eine Datei im Dateiexplorer bewirkt (oder Return bei einem Suchergebnis). `preview` öffnet die integrierte cmux-Dateivorschau (Standard und bisheriges Verhalten). `defaultEditor` öffnet die Datei mit der macOS-Standard-App für diesen Dateityp. `preferredEditor` öffnet mit dem Befehl `app.preferredEditor` und fällt auf die Standard-App zurück, wenn keiner gesetzt ist. Gilt nur für Dateien; Verzeichnisse werden immer ein-/ausgeklappt, und nicht-lokale (Remote-)Dateiexplorer öffnen immer die cmux-Vorschau." + "doubleClickAction": "Legt fest, was ein Doppelklick auf eine Datei im Dateiexplorer bewirkt (oder Return bei einem Suchergebnis). `preview` öffnet die integrierte cmux-Dateivorschau (Standard und bisheriges Verhalten). `defaultEditor` öffnet die Datei mit der macOS-Standard-App für diesen Dateityp. `preferredEditor` öffnet mit dem Befehl `app.preferredEditor` und fällt auf die Standard-App zurück, wenn keiner gesetzt ist. Gilt nur für Dateien; Verzeichnisse werden immer ein-/ausgeklappt, und nicht-lokale (Remote-)Dateiexplorer öffnen immer die cmux-Vorschau.", + "sortBy": "Legt fest, wie der Datei-Explorer Einträge sortiert. `name` behält die bisherige alphabetische Reihenfolge mit Ordnern zuerst bei; Datumssortierungen verwenden den Erstellungs- oder Änderungszeitpunkt jedes Eintrags und platzieren Einträge ohne diesen Zeitpunkt am Ende.", + "sortOrder": "Sortierrichtung für den Datei-Explorer. Bei Datumssortierung zeigt `descending` neuere Einträge zuerst an." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/en.json b/web/messages/en.json index b19ef75f8723..882c1fc2bd84 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1142,7 +1142,9 @@ }, "fileExplorer": { "description": "Right-sidebar file explorer (file tree) settings.", - "doubleClickAction": "What double-clicking (or pressing Return on a search result for) a file in the file explorer does. `preview` opens the built-in cmux file preview (the default and historical behavior). `defaultEditor` opens with the macOS default app for the file type. `preferredEditor` opens with the `app.preferredEditor` command, falling back to the default app when none is set. Only applies to files; directories always expand/collapse, and non-local (remote) file explorers always open the cmux preview." + "doubleClickAction": "What double-clicking (or pressing Return on a search result for) a file in the file explorer does. `preview` opens the built-in cmux file preview (the default and historical behavior). `defaultEditor` opens with the macOS default app for the file type. `preferredEditor` opens with the `app.preferredEditor` command, falling back to the default app when none is set. Only applies to files; directories always expand/collapse, and non-local (remote) file explorers always open the cmux preview.", + "sortBy": "How the file explorer orders entries. `name` preserves the historical folders-first alphabetical order; date sorts use each entry's created or modified timestamp, with entries missing that timestamp placed last.", + "sortOrder": "Sort direction for the file explorer. Date sorting with `descending` places newer entries first." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/es.json b/web/messages/es.json index 5dbcfba2e4c2..9a151b1fbaca 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Ajustes del explorador de archivos (árbol de archivos) de la barra lateral derecha.", - "doubleClickAction": "Qué hace al hacer doble clic (o presionar Return en un resultado de búsqueda) sobre un archivo en el explorador de archivos. `preview` abre la vista previa integrada de archivos de cmux (comportamiento predeterminado e histórico). `defaultEditor` abre con la app predeterminada de macOS para ese tipo de archivo. `preferredEditor` abre con el comando `app.preferredEditor`, con fallback a la app predeterminada cuando no está configurado. Solo aplica a archivos; los directorios siempre se expanden/contraen, y los exploradores de archivos no locales (remotos) siempre abren la vista previa de cmux." + "doubleClickAction": "Qué hace al hacer doble clic (o presionar Return en un resultado de búsqueda) sobre un archivo en el explorador de archivos. `preview` abre la vista previa integrada de archivos de cmux (comportamiento predeterminado e histórico). `defaultEditor` abre con la app predeterminada de macOS para ese tipo de archivo. `preferredEditor` abre con el comando `app.preferredEditor`, con fallback a la app predeterminada cuando no está configurado. Solo aplica a archivos; los directorios siempre se expanden/contraen, y los exploradores de archivos no locales (remotos) siempre abren la vista previa de cmux.", + "sortBy": "Cómo ordena las entradas el explorador de archivos. `name` conserva el orden alfabético histórico con las carpetas primero; las ordenaciones por fecha usan la marca de tiempo de creación o modificación de cada entrada, y las entradas sin esa marca se colocan al final.", + "sortOrder": "Dirección de ordenación del explorador de archivos. Al ordenar por fecha, `descending` coloca primero las entradas más recientes." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/fr.json b/web/messages/fr.json index 8181f3a87644..1b666c760082 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Paramètres de l’explorateur de fichiers (arborescence) dans la barre latérale droite.", - "doubleClickAction": "Ce que fait un double-clic (ou l’appui sur Return dans un résultat de recherche) sur un fichier dans l’explorateur de fichiers. `preview` ouvre l’aperçu de fichier intégré à cmux (comportement par défaut et historique). `defaultEditor` ouvre le fichier avec l’app macOS par défaut pour ce type de fichier. `preferredEditor` ouvre avec la commande `app.preferredEditor`, avec repli vers l’app par défaut lorsqu’aucune commande n’est définie. S’applique uniquement aux fichiers ; les dossiers se développent/se replient toujours, et les explorateurs de fichiers non locaux (distants) ouvrent toujours l’aperçu cmux." + "doubleClickAction": "Ce que fait un double-clic (ou l’appui sur Return dans un résultat de recherche) sur un fichier dans l’explorateur de fichiers. `preview` ouvre l’aperçu de fichier intégré à cmux (comportement par défaut et historique). `defaultEditor` ouvre le fichier avec l’app macOS par défaut pour ce type de fichier. `preferredEditor` ouvre avec la commande `app.preferredEditor`, avec repli vers l’app par défaut lorsqu’aucune commande n’est définie. S’applique uniquement aux fichiers ; les dossiers se développent/se replient toujours, et les explorateurs de fichiers non locaux (distants) ouvrent toujours l’aperçu cmux.", + "sortBy": "Définit la façon dont l’explorateur de fichiers trie les éléments. `name` conserve l’ordre alphabétique historique avec les dossiers en premier ; les tris par date utilisent l’horodatage de création ou de modification de chaque élément, et les éléments sans horodatage sont placés à la fin.", + "sortOrder": "Sens de tri de l’explorateur de fichiers. Pour un tri par date, `descending` place les éléments les plus récents en premier." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/it.json b/web/messages/it.json index bc8230ce127f..f68553fd95e9 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Impostazioni dell’esplora file (albero dei file) nella barra laterale destra.", - "doubleClickAction": "Definisce cosa succede quando fai doppio clic (o premi Return su un risultato di ricerca) su un file nell’esplora file. `preview` apre l’anteprima file integrata di cmux (comportamento predefinito e storico). `defaultEditor` apre il file con l’app macOS predefinita per quel tipo di file. `preferredEditor` apre con il comando `app.preferredEditor`, ricadendo sull’app predefinita quando non è impostato. Si applica solo ai file; le cartelle si espandono/chiudono sempre e gli esplora file non locali (remoti) aprono sempre l’anteprima di cmux." + "doubleClickAction": "Definisce cosa succede quando fai doppio clic (o premi Return su un risultato di ricerca) su un file nell’esplora file. `preview` apre l’anteprima file integrata di cmux (comportamento predefinito e storico). `defaultEditor` apre il file con l’app macOS predefinita per quel tipo di file. `preferredEditor` apre con il comando `app.preferredEditor`, ricadendo sull’app predefinita quando non è impostato. Si applica solo ai file; le cartelle si espandono/chiudono sempre e gli esplora file non locali (remoti) aprono sempre l’anteprima di cmux.", + "sortBy": "Come l’esplora file ordina gli elementi. `name` conserva il precedente ordine alfabetico con le cartelle per prime; gli ordinamenti per data usano il timestamp di creazione o modifica di ciascun elemento, mettendo alla fine gli elementi senza timestamp.", + "sortOrder": "Direzione dell’ordinamento per l’esplora file. Con l’ordinamento per data, `descending` mostra prima gli elementi più recenti." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/ja.json b/web/messages/ja.json index 261185592d09..6e1b85927df1 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1112,7 +1112,9 @@ }, "fileExplorer": { "description": "右サイドバーのファイルエクスプローラー(ファイルツリー)設定。", - "doubleClickAction": "ファイルエクスプローラーでファイルをダブルクリックした(または検索結果で Return を押した)ときの動作です。`preview` は組み込みの cmux ファイルプレビューを開きます(既定かつ従来の動作)。`defaultEditor` はそのファイル形式の macOS 既定アプリで開きます。`preferredEditor` は `app.preferredEditor` コマンドで開き、未設定の場合は既定アプリにフォールバックします。ファイルにのみ適用され、ディレクトリは常に展開・折りたたみされ、非ローカル(リモート)のファイルエクスプローラーは常に cmux プレビューを開きます。" + "doubleClickAction": "ファイルエクスプローラーでファイルをダブルクリックした(または検索結果で Return を押した)ときの動作です。`preview` は組み込みの cmux ファイルプレビューを開きます(既定かつ従来の動作)。`defaultEditor` はそのファイル形式の macOS 既定アプリで開きます。`preferredEditor` は `app.preferredEditor` コマンドで開き、未設定の場合は既定アプリにフォールバックします。ファイルにのみ適用され、ディレクトリは常に展開・折りたたみされ、非ローカル(リモート)のファイルエクスプローラーは常に cmux プレビューを開きます。", + "sortBy": "ファイルエクスプローラーで項目を並べる方法です。`name` は従来どおりフォルダを先にしたアルファベット順を保ちます。日付順では各項目の作成日時または変更日時を使用し、その日時がない項目は最後に置かれます。", + "sortOrder": "ファイルエクスプローラーの並び順です。日付順で `descending` を指定すると、新しい項目が先に表示されます。" }, "diffViewer": { "defaultLayout": "新しく開く diff ビューアの既定レイアウトです。ツールバーのレイアウト切り替えはユーザーの最後の選択を保持し、明示的な `cmux diff --layout` オプションはその両方を上書きします。" diff --git a/web/messages/km.json b/web/messages/km.json index d9fb77e7d6b6..270920eda404 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "ការកំណត់កម្មវិធីរុករកឯកសារ (មែកធាងឯកសារ) នៅក្នុងរបារចំហៀងស្តាំ។", - "doubleClickAction": "កំណត់អ្វីដែលកើតឡើងពេលចុចពីរដង (ឬចុច Return លើលទ្ធផលស្វែងរក) លើឯកសារនៅក្នុងកម្មវិធីរុករកឯកសារ។ `preview` បើកការមើលជាមុនឯកសារ cmux ដែលភ្ជាប់មកជាមួយ (ជាលំនាំដើម និងជាឥរិយាបថចាស់)។ `defaultEditor` បើកដោយប្រើកម្មវិធីលំនាំដើមរបស់ macOS សម្រាប់ប្រភេទឯកសារនោះ។ `preferredEditor` បើកដោយប្រើពាក្យបញ្ជា `app.preferredEditor` ហើយត្រឡប់ទៅកម្មវិធីលំនាំដើម ប្រសិនបើមិនបានកំណត់។ អនុវត្តតែលើឯកសារ; ថតតែងតែពង្រីក/បង្រួម ហើយកម្មវិធីរុករកឯកសារដែលមិនមែនក្នុងមូលដ្ឋាន (ពីចម្ងាយ) តែងតែបើកការមើលជាមុនរបស់ cmux។" + "doubleClickAction": "កំណត់អ្វីដែលកើតឡើងពេលចុចពីរដង (ឬចុច Return លើលទ្ធផលស្វែងរក) លើឯកសារនៅក្នុងកម្មវិធីរុករកឯកសារ។ `preview` បើកការមើលជាមុនឯកសារ cmux ដែលភ្ជាប់មកជាមួយ (ជាលំនាំដើម និងជាឥរិយាបថចាស់)។ `defaultEditor` បើកដោយប្រើកម្មវិធីលំនាំដើមរបស់ macOS សម្រាប់ប្រភេទឯកសារនោះ។ `preferredEditor` បើកដោយប្រើពាក្យបញ្ជា `app.preferredEditor` ហើយត្រឡប់ទៅកម្មវិធីលំនាំដើម ប្រសិនបើមិនបានកំណត់។ អនុវត្តតែលើឯកសារ; ថតតែងតែពង្រីក/បង្រួម ហើយកម្មវិធីរុករកឯកសារដែលមិនមែនក្នុងមូលដ្ឋាន (ពីចម្ងាយ) តែងតែបើកការមើលជាមុនរបស់ cmux។", + "sortBy": "របៀបដែលកម្មវិធីរុករកឯកសារតម្រៀបធាតុ។ `name` រក្សាលំដាប់អក្សរក្រមដូចមុន ដោយដាក់ថតនៅមុខ; ការតម្រៀបតាមកាលបរិច្ឆេទប្រើពេលបង្កើត ឬពេលកែប្រែរបស់ធាតុនីមួយៗ ហើយដាក់ធាតុដែលគ្មានពេលនោះនៅចុងក្រោយ។", + "sortOrder": "ទិសដៅតម្រៀបសម្រាប់កម្មវិធីរុករកឯកសារ។ ការតម្រៀបតាមកាលបរិច្ឆេទជាមួយ `descending` ដាក់ធាតុថ្មីជាងនៅមុខ។" }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/ko.json b/web/messages/ko.json index ea3ed14f8744..263ad314713d 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "오른쪽 사이드바 파일 탐색기(파일 트리) 설정입니다.", - "doubleClickAction": "파일 탐색기에서 파일을 더블클릭(또는 검색 결과에서 Return 키 입력)했을 때의 동작을 설정합니다. `preview`는 cmux 내장 파일 미리보기(기본값이자 기존 동작)를 엽니다. `defaultEditor`는 파일 형식의 macOS 기본 앱으로 엽니다. `preferredEditor`는 `app.preferredEditor` 명령으로 열고, 설정되지 않은 경우 기본 앱으로 대체합니다. 이 설정은 파일에만 적용되며, 디렉터리는 항상 펼치기/접기 동작을 하고, 로컬이 아닌(원격) 파일 탐색기는 항상 cmux 미리보기를 엽니다." + "doubleClickAction": "파일 탐색기에서 파일을 더블클릭(또는 검색 결과에서 Return 키 입력)했을 때의 동작을 설정합니다. `preview`는 cmux 내장 파일 미리보기(기본값이자 기존 동작)를 엽니다. `defaultEditor`는 파일 형식의 macOS 기본 앱으로 엽니다. `preferredEditor`는 `app.preferredEditor` 명령으로 열고, 설정되지 않은 경우 기본 앱으로 대체합니다. 이 설정은 파일에만 적용되며, 디렉터리는 항상 펼치기/접기 동작을 하고, 로컬이 아닌(원격) 파일 탐색기는 항상 cmux 미리보기를 엽니다.", + "sortBy": "파일 탐색기가 항목을 정렬하는 방식입니다. `name`은 기존처럼 폴더를 먼저 두는 알파벳순 정렬을 유지합니다. 날짜 정렬은 각 항목의 생성 또는 수정 시각을 사용하며, 해당 시각이 없는 항목은 마지막에 둡니다.", + "sortOrder": "파일 탐색기의 정렬 방향입니다. 날짜 정렬에서 `descending`을 사용하면 최신 항목이 먼저 표시됩니다." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/no.json b/web/messages/no.json index d9bd759aece1..1a0953625b70 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Innstillinger for filutforskeren (filtreet) i høyre sidepanel.", - "doubleClickAction": "Bestemmer hva som skjer når du dobbeltklikker (eller trykker Return på et søkeresultat for) en fil i filutforskeren. `preview` åpner den innebygde cmux-filforhåndsvisningen (standard og historisk oppførsel). `defaultEditor` åpner med macOS-standardappen for filtypen. `preferredEditor` åpner med kommandoen `app.preferredEditor`, med fallback til standardappen når ingen er satt. Gjelder bare filer; mapper utvides/skjules alltid, og ikke-lokale (eksterne) filutforskere åpner alltid cmux-forhåndsvisningen." + "doubleClickAction": "Bestemmer hva som skjer når du dobbeltklikker (eller trykker Return på et søkeresultat for) en fil i filutforskeren. `preview` åpner den innebygde cmux-filforhåndsvisningen (standard og historisk oppførsel). `defaultEditor` åpner med macOS-standardappen for filtypen. `preferredEditor` åpner med kommandoen `app.preferredEditor`, med fallback til standardappen når ingen er satt. Gjelder bare filer; mapper utvides/skjules alltid, og ikke-lokale (eksterne) filutforskere åpner alltid cmux-forhåndsvisningen.", + "sortBy": "Hvordan filutforskeren sorterer oppføringer. `name` beholder den historiske alfabetiske rekkefølgen med mapper først; datosortering bruker opprettelses- eller endringstidspunktet for hver oppføring, og oppføringer uten dette tidspunktet plasseres sist.", + "sortOrder": "Sorteringsretning for filutforskeren. Ved datosortering plasserer `descending` nyere oppføringer først." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/pl.json b/web/messages/pl.json index cda279f25e64..940700c95eaa 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Ustawienia eksploratora plików (drzewa plików) w prawym pasku bocznym.", - "doubleClickAction": "Określa, co robi dwukrotne kliknięcie pliku w eksploratorze plików (albo naciśnięcie Return na wyniku wyszukiwania). `preview` otwiera wbudowany podgląd pliku cmux (domyślne i dotychczasowe zachowanie). `defaultEditor` otwiera plik w domyślnej aplikacji macOS dla tego typu pliku. `preferredEditor` otwiera za pomocą polecenia `app.preferredEditor`, a gdy nie jest ono ustawione, wraca do domyślnej aplikacji. Dotyczy tylko plików; katalogi zawsze się rozwijają/zwijają, a nielokalne (zdalne) eksploratory plików zawsze otwierają podgląd cmux." + "doubleClickAction": "Określa, co robi dwukrotne kliknięcie pliku w eksploratorze plików (albo naciśnięcie Return na wyniku wyszukiwania). `preview` otwiera wbudowany podgląd pliku cmux (domyślne i dotychczasowe zachowanie). `defaultEditor` otwiera plik w domyślnej aplikacji macOS dla tego typu pliku. `preferredEditor` otwiera za pomocą polecenia `app.preferredEditor`, a gdy nie jest ono ustawione, wraca do domyślnej aplikacji. Dotyczy tylko plików; katalogi zawsze się rozwijają/zwijają, a nielokalne (zdalne) eksploratory plików zawsze otwierają podgląd cmux.", + "sortBy": "Sposób sortowania pozycji w eksploratorze plików. `name` zachowuje dotychczasową kolejność alfabetyczną z folderami na początku; sortowania według daty używają znacznika czasu utworzenia lub modyfikacji każdej pozycji, a pozycje bez tego znacznika trafiają na koniec.", + "sortOrder": "Kierunek sortowania w eksploratorze plików. Przy sortowaniu według daty `descending` umieszcza nowsze pozycje na początku." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 91aa528928f1..8586c2866fe7 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Configurações do explorador de arquivos (árvore de arquivos) na barra lateral direita.", - "doubleClickAction": "Define o que acontece ao clicar duas vezes (ou pressionar Return em um resultado de busca) em um arquivo no explorador de arquivos. `preview` abre a visualização de arquivo integrada do cmux (comportamento padrão e histórico). `defaultEditor` abre com o app padrão do macOS para o tipo de arquivo. `preferredEditor` abre com o comando `app.preferredEditor`, voltando para o app padrão quando nenhum estiver definido. Aplica-se apenas a arquivos; diretórios sempre expandem/recolhem, e exploradores de arquivos não locais (remotos) sempre abrem a visualização do cmux." + "doubleClickAction": "Define o que acontece ao clicar duas vezes (ou pressionar Return em um resultado de busca) em um arquivo no explorador de arquivos. `preview` abre a visualização de arquivo integrada do cmux (comportamento padrão e histórico). `defaultEditor` abre com o app padrão do macOS para o tipo de arquivo. `preferredEditor` abre com o comando `app.preferredEditor`, voltando para o app padrão quando nenhum estiver definido. Aplica-se apenas a arquivos; diretórios sempre expandem/recolhem, e exploradores de arquivos não locais (remotos) sempre abrem a visualização do cmux.", + "sortBy": "Como o explorador de arquivos ordena as entradas. `name` preserva a ordem alfabética histórica com pastas primeiro; ordenações por data usam o horário de criação ou modificação de cada entrada, colocando entradas sem esse horário no final.", + "sortOrder": "Direção de ordenação do explorador de arquivos. Ao ordenar por data, `descending` coloca entradas mais recentes primeiro." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/ru.json b/web/messages/ru.json index 5e87385515cd..181577e482e3 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Настройки проводника файлов (дерева файлов) в правой боковой панели.", - "doubleClickAction": "Что происходит при двойном щелчке по файлу в проводнике файлов (или при нажатии Return на результате поиска). `preview` открывает встроенный предпросмотр файлов cmux (поведение по умолчанию и историческое поведение). `defaultEditor` открывает файл в приложении macOS по умолчанию для этого типа файла. `preferredEditor` открывает через команду `app.preferredEditor`, а если она не задана, использует приложение по умолчанию. Применяется только к файлам; каталоги всегда разворачиваются/сворачиваются, а нелокальные (удалённые) проводники файлов всегда открывают предпросмотр cmux." + "doubleClickAction": "Что происходит при двойном щелчке по файлу в проводнике файлов (или при нажатии Return на результате поиска). `preview` открывает встроенный предпросмотр файлов cmux (поведение по умолчанию и историческое поведение). `defaultEditor` открывает файл в приложении macOS по умолчанию для этого типа файла. `preferredEditor` открывает через команду `app.preferredEditor`, а если она не задана, использует приложение по умолчанию. Применяется только к файлам; каталоги всегда разворачиваются/сворачиваются, а нелокальные (удалённые) проводники файлов всегда открывают предпросмотр cmux.", + "sortBy": "Как файловый обозреватель упорядочивает элементы. `name` сохраняет прежний алфавитный порядок с папками в начале; сортировки по дате используют время создания или изменения каждого элемента, а элементы без такого времени помещаются в конец.", + "sortOrder": "Направление сортировки в файловом обозревателе. При сортировке по дате `descending` помещает более новые элементы первыми." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/th.json b/web/messages/th.json index 3e365973e924..0db001ddef2b 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "การตั้งค่าตัวสำรวจไฟล์ (แผนผังไฟล์) ในแถบด้านขวา", - "doubleClickAction": "กำหนดว่าการดับเบิลคลิกไฟล์ในตัวสำรวจไฟล์ (หรือกด Return บนผลการค้นหา) จะทำอะไร `preview` เปิดตัวอย่างไฟล์ในตัวของ cmux (ค่าเริ่มต้นและพฤติกรรมเดิม) `defaultEditor` เปิดด้วยแอปเริ่มต้นของ macOS สำหรับชนิดไฟล์นั้น `preferredEditor` เปิดด้วยคำสั่ง `app.preferredEditor` และถ้าไม่ได้ตั้งค่าไว้จะกลับไปใช้แอปเริ่มต้น มีผลกับไฟล์เท่านั้น; ไดเรกทอรีจะขยาย/ยุบเสมอ และตัวสำรวจไฟล์ที่ไม่ใช่ในเครื่อง (รีโมต) จะเปิดตัวอย่างของ cmux เสมอ" + "doubleClickAction": "กำหนดว่าการดับเบิลคลิกไฟล์ในตัวสำรวจไฟล์ (หรือกด Return บนผลการค้นหา) จะทำอะไร `preview` เปิดตัวอย่างไฟล์ในตัวของ cmux (ค่าเริ่มต้นและพฤติกรรมเดิม) `defaultEditor` เปิดด้วยแอปเริ่มต้นของ macOS สำหรับชนิดไฟล์นั้น `preferredEditor` เปิดด้วยคำสั่ง `app.preferredEditor` และถ้าไม่ได้ตั้งค่าไว้จะกลับไปใช้แอปเริ่มต้น มีผลกับไฟล์เท่านั้น; ไดเรกทอรีจะขยาย/ยุบเสมอ และตัวสำรวจไฟล์ที่ไม่ใช่ในเครื่อง (รีโมต) จะเปิดตัวอย่างของ cmux เสมอ", + "sortBy": "วิธีที่ตัวสำรวจไฟล์เรียงลำดับรายการ `name` จะคงลำดับตัวอักษรแบบเดิมโดยให้โฟลเดอร์อยู่ก่อน ส่วนการเรียงตามวันที่จะใช้เวลาที่สร้างหรือแก้ไขของแต่ละรายการ และวางรายการที่ไม่มีเวลาดังกล่าวไว้ท้ายสุด", + "sortOrder": "ทิศทางการเรียงลำดับของตัวสำรวจไฟล์ เมื่อเรียงตามวันที่ `descending` จะแสดงรายการที่ใหม่กว่าไว้ก่อน" }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/tr.json b/web/messages/tr.json index 7f0dd773d5e0..498fa994d286 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Sağ kenar çubuğundaki dosya gezgini (dosya ağacı) ayarları.", - "doubleClickAction": "Dosya gezgininde bir dosyaya çift tıklamanın (veya bir arama sonucunda Return tuşuna basmanın) ne yapacağını belirler. `preview`, yerleşik cmux dosya önizlemesini açar (varsayılan ve geçmiş davranış). `defaultEditor`, dosya türü için macOS varsayılan uygulamasıyla açar. `preferredEditor`, `app.preferredEditor` komutuyla açar; ayarlı değilse varsayılan uygulamaya geri döner. Yalnızca dosyalar için geçerlidir; dizinler her zaman genişletilir/daraltılır ve yerel olmayan (uzak) dosya gezginleri her zaman cmux önizlemesini açar." + "doubleClickAction": "Dosya gezgininde bir dosyaya çift tıklamanın (veya bir arama sonucunda Return tuşuna basmanın) ne yapacağını belirler. `preview`, yerleşik cmux dosya önizlemesini açar (varsayılan ve geçmiş davranış). `defaultEditor`, dosya türü için macOS varsayılan uygulamasıyla açar. `preferredEditor`, `app.preferredEditor` komutuyla açar; ayarlı değilse varsayılan uygulamaya geri döner. Yalnızca dosyalar için geçerlidir; dizinler her zaman genişletilir/daraltılır ve yerel olmayan (uzak) dosya gezginleri her zaman cmux önizlemesini açar.", + "sortBy": "Dosya gezgininin öğeleri nasıl sıralayacağını belirler. `name`, klasörlerin önce geldiği eski alfabetik sıralamayı korur; tarih sıralamaları her öğenin oluşturulma veya değiştirilme zaman damgasını kullanır ve bu zaman damgası olmayan öğeleri sona koyar.", + "sortOrder": "Dosya gezgini için sıralama yönü. Tarih sıralamasında `descending`, daha yeni öğeleri önce gösterir." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/uk.json b/web/messages/uk.json index 1b232c14a8b6..8df3e01efb4b 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "Налаштування провідника файлів (дерева файлів) у правій бічній панелі.", - "doubleClickAction": "Визначає, що відбувається під час подвійного клацання файлу в провіднику файлів (або натискання Return на результаті пошуку). `preview` відкриває вбудований перегляд файлів cmux (типова й історична поведінка). `defaultEditor` відкриває файл у типовій програмі macOS для цього типу файлів. `preferredEditor` відкриває через команду `app.preferredEditor`, а якщо її не задано, повертається до типової програми. Застосовується лише до файлів; каталоги завжди розгортаються/згортаються, а нелокальні (віддалені) провідники файлів завжди відкривають перегляд cmux." + "doubleClickAction": "Визначає, що відбувається під час подвійного клацання файлу в провіднику файлів (або натискання Return на результаті пошуку). `preview` відкриває вбудований перегляд файлів cmux (типова й історична поведінка). `defaultEditor` відкриває файл у типовій програмі macOS для цього типу файлів. `preferredEditor` відкриває через команду `app.preferredEditor`, а якщо її не задано, повертається до типової програми. Застосовується лише до файлів; каталоги завжди розгортаються/згортаються, а нелокальні (віддалені) провідники файлів завжди відкривають перегляд cmux.", + "sortBy": "Як файловий провідник упорядковує елементи. `name` зберігає попередній алфавітний порядок із папками на початку; сортування за датою використовує час створення або зміни кожного елемента, а елементи без такого часу розміщує в кінці.", + "sortOrder": "Напрям сортування у файловому провіднику. Під час сортування за датою `descending` показує новіші елементи першими." }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 63d7e76eba50..bdfb6b5e33d2 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "右侧边栏文件浏览器(文件树)设置。", - "doubleClickAction": "设置在文件浏览器中双击文件(或在搜索结果上按 Return)时执行的操作。`preview` 会打开 cmux 内置文件预览(默认和历史行为)。`defaultEditor` 会使用该文件类型的 macOS 默认应用打开。`preferredEditor` 会使用 `app.preferredEditor` 命令打开;未设置时回退到默认应用。仅适用于文件;目录始终展开/折叠,非本地(远程)文件浏览器始终打开 cmux 预览。" + "doubleClickAction": "设置在文件浏览器中双击文件(或在搜索结果上按 Return)时执行的操作。`preview` 会打开 cmux 内置文件预览(默认和历史行为)。`defaultEditor` 会使用该文件类型的 macOS 默认应用打开。`preferredEditor` 会使用 `app.preferredEditor` 命令打开;未设置时回退到默认应用。仅适用于文件;目录始终展开/折叠,非本地(远程)文件浏览器始终打开 cmux 预览。", + "sortBy": "文件资源管理器排列条目的方式。`name` 会保留以文件夹优先的传统字母顺序;按日期排序会使用每个条目的创建或修改时间戳,缺少该时间戳的条目会排在最后。", + "sortOrder": "文件资源管理器的排序方向。按日期排序时,`descending` 会将较新的条目排在前面。" }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both." diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 23a22483d1d8..bc1b1bb6bc78 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -846,7 +846,9 @@ }, "fileExplorer": { "description": "右側邊欄檔案瀏覽器(檔案樹)設定。", - "doubleClickAction": "設定在檔案瀏覽器中按兩下檔案(或在搜尋結果上按 Return)時要執行的動作。`preview` 會開啟 cmux 內建檔案預覽(預設與既有行為)。`defaultEditor` 會使用該檔案類型的 macOS 預設 App 開啟。`preferredEditor` 會使用 `app.preferredEditor` 命令開啟;未設定時會退回預設 App。僅適用於檔案;目錄一律展開/收合,非本機(遠端)檔案瀏覽器一律開啟 cmux 預覽。" + "doubleClickAction": "設定在檔案瀏覽器中按兩下檔案(或在搜尋結果上按 Return)時要執行的動作。`preview` 會開啟 cmux 內建檔案預覽(預設與既有行為)。`defaultEditor` 會使用該檔案類型的 macOS 預設 App 開啟。`preferredEditor` 會使用 `app.preferredEditor` 命令開啟;未設定時會退回預設 App。僅適用於檔案;目錄一律展開/收合,非本機(遠端)檔案瀏覽器一律開啟 cmux 預覽。", + "sortBy": "檔案總管排列項目的方式。`name` 會保留以資料夾優先的既有字母順序;日期排序會使用每個項目的建立或修改時間戳,缺少該時間戳的項目會排在最後。", + "sortOrder": "檔案總管的排序方向。依日期排序時,`descending` 會將較新的項目排在前面。" }, "diffViewer": { "defaultLayout": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both."