From 843d695b8878bcb889b313dc170f43f139580c34 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:37:38 -0700 Subject: [PATCH 01/18] Add file explorer date sort options --- .github/swift-file-length-budget.tsv | 44 ++- Resources/Localizable.xcstrings | 153 ++++++++ Sources/CmuxSettingsJSONPathSupport.swift | 2 + Sources/FileExplorerHeaderView.swift | 90 ++++- Sources/FileExplorerStore.swift | 353 ++++++++++++++++-- Sources/FileExplorerView.swift | 24 +- ...rdShortcutSettingsFileStore+Template.swift | 2 + .../KeyboardShortcutSettingsFileStore.swift | 25 ++ .../FileExplorerDoubleClickActionTests.swift | 49 +++ cmuxTests/FileExplorerStoreTests.swift | 52 +++ ...hortcutSettingsFileStoreStartupTests.swift | 53 +++ web/data/cmux.schema.json | 14 + web/messages/ar.json | 4 +- web/messages/bs.json | 4 +- web/messages/da.json | 4 +- web/messages/de.json | 4 +- web/messages/en.json | 4 +- web/messages/es.json | 4 +- web/messages/fr.json | 4 +- web/messages/it.json | 4 +- web/messages/ja.json | 4 +- web/messages/km.json | 4 +- web/messages/ko.json | 4 +- web/messages/no.json | 4 +- web/messages/pl.json | 4 +- web/messages/pt-BR.json | 4 +- web/messages/ru.json | 4 +- web/messages/th.json | 4 +- web/messages/tr.json | 4 +- web/messages/uk.json | 4 +- web/messages/zh-CN.json | 4 +- web/messages/zh-TW.json | 4 +- 32 files changed, 857 insertions(+), 84 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7b3ae3f3ab43..1f31152f8089 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,23 +2,23 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34629 CLI/cmux.swift -17827 Sources/AppDelegate.swift -16128 Sources/ContentView.swift +17826 Sources/AppDelegate.swift +16109 Sources/ContentView.swift 13832 Sources/TerminalController.swift -12878 Sources/Workspace.swift +12838 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11578 Sources/Panels/BrowserPanel.swift +11567 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift -7986 Sources/Panels/BrowserPanelView.swift +7952 Sources/Panels/BrowserPanelView.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift -6916 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +6876 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6183 Sources/TabManager.swift -6084 Sources/TextBoxInput.swift +6077 Sources/TextBoxInput.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift 5573 cmuxTests/BrowserConfigTests.swift 4487 Sources/Panels/FilePreviewPanel.swift @@ -28,11 +28,11 @@ 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift -3745 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift +3688 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift 3364 cmuxTests/TabManagerSessionSnapshotTests.swift -3058 Sources/Update/UpdateTitlebarAccessory.swift +3053 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift 2606 Sources/KeyboardShortcutSettings.swift @@ -46,25 +46,25 @@ 2126 cmuxTests/CmuxConfigTests.swift 2091 cmuxTests/ShortcutAndCommandPaletteTests.swift 2078 Sources/SessionPersistence.swift -1952 Sources/KeyboardShortcutSettingsFileStore.swift -1949 Sources/Panels/BrowserWebAuthnSupport.swift +1977 Sources/KeyboardShortcutSettingsFileStore.swift 1945 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift +1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift 1748 Sources/WindowDragHandleView.swift +1736 Sources/FileExplorerStore.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift -1656 Sources/FileExplorerView.swift +1670 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift -1452 Sources/RemoteTmuxControlConnection.swift -1447 Sources/FileExplorerStore.swift -1384 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1437 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1433 Sources/RemoteTmuxControlConnection.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1363 Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -74,11 +74,11 @@ 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1277 cmuxTests/RestorableAgentSessionIndexTests.swift 1258 Sources/Feed/FeedCoordinator.swift +1256 cmuxTests/FileExplorerStoreTests.swift 1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift -1204 cmuxTests/FileExplorerStoreTests.swift 1197 cmuxTests/CodexAppServerSessionTests.swift -1169 Sources/RemoteTmuxController.swift 1166 Sources/VaultAgentProcessScanner.swift +1164 Sources/RemoteTmuxController.swift 1161 cmuxTests/SidebarOrderingTests.swift 1144 cmuxTests/PiVaultAgentPersistenceTests.swift 1121 cmuxTests/AgentHibernationTests.swift @@ -101,16 +101,16 @@ 905 Sources/CmuxSSHURLRequest.swift 899 Sources/Panels/MarkdownWebRenderer.swift 885 Sources/Panels/TerminalPanel.swift -878 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift +877 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 830 Sources/TaskManagerTypes.swift -825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 824 Sources/MainWindowFocusController.swift +813 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift 803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift 802 Sources/WorkspaceContentView.swift @@ -127,7 +127,7 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -725 Sources/Panels/BrowserPopupWindowController.swift +724 Sources/Panels/BrowserPopupWindowController.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift @@ -185,7 +185,6 @@ 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift -562 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift 560 cmuxTests/CLISSHPTYResizeInputTests.swift @@ -222,11 +221,10 @@ 509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift 507 Sources/TerminalControllerTopSupport.swift 506 Sources/App/MainWindowVisibilityController.swift -505 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift 505 cmuxUITests/DisplayResolutionRegressionUITests.swift 504 cmuxTests/TerminalNotificationSocketActionTests.swift 503 Sources/Settings/ConfigSource.swift 502 Sources/CmuxEventPublishing.swift 502 Sources/RemoteTmuxSessionMirror.swift -501 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +500 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift 500 Sources/KeyboardShortcutRecorder.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2fc5cd11de10..09b49db7856e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -139641,6 +139641,159 @@ } } }, + "fileExplorer.sort.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort Files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルの並び替え" + } + } + } + }, + "fileExplorer.sort.key.dateCreated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date Created" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成日" + } + } + } + }, + "fileExplorer.sort.key.dateModified": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date Modified" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更日" + } + } + } + }, + "fileExplorer.sort.key.name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前" + } + } + } + }, + "fileExplorer.sort.menu.order": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Order" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "順序" + } + } + } + }, + "fileExplorer.sort.menu.sortBy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort By" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "並び替え" + } + } + } + }, + "fileExplorer.sort.order.ascending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ascending" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昇順" + } + } + } + }, + "fileExplorer.sort.order.descending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Descending" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "降順" + } + } + } + }, + "fileExplorer.sort.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort: %@, %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "並び替え: %@、%@" + } + } + } + }, "fileExplorer.error.unavailable": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 85925e7464ef..0673c1147def 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -432,6 +432,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..b2edd78cced7 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( + format: 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/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 2422da8d8fe8..ffd4c456be77 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -210,6 +210,176 @@ 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 + } +} + +enum FileExplorerSortKey: String, CaseIterable, Sendable { + case name + case dateCreated + case dateModified + + 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") + } + } +} + +enum FileExplorerSortOrder: String, CaseIterable, Sendable { + case ascending + case descending + + 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") + } + } +} + +struct FileExplorerSortOptions: Equatable, Sendable { + let key: FileExplorerSortKey + let order: FileExplorerSortOrder + + static let defaultValue = FileExplorerSortOptions(key: .name, order: .ascending) +} + +enum FileExplorerSortSettings { + static let sortKeyKey = "fileExplorer.sortBy" + static let sortOrderKey = "fileExplorer.sortOrder" + static let didChangeNotification = Notification.Name("cmux.fileExplorerSortSettingsDidChange") + static let defaultValue = FileExplorerSortOptions.defaultValue + + static func sortKey(forRawValue raw: String?) -> FileExplorerSortKey { + guard let raw, let key = FileExplorerSortKey(rawValue: raw) else { + return defaultValue.key + } + return key + } + + static func sortOrder(forRawValue raw: String?) -> FileExplorerSortOrder { + guard let raw, let order = FileExplorerSortOrder(rawValue: raw) else { + return defaultValue.order + } + return order + } + + static func resolvedOptions(defaults: UserDefaults = .standard) -> FileExplorerSortOptions { + FileExplorerSortOptions( + key: sortKey(forRawValue: defaults.string(forKey: sortKeyKey)), + order: sortOrder(forRawValue: defaults.string(forKey: sortOrderKey)) + ) + } + + static func setOptions( + _ options: FileExplorerSortOptions, + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) { + defaults.set(options.key.rawValue, forKey: sortKeyKey) + defaults.set(options.order.rawValue, forKey: sortOrderKey) + notifyDidChange(notificationCenter: notificationCenter) + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + +enum FileExplorerNodeSorter { + static func sorted(_ nodes: [FileExplorerNode], options: FileExplorerSortOptions) -> [FileExplorerNode] { + nodes.sorted { lhs, rhs in + isOrderedBefore(lhs, rhs, options: options) + } + } + + private static func isOrderedBefore( + _ lhs: FileExplorerNode, + _ rhs: FileExplorerNode, + options: FileExplorerSortOptions + ) -> 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 static 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 static func orderedByFallback(_ lhs: FileExplorerNode, _ rhs: FileExplorerNode) -> Bool { + if lhs.isDirectory != rhs.isDirectory { + return lhs.isDirectory + } + return orderedByName(lhs, rhs, order: .ascending) + } + + private static 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 + } + } + } } final class FileExplorerNode: Identifiable { @@ -217,24 +387,35 @@ 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 - } + sortedChildren(using: .defaultValue) + } + + func sortedChildren(using options: FileExplorerSortOptions) -> [FileExplorerNode]? { + children.map { FileExplorerNodeSorter.sorted($0, options: options) } } } @@ -306,13 +487,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: [.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) + guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return nil } + let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + return FileExplorerEntry( + name: name, + path: url.path, + isDirectory: isDir.boolValue, + creationDate: values?.creationDate, + modificationDate: values?.contentModificationDate + ) } } } @@ -675,34 +867,51 @@ 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 showHiddenValue = showHidden ? "1" : "0" let output = try await runSSHCommand( connection: connection, - command: "ls \(lsFlags) \(escapedPath) 2>/dev/null" + command: """ + dir=\(escapedPath) + show_hidden=\(showHiddenValue) + for entry in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + name=${entry##*/} + if [ "$name" = "." ] || [ "$name" = ".." ]; then + continue + fi + if [ "$show_hidden" != "1" ]; then + case "$name" in .*) continue ;; esac + fi + if [ -d "$entry" ]; then kind=d; else kind=f; fi + mtime=$(stat -c %Y "$entry" 2>/dev/null || stat -f %m "$entry" 2>/dev/null || printf '') + btime=$(stat -c %W "$entry" 2>/dev/null || stat -f %B "$entry" 2>/dev/null || printf '') + printf '%s\\t%s\\t%s\\t%s\\n' "$kind" "$mtime" "$btime" "$name" + done + """ ) 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 name = String(parts[3]) 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) + return FileExplorerEntry( + name: name, + path: normalizedPath + name, + isDirectory: parts[0] == "d", + creationDate: dateFromEpochString(String(parts[2])), + modificationDate: dateFromEpochString(String(parts[1])) + ) } } + private static func dateFromEpochString(_ value: String) -> Date? { + guard let seconds = TimeInterval(value), seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } + private static func shellSingleQuote(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" } @@ -744,6 +953,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 + @Published private(set) var sortOptions: FileExplorerSortOptions + @Published private(set) var sortRevision = 0 @Published private(set) var rootStatusMessage: String? private(set) var workspaceRootIdentity: UUID? @@ -783,6 +994,25 @@ final class FileExplorerStore: ObservableObject { private var remoteHomeResolutionTask: Task? private var remoteHomeResolutionKey: String? + private let sortDefaults: UserDefaults + private let notificationCenter: NotificationCenter + private var sortSettingsObserver: NSObjectProtocol? + + init( + sortDefaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default + ) { + self.sortDefaults = sortDefaults + self.notificationCenter = notificationCenter + self.sortOptions = FileExplorerSortSettings.resolvedOptions(defaults: sortDefaults) + self.sortSettingsObserver = notificationCenter.addObserver( + forName: FileExplorerSortSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.applySortOptionsFromDefaults() + } + } var displayRootPath: String { if let sshProvider = provider as? SSHFileExplorerProvider { @@ -982,6 +1212,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]) } ?? [] @@ -1059,13 +1304,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 @@ -1131,6 +1379,38 @@ final class FileExplorerStore: ObservableObject { isRootLoading = false } + private func applySortOptionsFromDefaults() { + applySortOptions(FileExplorerSortSettings.resolvedOptions(defaults: sortDefaults), persist: false) + } + + private func applySortOptions(_ options: FileExplorerSortOptions, persist: Bool) { + guard sortOptions != options else { return } + sortOptions = options + resortLoadedNodes() + sortRevision &+= 1 + if persist { + FileExplorerSortSettings.setOptions( + options, + defaults: sortDefaults, + notificationCenter: notificationCenter + ) + } + objectWillChange.send() + } + + 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.sorted(nodes, options: sortOptions) + } + private func applyRemoteSSHWorkspaceRoot( workspaceId: UUID, connection: SSHFileExplorerConnection, @@ -1303,11 +1583,20 @@ 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.sorted(self, options: options) + } +} + // MARK: - Git Status enum GitFileStatus { diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 0c7c017ae904..5d59b2fba91b 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) @@ -230,7 +238,7 @@ struct FileExplorerPanelView: NSViewRepresentable { return store.rootNodes.count } guard let node = item as? FileExplorerNode else { return 0 } - return node.sortedChildren?.count ?? 0 + return node.children?.count ?? 0 } func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> 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 8be440770be7..549ad3751cea 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -213,6 +213,8 @@ extension CmuxSettingsFileStore { [ "fileExplorer": [ "doubleClickAction": FileExplorerDoubleClickActionSettings.defaultValue.rawValue, + "sortBy": FileExplorerSortSettings.defaultValue.key.rawValue, + "sortOrder": FileExplorerSortSettings.defaultValue.order.rawValue, ], ], [ diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index f0fa23e70ca3..5cd727994295 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -643,6 +643,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( @@ -1574,6 +1594,11 @@ final class CmuxSettingsFileStore { TerminalCopyOnSelectSettings.notifyDidChange(notificationCenter: notificationCenter) } + if change.defaultsKey == FileExplorerSortSettings.sortKeyKey || + change.defaultsKey == FileExplorerSortSettings.sortOrderKey { + FileExplorerSortSettings.notifyDidChange(notificationCenter: notificationCenter) + } + if change.defaultsKey == AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey { agentSessionAutoResumeDidChange = true } diff --git a/cmuxTests/FileExplorerDoubleClickActionTests.swift b/cmuxTests/FileExplorerDoubleClickActionTests.swift index fb472b1f1b2c..df677d1149d0 100644 --- a/cmuxTests/FileExplorerDoubleClickActionTests.swift +++ b/cmuxTests/FileExplorerDoubleClickActionTests.swift @@ -126,3 +126,52 @@ import Testing ) } } + +@Suite struct FileExplorerSortSettingsTests { + private func makeDefaults() -> UserDefaults { + let suiteName = "cmux-file-explorer-sort-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + @Test func defaultSortIsNameAscending() { + #expect(FileExplorerSortSettings.defaultValue == FileExplorerSortOptions(key: .name, order: .ascending)) + } + + @Test func parsesEachKnownSortKeyRawValue() { + #expect(FileExplorerSortSettings.sortKey(forRawValue: "name") == .name) + #expect(FileExplorerSortSettings.sortKey(forRawValue: "dateCreated") == .dateCreated) + #expect(FileExplorerSortSettings.sortKey(forRawValue: "dateModified") == .dateModified) + } + + @Test func parsesEachKnownSortOrderRawValue() { + #expect(FileExplorerSortSettings.sortOrder(forRawValue: "ascending") == .ascending) + #expect(FileExplorerSortSettings.sortOrder(forRawValue: "descending") == .descending) + } + + @Test func unknownSortValuesFallBackToDefaults() { + for raw in [nil, "", "modified", "date-created", "DESC", "newest"] { + #expect(FileExplorerSortSettings.sortKey(forRawValue: raw) == .name) + #expect(FileExplorerSortSettings.sortOrder(forRawValue: 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 options = FileExplorerSortOptions(key: .dateModified, order: .descending) + + FileExplorerSortSettings.setOptions( + options, + defaults: defaults, + notificationCenter: NotificationCenter() + ) + + #expect(FileExplorerSortSettings.resolvedOptions(defaults: defaults) == options) + } +} diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index cfba78a3e5f1..95c1aef9903d 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -186,6 +186,58 @@ 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.sorted( + nodes, + options: FileExplorerSortOptions(key: .name, order: .ascending) + ) + + #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.sorted( + nodes, + options: FileExplorerSortOptions(key: .dateModified, order: .descending) + ) + + #expect(sorted.map(\.name) == ["new-report.png", "middle.txt", "old-folder", "untimed.txt"]) + } + @Test func testDisplayRootPathUsesTilde() { let provider = MockFileExplorerProvider(homePath: "/home/user") diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 6017ec1622ae..ca590c0e3155 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -534,6 +534,59 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { } } + func testSettingsFileParsesFileExplorerSortOptions() 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) + + XCTAssertEqual( + FileExplorerSortSettings.resolvedOptions(defaults: defaults), + FileExplorerSortSettings.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) { + XCTAssertEqual(defaults.string(forKey: FileExplorerSortSettings.sortKeyKey), "dateModified") + XCTAssertEqual(defaults.string(forKey: FileExplorerSortSettings.sortOrderKey), "descending") + XCTAssertEqual( + FileExplorerSortSettings.resolvedOptions(defaults: defaults), + FileExplorerSortOptions(key: .dateModified, order: .descending) + ) + } + } + } + func testManagedAppearanceUserDefaultSurvivesSettingsFileReapplyUntilFileChanges() throws { let defaults = UserDefaults.standard let key = AppearanceSettings.appearanceModeKey diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 4e640706037b..0f325bd334b8 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1171,6 +1171,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 589784af5be5..0fe88abebecb 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "إعدادات مستكشف الملفات (شجرة الملفات) في الشريط الجانبي الأيمن.", - "doubleClickAction": "ما يفعله النقر المزدوج (أو الضغط على Return في نتيجة بحث) على ملف في مستكشف الملفات. يفتح `preview` معاينة ملفات cmux المضمنة (السلوك الافتراضي والتاريخي). يفتح `defaultEditor` الملف باستخدام تطبيق macOS الافتراضي لنوع الملف. يفتح `preferredEditor` باستخدام أمر `app.preferredEditor`، مع الرجوع إلى التطبيق الافتراضي عند عدم ضبطه. ينطبق ذلك على الملفات فقط؛ فالمجلدات تُوسَّع أو تُطوى دائمًا، ومستكشفات الملفات غير المحلية (البعيدة) تفتح دائمًا معاينة cmux." + "doubleClickAction": "ما يفعله النقر المزدوج (أو الضغط على Return في نتيجة بحث) على ملف في مستكشف الملفات. يفتح `preview` معاينة ملفات cmux المضمنة (السلوك الافتراضي والتاريخي). يفتح `defaultEditor` الملف باستخدام تطبيق macOS الافتراضي لنوع الملف. يفتح `preferredEditor` باستخدام أمر `app.preferredEditor`، مع الرجوع إلى التطبيق الافتراضي عند عدم ضبطه. ينطبق ذلك على الملفات فقط؛ فالمجلدات تُوسَّع أو تُطوى دائمًا، ومستكشفات الملفات غير المحلية (البعيدة) تفتح دائمًا معاينة cmux.", + "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/bs.json b/web/messages/bs.json index 24775c1e19f6..27634309d68b 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -841,7 +841,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": "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/da.json b/web/messages/da.json index 7d15a6bf7ff1..5557714a0c27 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -841,7 +841,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": "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/de.json b/web/messages/de.json index af6789f6dc07..4fdf1dd36fec 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -841,7 +841,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": "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/en.json b/web/messages/en.json index e94c5d562537..576b2b9f9814 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1033,7 +1033,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 1c445c0db15b..0d7c04614c99 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -841,7 +841,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": "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/fr.json b/web/messages/fr.json index 853e96c5f414..4b713853a3fd 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -841,7 +841,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": "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/it.json b/web/messages/it.json index 7d061dc0700d..b8a75f09cdd0 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -841,7 +841,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": "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/ja.json b/web/messages/ja.json index 5cd13ce871a3..d2bc4de67149 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -992,7 +992,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 1f854bc3c50f..a4701a999b12 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "ការកំណត់កម្មវិធីរុករកឯកសារ (មែកធាងឯកសារ) នៅក្នុងរបារចំហៀងស្តាំ។", - "doubleClickAction": "កំណត់អ្វីដែលកើតឡើងពេលចុចពីរដង (ឬចុច Return លើលទ្ធផលស្វែងរក) លើឯកសារនៅក្នុងកម្មវិធីរុករកឯកសារ។ `preview` បើកការមើលជាមុនឯកសារ cmux ដែលភ្ជាប់មកជាមួយ (ជាលំនាំដើម និងជាឥរិយាបថចាស់)។ `defaultEditor` បើកដោយប្រើកម្មវិធីលំនាំដើមរបស់ macOS សម្រាប់ប្រភេទឯកសារនោះ។ `preferredEditor` បើកដោយប្រើពាក្យបញ្ជា `app.preferredEditor` ហើយត្រឡប់ទៅកម្មវិធីលំនាំដើម ប្រសិនបើមិនបានកំណត់។ អនុវត្តតែលើឯកសារ; ថតតែងតែពង្រីក/បង្រួម ហើយកម្មវិធីរុករកឯកសារដែលមិនមែនក្នុងមូលដ្ឋាន (ពីចម្ងាយ) តែងតែបើកការមើលជាមុនរបស់ cmux។" + "doubleClickAction": "កំណត់អ្វីដែលកើតឡើងពេលចុចពីរដង (ឬចុច Return លើលទ្ធផលស្វែងរក) លើឯកសារនៅក្នុងកម្មវិធីរុករកឯកសារ។ `preview` បើកការមើលជាមុនឯកសារ cmux ដែលភ្ជាប់មកជាមួយ (ជាលំនាំដើម និងជាឥរិយាបថចាស់)។ `defaultEditor` បើកដោយប្រើកម្មវិធីលំនាំដើមរបស់ macOS សម្រាប់ប្រភេទឯកសារនោះ។ `preferredEditor` បើកដោយប្រើពាក្យបញ្ជា `app.preferredEditor` ហើយត្រឡប់ទៅកម្មវិធីលំនាំដើម ប្រសិនបើមិនបានកំណត់។ អនុវត្តតែលើឯកសារ; ថតតែងតែពង្រីក/បង្រួម ហើយកម្មវិធីរុករកឯកសារដែលមិនមែនក្នុងមូលដ្ឋាន (ពីចម្ងាយ) តែងតែបើកការមើលជាមុនរបស់ cmux។", + "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/ko.json b/web/messages/ko.json index 2d9c7bbeb581..3c99b2147bcf 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "오른쪽 사이드바 파일 탐색기(파일 트리) 설정입니다.", - "doubleClickAction": "파일 탐색기에서 파일을 더블클릭(또는 검색 결과에서 Return 키 입력)했을 때의 동작을 설정합니다. `preview`는 cmux 내장 파일 미리보기(기본값이자 기존 동작)를 엽니다. `defaultEditor`는 파일 형식의 macOS 기본 앱으로 엽니다. `preferredEditor`는 `app.preferredEditor` 명령으로 열고, 설정되지 않은 경우 기본 앱으로 대체합니다. 이 설정은 파일에만 적용되며, 디렉터리는 항상 펼치기/접기 동작을 하고, 로컬이 아닌(원격) 파일 탐색기는 항상 cmux 미리보기를 엽니다." + "doubleClickAction": "파일 탐색기에서 파일을 더블클릭(또는 검색 결과에서 Return 키 입력)했을 때의 동작을 설정합니다. `preview`는 cmux 내장 파일 미리보기(기본값이자 기존 동작)를 엽니다. `defaultEditor`는 파일 형식의 macOS 기본 앱으로 엽니다. `preferredEditor`는 `app.preferredEditor` 명령으로 열고, 설정되지 않은 경우 기본 앱으로 대체합니다. 이 설정은 파일에만 적용되며, 디렉터리는 항상 펼치기/접기 동작을 하고, 로컬이 아닌(원격) 파일 탐색기는 항상 cmux 미리보기를 엽니다.", + "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/no.json b/web/messages/no.json index e440e62049cd..021afa4b0922 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -841,7 +841,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": "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/pl.json b/web/messages/pl.json index 7b9db613c7b9..a70751a15631 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -841,7 +841,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": "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/pt-BR.json b/web/messages/pt-BR.json index 13247ecfaf50..d6cd7928e3bc 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -841,7 +841,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": "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/ru.json b/web/messages/ru.json index 800e1792a20d..0a80cc68f3b4 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "Настройки проводника файлов (дерева файлов) в правой боковой панели.", - "doubleClickAction": "Что происходит при двойном щелчке по файлу в проводнике файлов (или при нажатии Return на результате поиска). `preview` открывает встроенный предпросмотр файлов cmux (поведение по умолчанию и историческое поведение). `defaultEditor` открывает файл в приложении macOS по умолчанию для этого типа файла. `preferredEditor` открывает через команду `app.preferredEditor`, а если она не задана, использует приложение по умолчанию. Применяется только к файлам; каталоги всегда разворачиваются/сворачиваются, а нелокальные (удалённые) проводники файлов всегда открывают предпросмотр cmux." + "doubleClickAction": "Что происходит при двойном щелчке по файлу в проводнике файлов (или при нажатии Return на результате поиска). `preview` открывает встроенный предпросмотр файлов cmux (поведение по умолчанию и историческое поведение). `defaultEditor` открывает файл в приложении macOS по умолчанию для этого типа файла. `preferredEditor` открывает через команду `app.preferredEditor`, а если она не задана, использует приложение по умолчанию. Применяется только к файлам; каталоги всегда разворачиваются/сворачиваются, а нелокальные (удалённые) проводники файлов всегда открывают предпросмотр cmux.", + "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/th.json b/web/messages/th.json index c9a0629d52fd..a5677bf21989 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "การตั้งค่าตัวสำรวจไฟล์ (แผนผังไฟล์) ในแถบด้านขวา", - "doubleClickAction": "กำหนดว่าการดับเบิลคลิกไฟล์ในตัวสำรวจไฟล์ (หรือกด Return บนผลการค้นหา) จะทำอะไร `preview` เปิดตัวอย่างไฟล์ในตัวของ cmux (ค่าเริ่มต้นและพฤติกรรมเดิม) `defaultEditor` เปิดด้วยแอปเริ่มต้นของ macOS สำหรับชนิดไฟล์นั้น `preferredEditor` เปิดด้วยคำสั่ง `app.preferredEditor` และถ้าไม่ได้ตั้งค่าไว้จะกลับไปใช้แอปเริ่มต้น มีผลกับไฟล์เท่านั้น; ไดเรกทอรีจะขยาย/ยุบเสมอ และตัวสำรวจไฟล์ที่ไม่ใช่ในเครื่อง (รีโมต) จะเปิดตัวอย่างของ cmux เสมอ" + "doubleClickAction": "กำหนดว่าการดับเบิลคลิกไฟล์ในตัวสำรวจไฟล์ (หรือกด Return บนผลการค้นหา) จะทำอะไร `preview` เปิดตัวอย่างไฟล์ในตัวของ cmux (ค่าเริ่มต้นและพฤติกรรมเดิม) `defaultEditor` เปิดด้วยแอปเริ่มต้นของ macOS สำหรับชนิดไฟล์นั้น `preferredEditor` เปิดด้วยคำสั่ง `app.preferredEditor` และถ้าไม่ได้ตั้งค่าไว้จะกลับไปใช้แอปเริ่มต้น มีผลกับไฟล์เท่านั้น; ไดเรกทอรีจะขยาย/ยุบเสมอ และตัวสำรวจไฟล์ที่ไม่ใช่ในเครื่อง (รีโมต) จะเปิดตัวอย่างของ cmux เสมอ", + "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/tr.json b/web/messages/tr.json index 1b082069eace..69a0ae5a4f46 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -841,7 +841,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": "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/uk.json b/web/messages/uk.json index 36d918db5eba..5925ee78b06c 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "Налаштування провідника файлів (дерева файлів) у правій бічній панелі.", - "doubleClickAction": "Визначає, що відбувається під час подвійного клацання файлу в провіднику файлів (або натискання Return на результаті пошуку). `preview` відкриває вбудований перегляд файлів cmux (типова й історична поведінка). `defaultEditor` відкриває файл у типовій програмі macOS для цього типу файлів. `preferredEditor` відкриває через команду `app.preferredEditor`, а якщо її не задано, повертається до типової програми. Застосовується лише до файлів; каталоги завжди розгортаються/згортаються, а нелокальні (віддалені) провідники файлів завжди відкривають перегляд cmux." + "doubleClickAction": "Визначає, що відбувається під час подвійного клацання файлу в провіднику файлів (або натискання Return на результаті пошуку). `preview` відкриває вбудований перегляд файлів cmux (типова й історична поведінка). `defaultEditor` відкриває файл у типовій програмі macOS для цього типу файлів. `preferredEditor` відкриває через команду `app.preferredEditor`, а якщо її не задано, повертається до типової програми. Застосовується лише до файлів; каталоги завжди розгортаються/згортаються, а нелокальні (віддалені) провідники файлів завжди відкривають перегляд cmux.", + "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/zh-CN.json b/web/messages/zh-CN.json index a763291d3908..7fc377e5265f 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -841,7 +841,9 @@ }, "fileExplorer": { "description": "右侧边栏文件浏览器(文件树)设置。", - "doubleClickAction": "设置在文件浏览器中双击文件(或在搜索结果上按 Return)时执行的操作。`preview` 会打开 cmux 内置文件预览(默认和历史行为)。`defaultEditor` 会使用该文件类型的 macOS 默认应用打开。`preferredEditor` 会使用 `app.preferredEditor` 命令打开;未设置时回退到默认应用。仅适用于文件;目录始终展开/折叠,非本地(远程)文件浏览器始终打开 cmux 预览。" + "doubleClickAction": "设置在文件浏览器中双击文件(或在搜索结果上按 Return)时执行的操作。`preview` 会打开 cmux 内置文件预览(默认和历史行为)。`defaultEditor` 会使用该文件类型的 macOS 默认应用打开。`preferredEditor` 会使用 `app.preferredEditor` 命令打开;未设置时回退到默认应用。仅适用于文件;目录始终展开/折叠,非本地(远程)文件浏览器始终打开 cmux 预览。", + "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/zh-TW.json b/web/messages/zh-TW.json index 1d3940181965..3693cc64a180 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -841,7 +841,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": "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." From 5ca84a5c67c0d1d952a75401f96d303d99380112 Mon Sep 17 00:00:00 2001 From: cmux Date: Sun, 28 Jun 2026 20:29:39 -0700 Subject: [PATCH 02/18] Address file explorer sort review feedback --- Resources/Localizable.xcstrings | 1078 ++++++++++++++++- Sources/FileExplorerStore.swift | 89 +- ...rdShortcutSettingsFileStore+Template.swift | 4 +- .../KeyboardShortcutSettingsFileStore.swift | 2 +- .../FileExplorerDoubleClickActionTests.swift | 25 +- cmuxTests/FileExplorerStoreTests.swift | 12 +- ...hortcutSettingsFileStoreStartupTests.swift | 8 +- web/messages/ar.json | 4 +- web/messages/bs.json | 4 +- web/messages/da.json | 4 +- web/messages/de.json | 4 +- web/messages/es.json | 4 +- web/messages/fr.json | 4 +- web/messages/it.json | 4 +- web/messages/km.json | 4 +- web/messages/ko.json | 4 +- web/messages/no.json | 4 +- web/messages/pl.json | 4 +- web/messages/pt-BR.json | 4 +- web/messages/ru.json | 4 +- web/messages/th.json | 4 +- web/messages/tr.json | 4 +- web/messages/uk.json | 4 +- web/messages/zh-CN.json | 4 +- web/messages/zh-TW.json | 4 +- 25 files changed, 1124 insertions(+), 166 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3e105396934f..8db15aaf0e4b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -96426,6 +96426,114 @@ "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": "排序檔案" + } } } }, @@ -96435,98 +96543,746 @@ "en": { "stringUnit": { "state": "translated", - "value": "Date Created" + "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" } }, - "ja": { + "it": { "stringUnit": { "state": "translated", - "value": "作成日" + "value": "Crescente" } - } - } - }, - "fileExplorer.sort.key.dateModified": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "km": { "stringUnit": { "state": "translated", - "value": "Date Modified" + "value": "ឡើង" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "変更日" + "value": "오름차순" } - } - } - }, - "fileExplorer.sort.key.name": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "nb": { "stringUnit": { "state": "translated", - "value": "Name" + "value": "Stigende" } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "名前" + "value": "Rosnąco" } - } - } - }, - "fileExplorer.sort.menu.order": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "pt-BR": { "stringUnit": { "state": "translated", - "value": "Order" + "value": "Crescente" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "順序" + "value": "По возрастанию" } - } - } - }, - "fileExplorer.sort.menu.sortBy": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "th": { "stringUnit": { "state": "translated", - "value": "Sort By" + "value": "จากน้อยไปมาก" } }, - "ja": { + "tr": { "stringUnit": { "state": "translated", - "value": "並び替え" + "value": "Artan" } - } - } - }, - "fileExplorer.sort.order.ascending": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "uk": { "stringUnit": { "state": "translated", - "value": "Ascending" + "value": "За зростанням" } }, - "ja": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "昇順" + "value": "升序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遞增" } } } @@ -96545,6 +97301,114 @@ "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": "遞減" + } } } }, @@ -96562,6 +97426,114 @@ "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": "排序:%@,%@" + } } } }, diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index ffd4c456be77..4fdca4de9b7c 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -233,6 +233,10 @@ enum FileExplorerSortKey: String, CaseIterable, Sendable { case dateCreated case dateModified + init(resolvingRawValue raw: String?) { + self = raw.flatMap { Self(rawValue: $0) } ?? .name + } + var localizedTitle: String { switch self { case .name: @@ -249,6 +253,10 @@ 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: @@ -266,59 +274,49 @@ struct FileExplorerSortOptions: Equatable, Sendable { static let defaultValue = FileExplorerSortOptions(key: .name, order: .ascending) } -enum FileExplorerSortSettings { +struct FileExplorerSortSettings { static let sortKeyKey = "fileExplorer.sortBy" static let sortOrderKey = "fileExplorer.sortOrder" static let didChangeNotification = Notification.Name("cmux.fileExplorerSortSettingsDidChange") - static let defaultValue = FileExplorerSortOptions.defaultValue - static func sortKey(forRawValue raw: String?) -> FileExplorerSortKey { - guard let raw, let key = FileExplorerSortKey(rawValue: raw) else { - return defaultValue.key - } - return key - } + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter - static func sortOrder(forRawValue raw: String?) -> FileExplorerSortOrder { - guard let raw, let order = FileExplorerSortOrder(rawValue: raw) else { - return defaultValue.order - } - return order + init(defaults: UserDefaults, notificationCenter: NotificationCenter) { + self.defaults = defaults + self.notificationCenter = notificationCenter } - static func resolvedOptions(defaults: UserDefaults = .standard) -> FileExplorerSortOptions { + func resolvedOptions() -> FileExplorerSortOptions { FileExplorerSortOptions( - key: sortKey(forRawValue: defaults.string(forKey: sortKeyKey)), - order: sortOrder(forRawValue: defaults.string(forKey: sortOrderKey)) + key: FileExplorerSortKey(resolvingRawValue: defaults.string(forKey: Self.sortKeyKey)), + order: FileExplorerSortOrder(resolvingRawValue: defaults.string(forKey: Self.sortOrderKey)) ) } - static func setOptions( - _ options: FileExplorerSortOptions, - defaults: UserDefaults = .standard, - notificationCenter: NotificationCenter = .default - ) { - defaults.set(options.key.rawValue, forKey: sortKeyKey) - defaults.set(options.order.rawValue, forKey: sortOrderKey) - notifyDidChange(notificationCenter: notificationCenter) + func setOptions(_ options: FileExplorerSortOptions) { + defaults.set(options.key.rawValue, forKey: Self.sortKeyKey) + defaults.set(options.order.rawValue, forKey: Self.sortOrderKey) + notifyDidChange() } - static func notifyDidChange(notificationCenter: NotificationCenter = .default) { - notificationCenter.post(name: didChangeNotification, object: nil) + func notifyDidChange() { + notificationCenter.post(name: Self.didChangeNotification, object: nil) } } -enum FileExplorerNodeSorter { - static func sorted(_ nodes: [FileExplorerNode], options: FileExplorerSortOptions) -> [FileExplorerNode] { +struct FileExplorerNodeSorter { + let options: FileExplorerSortOptions + + func sorted(_ nodes: [FileExplorerNode]) -> [FileExplorerNode] { nodes.sorted { lhs, rhs in - isOrderedBefore(lhs, rhs, options: options) + isOrderedBefore(lhs, rhs) } } - private static func isOrderedBefore( + private func isOrderedBefore( _ lhs: FileExplorerNode, - _ rhs: FileExplorerNode, - options: FileExplorerSortOptions + _ rhs: FileExplorerNode ) -> Bool { switch options.key { case .name: @@ -339,7 +337,7 @@ enum FileExplorerNodeSorter { } } - private static func orderedByDate(_ lhs: Date?, _ rhs: Date?, order: FileExplorerSortOrder) -> Bool? { + 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 @@ -352,14 +350,14 @@ enum FileExplorerNodeSorter { } } - private static func orderedByFallback(_ lhs: FileExplorerNode, _ rhs: FileExplorerNode) -> Bool { + private func orderedByFallback(_ lhs: FileExplorerNode, _ rhs: FileExplorerNode) -> Bool { if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory } return orderedByName(lhs, rhs, order: .ascending) } - private static func orderedByName( + private func orderedByName( _ lhs: FileExplorerNode, _ rhs: FileExplorerNode, order: FileExplorerSortOrder @@ -415,7 +413,7 @@ final class FileExplorerNode: Identifiable { } func sortedChildren(using options: FileExplorerSortOptions) -> [FileExplorerNode]? { - children.map { FileExplorerNodeSorter.sorted($0, options: options) } + children.map { FileExplorerNodeSorter(options: options).sorted($0) } } } @@ -994,7 +992,7 @@ final class FileExplorerStore: ObservableObject { private var remoteHomeResolutionTask: Task? private var remoteHomeResolutionKey: String? - private let sortDefaults: UserDefaults + private let sortSettings: FileExplorerSortSettings private let notificationCenter: NotificationCenter private var sortSettingsObserver: NSObjectProtocol? @@ -1002,9 +1000,10 @@ final class FileExplorerStore: ObservableObject { sortDefaults: UserDefaults = .standard, notificationCenter: NotificationCenter = .default ) { - self.sortDefaults = sortDefaults self.notificationCenter = notificationCenter - self.sortOptions = FileExplorerSortSettings.resolvedOptions(defaults: sortDefaults) + let sortSettings = FileExplorerSortSettings(defaults: sortDefaults, notificationCenter: notificationCenter) + self.sortSettings = sortSettings + self.sortOptions = sortSettings.resolvedOptions() self.sortSettingsObserver = notificationCenter.addObserver( forName: FileExplorerSortSettings.didChangeNotification, object: nil, @@ -1380,7 +1379,7 @@ final class FileExplorerStore: ObservableObject { } private func applySortOptionsFromDefaults() { - applySortOptions(FileExplorerSortSettings.resolvedOptions(defaults: sortDefaults), persist: false) + applySortOptions(sortSettings.resolvedOptions(), persist: false) } private func applySortOptions(_ options: FileExplorerSortOptions, persist: Bool) { @@ -1389,11 +1388,7 @@ final class FileExplorerStore: ObservableObject { resortLoadedNodes() sortRevision &+= 1 if persist { - FileExplorerSortSettings.setOptions( - options, - defaults: sortDefaults, - notificationCenter: notificationCenter - ) + sortSettings.setOptions(options) } objectWillChange.send() } @@ -1408,7 +1403,7 @@ final class FileExplorerStore: ObservableObject { } private func sortNodes(_ nodes: [FileExplorerNode]) -> [FileExplorerNode] { - FileExplorerNodeSorter.sorted(nodes, options: sortOptions) + FileExplorerNodeSorter(options: sortOptions).sorted(nodes) } private func applyRemoteSSHWorkspaceRoot( @@ -1593,7 +1588,7 @@ final class FileExplorerStore: ObservableObject { private extension Array where Element == FileExplorerNode { func sorted(using options: FileExplorerSortOptions) -> [FileExplorerNode] { - FileExplorerNodeSorter.sorted(self, options: options) + FileExplorerNodeSorter(options: options).sorted(self) } } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 549ad3751cea..b1ec139cdf8c 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -213,8 +213,8 @@ extension CmuxSettingsFileStore { [ "fileExplorer": [ "doubleClickAction": FileExplorerDoubleClickActionSettings.defaultValue.rawValue, - "sortBy": FileExplorerSortSettings.defaultValue.key.rawValue, - "sortOrder": FileExplorerSortSettings.defaultValue.order.rawValue, + "sortBy": FileExplorerSortOptions.defaultValue.key.rawValue, + "sortOrder": FileExplorerSortOptions.defaultValue.order.rawValue, ], ], [ diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 5cd727994295..e0259ec989ac 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -1596,7 +1596,7 @@ final class CmuxSettingsFileStore { if change.defaultsKey == FileExplorerSortSettings.sortKeyKey || change.defaultsKey == FileExplorerSortSettings.sortOrderKey { - FileExplorerSortSettings.notifyDidChange(notificationCenter: notificationCenter) + notificationCenter.post(name: FileExplorerSortSettings.didChangeNotification, object: nil) } if change.defaultsKey == AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey { diff --git a/cmuxTests/FileExplorerDoubleClickActionTests.swift b/cmuxTests/FileExplorerDoubleClickActionTests.swift index df677d1149d0..041d36beee43 100644 --- a/cmuxTests/FileExplorerDoubleClickActionTests.swift +++ b/cmuxTests/FileExplorerDoubleClickActionTests.swift @@ -136,24 +136,24 @@ import Testing } @Test func defaultSortIsNameAscending() { - #expect(FileExplorerSortSettings.defaultValue == FileExplorerSortOptions(key: .name, order: .ascending)) + #expect(FileExplorerSortOptions.defaultValue == FileExplorerSortOptions(key: .name, order: .ascending)) } @Test func parsesEachKnownSortKeyRawValue() { - #expect(FileExplorerSortSettings.sortKey(forRawValue: "name") == .name) - #expect(FileExplorerSortSettings.sortKey(forRawValue: "dateCreated") == .dateCreated) - #expect(FileExplorerSortSettings.sortKey(forRawValue: "dateModified") == .dateModified) + #expect(FileExplorerSortKey(resolvingRawValue: "name") == .name) + #expect(FileExplorerSortKey(resolvingRawValue: "dateCreated") == .dateCreated) + #expect(FileExplorerSortKey(resolvingRawValue: "dateModified") == .dateModified) } @Test func parsesEachKnownSortOrderRawValue() { - #expect(FileExplorerSortSettings.sortOrder(forRawValue: "ascending") == .ascending) - #expect(FileExplorerSortSettings.sortOrder(forRawValue: "descending") == .descending) + #expect(FileExplorerSortOrder(resolvingRawValue: "ascending") == .ascending) + #expect(FileExplorerSortOrder(resolvingRawValue: "descending") == .descending) } @Test func unknownSortValuesFallBackToDefaults() { for raw in [nil, "", "modified", "date-created", "DESC", "newest"] { - #expect(FileExplorerSortSettings.sortKey(forRawValue: raw) == .name) - #expect(FileExplorerSortSettings.sortOrder(forRawValue: raw) == .ascending) + #expect(FileExplorerSortKey(resolvingRawValue: raw) == .name) + #expect(FileExplorerSortOrder(resolvingRawValue: raw) == .ascending) } } @@ -164,14 +164,11 @@ import Testing @Test func resolvedOptionsRoundTripThroughUserDefaults() { let defaults = makeDefaults() + let settings = FileExplorerSortSettings(defaults: defaults, notificationCenter: NotificationCenter()) let options = FileExplorerSortOptions(key: .dateModified, order: .descending) - FileExplorerSortSettings.setOptions( - options, - defaults: defaults, - notificationCenter: NotificationCenter() - ) + settings.setOptions(options) - #expect(FileExplorerSortSettings.resolvedOptions(defaults: defaults) == options) + #expect(settings.resolvedOptions() == options) } } diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 95c1aef9903d..9158ae52e21a 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -195,10 +195,8 @@ struct FileExplorerStoreTests { FileExplorerNode(name: "alpha", path: "/project/alpha", isDirectory: true), ] - let sorted = FileExplorerNodeSorter.sorted( - nodes, - options: FileExplorerSortOptions(key: .name, order: .ascending) - ) + let sorted = FileExplorerNodeSorter(options: FileExplorerSortOptions(key: .name, order: .ascending)) + .sorted(nodes) #expect(sorted.map(\.name) == ["alpha", "beta", "alpha.txt", "z-file.txt"]) } @@ -230,10 +228,8 @@ struct FileExplorerStoreTests { FileExplorerNode(name: "untimed.txt", path: "/project/untimed.txt", isDirectory: false), ] - let sorted = FileExplorerNodeSorter.sorted( - nodes, - options: FileExplorerSortOptions(key: .dateModified, order: .descending) - ) + let sorted = FileExplorerNodeSorter(options: FileExplorerSortOptions(key: .dateModified, order: .descending)) + .sorted(nodes) #expect(sorted.map(\.name) == ["new-report.png", "middle.txt", "old-folder", "untimed.txt"]) } diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index ca590c0e3155..0b522e8cbf90 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -547,11 +547,9 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { defaults.removeObject(forKey: FileExplorerSortSettings.sortOrderKey) defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) defaults.removeObject(forKey: importedManagedDefaultsKey) + let sortSettings = FileExplorerSortSettings(defaults: defaults, notificationCenter: NotificationCenter()) - XCTAssertEqual( - FileExplorerSortSettings.resolvedOptions(defaults: defaults), - FileExplorerSortSettings.defaultValue - ) + XCTAssertEqual(sortSettings.resolvedOptions(), FileExplorerSortOptions.defaultValue) let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -580,7 +578,7 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { XCTAssertEqual(defaults.string(forKey: FileExplorerSortSettings.sortKeyKey), "dateModified") XCTAssertEqual(defaults.string(forKey: FileExplorerSortSettings.sortOrderKey), "descending") XCTAssertEqual( - FileExplorerSortSettings.resolvedOptions(defaults: defaults), + sortSettings.resolvedOptions(), FileExplorerSortOptions(key: .dateModified, order: .descending) ) } diff --git a/web/messages/ar.json b/web/messages/ar.json index 0fe88abebecb..9d5495212431 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "إعدادات مستكشف الملفات (شجرة الملفات) في الشريط الجانبي الأيمن.", "doubleClickAction": "ما يفعله النقر المزدوج (أو الضغط على Return في نتيجة بحث) على ملف في مستكشف الملفات. يفتح `preview` معاينة ملفات cmux المضمنة (السلوك الافتراضي والتاريخي). يفتح `defaultEditor` الملف باستخدام تطبيق macOS الافتراضي لنوع الملف. يفتح `preferredEditor` باستخدام أمر `app.preferredEditor`، مع الرجوع إلى التطبيق الافتراضي عند عدم ضبطه. ينطبق ذلك على الملفات فقط؛ فالمجلدات تُوسَّع أو تُطوى دائمًا، ومستكشفات الملفات غير المحلية (البعيدة) تفتح دائمًا معاينة cmux.", - "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." + "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 27634309d68b..2b9070beae7a 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 5557714a0c27..5758875487a4 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 4fdf1dd36fec..f0b89da30de8 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -842,8 +842,8 @@ "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.", - "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." + "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/es.json b/web/messages/es.json index 0d7c04614c99..a7038dbee8e9 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 4b713853a3fd..3f9b2ebed416 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 b8a75f09cdd0..dd812e573d69 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -842,8 +842,8 @@ "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.", - "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." + "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/km.json b/web/messages/km.json index a4701a999b12..b1df4679bef2 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "ការកំណត់កម្មវិធីរុករកឯកសារ (មែកធាងឯកសារ) នៅក្នុងរបារចំហៀងស្តាំ។", "doubleClickAction": "កំណត់អ្វីដែលកើតឡើងពេលចុចពីរដង (ឬចុច Return លើលទ្ធផលស្វែងរក) លើឯកសារនៅក្នុងកម្មវិធីរុករកឯកសារ។ `preview` បើកការមើលជាមុនឯកសារ cmux ដែលភ្ជាប់មកជាមួយ (ជាលំនាំដើម និងជាឥរិយាបថចាស់)។ `defaultEditor` បើកដោយប្រើកម្មវិធីលំនាំដើមរបស់ macOS សម្រាប់ប្រភេទឯកសារនោះ។ `preferredEditor` បើកដោយប្រើពាក្យបញ្ជា `app.preferredEditor` ហើយត្រឡប់ទៅកម្មវិធីលំនាំដើម ប្រសិនបើមិនបានកំណត់។ អនុវត្តតែលើឯកសារ; ថតតែងតែពង្រីក/បង្រួម ហើយកម្មវិធីរុករកឯកសារដែលមិនមែនក្នុងមូលដ្ឋាន (ពីចម្ងាយ) តែងតែបើកការមើលជាមុនរបស់ cmux។", - "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." + "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 3c99b2147bcf..6a59f897dff1 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "오른쪽 사이드바 파일 탐색기(파일 트리) 설정입니다.", "doubleClickAction": "파일 탐색기에서 파일을 더블클릭(또는 검색 결과에서 Return 키 입력)했을 때의 동작을 설정합니다. `preview`는 cmux 내장 파일 미리보기(기본값이자 기존 동작)를 엽니다. `defaultEditor`는 파일 형식의 macOS 기본 앱으로 엽니다. `preferredEditor`는 `app.preferredEditor` 명령으로 열고, 설정되지 않은 경우 기본 앱으로 대체합니다. 이 설정은 파일에만 적용되며, 디렉터리는 항상 펼치기/접기 동작을 하고, 로컬이 아닌(원격) 파일 탐색기는 항상 cmux 미리보기를 엽니다.", - "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." + "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 021afa4b0922..1183a06eed5a 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 a70751a15631..e6608376f894 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 d6cd7928e3bc..6c7271a7ccb1 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 0a80cc68f3b4..d8289fdf42e4 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "Настройки проводника файлов (дерева файлов) в правой боковой панели.", "doubleClickAction": "Что происходит при двойном щелчке по файлу в проводнике файлов (или при нажатии Return на результате поиска). `preview` открывает встроенный предпросмотр файлов cmux (поведение по умолчанию и историческое поведение). `defaultEditor` открывает файл в приложении macOS по умолчанию для этого типа файла. `preferredEditor` открывает через команду `app.preferredEditor`, а если она не задана, использует приложение по умолчанию. Применяется только к файлам; каталоги всегда разворачиваются/сворачиваются, а нелокальные (удалённые) проводники файлов всегда открывают предпросмотр cmux.", - "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." + "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 a5677bf21989..1b11e21a492b 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "การตั้งค่าตัวสำรวจไฟล์ (แผนผังไฟล์) ในแถบด้านขวา", "doubleClickAction": "กำหนดว่าการดับเบิลคลิกไฟล์ในตัวสำรวจไฟล์ (หรือกด Return บนผลการค้นหา) จะทำอะไร `preview` เปิดตัวอย่างไฟล์ในตัวของ cmux (ค่าเริ่มต้นและพฤติกรรมเดิม) `defaultEditor` เปิดด้วยแอปเริ่มต้นของ macOS สำหรับชนิดไฟล์นั้น `preferredEditor` เปิดด้วยคำสั่ง `app.preferredEditor` และถ้าไม่ได้ตั้งค่าไว้จะกลับไปใช้แอปเริ่มต้น มีผลกับไฟล์เท่านั้น; ไดเรกทอรีจะขยาย/ยุบเสมอ และตัวสำรวจไฟล์ที่ไม่ใช่ในเครื่อง (รีโมต) จะเปิดตัวอย่างของ cmux เสมอ", - "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." + "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 69a0ae5a4f46..41cac3778ba0 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -842,8 +842,8 @@ "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.", - "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." + "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 5925ee78b06c..a9bd4918009a 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "Налаштування провідника файлів (дерева файлів) у правій бічній панелі.", "doubleClickAction": "Визначає, що відбувається під час подвійного клацання файлу в провіднику файлів (або натискання Return на результаті пошуку). `preview` відкриває вбудований перегляд файлів cmux (типова й історична поведінка). `defaultEditor` відкриває файл у типовій програмі macOS для цього типу файлів. `preferredEditor` відкриває через команду `app.preferredEditor`, а якщо її не задано, повертається до типової програми. Застосовується лише до файлів; каталоги завжди розгортаються/згортаються, а нелокальні (віддалені) провідники файлів завжди відкривають перегляд cmux.", - "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." + "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 7fc377e5265f..1499f5e1f59b 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "右侧边栏文件浏览器(文件树)设置。", "doubleClickAction": "设置在文件浏览器中双击文件(或在搜索结果上按 Return)时执行的操作。`preview` 会打开 cmux 内置文件预览(默认和历史行为)。`defaultEditor` 会使用该文件类型的 macOS 默认应用打开。`preferredEditor` 会使用 `app.preferredEditor` 命令打开;未设置时回退到默认应用。仅适用于文件;目录始终展开/折叠,非本地(远程)文件浏览器始终打开 cmux 预览。", - "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." + "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 3693cc64a180..c5742303b9d3 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -842,8 +842,8 @@ "fileExplorer": { "description": "右側邊欄檔案瀏覽器(檔案樹)設定。", "doubleClickAction": "設定在檔案瀏覽器中按兩下檔案(或在搜尋結果上按 Return)時要執行的動作。`preview` 會開啟 cmux 內建檔案預覽(預設與既有行為)。`defaultEditor` 會使用該檔案類型的 macOS 預設 App 開啟。`preferredEditor` 會使用 `app.preferredEditor` 命令開啟;未設定時會退回預設 App。僅適用於檔案;目錄一律展開/收合,非本機(遠端)檔案瀏覽器一律開啟 cmux 預覽。", - "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." + "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." From b5d8bf87939bc2f1a8bb6404f6a4914d7e1a9df6 Mon Sep 17 00:00:00 2001 From: cmux Date: Sun, 28 Jun 2026 20:31:20 -0700 Subject: [PATCH 03/18] Harden SSH file birth time parsing --- Sources/FileExplorerStore.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 4fdca4de9b7c..9806eb955a69 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -884,6 +884,10 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { if [ -d "$entry" ]; then kind=d; else kind=f; fi mtime=$(stat -c %Y "$entry" 2>/dev/null || stat -f %m "$entry" 2>/dev/null || printf '') btime=$(stat -c %W "$entry" 2>/dev/null || stat -f %B "$entry" 2>/dev/null || printf '') + case "$btime" in + ''|*[!0-9]*) btime='' ;; + *) if [ "$btime" -lt 100000000 ]; then btime=''; fi ;; + esac printf '%s\\t%s\\t%s\\t%s\\n' "$kind" "$mtime" "$btime" "$name" done """ From 5e0d9d81bee0b31e1edd50ced5b31bed104b6519 Mon Sep 17 00:00:00 2001 From: cmux Date: Sun, 28 Jun 2026 20:40:57 -0700 Subject: [PATCH 04/18] Clean up file explorer sort review nits --- Sources/FileExplorerStore.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 9806eb955a69..9efb24c1aa0e 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -407,14 +407,6 @@ final class FileExplorerNode: Identifiable { } var isExpandable: Bool { isDirectory } - - var sortedChildren: [FileExplorerNode]? { - sortedChildren(using: .defaultValue) - } - - func sortedChildren(using options: FileExplorerSortOptions) -> [FileExplorerNode]? { - children.map { FileExplorerNodeSorter(options: options).sorted($0) } - } } // MARK: - Root Resolver @@ -1394,7 +1386,6 @@ final class FileExplorerStore: ObservableObject { if persist { sortSettings.setOptions(options) } - objectWillChange.send() } private func resortLoadedNodes() { From 18e9e4ded0529ce24d9d7f05be397fb2978ca00d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:30:35 -0700 Subject: [PATCH 05/18] Fix remote file explorer SSH listing regressions The date-sort rewrite replaced the simple `ls` listing with a multi-line POSIX script sent as the SSH command, which OpenSSH runs through the account's login shell. fish/csh/tcsh cannot parse `for`/`if`/`case`, so the remote file explorer could fail entirely for those users. The script also ran two-plus `stat` processes per entry to gather timestamps, even for the default name sort that needs none, turning one `ls` into O(entries) remote process spawns and making large remote directories appear to hang. - Run the listing under `/bin/sh` regardless of login shell by base64-encoding the script and bootstrapping via `/bin/sh -c` (only a base64 payload with no shell metacharacters reaches the login shell), mirroring the existing DockSplitStore remote-script pattern. - Detect GNU vs BSD `stat` once, then run a single batched `stat` over all entries (O(1) remote processes). Timestamps are always collected so switching the sort key re-sorts the cached listing without a re-fetch. - Decompose into testable `remoteListingScript`/`posixShellBootstrap`/ `parseRemoteListing` helpers and add regression coverage for the POSIX-shell wrapping, batched stat, hidden/`.`/`..` filtering, and the birth-time threshold. Verified the generated command empirically against BSD `stat` and across /bin/sh, zsh, and bash. Surfaced by autoreview on PR #6986. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 +- Sources/FileExplorerStore.swift | 102 ++++++++++---- cmuxTests/FileExplorerStoreTests.swift | 177 +++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 31 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a221d0387e0c..bdeafe5e4f87 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -51,9 +51,9 @@ 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift +1770 Sources/FileExplorerStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift -1726 Sources/FileExplorerStore.swift 1687 cmuxTests/MarkdownPanelTests.swift 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1670 Sources/FileExplorerView.swift @@ -65,6 +65,7 @@ 1499 cmuxTests/OmnibarAndToolsTests.swift 1435 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1433 Sources/RemoteTmuxControlConnection.swift +1429 cmuxTests/FileExplorerStoreTests.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1363 Sources/CMUXInstalledExtensionSidebarHostView.swift @@ -74,7 +75,6 @@ 1287 cmuxTests/RestorableAgentSessionIndexTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1258 Sources/Feed/FeedCoordinator.swift -1252 cmuxTests/FileExplorerStoreTests.swift 1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1205 Sources/RemoteTmuxController.swift 1197 cmuxTests/CodexAppServerSessionTests.swift diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 9efb24c1aa0e..4c6feebd6991 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -857,52 +857,96 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { connection: SSHFileExplorerConnection, showHidden: Bool ) async throws -> [FileExplorerEntry] { - let escapedPath = shellSingleQuote(path) - let showHiddenValue = showHidden ? "1" : "0" let output = try await runSSHCommand( connection: connection, - command: """ - dir=\(escapedPath) - show_hidden=\(showHiddenValue) - for entry in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do - [ -e "$entry" ] || [ -L "$entry" ] || continue - name=${entry##*/} - if [ "$name" = "." ] || [ "$name" = ".." ]; then - continue - fi - if [ "$show_hidden" != "1" ]; then - case "$name" in .*) continue ;; esac - fi - if [ -d "$entry" ]; then kind=d; else kind=f; fi - mtime=$(stat -c %Y "$entry" 2>/dev/null || stat -f %m "$entry" 2>/dev/null || printf '') - btime=$(stat -c %W "$entry" 2>/dev/null || stat -f %B "$entry" 2>/dev/null || printf '') - case "$btime" in - ''|*[!0-9]*) btime='' ;; - *) if [ "$btime" -lt 100000000 ]; then btime=''; fi ;; - esac - printf '%s\\t%s\\t%s\\t%s\\n' "$kind" "$mtime" "$btime" "$name" - done - """ + 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 runs a single batched `stat` + /// over the directory's entries, so remote process creation stays O(1) + /// regardless of entry count. The previous implementation spawned two or + /// more `stat` processes per entry, which could make large remote + /// directories appear to hang. Timestamps are always collected so changing + /// the sort key re-sorts the cached listing without a re-fetch. + /// + /// The trailing `exit 0` makes empty or unreadable directories report + /// success with an empty listing instead of the non-zero status that + /// `runSSHCommand` would otherwise surface as an error (an unmatched glob + /// is passed literally to `stat`, which fails on it). + static func remoteListingScript(path: String, showHidden: Bool) -> String { + let escapedPath = shellSingleQuote(path) + // `.[!.]*` and `..?*` match dotfiles while excluding `.` and `..`. + let globs = showHidden ? "-- * .[!.]* ..?*" : "-- *" + // Literal tabs separate the fields; GNU `stat -c` does not expand `\t`. + return """ + cd \(escapedPath) 2>/dev/null || exit 0 + if stat -c %Y / >/dev/null 2>&1; then + stat -L -c '%F\t%Y\t%W\t%n' \(globs) 2>/dev/null + else + stat -L -f '%HT\t%m\t%B\t%N' \(globs) 2>/dev/null + 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). + static func posixShellBootstrap(script: String) -> String { + let encoded = Data(script.utf8).base64EncodedString() + return "/bin/sh -c 'b=\(encoded); eval \"$(printf %s \"$b\" | base64 -d 2>/dev/null || printf %s \"$b\" | base64 -D 2>/dev/null)\"'" + } + + /// Parses the tab-separated output of ``remoteListingScript(path:showHidden:)``. + /// + /// Each line is `typemtimebtimename`. The trailing `name` + /// field is split last so names containing spaces survive; the leading + /// type description ("directory"/"Directory", "regular file", …) may also + /// contain spaces but never a tab. + 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 parts = line.split(separator: "\t", maxSplits: 3, omittingEmptySubsequences: false) guard parts.count == 4 else { return nil } let name = String(parts[3]) + guard name != "." && name != ".." else { return nil } guard showHidden || !name.hasPrefix(".") else { return nil } + let isDirectory = String(parts[0]).caseInsensitiveCompare("directory") == .orderedSame return FileExplorerEntry( name: name, path: normalizedPath + name, - isDirectory: parts[0] == "d", - creationDate: dateFromEpochString(String(parts[2])), + isDirectory: isDirectory, + creationDate: dateFromEpochString(String(parts[2]), minimumEpoch: birthTimeMinimumEpoch), modificationDate: dateFromEpochString(String(parts[1])) ) } } - private static func dateFromEpochString(_ value: String) -> Date? { - guard let seconds = TimeInterval(value), seconds > 0 else { return nil } + /// 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) } diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 9158ae52e21a..8efadc6eeb7b 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1250,3 +1250,180 @@ struct FileSearchControllerTests { } } } + +// MARK: - SSH remote listing command construction & parsing + +/// Regression coverage for the two P1 issues in the date-sort SSH listing +/// rewrite: the remote command must run under a POSIX shell (not the account's +/// login shell, which may be fish/csh) and must not spawn `stat` per entry. +@Suite +struct ProcessSSHFileExplorerListingTests { + + // MARK: Batched stat (no per-entry process fan-out) + + @Test + func testRemoteListingScriptUsesOneBatchedStatNotPerEntryLoop() { + let script = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/app", + showHidden: false + ) + // The previous implementation looped over every entry and ran `stat` + // (twice) per entry. The fix detects the stat dialect once and runs a + // single batched stat, so the script must not contain a per-entry loop. + #expect(!script.contains("for entry")) + #expect(!script.contains("for ")) + // One GNU branch and one BSD branch, each a single batched stat call. + #expect(script.contains("stat -c %Y /")) + #expect(script.contains("stat -L -c '%F\t%Y\t%W\t%n'")) + #expect(script.contains("stat -L -f '%HT\t%m\t%B\t%N'")) + // Empty/unreadable dirs must report success, not a non-zero status that + // would surface as an error. + #expect(script.hasSuffix("exit 0")) + } + + @Test + func testRemoteListingScriptIncludesHiddenGlobsOnlyWhenRequested() { + let visible = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/app", + showHidden: false + ) + #expect(visible.contains("-- *")) + #expect(!visible.contains(".[!.]*")) + + let hidden = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/app", + showHidden: true + ) + #expect(hidden.contains("-- * .[!.]* ..?*")) + } + + @Test + func testRemoteListingScriptSingleQuotesThePath() { + let plain = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/my app", + showHidden: false + ) + #expect(plain.contains("cd '/srv/my app'")) + + // A single quote in the path must be escaped so the script stays valid. + let quoted = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/a'b", + showHidden: false + ) + #expect(quoted.contains("cd '/srv/a'\\''b'")) + } + + // MARK: POSIX shell bootstrap (login-shell independence) + + @Test + func testPosixShellBootstrapRunsUnderBinShWithEncodedScript() { + let script = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/app", + showHidden: true + ) + let command = ProcessSSHFileExplorerTransport.posixShellBootstrap(script: script) + + // The login shell only ever sees `/bin/sh -c` plus a base64 payload, so + // fish/csh/tcsh cannot mis-parse the POSIX control flow. + #expect(command.hasPrefix("/bin/sh -c '")) + #expect(command.contains("base64 -d")) + #expect(command.contains("base64 -D")) + // The raw POSIX script must NOT leak into the login-shell command line. + #expect(!command.contains("stat -L")) + #expect(!command.contains("..?*")) + } + + @Test + func testPosixShellBootstrapEncodesExactScriptRoundTrip() throws { + let script = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/app", + showHidden: true + ) + let command = ProcessSSHFileExplorerTransport.posixShellBootstrap(script: script) + + // Recover the base64 payload (between `b=` and `;`) and confirm it + // decodes back to exactly the script we asked to run. + let afterAssign = try #require(command.range(of: "b=")) + let semicolon = try #require( + command.range(of: ";", range: afterAssign.upperBound..mtimebtimename`. Cover GNU ("directory") + // and BSD ("Directory"/"Regular File") type spellings plus a name with a + // space. + let output = [ + "Directory\t1700000000\t1690000000\tsub", + "regular file\t1700000100\t1690000100\tmain.swift", + "Regular File\t1700000200\t1690000200\tfile 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 testParseRemoteListingExcludesDotEntriesAndRespectsHidden() { + let output = [ + "Directory\t1700000000\t1690000000\t.", + "Directory\t1700000000\t1690000000\t..", + "Regular File\t1700000000\t1690000000\t.hidden", + "Regular File\t1700000000\t1690000000\tvisible.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 = [ + "Regular File\t1700000000\t0\tzero-birth.txt", + "Regular File\t1700000000\t42\ttiny-birth.txt", + "Regular File\t1700000000\t1690000000\treal-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) }) + } +} From ac07999f48ec8232cdb137356a77f67624faeb5a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:38:27 -0700 Subject: [PATCH 06/18] Surface inaccessible remote directories as errors, not empty listings autoreview flagged that the batched SSH listing's `cd ... || exit 0` plus the unconditional trailing `exit 0` reported permission/command failures as empty directories, losing the pre-rewrite ability to distinguish an inaccessible directory from an empty one. Exit non-zero (so `runSSHCommand` raises `sshCommandFailed`) when `cd` fails, when the directory is not readable (`[ -r . ]`), or when no usable `stat` is found. A readable-but-empty directory still reaches `exit 0` and lists as empty even though its unmatched glob makes `stat` exit non-zero. Verified on macOS that normal/empty dirs succeed while nonexistent and unreadable (x-only) dirs exit 1; added regression coverage. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 ++-- Sources/FileExplorerStore.swift | 17 +++++++++++------ cmuxTests/FileExplorerStoreTests.swift | 20 ++++++++++++++++++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index bdeafe5e4f87..013f9f3ba5e5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -51,7 +51,7 @@ 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift -1770 Sources/FileExplorerStore.swift +1775 Sources/FileExplorerStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift @@ -63,9 +63,9 @@ 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift +1445 cmuxTests/FileExplorerStoreTests.swift 1435 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1433 Sources/RemoteTmuxControlConnection.swift -1429 cmuxTests/FileExplorerStoreTests.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1363 Sources/CMUXInstalledExtensionSidebarHostView.swift diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 4c6feebd6991..f674434b0a6c 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -873,21 +873,26 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { /// directories appear to hang. Timestamps are always collected so changing /// the sort key re-sorts the cached listing without a re-fetch. /// - /// The trailing `exit 0` makes empty or unreadable directories report - /// success with an empty listing instead of the non-zero status that - /// `runSSHCommand` would otherwise surface as an error (an unmatched glob - /// is passed literally to `stat`, which fails on it). + /// Access failures stay distinguishable from empty directories: an + /// unreadable or missing directory (`cd` fails or `.` is not readable), or + /// a host without a usable `stat`, exits non-zero so `runSSHCommand` raises + /// `sshCommandFailed`. A readable but genuinely empty directory reaches the + /// trailing `exit 0` and lists as empty, even though its unmatched glob + /// makes `stat` itself exit non-zero. static func remoteListingScript(path: String, showHidden: Bool) -> String { let escapedPath = shellSingleQuote(path) // `.[!.]*` and `..?*` match dotfiles while excluding `.` and `..`. let globs = showHidden ? "-- * .[!.]* ..?*" : "-- *" // Literal tabs separate the fields; GNU `stat -c` does not expand `\t`. return """ - cd \(escapedPath) 2>/dev/null || exit 0 + cd \(escapedPath) 2>/dev/null || exit 1 + [ -r . ] || exit 1 if stat -c %Y / >/dev/null 2>&1; then stat -L -c '%F\t%Y\t%W\t%n' \(globs) 2>/dev/null - else + elif stat -f %m / >/dev/null 2>&1; then stat -L -f '%HT\t%m\t%B\t%N' \(globs) 2>/dev/null + else + exit 1 fi exit 0 """ diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 8efadc6eeb7b..99eec7152dec 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1276,8 +1276,24 @@ struct ProcessSSHFileExplorerListingTests { #expect(script.contains("stat -c %Y /")) #expect(script.contains("stat -L -c '%F\t%Y\t%W\t%n'")) #expect(script.contains("stat -L -f '%HT\t%m\t%B\t%N'")) - // Empty/unreadable dirs must report success, not a non-zero status that - // would surface as an error. + // A readable but empty directory must report success (trailing exit 0), + // not a non-zero status that would surface as an error. + #expect(script.hasSuffix("exit 0")) + } + + @Test + func testRemoteListingScriptSurfacesInaccessibleDirectoriesAsErrors() { + let script = ProcessSSHFileExplorerTransport.remoteListingScript( + path: "/srv/secret", + showHidden: false + ) + // A missing/unsearchable directory, an unreadable directory, and a host + // without a usable `stat` must exit non-zero so the listing surfaces as + // an error instead of masquerading as an empty directory. + #expect(script.contains("cd '/srv/secret' 2>/dev/null || exit 1")) + #expect(script.contains("[ -r . ] || exit 1")) + #expect(script.contains("else\n exit 1")) + // A readable but empty directory still succeeds (trailing exit 0). #expect(script.hasSuffix("exit 0")) } From 4d74507e3f160788a44c16ef3d49f1deb6b95294 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:44:12 -0700 Subject: [PATCH 07/18] Keep dangling remote symlinks visible in the file explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autoreview flagged that the batched listing used `stat -L`, which follows symlinks. On GNU, a following stat of a dangling symlink emits no record and exits non-zero for that operand, so with stderr suppressed the broken symlink was silently dropped — users could no longer see, delete, rename, or fix it. The pre-rewrite `ls -F` still listed dangling symlinks. Drop `-L` so the listing uses lstat: every entry, including dangling and directory symlinks, is reported with its own type ("symbolic link"), matching the previous `ls -F` behavior (symlinks shown, non-expandable). Added regression coverage that symlink rows stay listed and non-directory. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 ++-- Sources/FileExplorerStore.swift | 9 ++++++-- cmuxTests/FileExplorerStoreTests.swift | 29 +++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 013f9f3ba5e5..0d61bc01e782 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -51,7 +51,7 @@ 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift -1775 Sources/FileExplorerStore.swift +1780 Sources/FileExplorerStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift @@ -63,7 +63,7 @@ 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift -1445 cmuxTests/FileExplorerStoreTests.swift +1468 cmuxTests/FileExplorerStoreTests.swift 1435 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1433 Sources/RemoteTmuxControlConnection.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index f674434b0a6c..aeb097055f21 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -883,14 +883,19 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { let escapedPath = shellSingleQuote(path) // `.[!.]*` and `..?*` match dotfiles while excluding `.` and `..`. let globs = showHidden ? "-- * .[!.]* ..?*" : "-- *" + // `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 - stat -L -c '%F\t%Y\t%W\t%n' \(globs) 2>/dev/null + stat -c '%F\t%Y\t%W\t%n' \(globs) 2>/dev/null elif stat -f %m / >/dev/null 2>&1; then - stat -L -f '%HT\t%m\t%B\t%N' \(globs) 2>/dev/null + stat -f '%HT\t%m\t%B\t%N' \(globs) 2>/dev/null else exit 1 fi diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 99eec7152dec..01d77f225d98 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1273,9 +1273,12 @@ struct ProcessSSHFileExplorerListingTests { #expect(!script.contains("for entry")) #expect(!script.contains("for ")) // One GNU branch and one BSD branch, each a single batched stat call. + // `stat` must not dereference (no `-L`) so dangling symlinks are listed + // rather than dropped. #expect(script.contains("stat -c %Y /")) - #expect(script.contains("stat -L -c '%F\t%Y\t%W\t%n'")) - #expect(script.contains("stat -L -f '%HT\t%m\t%B\t%N'")) + #expect(script.contains("stat -c '%F\t%Y\t%W\t%n'")) + #expect(script.contains("stat -f '%HT\t%m\t%B\t%N'")) + #expect(!script.contains("stat -L")) // A readable but empty directory must report success (trailing exit 0), // not a non-zero status that would surface as an error. #expect(script.hasSuffix("exit 0")) @@ -1345,7 +1348,7 @@ struct ProcessSSHFileExplorerListingTests { #expect(command.contains("base64 -d")) #expect(command.contains("base64 -D")) // The raw POSIX script must NOT leak into the login-shell command line. - #expect(!command.contains("stat -L")) + #expect(!command.contains("stat -c")) #expect(!command.contains("..?*")) } @@ -1397,6 +1400,26 @@ struct ProcessSSHFileExplorerListingTests { #expect(entries[1].creationDate == Date(timeIntervalSince1970: 1690000100)) } + @Test + func testParseRemoteListingKeepsSymlinksVisibleAsNonDirectories() { + // Dangling and directory symlinks both report as "Symbolic Link" under + // non-dereferencing stat; they must stay listed (so users can manage + // them) and never be treated as expandable directories. + let output = [ + "Symbolic Link\t1700000000\t1690000000\tbroken-link", + "Symbolic Link\t1700000000\t1690000000\tdir-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() { let output = [ From 297c337e7522f2b94640469f64fbf59eea1d75bc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 17:55:08 -0700 Subject: [PATCH 08/18] Harden remote SSH listing against ARG_MAX and missing base64 autoreview flagged two more remote-listing regressions: - The glob `* .[!.]* ..?*` expands in the remote shell before `stat`, so a large directory could exceed ARG_MAX; `stat` then fails to exec and, with stderr suppressed and the trailing `exit 0`, the directory looked empty. Replace the glob with `find . -mindepth 1 -maxdepth 1 ... -exec stat {} +`, which enumerates the directory and batches `stat` within ARG_MAX (still a small bounded number of processes, never per-entry). - The bootstrap ran `eval "$(...base64...)"` without checking the decode, so a host lacking a compatible `base64` silently listed every directory as empty. Capture the decoded script and `exit 1` when it is empty. `find .` reports entries as `./name`, so the parser now keeps only the final path component. Dotfiles are excluded with `! -name '.*'` unless hidden entries are requested. Verified on macOS across normal/empty/missing/unreadable dirs, dangling symlinks, and a simulated missing-base64 host; extended the tests. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 +- Sources/FileExplorerStore.swift | 41 +++++++++------- cmuxTests/FileExplorerStoreTests.swift | 68 +++++++++++++++----------- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0d61bc01e782..72d12e1e9665 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -51,7 +51,7 @@ 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift -1780 Sources/FileExplorerStore.swift +1787 Sources/FileExplorerStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift @@ -63,7 +63,7 @@ 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift -1468 cmuxTests/FileExplorerStoreTests.swift +1478 cmuxTests/FileExplorerStoreTests.swift 1435 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1433 Sources/RemoteTmuxControlConnection.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index aeb097055f21..69d0f00dbf43 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -866,23 +866,24 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { /// POSIX `sh` script that lists `path` for the file explorer. /// - /// It detects GNU vs BSD `stat` once, then runs a single batched `stat` - /// over the directory's entries, so remote process creation stays O(1) - /// regardless of entry count. The previous implementation spawned two or - /// more `stat` processes per entry, which could make large remote - /// directories appear to hang. Timestamps are always collected so changing - /// the sort key re-sorts the cached listing without a re-fetch. + /// 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), or /// a host without a usable `stat`, exits non-zero so `runSSHCommand` raises - /// `sshCommandFailed`. A readable but genuinely empty directory reaches the - /// trailing `exit 0` and lists as empty, even though its unmatched glob - /// makes `stat` itself exit non-zero. + /// `sshCommandFailed`. A readable but genuinely empty directory produces no + /// `find` output and reaches the trailing `exit 0`, listing as empty. static func remoteListingScript(path: String, showHidden: Bool) -> String { let escapedPath = shellSingleQuote(path) - // `.[!.]*` and `..?*` match dotfiles while excluding `.` and `..`. - let globs = showHidden ? "-- * .[!.]* ..?*" : "-- *" + // 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 @@ -893,9 +894,9 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { cd \(escapedPath) 2>/dev/null || exit 1 [ -r . ] || exit 1 if stat -c %Y / >/dev/null 2>&1; then - stat -c '%F\t%Y\t%W\t%n' \(globs) 2>/dev/null + find . -mindepth 1 -maxdepth 1 \(nameFilter)-exec stat -c '%F\t%Y\t%W\t%n' {} + 2>/dev/null elif stat -f %m / >/dev/null 2>&1; then - stat -f '%HT\t%m\t%B\t%N' \(globs) 2>/dev/null + find . -mindepth 1 -maxdepth 1 \(nameFilter)-exec stat -f '%HT\t%m\t%B\t%N' {} + 2>/dev/null else exit 1 fi @@ -911,9 +912,13 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { /// `$(...)`. 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); eval \"$(printf %s \"$b\" | base64 -d 2>/dev/null || printf %s \"$b\" | base64 -D 2>/dev/null)\"'" + 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:)``. @@ -921,7 +926,8 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { /// Each line is `typemtimebtimename`. The trailing `name` /// field is split last so names containing spaces survive; the leading /// type description ("directory"/"Directory", "regular file", …) may also - /// contain spaces but never a tab. + /// contain spaces but never a tab. `find .` reports each entry as `./name`, + /// so only the final path component is kept. static func parseRemoteListing( _ output: String, path: String, @@ -931,8 +937,9 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in let parts = line.split(separator: "\t", maxSplits: 3, omittingEmptySubsequences: false) guard parts.count == 4 else { return nil } - let name = String(parts[3]) - guard name != "." && name != ".." 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 } let isDirectory = String(parts[0]).caseInsensitiveCompare("directory") == .orderedSame return FileExplorerEntry( diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 01d77f225d98..dd0b6d1457db 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1253,31 +1253,36 @@ struct FileSearchControllerTests { // MARK: - SSH remote listing command construction & parsing -/// Regression coverage for the two P1 issues in the date-sort SSH listing -/// rewrite: the remote command must run under a POSIX shell (not the account's -/// login shell, which may be fish/csh) and must not spawn `stat` per entry. +/// 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: Batched stat (no per-entry process fan-out) + // MARK: Batched stat (no per-entry process fan-out, no ARG_MAX overflow) @Test - func testRemoteListingScriptUsesOneBatchedStatNotPerEntryLoop() { + func testRemoteListingScriptBatchesStatViaFindWithoutPerEntryFanout() { let script = ProcessSSHFileExplorerTransport.remoteListingScript( path: "/srv/app", showHidden: false ) // The previous implementation looped over every entry and ran `stat` - // (twice) per entry. The fix detects the stat dialect once and runs a - // single batched stat, so the script must not contain a per-entry loop. + // (twice) per entry. The fix detects the stat dialect once and enumerates + // with `find ... -exec stat {} +`, so there is no per-entry loop and no + // giant glob that could overflow ARG_MAX in large directories. #expect(!script.contains("for entry")) #expect(!script.contains("for ")) - // One GNU branch and one BSD branch, each a single batched stat call. + #expect(script.contains("find . -mindepth 1 -maxdepth 1")) + #expect(script.contains("-exec stat -c '%F\t%Y\t%W\t%n' {} +")) + #expect(script.contains("-exec stat -f '%HT\t%m\t%B\t%N' {} +")) + // GNU vs BSD stat is detected once, against a path that always exists. + #expect(script.contains("stat -c %Y /")) + #expect(script.contains("stat -f %m /")) // `stat` must not dereference (no `-L`) so dangling symlinks are listed // rather than dropped. - #expect(script.contains("stat -c %Y /")) - #expect(script.contains("stat -c '%F\t%Y\t%W\t%n'")) - #expect(script.contains("stat -f '%HT\t%m\t%B\t%N'")) #expect(!script.contains("stat -L")) // A readable but empty directory must report success (trailing exit 0), // not a non-zero status that would surface as an error. @@ -1301,19 +1306,18 @@ struct ProcessSSHFileExplorerListingTests { } @Test - func testRemoteListingScriptIncludesHiddenGlobsOnlyWhenRequested() { + func testRemoteListingScriptFiltersDotfilesUnlessHiddenRequested() { let visible = ProcessSSHFileExplorerTransport.remoteListingScript( path: "/srv/app", showHidden: false ) - #expect(visible.contains("-- *")) - #expect(!visible.contains(".[!.]*")) + #expect(visible.contains("! -name '.*'")) let hidden = ProcessSSHFileExplorerTransport.remoteListingScript( path: "/srv/app", showHidden: true ) - #expect(hidden.contains("-- * .[!.]* ..?*")) + #expect(!hidden.contains("-name")) } @Test @@ -1347,9 +1351,12 @@ struct ProcessSSHFileExplorerListingTests { #expect(command.hasPrefix("/bin/sh -c '")) #expect(command.contains("base64 -d")) #expect(command.contains("base64 -D")) + // A failed decode (no/incompatible base64) must error, not run `eval ""` + // and report an empty listing. + #expect(command.contains("[ -n \"$s\" ] || exit 1")) // The raw POSIX script must NOT leak into the login-shell command line. #expect(!command.contains("stat -c")) - #expect(!command.contains("..?*")) + #expect(!command.contains("-mindepth")) } @Test @@ -1375,13 +1382,14 @@ struct ProcessSSHFileExplorerListingTests { @Test func testParseRemoteListingParsesTypeTimesAndName() { - // Lines are `typemtimebtimename`. Cover GNU ("directory") - // and BSD ("Directory"/"Regular File") type spellings plus a name with a - // space. + // Lines are `typemtimebtimename`. `find .` reports each + // entry as `./name`, so only the final component is kept. Cover GNU + // ("directory") and BSD ("Directory"/"Regular File") type spellings plus + // a name with a space. let output = [ - "Directory\t1700000000\t1690000000\tsub", - "regular file\t1700000100\t1690000100\tmain.swift", - "Regular File\t1700000200\t1690000200\tfile one.txt", + "Directory\t1700000000\t1690000000\t./sub", + "regular file\t1700000100\t1690000100\t./main.swift", + "Regular File\t1700000200\t1690000200\t./file one.txt", ].joined(separator: "\n") let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( @@ -1406,8 +1414,8 @@ struct ProcessSSHFileExplorerListingTests { // non-dereferencing stat; they must stay listed (so users can manage // them) and never be treated as expandable directories. let output = [ - "Symbolic Link\t1700000000\t1690000000\tbroken-link", - "Symbolic Link\t1700000000\t1690000000\tdir-link", + "Symbolic Link\t1700000000\t1690000000\t./broken-link", + "Symbolic Link\t1700000000\t1690000000\t./dir-link", ].joined(separator: "\n") let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( @@ -1422,11 +1430,13 @@ struct ProcessSSHFileExplorerListingTests { @Test func testParseRemoteListingExcludesDotEntriesAndRespectsHidden() { + // `find` never emits `.`/`..`, but the parser excludes them defensively; + // the real entries arrive as `find`'s `./name` form. let output = [ "Directory\t1700000000\t1690000000\t.", "Directory\t1700000000\t1690000000\t..", - "Regular File\t1700000000\t1690000000\t.hidden", - "Regular File\t1700000000\t1690000000\tvisible.txt", + "Regular File\t1700000000\t1690000000\t./.hidden", + "Regular File\t1700000000\t1690000000\t./visible.txt", ].joined(separator: "\n") let visibleOnly = ProcessSSHFileExplorerTransport.parseRemoteListing( @@ -1448,9 +1458,9 @@ struct ProcessSSHFileExplorerListingTests { @Test func testParseRemoteListingTreatsMissingOrLowBirthTimeAsUnknown() { let output = [ - "Regular File\t1700000000\t0\tzero-birth.txt", - "Regular File\t1700000000\t42\ttiny-birth.txt", - "Regular File\t1700000000\t1690000000\treal-birth.txt", + "Regular File\t1700000000\t0\t./zero-birth.txt", + "Regular File\t1700000000\t42\t./tiny-birth.txt", + "Regular File\t1700000000\t1690000000\t./real-birth.txt", ].joined(separator: "\n") let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( From 77db568e2308cf80afcaae93c079de10f0f6ed01 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 29 Jun 2026 18:07:14 -0700 Subject: [PATCH 09/18] Replace SSH listing grep-style tests with behavioral coverage autoreview flagged that the remote-listing tests mostly asserted substrings of the generated shell script, which the repo's test-quality rule disallows and which can pass even when the command is invalid under /bin/sh or behaves differently at runtime. Replace the implementation-text assertions with executable behavioral tests: build the exact command the SSH transport sends and run it locally through a real shell against temp-directory fixtures, then assert the parsed result and exit status. Coverage now includes directory contents, hidden-file filtering, dangling/directory symlink visibility, empty-directory success, missing and unreadable directories surfacing as non-zero exits, single-quoted paths, identical results across /bin/sh and /bin/zsh, and a missing-base64 host (via an emptied PATH) erroring instead of listing empty. The synthetic parser unit tests are kept to cover GNU stat output that the macOS test host cannot produce. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- cmuxTests/FileExplorerStoreTests.swift | 270 ++++++++++++++++--------- 2 files changed, 181 insertions(+), 91 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 72d12e1e9665..07466fb349ec 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -59,11 +59,11 @@ 1670 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1568 cmuxTests/FileExplorerStoreTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift -1478 cmuxTests/FileExplorerStoreTests.swift 1435 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1433 Sources/RemoteTmuxControlConnection.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index dd0b6d1457db..52352d1511e3 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Testing #if canImport(cmux_DEV) @@ -1261,121 +1262,210 @@ struct FileSearchControllerTests { @Suite struct ProcessSSHFileExplorerListingTests { - // MARK: Batched stat (no per-entry process fan-out, no ARG_MAX overflow) + // MARK: Behavioral coverage — execute the real generated command - @Test - func testRemoteListingScriptBatchesStatViaFindWithoutPerEntryFanout() { - let script = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/app", - showHidden: false + /// 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 + ) ) - // The previous implementation looped over every entry and ran `stat` - // (twice) per entry. The fix detects the stat dialect once and enumerates - // with `find ... -exec stat {} +`, so there is no per-entry loop and no - // giant glob that could overflow ARG_MAX in large directories. - #expect(!script.contains("for entry")) - #expect(!script.contains("for ")) - #expect(script.contains("find . -mindepth 1 -maxdepth 1")) - #expect(script.contains("-exec stat -c '%F\t%Y\t%W\t%n' {} +")) - #expect(script.contains("-exec stat -f '%HT\t%m\t%B\t%N' {} +")) - // GNU vs BSD stat is detected once, against a path that always exists. - #expect(script.contains("stat -c %Y /")) - #expect(script.contains("stat -f %m /")) - // `stat` must not dereference (no `-L`) so dangling symlinks are listed - // rather than dropped. - #expect(!script.contains("stat -L")) - // A readable but empty directory must report success (trailing exit 0), - // not a non-zero status that would surface as an error. - #expect(script.hasSuffix("exit 0")) + 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) } - @Test - func testRemoteListingScriptSurfacesInaccessibleDirectoriesAsErrors() { - let script = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/secret", - showHidden: false - ) - // A missing/unsearchable directory, an unreadable directory, and a host - // without a usable `stat` must exit non-zero so the listing surfaces as - // an error instead of masquerading as an empty directory. - #expect(script.contains("cd '/srv/secret' 2>/dev/null || exit 1")) - #expect(script.contains("[ -r . ] || exit 1")) - #expect(script.contains("else\n exit 1")) - // A readable but empty directory still succeeds (trailing exit 0). - #expect(script.hasSuffix("exit 0")) + 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 testRemoteListingScriptFiltersDotfilesUnlessHiddenRequested() { - let visible = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/app", - showHidden: false + 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 ) - #expect(visible.contains("! -name '.*'")) - let hidden = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/app", - showHidden: true - ) - #expect(!hidden.contains("-name")) + 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 testRemoteListingScriptSingleQuotesThePath() { - let plain = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/my app", - showHidden: false - ) - #expect(plain.contains("cd '/srv/my app'")) + 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"]) + } - // A single quote in the path must be escaped so the script stays valid. - let quoted = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/a'b", - showHidden: false + @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 ) - #expect(quoted.contains("cd '/srv/a'\\''b'")) + 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) } - // MARK: POSIX shell bootstrap (login-shell independence) + @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 testPosixShellBootstrapRunsUnderBinShWithEncodedScript() { - let script = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/app", - showHidden: true - ) - let command = ProcessSSHFileExplorerTransport.posixShellBootstrap(script: script) + 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) + } - // The login shell only ever sees `/bin/sh -c` plus a base64 payload, so - // fish/csh/tcsh cannot mis-parse the POSIX control flow. - #expect(command.hasPrefix("/bin/sh -c '")) - #expect(command.contains("base64 -d")) - #expect(command.contains("base64 -D")) - // A failed decode (no/incompatible base64) must error, not run `eval ""` - // and report an empty listing. - #expect(command.contains("[ -n \"$s\" ] || exit 1")) - // The raw POSIX script must NOT leak into the login-shell command line. - #expect(!command.contains("stat -c")) - #expect(!command.contains("-mindepth")) + @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 testPosixShellBootstrapEncodesExactScriptRoundTrip() throws { - let script = ProcessSSHFileExplorerTransport.remoteListingScript( - path: "/srv/app", - showHidden: true + 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"]) + } + + // 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 command = ProcessSSHFileExplorerTransport.posixShellBootstrap(script: script) - // Recover the base64 payload (between `b=` and `;`) and confirm it - // decodes back to exactly the script we asked to run. - let afterAssign = try #require(command.range(of: "b=")) - let semicolon = try #require( - command.range(of: ";", range: afterAssign.upperBound.. Date: Wed, 1 Jul 2026 19:08:57 -0700 Subject: [PATCH 10/18] Add failing test for masked remote SSH listing failures The remote listing script runs `find ... -exec stat ...` then reaches the trailing `exit 0` unconditionally, so a wholesale `find`/`stat` failure (e.g. a remote `find` lacking `-mindepth`/`-maxdepth`) is swallowed and the directory appears empty instead of raising `sshCommandFailed`. This behavioral test shadows `find` with a stub that exits non-zero and asserts the generated command exits non-zero. It fails against the current script (which masks the failure as exit 0); the fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- cmuxTests/FileExplorerStoreTests.swift | 41 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 65c14fbef04a..155ff0b9170e 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -59,8 +59,8 @@ 1670 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift +1609 cmuxTests/FileExplorerStoreTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift -1568 cmuxTests/FileExplorerStoreTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 52352d1511e3..4d5b0a078311 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1418,6 +1418,47 @@ struct ProcessSSHFileExplorerListingTests { #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(String(decoding: data, as: UTF8.self).isEmpty) + } + // MARK: POSIX shell bootstrap (login-shell independence, base64 dependency) @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"), From d5ce0ebd7bd3245c6793025ff7c8bd8daa29d390 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 19:23:46 -0700 Subject: [PATCH 11/18] Surface remote SSH listing command failures instead of masking as empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listing script ran `find ... -exec stat ...` and then reached the trailing `exit 0` unconditionally, with the `find` stderr suppressed. Any wholesale failure of the listing command — a remote `find` that lacks `-mindepth`/`-maxdepth`, a `stat` format the probe did not exercise, or a per-entry error — was swallowed and reported as an empty directory, regressing the pre-existing behavior where a failed `ls` raised `sshCommandFailed`. Propagate the failure with `|| exit 1` on both `find` branches so a failed listing exits non-zero. A genuinely empty directory still makes `find` exit zero with no output and reaches the trailing `exit 0`, so empty listings are unchanged. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- Sources/FileExplorerStore.swift | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 155ff0b9170e..51306dbb4927 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -51,7 +51,7 @@ 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift -1787 Sources/FileExplorerStore.swift +1790 Sources/FileExplorerStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 69d0f00dbf43..27590c3c88bf 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -875,10 +875,13 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { /// 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), or - /// a host without a usable `stat`, exits non-zero so `runSSHCommand` raises - /// `sshCommandFailed`. A readable but genuinely empty directory produces no - /// `find` output and reaches the trailing `exit 0`, listing as empty. + /// 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 @@ -894,9 +897,9 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { 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 '%F\t%Y\t%W\t%n' {} + 2>/dev/null + find . -mindepth 1 -maxdepth 1 \(nameFilter)-exec stat -c '%F\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 '%HT\t%m\t%B\t%N' {} + 2>/dev/null + find . -mindepth 1 -maxdepth 1 \(nameFilter)-exec stat -f '%HT\t%m\t%B\t%N' {} + 2>/dev/null || exit 1 else exit 1 fi From 9d5644b3bbdd1e366339eb6ed917e8094967f18a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 22:54:25 -0700 Subject: [PATCH 12/18] Address review: extract sort model, localize tooltip, prune redundant stat - Move FileExplorerSortKey/Order/Options/Settings/NodeSorter out of FileExplorerStore.swift into a dedicated Sources/FileExplorerSortOptions.swift (CodeRabbit Major + file-organization findings). Wire the new file into the Xcode project (UUID FE59960A) and refresh the file-length budget. - Use String.localizedStringWithFormat for the sort tooltip so locales that reorder positional arguments render correctly (CodeRabbit). - Prefetch .isDirectoryKey and read isDirectory from the cached resource values in the local listDirectory, dropping the redundant per-entry fileExists stat (CodeRabbit). Local dangling symlinks now stay visible, matching the remote listing behavior. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- Sources/FileExplorerHeaderView.swift | 4 +- Sources/FileExplorerSortOptions.swift | 155 ++++++++++++++++++++++++ Sources/FileExplorerStore.swift | 162 +------------------------- cmux.xcodeproj/project.pbxproj | 4 + 5 files changed, 167 insertions(+), 160 deletions(-) create mode 100644 Sources/FileExplorerSortOptions.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 51306dbb4927..f57f2c2ca133 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -51,7 +51,6 @@ 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift -1790 Sources/FileExplorerStore.swift 1748 Sources/WindowDragHandleView.swift 1732 cmuxTests/WorkspacePullRequestSidebarTests.swift 1687 cmuxTests/MarkdownPanelTests.swift @@ -59,6 +58,7 @@ 1670 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift 1649 cmuxTests/TerminalControllerSocketSecurityTests.swift +1638 Sources/FileExplorerStore.swift 1609 cmuxTests/FileExplorerStoreTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift diff --git a/Sources/FileExplorerHeaderView.swift b/Sources/FileExplorerHeaderView.swift index b2edd78cced7..09452b85d1a1 100644 --- a/Sources/FileExplorerHeaderView.swift +++ b/Sources/FileExplorerHeaderView.swift @@ -103,8 +103,8 @@ final class FileExplorerHeaderView: NSView { } sortButton.image = NSImage(systemSymbolName: "arrow.up.arrow.down", accessibilityDescription: nil)? .withSymbolConfiguration(config) - sortButton.toolTip = String( - format: String(localized: "fileExplorer.sort.tooltip", defaultValue: "Sort: %@, %@"), + sortButton.toolTip = String.localizedStringWithFormat( + String(localized: "fileExplorer.sort.tooltip", defaultValue: "Sort: %@, %@"), sortOptions.key.localizedTitle, sortOptions.order.localizedTitle ) diff --git a/Sources/FileExplorerSortOptions.swift b/Sources/FileExplorerSortOptions.swift new file mode 100644 index 000000000000..5ff6248637f6 --- /dev/null +++ b/Sources/FileExplorerSortOptions.swift @@ -0,0 +1,155 @@ +import Foundation + +// MARK: - File Explorer Sort Model + +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") + } + } +} + +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") + } + } +} + +struct FileExplorerSortOptions: Equatable, Sendable { + let key: FileExplorerSortKey + let order: FileExplorerSortOrder + + static let defaultValue = FileExplorerSortOptions(key: .name, order: .ascending) +} + +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) + } +} + +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/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 27590c3c88bf..2656d1f1d848 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -228,158 +228,6 @@ struct FileExplorerEntry: Sendable { } } -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") - } - } -} - -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") - } - } -} - -struct FileExplorerSortOptions: Equatable, Sendable { - let key: FileExplorerSortKey - let order: FileExplorerSortOrder - - static let defaultValue = FileExplorerSortOptions(key: .name, order: .ascending) -} - -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) - } -} - -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 - } - } - } -} - final class FileExplorerNode: Identifiable { let id: String let name: String @@ -479,19 +327,19 @@ final class LocalFileExplorerProvider: FileExplorerProvider { let fm = FileManager.default let contents = try fm.contentsOfDirectory( at: URL(fileURLWithPath: path, isDirectory: true), - includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey], + includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey], options: [] ) return contents.compactMap { url in let name = url.lastPathComponent guard showHidden || !name.hasPrefix(".") else { return nil } - var isDir: ObjCBool = false - guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return nil } - let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + let values = try? url.resourceValues( + forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey] + ) return FileExplorerEntry( name: name, path: url.path, - isDirectory: isDir.boolValue, + isDirectory: values?.isDirectory ?? false, creationDate: values?.creationDate, modificationDate: values?.contentModificationDate ) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index dd9f6338c557..234f7b1526c1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -516,6 +516,7 @@ 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 */; }; + FE59960A0000000000000002 /* FileExplorerSortOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE59960A0000000000000001 /* FileExplorerSortOptions.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 */; }; @@ -1758,6 +1759,7 @@ 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 = ""; }; + FE59960A0000000000000001 /* FileExplorerSortOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSortOptions.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 = ""; }; @@ -3356,6 +3358,7 @@ FE5996080000000000000001 /* FileExplorerExternalOpenMenuItems.swift */, FE5996090000000000000001 /* FileExplorerExternalOpenRequest.swift */, FE5996020000000000000001 /* FileExplorerHeaderView.swift */, + FE59960A0000000000000001 /* FileExplorerSortOptions.swift */, FE001005 /* FileExplorerKeyboardShortcuts.swift */, FE5996030000000000000001 /* FileExplorerNSOutlineView.swift */, FE5996040000000000000001 /* FileExplorerRowView.swift */, @@ -4513,6 +4516,7 @@ FE5996050000000000000002 /* FileExplorerSearchField.swift in Sources */, FE5996060000000000000002 /* FileExplorerSearchResultCellView.swift in Sources */, FE5996070000000000000002 /* FileExplorerSearchResultsTableView.swift in Sources */, + FE59960A0000000000000002 /* FileExplorerSortOptions.swift in Sources */, B37A00000000000000000009 /* FileExplorerState.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001104 /* FileExplorerTerminalPathInsertion.swift in Sources */, From 87138ecbbf040ad36cead414765a83b353cae84a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 23:06:27 -0700 Subject: [PATCH 13/18] Assert on data.isEmpty in SSH listing regression test Avoids the redundant Data->String decode (SwiftLint optional_data_string_conversion) while asserting the same masked-empty-output regression. (CodeRabbit) Co-Authored-By: Claude Opus 4.8 --- cmuxTests/FileExplorerStoreTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 4d5b0a078311..d01b4c3836a9 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1456,7 +1456,7 @@ struct ProcessSSHFileExplorerListingTests { // 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(String(decoding: data, as: UTF8.self).isEmpty) + #expect(data.isEmpty) } // MARK: POSIX shell bootstrap (login-shell independence, base64 dependency) From a24ef2b3ce600de8e042d99ddf1131576bed17f8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 2 Jul 2026 02:06:20 -0700 Subject: [PATCH 14/18] Fold FileExplorerStore initializers into one designated init The origin/main merge left two all-default initializers on FileExplorerStore: the branch's sort-settings init(sortDefaults:notificationCenter:) and main's init(gitStatusProvider:). Each left the other's stored properties uninitialized, and FileExplorerStore() was ambiguous between them, so the target could not compile. Merge them into a single designated initializer that initializes notificationCenter, gitStatusProvider, sortSettings, sortOptions, and sortSettingsObserver, exposing a superset of parameters (all with defaults) so every existing call site keeps working. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- Sources/FileExplorerStore.swift | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 107620d46bd7..b3317a84f576 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -61,7 +61,7 @@ 1609 cmuxTests/FileExplorerStoreTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift -1508 Sources/FileExplorerStore.swift +1506 Sources/FileExplorerStore.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift 1435 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index acb2322605d4..a086e61f3ce9 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -904,11 +904,15 @@ final class FileExplorerStore: ObservableObject { private let notificationCenter: NotificationCenter private var sortSettingsObserver: NSObjectProtocol? + private let gitStatusProvider: GitStatusProvider + init( sortDefaults: UserDefaults = .standard, - notificationCenter: NotificationCenter = .default + 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() @@ -921,12 +925,6 @@ final class FileExplorerStore: ObservableObject { } } - private let gitStatusProvider: GitStatusProvider - - init(gitStatusProvider: GitStatusProvider = GitStatusProvider()) { - self.gitStatusProvider = gitStatusProvider - } - var displayRootPath: String { if let sshProvider = provider as? SSHFileExplorerProvider { guard !rootPath.isEmpty else { From 669e3d200fe71cf19e5da31be562c9c467ac7c8d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 14:06:37 -0700 Subject: [PATCH 15/18] Preserve nested file explorer expansion on sort reload --- Sources/FileExplorerView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 5d59b2fba91b..dbc32940d01b 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -201,9 +201,9 @@ struct FileExplorerPanelView: NSViewRepresentable { } private func restoreExpansionState(_ expandedPaths: Set, in outlineView: NSOutlineView) { - for row in 0.. Date: Fri, 3 Jul 2026 15:35:48 -0700 Subject: [PATCH 16/18] Split file explorer sort types by responsibility --- Sources/FileExplorerNodeSorter.swift | 76 +++++++++++++ Sources/FileExplorerSortKey.swift | 22 ++++ Sources/FileExplorerSortOptions.swift | 147 ------------------------- Sources/FileExplorerSortOrder.swift | 19 ++++ Sources/FileExplorerSortSettings.swift | 32 ++++++ cmux.xcodeproj/project.pbxproj | 16 +++ 6 files changed, 165 insertions(+), 147 deletions(-) create mode 100644 Sources/FileExplorerNodeSorter.swift create mode 100644 Sources/FileExplorerSortKey.swift create mode 100644 Sources/FileExplorerSortOrder.swift create mode 100644 Sources/FileExplorerSortSettings.swift 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 index 5ff6248637f6..c0685f15047d 100644 --- a/Sources/FileExplorerSortOptions.swift +++ b/Sources/FileExplorerSortOptions.swift @@ -1,155 +1,8 @@ import Foundation -// MARK: - File Explorer Sort Model - -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") - } - } -} - -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") - } - } -} - struct FileExplorerSortOptions: Equatable, Sendable { let key: FileExplorerSortKey let order: FileExplorerSortOrder static let defaultValue = FileExplorerSortOptions(key: .name, order: .ascending) } - -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) - } -} - -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/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/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 16c9d6cc6a7e..46f60476f51a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -525,6 +525,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 */; }; @@ -534,7 +535,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 */; }; @@ -1821,6 +1825,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 = ""; }; @@ -1830,7 +1835,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 = ""; }; @@ -3490,7 +3498,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 */, @@ -4672,13 +4684,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 */, From 477c5f6a9eeb2c8c876a4c40883aeb6dcbceba5b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 16:06:30 -0700 Subject: [PATCH 17/18] Use stable remote file mode for explorer listing --- Sources/FileExplorerStore.swift | 16 ++++++------ cmuxTests/FileExplorerStoreTests.swift | 35 +++++++++++++------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index a086e61f3ce9..a637fbde1ede 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -745,9 +745,9 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { 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 '%F\t%Y\t%W\t%n' {} + 2>/dev/null || exit 1 + 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 '%HT\t%m\t%B\t%N' {} + 2>/dev/null || exit 1 + 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 @@ -774,11 +774,11 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { /// Parses the tab-separated output of ``remoteListingScript(path:showHidden:)``. /// - /// Each line is `typemtimebtimename`. The trailing `name` - /// field is split last so names containing spaces survive; the leading - /// type description ("directory"/"Directory", "regular file", …) may also - /// contain spaces but never a tab. `find .` reports each entry as `./name`, - /// so only the final path component is kept. + /// 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, @@ -792,7 +792,7 @@ final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport { let name = String(rawName.split(separator: "/").last ?? rawName) guard !name.isEmpty, name != ".", name != ".." else { return nil } guard showHidden || !name.hasPrefix(".") else { return nil } - let isDirectory = String(parts[0]).caseInsensitiveCompare("directory") == .orderedSame + let isDirectory = parts[0].first == "d" return FileExplorerEntry( name: name, path: normalizedPath + name, diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index d01b4c3836a9..b5ba1f55ac36 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -1512,15 +1512,14 @@ struct ProcessSSHFileExplorerListingTests { // MARK: Parsing of the tab-separated stat output @Test - func testParseRemoteListingParsesTypeTimesAndName() { - // Lines are `typemtimebtimename`. `find .` reports each - // entry as `./name`, so only the final component is kept. Cover GNU - // ("directory") and BSD ("Directory"/"Regular File") type spellings plus - // a name with a space. + 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 = [ - "Directory\t1700000000\t1690000000\t./sub", - "regular file\t1700000100\t1690000100\t./main.swift", - "Regular File\t1700000200\t1690000200\t./file one.txt", + "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( @@ -1541,12 +1540,12 @@ struct ProcessSSHFileExplorerListingTests { @Test func testParseRemoteListingKeepsSymlinksVisibleAsNonDirectories() { - // Dangling and directory symlinks both report as "Symbolic Link" under + // 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 = [ - "Symbolic Link\t1700000000\t1690000000\t./broken-link", - "Symbolic Link\t1700000000\t1690000000\t./dir-link", + "lrwxr-xr-x\t1700000000\t1690000000\t./broken-link", + "lrwxr-xr-x\t1700000000\t1690000000\t./dir-link", ].joined(separator: "\n") let entries = ProcessSSHFileExplorerTransport.parseRemoteListing( @@ -1564,10 +1563,10 @@ struct ProcessSSHFileExplorerListingTests { // `find` never emits `.`/`..`, but the parser excludes them defensively; // the real entries arrive as `find`'s `./name` form. let output = [ - "Directory\t1700000000\t1690000000\t.", - "Directory\t1700000000\t1690000000\t..", - "Regular File\t1700000000\t1690000000\t./.hidden", - "Regular File\t1700000000\t1690000000\t./visible.txt", + "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( @@ -1589,9 +1588,9 @@ struct ProcessSSHFileExplorerListingTests { @Test func testParseRemoteListingTreatsMissingOrLowBirthTimeAsUnknown() { let output = [ - "Regular File\t1700000000\t0\t./zero-birth.txt", - "Regular File\t1700000000\t42\t./tiny-birth.txt", - "Regular File\t1700000000\t1690000000\t./real-birth.txt", + "-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( From 9c17d3d86123a151841503ef7a7917db8775f5e5 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 16:22:02 -0700 Subject: [PATCH 18/18] Address file explorer sort policy findings --- Sources/FileExplorerStore.swift | 6 +- .../FileExplorerDoubleClickActionTests.swift | 77 +++++++++++++++++++ ...hortcutSettingsFileStoreStartupTests.swift | 51 ------------ 3 files changed, 80 insertions(+), 54 deletions(-) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index a637fbde1ede..6b40f2b052f3 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -859,8 +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 - @Published private(set) var sortOptions: FileExplorerSortOptions - @Published private(set) var sortRevision = 0 + private(set) var sortOptions: FileExplorerSortOptions + private(set) var sortRevision = 0 @Published private(set) var rootStatusMessage: String? private(set) var workspaceRootIdentity: UUID? @@ -1297,7 +1297,7 @@ final class FileExplorerStore: ObservableObject { } private func applySortOptions(_ options: FileExplorerSortOptions, persist: Bool) { - guard sortOptions != options else { return } + guard sortOptions != options else { return }; objectWillChange.send() sortOptions = options resortLoadedNodes() sortRevision &+= 1 diff --git a/cmuxTests/FileExplorerDoubleClickActionTests.swift b/cmuxTests/FileExplorerDoubleClickActionTests.swift index 041d36beee43..fcca4361a5ea 100644 --- a/cmuxTests/FileExplorerDoubleClickActionTests.swift +++ b/cmuxTests/FileExplorerDoubleClickActionTests.swift @@ -128,6 +128,9 @@ 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)! @@ -135,6 +138,32 @@ import Testing 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)) } @@ -171,4 +200,52 @@ import Testing #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/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 0b522e8cbf90..6017ec1622ae 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -534,57 +534,6 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { } } - func testSettingsFileParsesFileExplorerSortOptions() 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()) - - XCTAssertEqual(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) { - XCTAssertEqual(defaults.string(forKey: FileExplorerSortSettings.sortKeyKey), "dateModified") - XCTAssertEqual(defaults.string(forKey: FileExplorerSortSettings.sortOrderKey), "descending") - XCTAssertEqual( - sortSettings.resolvedOptions(), - FileExplorerSortOptions(key: .dateModified, order: .descending) - ) - } - } - } - func testManagedAppearanceUserDefaultSurvivesSettingsFileReapplyUntilFileChanges() throws { let defaults = UserDefaults.standard let key = AppearanceSettings.appearanceModeKey