diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ab8673d1fd..2aae9f1b85 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; + FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; + FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; + FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; @@ -205,6 +209,10 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; + FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; + FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; + FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = ""; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -518,6 +526,8 @@ A5001651 /* CmuxConfig.swift */, A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxDirectoryTrust.swift */, + FE001001 /* FileExplorerStore.swift */, + FE001002 /* FileExplorerView.swift */, ); path = Sources; sourceTree = ""; @@ -612,6 +622,8 @@ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, + FE002001 /* FileExplorerRootResolverTests.swift */, + FE002002 /* FileExplorerStoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -841,9 +853,11 @@ A5001610 /* SessionPersistence.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, - A5001652 /* CmuxConfigExecutor.swift in Sources */, - A5001654 /* CmuxDirectoryTrust.swift in Sources */, - ); + A5001652 /* CmuxConfigExecutor.swift in Sources */, + A5001654 /* CmuxDirectoryTrust.swift in Sources */, + FE001101 /* FileExplorerStore.swift in Sources */, + FE001102 /* FileExplorerView.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; D1320AA0D1320AA0D1320AB0 /* Sources */ = { @@ -912,6 +926,8 @@ 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, + FE002101 /* FileExplorerRootResolverTests.swift in Sources */, + FE002102 /* FileExplorerStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cea9379bda..a4d11fa873 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -69323,6 +69323,91 @@ } } }, + "shortcut.toggleFileExplorer.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle File Explorer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルエクスプローラーを切替" + } + } + } + }, + "fileExplorer.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No folder open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダが開かれていません" + } + } + } + }, + "fileExplorer.error.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File explorer is not available" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルエクスプローラーは利用できません" + } + } + } + }, + "titlebar.fileExplorer.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle File Explorer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルエクスプローラーを切替" + } + } + } + }, + "titlebar.fileExplorer.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show or hide the file explorer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルエクスプローラーの表示/非表示" + } + } + } + }, "shortcut.toggleTerminalCopyMode.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e3e6966ff1..67fbb7d2e3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2275,6 +2275,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? + weak var fileExplorerState: FileExplorerState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) @@ -7100,11 +7101,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) cmuxConfigStore.loadAll() + let fileExplorerState = FileExplorerState() + let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) .environmentObject(tabManager) .environmentObject(notificationStore) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) .environmentObject(cmuxConfigStore) // Use the current key window's size for new windows so Cmd+Shift+N @@ -11065,6 +11069,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .toggleFileExplorer) { + // Dispatch async to escape AppKit's performKeyEquivalent animation context. + // Without this, NSAnimationContext implicitly animates the layout change. + DispatchQueue.main.async { [weak self] in + self?.fileExplorerState?.toggle() + } + return true + } + if matchConfiguredShortcut(event: event, action: .sendFeedback) { guard let targetContext = preferredMainWindowContextForShortcuts(event: event), let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 742a334fd5..7254c9345b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -172,6 +172,38 @@ struct ShortcutHintPillBackground: View { } } +/// Reusable shortcut hint pill that shows a keyboard shortcut string. +struct ShortcutHintPill: View { + let text: String + var fontSize: CGFloat = 9 + var emphasis: Double = 1.0 + + init(shortcut: StoredShortcut, fontSize: CGFloat = 9, emphasis: Double = 1.0) { + self.text = shortcut.displayString + self.fontSize = fontSize + self.emphasis = emphasis + } + + init(text: String, fontSize: CGFloat = 9, emphasis: Double = 1.0) { + self.text = text + self.fontSize = fontSize + self.emphasis = emphasis + } + + var body: some View { + Text(text) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .foregroundColor(.primary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ShortcutHintPillBackground(emphasis: emphasis)) + } +} + + /// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView enum WindowGlassEffect { private static var glassViewKey: UInt8 = 0 @@ -1799,6 +1831,7 @@ struct ContentView: View { @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState @EnvironmentObject var cmuxConfigStore: CmuxConfigStore + @EnvironmentObject var fileExplorerState: FileExplorerState @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false @@ -1810,6 +1843,9 @@ struct ContentView: View { @State private var isFullScreen: Bool = false @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() + @StateObject private var fileExplorerStore = FileExplorerStore() + @State private var fileExplorerWidth: CGFloat = 220 + @State private var fileExplorerDragStartWidth: CGFloat? @State private var previousSelectedWorkspaceId: UUID? @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffGeneration: UInt64 = 0 @@ -2298,6 +2334,50 @@ struct ContentView: View { private enum SidebarResizerHandle: Hashable { case divider + case explorerDivider + } + + /// Returns the current drag width, start width capture, width update, and drag end cleanup for a resizer handle. + private func resizerConfig(for handle: SidebarResizerHandle, availableWidth: CGFloat) -> ( + currentWidth: CGFloat, + captureStart: () -> Void, + updateWidth: (CGFloat) -> Void, + finishDrag: () -> Void + ) { + switch handle { + case .divider: + return ( + currentWidth: sidebarWidth, + captureStart: { sidebarDragStartWidth = sidebarWidth }, + updateWidth: { translation in + let startWidth = sidebarDragStartWidth ?? sidebarWidth + let nextWidth = Self.clampedSidebarWidth( + startWidth + translation, + maximumWidth: maxSidebarWidth(availableWidth: availableWidth) + ) + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } + }, + finishDrag: { sidebarDragStartWidth = nil } + ) + case .explorerDivider: + return ( + currentWidth: fileExplorerWidth, + captureStart: { fileExplorerDragStartWidth = fileExplorerWidth }, + updateWidth: { translation in + let startWidth = fileExplorerDragStartWidth ?? fileExplorerWidth + let nextWidth = min(500, max(150, startWidth - translation)) + withTransaction(Transaction(animation: nil)) { + fileExplorerWidth = nextWidth + } + }, + finishDrag: { + fileExplorerDragStartWidth = nil + fileExplorerState.width = fileExplorerWidth + } + ) + } } private var sidebarResizerSidebarHitWidth: CGFloat { @@ -2529,27 +2609,21 @@ struct ContentView: View { .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in + let config = resizerConfig(for: handle, availableWidth: availableWidth) if !isResizerDragging { TerminalWindowPortalRegistry.beginInteractiveGeometryResize() isResizerDragging = true - sidebarDragStartWidth = sidebarWidth + config.captureStart() } - activateSidebarResizerCursor() - let startWidth = sidebarDragStartWidth ?? sidebarWidth - let nextWidth = Self.clampedSidebarWidth( - startWidth + value.translation.width, - maximumWidth: maxSidebarWidth(availableWidth: availableWidth) - ) - withTransaction(Transaction(animation: nil)) { - sidebarWidth = nextWidth - } + config.updateWidth(value.translation.width) } .onEnded { _ in if isResizerDragging { TerminalWindowPortalRegistry.endInteractiveGeometryResize() isResizerDragging = false - sidebarDragStartWidth = nil + let config = resizerConfig(for: handle, availableWidth: availableWidth) + config.finishDrag() } activateSidebarResizerCursor() scheduleSidebarResizerCursorRelease() @@ -2593,6 +2667,7 @@ struct ContentView: View { private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, + fileExplorerState: fileExplorerState, onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, @@ -2686,10 +2761,48 @@ struct ContentView: View { } private var terminalContentWithSidebarDropOverlay: some View { - terminalContent - .overlay { - SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) + // File explorer is always in the view tree. Visibility is controlled by + // frame width (0 when hidden), avoiding SwiftUI view insertion/removal + // and all associated transition animations. + let explorerVisible = fileExplorerState.isVisible + return HStack(spacing: 0) { + terminalContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + .overlay { + SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) + } + if explorerVisible { + Divider() } + FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + .frame(width: explorerVisible ? fileExplorerWidth : 0) + .clipped() + .allowsHitTesting(explorerVisible) + .accessibilityHidden(!explorerVisible) + .overlay(alignment: .leading) { + if explorerVisible { + fileExplorerResizerHandle + } + } + } + .transaction { $0.animation = nil } + .onAppear { + fileExplorerWidth = fileExplorerState.width + } + .onChange(of: fileExplorerState.width) { newValue in + if fileExplorerDragStartWidth == nil { + fileExplorerWidth = newValue + } + } + } + + private var fileExplorerResizerHandle: some View { + sidebarResizerHandleOverlay( + .explorerDivider, + width: SidebarResizeInteraction.totalHitWidth, + availableWidth: observedWindow?.contentView?.bounds.width ?? 1920 + ) } @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @@ -2851,6 +2964,67 @@ struct ContentView: View { ) } + private func syncFileExplorerDirectory() { + guard let selectedId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { + return + } + + let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !dir.isEmpty else { return } + + fileExplorerStore.showHiddenFiles = true + + if tab.isRemoteWorkspace { + let config = tab.remoteConfiguration + let remotePath = tab.remoteDaemonStatus.remotePath + let isReady = tab.remoteDaemonStatus.state == .ready + let homePath = remotePath.flatMap { path -> String? in + let components = dir.split(separator: "/") + if components.count >= 2, components[0] == "home" { + return "/home/\(components[1])" + } + if dir.hasPrefix("/root") { + return "/root" + } + return nil + } ?? "" + + #if DEBUG + dlog("fileExplorer.sync remote dir=\(dir) ready=\(isReady) dest=\(config?.destination ?? "nil")") + #endif + + if let existingProvider = fileExplorerStore.provider as? SSHFileExplorerProvider, + existingProvider.destination == config?.destination { + existingProvider.updateAvailability(isReady, homePath: isReady ? homePath : nil) + if isReady { + // Only reload if the path actually changed + let pathChanged = fileExplorerStore.rootPath != dir + fileExplorerStore.setRootPath(dir) + if pathChanged { + fileExplorerStore.hydrateExpandedNodes() + } + } + } else if let config { + let provider = SSHFileExplorerProvider( + destination: config.destination, + port: config.port, + identityFile: config.identityFile, + sshOptions: config.sshOptions, + homePath: homePath, + isAvailable: isReady + ) + fileExplorerStore.setProvider(provider) + fileExplorerStore.setRootPath(dir) + } + } else { + if !(fileExplorerStore.provider is LocalFileExplorerProvider) { + fileExplorerStore.setProvider(LocalFileExplorerProvider()) + } + fileExplorerStore.setRootPath(dir) + } + } + private var focusedDirectory: String? { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -3019,6 +3193,24 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() }) + // File explorer: reactively sync CWD when selected workspace or its directory changes. + // Uses switchToLatest to automatically unsubscribe from the old workspace's publisher. + view = AnyView(view.onReceive( + tabManager.$selectedTabId + .compactMap { [weak tabManager] tabId -> Workspace? in + guard let tabId, let tabManager else { return nil } + return tabManager.tabs.first(where: { $0.id == tabId }) + } + .map { workspace -> AnyPublisher in + workspace.$currentDirectory.eraseToAnyPublisher() + } + .switchToLatest() + .removeDuplicates() + .receive(on: DispatchQueue.main) + ) { _ in + syncFileExplorerDirectory() + }) + view = AnyView(view.onChange(of: tabManager.isWorkspaceCycleHot) { _ in #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -9896,6 +10088,7 @@ private struct SidebarTabItemPresentationSnapshot: Equatable { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel + @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -10072,7 +10265,7 @@ struct VerticalTabsSidebar: View { .background(Color.clear) .modifier(ClearScrollBackground()) } - SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + SidebarFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) } .accessibilityIdentifier("Sidebar") @@ -11018,13 +11211,14 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { private struct SidebarFooter: View { @ObservedObject var updateViewModel: UpdateViewModel + @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void var body: some View { #if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + SidebarDevFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) #else - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) .padding(.leading, 6) .padding(.trailing, 10) .padding(.bottom, 6) @@ -11034,6 +11228,7 @@ private struct SidebarFooter: View { private struct SidebarFooterButtons: View { @ObservedObject var updateViewModel: UpdateViewModel + @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void var body: some View { @@ -12157,13 +12352,14 @@ private struct SidebarFooterIconButtonStyleBody: View { #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel + @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner var body: some View { VStack(alignment: .leading, spacing: 6) { - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + SidebarFooterButtons(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) if showSidebarDevBuildBanner { Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) .font(.system(size: 11, weight: .semibold)) @@ -12777,15 +12973,7 @@ private struct TabItemView: View, Equatable { .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - Text(workspaceShortcutLabel) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundColor(activePrimaryTextColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) + ShortcutHintPill(text: workspaceShortcutLabel, fontSize: 10, emphasis: shortcutHintEmphasis) .offset( x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) @@ -12793,7 +12981,7 @@ private struct TabItemView: View, Equatable { .transition(.opacity) } } - .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) + .animation(.easeOut(duration: 0.12), value: showsModifierShortcutHints || alwaysShowShortcutHints) .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) } diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift new file mode 100644 index 0000000000..a6459f6c21 --- /dev/null +++ b/Sources/FileExplorerStore.swift @@ -0,0 +1,939 @@ +import AppKit +import Combine +import Foundation +import QuartzCore +import SwiftUI + +// MARK: - Explorer Visual Style + +enum FileExplorerStyle: Int, CaseIterable { + case liquidGlass = 0 + case highDensity = 1 + case terminalStealth = 2 + case proStudio = 3 + case finder = 4 + + var label: String { + switch self { + case .liquidGlass: return "Liquid Glass" + case .highDensity: return "High-Density IDE" + case .terminalStealth: return "Terminal Stealth" + case .proStudio: return "Pro Studio" + case .finder: return "Finder" + } + } + + var rowHeight: CGFloat { + switch self { + case .liquidGlass: return 28 + case .highDensity: return 20 + case .terminalStealth: return 24 + case .proStudio: return 32 + case .finder: return 26 + } + } + + var indentation: CGFloat { + switch self { + case .liquidGlass: return 16 + case .highDensity: return 12 + case .terminalStealth: return 14 + case .proStudio: return 20 + case .finder: return 18 + } + } + + var iconSize: CGFloat { + switch self { + case .liquidGlass: return 16 + case .highDensity: return 14 + case .terminalStealth: return 12 + case .proStudio: return 18 + case .finder: return 18 + } + } + + var iconWeight: NSFont.Weight { + switch self { + case .liquidGlass: return .regular + case .highDensity: return .regular + case .terminalStealth: return .light + case .proStudio: return .regular + case .finder: return .medium + } + } + + var nameFont: NSFont { + switch self { + case .liquidGlass: return .systemFont(ofSize: 13, weight: .medium) + case .highDensity: return .systemFont(ofSize: 11, weight: .regular) + case .terminalStealth: return .monospacedSystemFont(ofSize: 12, weight: .regular) + case .proStudio: return .systemFont(ofSize: 14, weight: .semibold) + case .finder: return .systemFont(ofSize: 13, weight: .regular) + } + } + + var iconToTextSpacing: CGFloat { + switch self { + case .liquidGlass: return 8 + case .highDensity: return 4 + case .terminalStealth: return 6 + case .proStudio: return 12 + case .finder: return 6 + } + } + + var selectionInset: CGFloat { + switch self { + case .liquidGlass: return 8 + case .highDensity: return 0 + case .terminalStealth: return 0 + case .proStudio: return 4 + case .finder: return 4 + } + } + + var selectionRadius: CGFloat { + switch self { + case .liquidGlass: return 6 + case .highDensity: return 0 + case .terminalStealth: return 0 + case .proStudio: return 8 + case .finder: return 5 + } + } + + var selectionColor: NSColor { + switch self { + case .liquidGlass: return .controlAccentColor.withAlphaComponent(0.15) + case .highDensity: return .selectedContentBackgroundColor + case .terminalStealth: return .controlAccentColor + case .proStudio: return .controlAccentColor + case .finder: return .controlAccentColor.withAlphaComponent(0.15) + } + } + + var hoverColor: NSColor { + switch self { + case .liquidGlass: return .labelColor.withAlphaComponent(0.05) + case .highDensity: return .white.withAlphaComponent(0.05) + case .terminalStealth: return .white.withAlphaComponent(0.03) + case .proStudio: return .white.withAlphaComponent(0.1) + case .finder: return .labelColor.withAlphaComponent(0.04) + } + } + + var usesBorderSelection: Bool { + self == .terminalStealth + } + + var fileIconTint: NSColor { + switch self { + case .liquidGlass: return .secondaryLabelColor + case .highDensity: return .secondaryLabelColor + case .terminalStealth: return .tertiaryLabelColor + case .proStudio: return .secondaryLabelColor + case .finder: return NSColor(white: 0.55, alpha: 1.0) + } + } + + var folderIconTint: NSColor { + switch self { + case .liquidGlass: return .systemBlue + case .highDensity: return .secondaryLabelColor + case .terminalStealth: return .tertiaryLabelColor + case .proStudio: return .systemBlue + case .finder: return .systemBlue + } + } + + func gitColor(for status: GitFileStatus) -> NSColor { + switch self { + case .liquidGlass: + switch status { + case .modified: return .systemOrange + case .added: return .systemTeal + case .deleted: return .systemRed + case .renamed: return .systemPurple + case .untracked: return .quaternaryLabelColor + } + case .highDensity: + switch status { + case .modified: return .systemYellow + case .added: return .systemGreen + case .deleted: return .systemRed + case .renamed: return .systemBlue + case .untracked: return .tertiaryLabelColor + } + case .terminalStealth: + switch status { + case .modified: return NSColor(red: 0.8, green: 0.7, blue: 0.4, alpha: 1.0) + case .added: return NSColor(red: 0.5, green: 0.8, blue: 0.5, alpha: 1.0) + case .deleted: return NSColor(red: 0.8, green: 0.4, blue: 0.4, alpha: 1.0) + case .renamed: return NSColor(red: 0.5, green: 0.7, blue: 0.9, alpha: 1.0) + case .untracked: return NSColor(white: 0.5, alpha: 1.0) + } + case .proStudio: + switch status { + case .modified: return .systemYellow + case .added: return .systemGreen + case .deleted: return .systemPink + case .renamed: return .systemCyan + case .untracked: return .systemGray + } + case .finder: + switch status { + case .modified: return .systemOrange + case .added: return .systemGreen + case .deleted: return .systemRed + case .renamed: return .systemBlue + case .untracked: return .tertiaryLabelColor + } + } + } + + static var current: FileExplorerStyle { + let defaults = UserDefaults.standard + if defaults.object(forKey: "fileExplorer.style") == nil { + return .highDensity + } + return FileExplorerStyle(rawValue: defaults.integer(forKey: "fileExplorer.style")) ?? .highDensity + } +} + +// MARK: - Models + +struct FileExplorerEntry { + let name: String + let path: String + let isDirectory: Bool +} + +final class FileExplorerNode: Identifiable { + let id: String + let name: String + let path: String + let isDirectory: Bool + var children: [FileExplorerNode]? + var isLoading: Bool = false + var error: String? + + init(name: String, path: String, isDirectory: Bool) { + self.id = path + self.name = name + self.path = path + self.isDirectory = isDirectory + } + + var isExpandable: Bool { isDirectory } + + var sortedChildren: [FileExplorerNode]? { + children?.sorted { a, b in + if a.isDirectory != b.isDirectory { return a.isDirectory } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } + } +} + +// MARK: - Root Resolver + +enum FileExplorerRootResolver { + static func displayPath(for fullPath: String, homePath: String?) -> String { + guard let home = homePath, !home.isEmpty else { return fullPath } + let normalizedHome = home.hasSuffix("/") ? String(home.dropLast()) : home + let normalizedPath = fullPath.hasSuffix("/") ? String(fullPath.dropLast()) : fullPath + if normalizedPath == normalizedHome { + return "~" + } + let homePrefix = normalizedHome + "/" + if normalizedPath.hasPrefix(homePrefix) { + return "~/" + normalizedPath.dropFirst(homePrefix.count) + } + return fullPath + } +} + +// MARK: - Provider Protocol + +protocol FileExplorerProvider: AnyObject { + func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] + var homePath: String { get } + var isAvailable: Bool { get } +} + +// MARK: - Local Provider + +final class LocalFileExplorerProvider: FileExplorerProvider { + var homePath: String { NSHomeDirectory() } + var isAvailable: Bool { true } + + 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 + 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) + } + } +} + +// MARK: - SSH Provider + +final class SSHFileExplorerProvider: FileExplorerProvider { + let destination: String + let port: Int? + let identityFile: String? + let sshOptions: [String] + private(set) var homePath: String + private(set) var isAvailable: Bool + + init( + destination: String, + port: Int?, + identityFile: String?, + sshOptions: [String], + homePath: String, + isAvailable: Bool + ) { + self.destination = destination + self.port = port + self.identityFile = identityFile + self.sshOptions = sshOptions + self.homePath = homePath + self.isAvailable = isAvailable + } + + func updateAvailability(_ available: Bool, homePath: String?) { + self.isAvailable = available + if let homePath { + self.homePath = homePath + } + } + + func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] { + guard isAvailable else { + throw FileExplorerError.providerUnavailable + } + // Capture immutable config values for Sendable closure + let dest = destination + let p = port + let identity = identityFile + let opts = sshOptions + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let result = try SSHFileExplorerProvider.runSSHListCommand( + path: path, destination: dest, port: p, + identityFile: identity, sshOptions: opts, + showHidden: showHidden + ) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func runSSHListCommand( + path: String, destination: String, port: Int?, + identityFile: String?, sshOptions: [String], + showHidden: Bool + ) throws -> [FileExplorerEntry] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + + var args: [String] = [] + if let port { + args += ["-p", String(port)] + } + if let identityFile { + args += ["-i", identityFile] + } + for option in sshOptions { + args += ["-o", option] + } + // Batch mode, no TTY, connection timeout + args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"] + // Escape single quotes in path for shell safety + let escapedPath = path.replacingOccurrences(of: "'", with: "'\\''") + let lsFlags = showHidden ? "-1paFA" : "-1paF" + args += [destination, "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"] + + process.arguments = args + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + try process.run() + // Read pipe data before waitUntilExit to avoid deadlock when pipe buffer fills + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrStr = String(data: stderrData, encoding: .utf8) ?? "" + throw FileExplorerError.sshCommandFailed(stderrStr) + } + guard let output = String(data: data, encoding: .utf8) else { + return [] + } + + 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 + 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) + } + } +} + +enum FileExplorerError: LocalizedError { + case providerUnavailable + case sshCommandFailed(String) + + var errorDescription: String? { + switch self { + case .providerUnavailable: + return String(localized: "fileExplorer.error.unavailable", defaultValue: "File explorer is not available") + case .sshCommandFailed(let detail): + return String(localized: "fileExplorer.error.sshFailed", defaultValue: "SSH command failed: \(detail)") + } + } +} + +// MARK: - State (visibility toggle) + +final class FileExplorerState: ObservableObject { + @Published var isVisible: Bool { + didSet { UserDefaults.standard.set(isVisible, forKey: "fileExplorer.isVisible") } + } + @Published var width: CGFloat { + didSet { UserDefaults.standard.set(Double(width), forKey: "fileExplorer.width") } + } + + /// Proportion of sidebar height allocated to the tab list (0.0-1.0). + /// The file explorer gets the remaining space below. + @Published var dividerPosition: CGFloat { + didSet { UserDefaults.standard.set(Double(dividerPosition), forKey: "fileExplorer.dividerPosition") } + } + + /// Whether hidden files (dotfiles) are shown in the tree. + @Published var showHiddenFiles: Bool { + didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } + } + + init() { + let defaults = UserDefaults.standard + self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") + let storedWidth = defaults.double(forKey: "fileExplorer.width") + self.width = storedWidth > 0 ? CGFloat(storedWidth) : 220 + let storedPosition = defaults.double(forKey: "fileExplorer.dividerPosition") + self.dividerPosition = storedPosition > 0 ? CGFloat(storedPosition) : 0.6 + let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") + self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") + } + + func toggle() { + setVisible(!isVisible) + } + + func setVisible(_ nextValue: Bool) { + guard isVisible != nextValue else { return } + + // Suppress both SwiftUI transactions and AppKit/Core Animation implicit layout changes. + NSAnimationContext.beginGrouping() + CATransaction.begin() + defer { + CATransaction.commit() + NSAnimationContext.endGrouping() + } + + NSAnimationContext.current.duration = 0 + NSAnimationContext.current.allowsImplicitAnimation = false + CATransaction.setDisableActions(true) + + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + isVisible = nextValue + } + } +} + +// MARK: - Store + +/// All access must happen on the main thread. Properties are not marked @MainActor +/// because NSOutlineView data source/delegate methods are called on the main thread +/// but are not annotated @MainActor. +final class FileExplorerStore: ObservableObject { + @Published var rootPath: String = "" + @Published var rootNodes: [FileExplorerNode] = [] + @Published private(set) var isRootLoading: Bool = false + @Published private(set) var gitStatusByPath: [String: GitFileStatus] = [:] + + var provider: FileExplorerProvider? + + /// Whether hidden files are shown. Set from FileExplorerState externally. + var showHiddenFiles: Bool = false + + /// Watches the root directory for filesystem changes (local only). + private var directoryWatcher: FileExplorerDirectoryWatcher? + + /// Paths that are logically expanded (persisted across provider changes) + private(set) var expandedPaths: Set = [] + + /// Paths currently being loaded + private(set) var loadingPaths: Set = [] + + /// In-flight load tasks keyed by path + private var loadTasks: [String: Task] = [:] + + /// Cache of path -> node for quick lookup + private var nodesByPath: [String: FileExplorerNode] = [:] + + /// Prefetch debounce: path -> work item + private var prefetchWorkItems: [String: DispatchWorkItem] = [:] + + var displayRootPath: String { + FileExplorerRootResolver.displayPath(for: rootPath, homePath: provider?.homePath) + } + + // MARK: - Public API + + func setRootPath(_ path: String) { + guard path != rootPath else { + #if DEBUG + NSLog("[FileExplorer] setRootPath skipped (same path): \(path)") + #endif + return + } + #if DEBUG + NSLog("[FileExplorer] setRootPath: \(rootPath) -> \(path)") + #endif + rootPath = path + reload() + refreshGitStatus() + updateDirectoryWatcher() + } + + func refreshGitStatus() { + guard !rootPath.isEmpty else { + gitStatusByPath = [:] + return + } + let path = rootPath + if let sshProvider = provider as? SSHFileExplorerProvider { + let dest = sshProvider.destination + let port = sshProvider.port + let identity = sshProvider.identityFile + let opts = sshProvider.sshOptions + DispatchQueue.global(qos: .utility).async { + let status = GitStatusProvider.fetchStatusSSH( + directory: path, destination: dest, port: port, + identityFile: identity, sshOptions: opts + ) + DispatchQueue.main.async { [weak self] in + self?.gitStatusByPath = status + } + } + } else { + DispatchQueue.global(qos: .utility).async { + let status = GitStatusProvider.fetchStatus(directory: path) + DispatchQueue.main.async { [weak self] in + self?.gitStatusByPath = status + } + } + } + } + + private func updateDirectoryWatcher() { + if provider is LocalFileExplorerProvider, !rootPath.isEmpty { + if directoryWatcher == nil { + directoryWatcher = FileExplorerDirectoryWatcher { [weak self] in + self?.reload() + self?.refreshGitStatus() + } + } + directoryWatcher?.watch(path: rootPath) + } else { + directoryWatcher?.stop() + } + } + + func setProvider(_ newProvider: FileExplorerProvider?) { + #if DEBUG + NSLog("[FileExplorer] setProvider: \(type(of: newProvider).self) available=\(newProvider?.isAvailable ?? false)") + #endif + provider = newProvider + // Re-expand previously expanded nodes if provider becomes available + if newProvider?.isAvailable == true { + reload() + } + } + + func reload() { + #if DEBUG + NSLog("[FileExplorer] reload() path=\(rootPath) provider=\(type(of: provider).self)") + #endif + cancelAllLoads() + rootNodes = [] + nodesByPath = [:] + guard !rootPath.isEmpty, provider != nil else { return } + isRootLoading = true + let path = rootPath + let task = Task { [weak self] in + guard let self else { return } + await self.loadChildren(for: nil, at: path) + } + loadTasks[rootPath] = task + } + + func expand(node: FileExplorerNode) { + guard node.isDirectory else { return } + expandedPaths.insert(node.path) + if node.children == nil { + node.isLoading = true + node.error = nil + objectWillChange.send() + let nodePath = node.path + let task = Task { [weak self] in + guard let self else { return } + await self.loadChildren(for: node, at: nodePath) + } + loadTasks[node.path] = task + } + } + + func collapse(node: FileExplorerNode) { + expandedPaths.remove(node.path) + objectWillChange.send() + } + + func isExpanded(_ node: FileExplorerNode) -> Bool { + expandedPaths.contains(node.path) + } + + func prefetchChildren(for node: FileExplorerNode) { + guard node.isDirectory, node.children == nil, !loadingPaths.contains(node.path) else { return } + // Debounce: only prefetch if hover persists for 200ms + let path = node.path + prefetchWorkItems[path]?.cancel() + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self, node.children == nil, !self.loadingPaths.contains(path) else { return } + // Silent prefetch: don't show loading indicator + await self.loadChildren(for: node, at: path, silent: true) + } + } + prefetchWorkItems[path] = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem) + } + + func cancelPrefetch(for node: FileExplorerNode) { + prefetchWorkItems[node.path]?.cancel() + prefetchWorkItems.removeValue(forKey: node.path) + } + + /// Called when SSH provider becomes available after being unavailable. + /// Re-hydrates expanded nodes that were waiting. + func hydrateExpandedNodes() { + guard let provider, provider.isAvailable, !expandedPaths.isEmpty else { return } + #if DEBUG + NSLog("[FileExplorer] hydrateExpandedNodes: \(expandedPaths.count) paths to hydrate") + #endif + reload() + } + + // MARK: - Private + + @MainActor + private func loadChildren(for parentNode: FileExplorerNode?, at path: String, silent: Bool = false) async { + guard let provider else { return } + + if !silent { + loadingPaths.insert(path) + parentNode?.error = nil + objectWillChange.send() + } + + do { + let entries = try await provider.listDirectory(path: path, showHidden: showHiddenFiles) + let children = entries.map { entry in + let node = FileExplorerNode(name: entry.name, path: entry.path, isDirectory: entry.isDirectory) + 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 + } + + if let parentNode { + parentNode.children = children + parentNode.isLoading = false + parentNode.error = nil + } else { + rootNodes = children + isRootLoading = false + } + loadingPaths.remove(path) + loadTasks.removeValue(forKey: path) + objectWillChange.send() + + // Auto-expand children that were previously expanded + for child in children where child.isDirectory && expandedPaths.contains(child.path) { + child.isLoading = true + objectWillChange.send() + let childPath = child.path + let childTask = Task { [weak self] in + guard let self else { return } + await self.loadChildren(for: child, at: childPath) + } + loadTasks[child.path] = childTask + } + } catch { + if !Task.isCancelled { + if let parentNode { + parentNode.isLoading = false + parentNode.error = error.localizedDescription + } else { + isRootLoading = false + } + loadingPaths.remove(path) + loadTasks.removeValue(forKey: path) + objectWillChange.send() + } + } + } + + private func cancelAllLoads() { + for (_, task) in loadTasks { + task.cancel() + } + loadTasks.removeAll() + loadingPaths.removeAll() + for (_, item) in prefetchWorkItems { + item.cancel() + } + prefetchWorkItems.removeAll() + isRootLoading = false + } +} + +// MARK: - Directory Watcher + +/// Watches a local directory for filesystem changes and calls back on the main thread. +/// Debounces events to avoid rapid-fire reloads during bulk operations (e.g., git checkout). +final class FileExplorerDirectoryWatcher { + private var fileDescriptor: Int32 = -1 + private var watchSource: DispatchSourceFileSystemObject? + private let watchQueue = DispatchQueue(label: "com.cmux.fileExplorerWatcher", qos: .utility) + private var debounceWorkItem: DispatchWorkItem? + private let onChange: () -> Void + + init(onChange: @escaping () -> Void) { + self.onChange = onChange + } + + func watch(path: String) { + stop() + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return } + fileDescriptor = fd + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .link, .rename, .delete], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + self?.scheduleReload() + } + + source.setCancelHandler { + Darwin.close(fd) + } + + source.resume() + watchSource = source + } + + func stop() { + debounceWorkItem?.cancel() + debounceWorkItem = nil + watchSource?.cancel() + watchSource = nil + fileDescriptor = -1 + } + + private func scheduleReload() { + debounceWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + DispatchQueue.main.async { + self?.onChange() + } + } + debounceWorkItem = work + watchQueue.asyncAfter(deadline: .now() + 0.3, execute: work) + } + + deinit { + stop() + } +} + +// MARK: - Git Status + +enum GitFileStatus { + case modified, added, deleted, renamed, untracked +} + +/// Runs `git status --porcelain` and parses results into a path-to-status map. +enum GitStatusProvider { + + static func fetchStatus(directory: String) -> [String: GitFileStatus] { + guard let repoRoot = gitRepoRoot(for: directory) else { return [:] } + return parseGitStatus( + output: runGit(in: repoRoot, arguments: ["status", "--porcelain"]), + repoRoot: repoRoot, + explorerRoot: directory + ) + } + + static func fetchStatusSSH( + directory: String, destination: String, port: Int?, + identityFile: String?, sshOptions: [String] + ) -> [String: GitFileStatus] { + let escapedDir = directory.replacingOccurrences(of: "'", with: "'\\''") + let cmd = "cd '\(escapedDir)' 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null && echo '---GIT_STATUS---' && git status --porcelain 2>/dev/null" + guard let output = runSSH( + command: cmd, destination: destination, + port: port, identityFile: identityFile, sshOptions: sshOptions + ) else { return [:] } + + let parts = output.components(separatedBy: "---GIT_STATUS---\n") + guard parts.count == 2 else { return [:] } + let repoRoot = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + return parseGitStatus(output: parts[1], repoRoot: repoRoot, explorerRoot: directory) + } + + private static func parseGitStatus( + output: String?, repoRoot: String, explorerRoot: String + ) -> [String: GitFileStatus] { + guard let output, !output.isEmpty else { return [:] } + var statusMap: [String: GitFileStatus] = [:] + + for line in output.components(separatedBy: "\n") where line.count >= 4 { + let indexStatus = line[line.startIndex] + let workTreeStatus = line[line.index(after: line.startIndex)] + var path = String(line.dropFirst(3)) + .trimmingCharacters(in: .whitespaces) + .replacingOccurrences(of: "\"", with: "") + + if path.contains(" -> ") { + path = String(path.split(separator: " -> ").last ?? Substring(path)) + } + + guard let status = parseStatusChars(index: indexStatus, workTree: workTreeStatus) else { continue } + + let absolutePath = repoRoot.hasSuffix("/") ? repoRoot + path : repoRoot + "/" + path + guard absolutePath.hasPrefix(explorerRoot) else { continue } + + statusMap[absolutePath] = status + markParentDirectories(absolutePath: absolutePath, explorerRoot: explorerRoot, status: status, in: &statusMap) + } + return statusMap + } + + private static func parseStatusChars(index: Character, workTree: Character) -> GitFileStatus? { + if index == "?" && workTree == "?" { return .untracked } + if index == "A" || workTree == "A" { return .added } + if index == "D" || workTree == "D" { return .deleted } + if index == "R" || workTree == "R" { return .renamed } + if index == "M" || workTree == "M" { return .modified } + return nil + } + + private static func markParentDirectories( + absolutePath: String, explorerRoot: String, + status: GitFileStatus, in map: inout [String: GitFileStatus] + ) { + let dirStatus: GitFileStatus = (status == .untracked) ? .untracked : .modified + var current = (absolutePath as NSString).deletingLastPathComponent + while current.hasPrefix(explorerRoot) && current != explorerRoot { + if map[current] == nil { + map[current] = dirStatus + } + current = (current as NSString).deletingLastPathComponent + } + } + + private static func gitRepoRoot(for directory: String) -> String? { + runGit(in: directory, arguments: ["rev-parse", "--show-toplevel"])? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func runGit(in directory: String, arguments: [String]) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: directory) + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } + + private static func runSSH( + command: String, destination: String, + port: Int?, identityFile: String?, sshOptions: [String] + ) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + var args: [String] = [] + if let port { args += ["-p", String(port)] } + if let identityFile { args += ["-i", identityFile] } + for option in sshOptions { args += ["-o", option] } + args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"] + args += [destination, command] + process.arguments = args + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift new file mode 100644 index 0000000000..fe52d5dbfb --- /dev/null +++ b/Sources/FileExplorerView.swift @@ -0,0 +1,670 @@ +import AppKit +import Bonsplit +import Combine +import SwiftUI + +// MARK: - File Explorer Panel (single NSViewRepresentable) + +/// The entire file explorer panel as one AppKit view hierarchy. +/// Contains the header bar (path + controls) and NSOutlineView, with no SwiftUI intermediaries. +struct FileExplorerPanelView: NSViewRepresentable { + @ObservedObject var store: FileExplorerStore + @ObservedObject var state: FileExplorerState + + func makeCoordinator() -> Coordinator { + Coordinator(store: store, state: state) + } + + func makeNSView(context: Context) -> FileExplorerContainerView { + let container = FileExplorerContainerView(coordinator: context.coordinator) + context.coordinator.containerView = container + return container + } + + func updateNSView(_ container: FileExplorerContainerView, context: Context) { + context.coordinator.store = store + context.coordinator.state = state + container.updateHeader(store: store) + context.coordinator.reloadIfNeeded() + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate, NSMenuDelegate { + var store: FileExplorerStore + var state: FileExplorerState + weak var containerView: FileExplorerContainerView? + weak var outlineView: NSOutlineView? + private var lastRootNodeCount: Int = -1 + private var observationCancellable: AnyCancellable? + private var styleObserver: Any? + + init(store: FileExplorerStore, state: FileExplorerState) { + self.store = store + self.state = state + super.init() + observeStore() + styleObserver = NotificationCenter.default.addObserver( + forName: .fileExplorerStyleDidChange, object: nil, queue: .main + ) { [weak self] _ in + guard let self, let outlineView = self.outlineView else { return } + let style = FileExplorerStyle.current + outlineView.indentationPerLevel = style.indentation + outlineView.noteHeightOfRows(withIndexesChanged: IndexSet(0.., in outlineView: NSOutlineView) { + for row in 0.. Int { + if item == nil { + return store.rootNodes.count + } + guard let node = item as? FileExplorerNode else { return 0 } + return node.sortedChildren?.count ?? 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { + return store.rootNodes[index] + } + guard let node = item as? FileExplorerNode, + let children = node.sortedChildren else { + return FileExplorerNode(name: "", path: "", isDirectory: false) + } + return children[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + guard let node = item as? FileExplorerNode else { return false } + return node.isExpandable + } + + // MARK: - NSOutlineViewDelegate + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? FileExplorerNode else { return nil } + + let identifier = NSUserInterfaceItemIdentifier("FileExplorerCell") + let cellView: FileExplorerCellView + if let existing = outlineView.makeView(withIdentifier: identifier, owner: nil) as? FileExplorerCellView { + cellView = existing + } else { + cellView = FileExplorerCellView(identifier: identifier) + } + + let gitStatus = store.gitStatusByPath[node.path] + cellView.configure(with: node, gitStatus: gitStatus) + cellView.onHover = { [weak self] isHovering in + guard let self else { return } + if isHovering { + self.store.prefetchChildren(for: node) + } else { + self.store.cancelPrefetch(for: node) + } + } + + return cellView + } + + func outlineView(_ outlineView: NSOutlineView, shouldExpandItem item: Any) -> Bool { + guard let node = item as? FileExplorerNode, node.isDirectory else { return false } + store.expand(node: node) + return node.children != nil + } + + func outlineView(_ outlineView: NSOutlineView, shouldCollapseItem item: Any) -> Bool { + guard let node = item as? FileExplorerNode else { return false } + store.collapse(node: node) + return true + } + + func outlineViewItemDidExpand(_ notification: Notification) { + guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } + if !store.isExpanded(node) { + store.expand(node: node) + } + } + + func outlineViewItemDidCollapse(_ notification: Notification) { + guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } + if store.isExpanded(node) { + store.collapse(node: node) + } + } + + func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + FileExplorerRowView() + } + + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + FileExplorerStyle.current.rowHeight + } + + // MARK: - Drag-to-Terminal + + func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> (any NSPasteboardWriting)? { + guard let node = item as? FileExplorerNode, !node.isDirectory else { return nil } + guard store.provider is LocalFileExplorerProvider else { return nil } + return NSURL(fileURLWithPath: node.path) + } + + // MARK: - Context Menu (NSMenuDelegate) + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let outlineView else { return } + let clickedRow = outlineView.clickedRow + guard clickedRow >= 0, + let node = outlineView.item(atRow: clickedRow) as? FileExplorerNode else { return } + + let isLocal = store.provider is LocalFileExplorerProvider + + if !node.isDirectory && isLocal { + let openItem = NSMenuItem( + title: String(localized: "fileExplorer.contextMenu.openDefault", defaultValue: "Open in Default Editor"), + action: #selector(contextMenuOpenInDefaultEditor(_:)), + keyEquivalent: "" + ) + openItem.target = self + openItem.representedObject = node + menu.addItem(openItem) + } + + if isLocal { + let revealItem = NSMenuItem( + title: String(localized: "fileExplorer.contextMenu.revealInFinder", defaultValue: "Reveal in Finder"), + action: #selector(contextMenuRevealInFinder(_:)), + keyEquivalent: "" + ) + revealItem.target = self + revealItem.representedObject = node + menu.addItem(revealItem) + + menu.addItem(.separator()) + } + + let copyPathItem = NSMenuItem( + title: String(localized: "fileExplorer.contextMenu.copyPath", defaultValue: "Copy Path"), + action: #selector(contextMenuCopyPath(_:)), + keyEquivalent: "" + ) + copyPathItem.target = self + copyPathItem.representedObject = node + menu.addItem(copyPathItem) + + let copyRelItem = NSMenuItem( + title: String(localized: "fileExplorer.contextMenu.copyRelativePath", defaultValue: "Copy Relative Path"), + action: #selector(contextMenuCopyRelativePath(_:)), + keyEquivalent: "" + ) + copyRelItem.target = self + copyRelItem.representedObject = node + menu.addItem(copyRelItem) + } + + @objc private func contextMenuOpenInDefaultEditor(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? FileExplorerNode else { return } + NSWorkspace.shared.open(URL(fileURLWithPath: node.path)) + } + + @objc private func contextMenuRevealInFinder(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? FileExplorerNode else { return } + NSWorkspace.shared.selectFile(node.path, inFileViewerRootedAtPath: "") + } + + @objc private func contextMenuCopyPath(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? FileExplorerNode else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(node.path, forType: .string) + } + + @objc private func contextMenuCopyRelativePath(_ sender: NSMenuItem) { + guard let node = sender.representedObject as? FileExplorerNode else { return } + let rootPath = store.rootPath + var relativePath = node.path + if relativePath.hasPrefix(rootPath) { + relativePath = String(relativePath.dropFirst(rootPath.count)) + if relativePath.hasPrefix("/") { + relativePath = String(relativePath.dropFirst()) + } + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(relativePath, forType: .string) + } + } +} + +// MARK: - Container View (all-AppKit) + +/// Pure AppKit container holding the header bar and outline view. +final class FileExplorerContainerView: NSView { + private let headerView: FileExplorerHeaderView + private let scrollView: NSScrollView + private let outlineView: FileExplorerNSOutlineView + private let emptyLabel: NSTextField + private let loadingIndicator: NSProgressIndicator + + init(coordinator: FileExplorerPanelView.Coordinator) { + headerView = FileExplorerHeaderView() + scrollView = NSScrollView() + outlineView = FileExplorerNSOutlineView() + emptyLabel = NSTextField(labelWithString: String(localized: "fileExplorer.empty", defaultValue: "No folder open")) + loadingIndicator = NSProgressIndicator() + + super.init(frame: .zero) + + // Header + headerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(headerView) + + // Empty state label + emptyLabel.translatesAutoresizingMaskIntoConstraints = false + emptyLabel.font = .systemFont(ofSize: 13) + emptyLabel.textColor = .secondaryLabelColor + emptyLabel.alignment = .center + emptyLabel.isHidden = true + addSubview(emptyLabel) + + // Loading indicator + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + loadingIndicator.style = .spinning + loadingIndicator.controlSize = .small + loadingIndicator.isHidden = true + addSubview(loadingIndicator) + + // Outline view setup + outlineView.headerView = nil + outlineView.usesAlternatingRowBackgroundColors = false + outlineView.style = .plain + outlineView.selectionHighlightStyle = .regular + outlineView.rowSizeStyle = .default + outlineView.indentationPerLevel = FileExplorerStyle.current.indentation + outlineView.autoresizesOutlineColumn = true + outlineView.floatsGroupRows = false + outlineView.backgroundColor = .clear + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + column.isEditable = false + column.resizingMask = .autoresizingMask + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + outlineView.dataSource = coordinator + outlineView.delegate = coordinator + coordinator.outlineView = outlineView + + // Context menu + let menu = NSMenu() + menu.delegate = coordinator + outlineView.menu = menu + + // Scroll view + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.documentView = outlineView + addSubview(scrollView) + + NSLayoutConstraint.activate([ + headerView.topAnchor.constraint(equalTo: topAnchor), + headerView.leadingAnchor.constraint(equalTo: leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: trailingAnchor), + + scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + + emptyLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateHeader(store: FileExplorerStore) { + headerView.update(displayPath: store.displayRootPath) + } + + func updateVisibility(hasContent: Bool, isLoading: Bool) { + scrollView.isHidden = !hasContent || isLoading + headerView.isHidden = !hasContent + emptyLabel.isHidden = hasContent + loadingIndicator.isHidden = !isLoading + if isLoading { + loadingIndicator.startAnimation(nil) + } else { + loadingIndicator.stopAnimation(nil) + } + } +} + +// MARK: - Header View (AppKit) + +/// Pure AppKit header bar with folder icon, path label, and hidden files toggle. +final class FileExplorerHeaderView: NSView { + private let iconView = NSImageView() + private let pathLabel = NSTextField(labelWithString: "") + + override init(frame: NSRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + iconView.translatesAutoresizingMaskIntoConstraints = false + let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) + iconView.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(config) + iconView.contentTintColor = .secondaryLabelColor + + pathLabel.translatesAutoresizingMaskIntoConstraints = false + pathLabel.font = .systemFont(ofSize: 11, weight: .medium) + pathLabel.textColor = .secondaryLabelColor + pathLabel.lineBreakMode = .byTruncatingMiddle + pathLabel.maximumNumberOfLines = 1 + pathLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addSubview(iconView) + addSubview(pathLabel) + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 28), + + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 14), + iconView.heightAnchor.constraint(equalToConstant: 14), + + pathLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4), + pathLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + pathLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + ]) + } + + func update(displayPath: String) { + pathLabel.stringValue = displayPath + } +} + +// MARK: - Cell View + +final class FileExplorerCellView: NSTableCellView { + private let iconView = NSImageView() + private let nameLabel = NSTextField(labelWithString: "") + private let loadingIndicator = NSProgressIndicator() + private var trackingArea: NSTrackingArea? + var onHover: ((Bool) -> Void)? + + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var iconWidthConstraint: NSLayoutConstraint! + private var iconHeightConstraint: NSLayoutConstraint! + private var iconToTextConstraint: NSLayoutConstraint! + + private func setupViews() { + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.imageScaling = .scaleProportionallyDown + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.textColor = .labelColor + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.maximumNumberOfLines = 1 + + loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + loadingIndicator.style = .spinning + loadingIndicator.controlSize = .small + loadingIndicator.isHidden = true + + addSubview(iconView) + addSubview(nameLabel) + addSubview(loadingIndicator) + + iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: 16) + iconHeightConstraint = iconView.heightAnchor.constraint(equalToConstant: 16) + iconToTextConstraint = nameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4) + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconWidthConstraint, + iconHeightConstraint, + + iconToTextConstraint, + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: loadingIndicator.leadingAnchor, constant: -4), + + loadingIndicator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), + loadingIndicator.widthAnchor.constraint(equalToConstant: 12), + loadingIndicator.heightAnchor.constraint(equalToConstant: 12), + ]) + } + + func configure(with node: FileExplorerNode, gitStatus: GitFileStatus? = nil) { + let style = FileExplorerStyle.current + + nameLabel.stringValue = node.name + nameLabel.font = style.nameFont + + iconWidthConstraint.constant = style.iconSize + iconHeightConstraint.constant = style.iconSize + iconToTextConstraint.constant = style.iconToTextSpacing + + if style == .finder { + if node.isDirectory { + let folderIcon = NSWorkspace.shared.icon(for: .folder) + folderIcon.size = NSSize(width: style.iconSize, height: style.iconSize) + iconView.image = folderIcon + iconView.contentTintColor = nil + } else { + let fileIcon = NSWorkspace.shared.icon(forFileType: (node.name as NSString).pathExtension) + fileIcon.size = NSSize(width: style.iconSize, height: style.iconSize) + iconView.image = fileIcon + iconView.contentTintColor = nil + } + } else { + let symbolConfig = NSImage.SymbolConfiguration(pointSize: style.iconSize, weight: style.iconWeight) + if node.isDirectory { + iconView.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfig) + iconView.contentTintColor = style.folderIconTint + } else { + iconView.image = NSImage(systemSymbolName: "doc", accessibilityDescription: nil)? + .withSymbolConfiguration(symbolConfig) + iconView.contentTintColor = style.fileIconTint + } + } + + if node.isLoading { + loadingIndicator.isHidden = false + loadingIndicator.startAnimation(nil) + } else { + loadingIndicator.isHidden = true + loadingIndicator.stopAnimation(nil) + } + + if let error = node.error { + nameLabel.textColor = .systemRed + nameLabel.toolTip = error + } else if let gitStatus { + nameLabel.textColor = style.gitColor(for: gitStatus) + nameLabel.toolTip = node.path + } else { + nameLabel.textColor = .labelColor + nameLabel.toolTip = node.path + } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let existing = trackingArea { + removeTrackingArea(existing) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + onHover?(true) + } + + override func mouseExited(with event: NSEvent) { + onHover?(false) + } +} + +// MARK: - Non-Animating Outline View + +/// NSOutlineView subclass that disables expand/collapse animations and adds leading margin. +final class FileExplorerNSOutlineView: NSOutlineView { + /// Leading margin applied to disclosure triangles and content. + static let leadingMargin: CGFloat = 8 + + override func expandItem(_ item: Any?, expandChildren: Bool) { + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + super.expandItem(item, expandChildren: expandChildren) + NSAnimationContext.endGrouping() + } + + override func collapseItem(_ item: Any?, collapseChildren: Bool) { + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + super.collapseItem(item, collapseChildren: collapseChildren) + NSAnimationContext.endGrouping() + } + + override func frameOfOutlineCell(atRow row: Int) -> NSRect { + var frame = super.frameOfOutlineCell(atRow: row) + frame.origin.x += Self.leadingMargin + return frame + } + + override func frameOfCell(atColumn column: Int, row: Int) -> NSRect { + var frame = super.frameOfCell(atColumn: column, row: row) + let cellShift: CGFloat = Self.leadingMargin - 6 + frame.origin.x += cellShift + frame.size.width -= cellShift + return frame + } +} + +// MARK: - Row View + +final class FileExplorerRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + let style = FileExplorerStyle.current + + if style.usesBorderSelection { + let borderRect = NSRect(x: 0, y: 1, width: 2, height: bounds.height - 2) + style.selectionColor.setFill() + borderRect.fill() + } else { + let inset = style.selectionInset + let insetRect = bounds.insetBy(dx: inset, dy: inset > 0 ? 1 : 0) + let path = NSBezierPath(roundedRect: insetRect, xRadius: style.selectionRadius, yRadius: style.selectionRadius) + style.selectionColor.setFill() + path.fill() + } + } + + override var interiorBackgroundStyle: NSView.BackgroundStyle { + isSelected ? .emphasized : .normal + } +} + + + diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 155f2e1237..effd8a3a47 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -63,6 +63,9 @@ enum KeyboardShortcutSettings { case splitBrowserRight case splitBrowserDown + // File Explorer + case toggleFileExplorer + // Panels case openBrowser case focusBrowserAddressBar @@ -125,6 +128,7 @@ enum KeyboardShortcutSettings { case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom") case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right") case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down") + case .toggleFileExplorer: return String(localized: "shortcut.toggleFileExplorer.label", defaultValue: "Toggle File Explorer") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back") @@ -232,6 +236,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false) case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) + case .toggleFileExplorer: + return StoredShortcut(key: "b", command: true, shift: false, option: true, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) case .focusBrowserAddressBar: diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index bca1640067..cb0a73194e 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -407,6 +407,7 @@ struct TitlebarControlsView: View { .accessibilityIdentifier("titlebarControl.newTab") .accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace")) .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) + } let paddedContent = content.padding(config.groupPadding) @@ -508,7 +509,7 @@ struct TitlebarControlsView: View { .offset(x: item.leftEdge, y: yOffset) } } - .animation(.easeInOut(duration: 0.14), value: shouldShowTitlebarShortcutHints) + .animation(.easeOut(duration: 0.12), value: shouldShowTitlebarShortcutHints) .transition(.opacity) .allowsHitTesting(false) } @@ -517,16 +518,8 @@ struct TitlebarControlsView: View { shortcut: StoredShortcut, config: TitlebarControlsStyleConfig ) -> some View { - Text(shortcut.displayString) - .font(.system(size: max(8, config.iconSize - 5), weight: .semibold, design: .rounded)) - .monospacedDigit() - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .foregroundColor(.primary) - .padding(.horizontal, 6) - .padding(.vertical, 2) + ShortcutHintPill(shortcut: shortcut, fontSize: max(8, config.iconSize - 5)) .frame(minHeight: titlebarShortcutHintHeight(for: config)) - .background(ShortcutHintPillBackground()) } @ViewBuilder @@ -579,7 +572,16 @@ enum TitlebarControlsVisibilityMode { @MainActor private final class TitlebarShortcutHintModifierMonitor: ObservableObject { - @Published private(set) var isModifierPressed = false + @Published private(set) var isModifierPressed = false { + didSet { + guard oldValue != isModifierPressed else { return } + NotificationCenter.default.post( + name: .titlebarShortcutHintsVisibilityChanged, + object: nil, + userInfo: ["visible": isModifierPressed] + ) + } + } private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? @@ -790,7 +792,6 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() } let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) } let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() } - hostingView = NonDraggableHostingView( rootView: TitlebarControlsView( notificationStore: notificationStore, @@ -1272,6 +1273,7 @@ final class UpdateTitlebarAccessoryController { } private func reattachIfPresentationModeChanged() { + let currentMode = WorkspacePresentationModeSettings.mode() guard currentMode != lastKnownPresentationMode else { return } lastKnownPresentationMode = currentMode @@ -1349,10 +1351,7 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) - // Don't remove accessories in minimal mode. TitlebarControlsAccessoryViewController - // hides itself and zeros its frame via its own UserDefaults observer. Keeping it - // attached avoids fragile remove/re-add cycles on mode toggle. - + // Don't re-attach controls if already attached. guard !attachedWindows.contains(window) else { return } if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { @@ -1378,7 +1377,8 @@ final class UpdateTitlebarAccessoryController { private func removeAccessoryIfPresent(from window: NSWindow) { let matchingIndices = window.titlebarAccessoryViewControllers.indices.reversed().filter { index in - window.titlebarAccessoryViewControllers[index].view.identifier == controlsIdentifier + let id = window.titlebarAccessoryViewControllers[index].view.identifier + return id == controlsIdentifier } guard !matchingIndices.isEmpty || attachedWindows.contains(window) else { return } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index bdfc11798e..321691e487 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -139,6 +139,7 @@ struct cmuxApp: App { @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var sidebarState = SidebarState() @StateObject private var sidebarSelectionState = SidebarSelectionState() + @StateObject private var fileExplorerState = FileExplorerState() @StateObject private var cmuxConfigStore = CmuxConfigStore() @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let primaryWindowId = UUID() @@ -323,6 +324,7 @@ struct cmuxApp: App { .environmentObject(notificationStore) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) .environmentObject(cmuxConfigStore) .onAppear { #if DEBUG @@ -333,6 +335,7 @@ struct cmuxApp: App { // Start the Unix socket controller for programmatic access updateSocketController() appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + appDelegate.fileExplorerState = fileExplorerState cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) cmuxConfigStore.loadAll() applyAppearance() @@ -494,6 +497,9 @@ struct cmuxApp: App { Button("Split Button Layout Debug…") { SplitButtonLayoutDebugWindowController.shared.show() } + Button("File Explorer Style Debug…") { + FileExplorerStyleDebugWindowController.shared.show() + } Button("Open All Debug Windows") { openAllDebugWindows() } @@ -2635,6 +2641,117 @@ enum SettingsNavigationRequest { } } +// MARK: - File Explorer Style Debug + +private struct FileExplorerStyleDebugView: View { + @AppStorage("fileExplorer.style") private var styleRawValue: Int = 0 + + private var currentStyle: FileExplorerStyle { + FileExplorerStyle(rawValue: styleRawValue) ?? .liquidGlass + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("File Explorer Style") + .font(.headline) + + ForEach(FileExplorerStyle.allCases, id: \.rawValue) { style in + HStack(spacing: 8) { + Button(action: { + styleRawValue = style.rawValue + // Post notification so outline view reloads with new style + NotificationCenter.default.post(name: .fileExplorerStyleDidChange, object: nil) + }) { + HStack(spacing: 8) { + Image(systemName: styleRawValue == style.rawValue ? "checkmark.circle.fill" : "circle") + .foregroundColor(styleRawValue == style.rawValue ? .accentColor : .secondary) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(style.label) + .font(.system(size: 13, weight: .medium)) + Text(styleDescription(style)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(styleRawValue == style.rawValue + ? Color.accentColor.opacity(0.1) + : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 4) { + Text("Current: \(currentStyle.label)") + .font(.system(size: 11, weight: .medium)) + Text("Row: \(Int(currentStyle.rowHeight))pt, Indent: \(Int(currentStyle.indentation))pt, Icon: \(Int(currentStyle.iconSize))pt") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(16) + .frame(width: 320) + } + + private func styleDescription(_ style: FileExplorerStyle) -> String { + switch style { + case .liquidGlass: return "Modern macOS, vibrancy, rounded selections" + case .highDensity: return "VS Code, compact rows, edge-to-edge" + case .terminalStealth: return "Monospace, border selection, desaturated" + case .proStudio: return "Logic Pro, chunky rows, pill selection" + case .finder: return "Finder sidebar, filled icons, hover tint" + } + } +} + +extension Notification.Name { + static let fileExplorerStyleDidChange = Notification.Name("fileExplorerStyleDidChange") + static let titlebarShortcutHintsVisibilityChanged = Notification.Name("titlebarShortcutHintsVisibilityChanged") +} + +private final class FileExplorerStyleDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = FileExplorerStyleDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 380), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "File Explorer Style" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.fileExplorerStyleDebug") + window.center() + window.contentView = NSHostingView(rootView: FileExplorerStyleDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { static let shared = SidebarDebugWindowController() diff --git a/cmuxTests/FileExplorerRootResolverTests.swift b/cmuxTests/FileExplorerRootResolverTests.swift new file mode 100644 index 0000000000..40c00f36dd --- /dev/null +++ b/cmuxTests/FileExplorerRootResolverTests.swift @@ -0,0 +1,115 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class FileExplorerRootResolverTests: XCTestCase { + + // MARK: - Local home paths + + func testHomeDirectoryDisplaysAsTilde() { + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alice", + homePath: "/Users/alice" + ) + XCTAssertEqual(result, "~") + } + + func testSubdirectoryOfHomeDisplaysWithTilde() { + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alice/Projects/myapp", + homePath: "/Users/alice" + ) + XCTAssertEqual(result, "~/Projects/myapp") + } + + func testNonHomePathDisplaysVerbatim() { + let result = FileExplorerRootResolver.displayPath( + for: "/var/log", + homePath: "/Users/alice" + ) + XCTAssertEqual(result, "/var/log") + } + + func testNilHomePathReturnsFullPath() { + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alice/Documents", + homePath: nil + ) + XCTAssertEqual(result, "/Users/alice/Documents") + } + + func testEmptyHomePathReturnsFullPath() { + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alice/Documents", + homePath: "" + ) + XCTAssertEqual(result, "/Users/alice/Documents") + } + + // MARK: - SSH home paths + + func testSSHHomePathDisplaysAsTilde() { + let result = FileExplorerRootResolver.displayPath( + for: "/home/deploy", + homePath: "/home/deploy" + ) + XCTAssertEqual(result, "~") + } + + func testSSHSubdirectoryDisplaysWithTilde() { + let result = FileExplorerRootResolver.displayPath( + for: "/home/deploy/app/src", + homePath: "/home/deploy" + ) + XCTAssertEqual(result, "~/app/src") + } + + func testSSHRootPathDisplaysVerbatim() { + let result = FileExplorerRootResolver.displayPath( + for: "/etc/nginx", + homePath: "/root" + ) + XCTAssertEqual(result, "/etc/nginx") + } + + // MARK: - Trailing slash normalization + + func testTrailingSlashOnHomeIsNormalized() { + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alice/", + homePath: "/Users/alice/" + ) + XCTAssertEqual(result, "~") + } + + func testTrailingSlashOnPathIsNormalized() { + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alice/Documents/", + homePath: "/Users/alice" + ) + XCTAssertEqual(result, "~/Documents") + } + + // MARK: - Edge cases + + func testSimilarPrefixDoesNotMatch() { + // "/Users/alicex" should NOT match home="/Users/alice" + let result = FileExplorerRootResolver.displayPath( + for: "/Users/alicex/Documents", + homePath: "/Users/alice" + ) + XCTAssertEqual(result, "/Users/alicex/Documents") + } + + func testEmptyPathReturnsEmpty() { + let result = FileExplorerRootResolver.displayPath( + for: "", + homePath: "/Users/alice" + ) + XCTAssertEqual(result, "") + } +} diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift new file mode 100644 index 0000000000..b823a43573 --- /dev/null +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -0,0 +1,263 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +// MARK: - Mock Provider + +private final class MockFileExplorerProvider: FileExplorerProvider { + var homePath: String + var isAvailable: Bool + var listings: [String: Result<[FileExplorerEntry], Error>] = [:] + var listCallCount = 0 + var listCallPaths: [String] = [] + /// Optional delay (seconds) before returning results + var delay: TimeInterval = 0 + + init(homePath: String = "/home/user", isAvailable: Bool = true) { + self.homePath = homePath + self.isAvailable = isAvailable + } + + func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] { + listCallCount += 1 + listCallPaths.append(path) + + if delay > 0 { + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + + guard isAvailable else { + throw FileExplorerError.providerUnavailable + } + + if let result = listings[path] { + return try result.get() + } + return [] + } +} + +// MARK: - Store Tests + +final class FileExplorerStoreTests: XCTestCase { + + // MARK: - Basic loading + + func testLoadRootPopulatesNodes() async throws { + let provider = MockFileExplorerProvider() + provider.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "src", path: "/home/user/project/src", isDirectory: true), + FileExplorerEntry(name: "README.md", path: "/home/user/project/README.md", isDirectory: false), + ]) + + let store = FileExplorerStore() + store.setProvider(provider) + store.setRootPath("/home/user/project") + + // Wait for async load + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(store.rootNodes.count, 2) + // Directories should sort before files + XCTAssertEqual(store.rootNodes[0].name, "src") + XCTAssertTrue(store.rootNodes[0].isDirectory) + XCTAssertEqual(store.rootNodes[1].name, "README.md") + XCTAssertFalse(store.rootNodes[1].isDirectory) + } + + func testDisplayRootPathUsesTilde() { + let provider = MockFileExplorerProvider(homePath: "/home/user") + let store = FileExplorerStore() + store.setProvider(provider) + store.rootPath = "/home/user/project" + XCTAssertEqual(store.displayRootPath, "~/project") + } + + // MARK: - Expansion state persistence + + func testExpandedPathsPersistAcrossProviderChange() async throws { + let provider1 = MockFileExplorerProvider() + provider1.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "src", path: "/home/user/project/src", isDirectory: true), + ]) + provider1.listings["/home/user/project/src"] = .success([ + FileExplorerEntry(name: "main.swift", path: "/home/user/project/src/main.swift", isDirectory: false), + ]) + + let store = FileExplorerStore() + store.setProvider(provider1) + store.setRootPath("/home/user/project") + try await Task.sleep(nanoseconds: 100_000_000) + + // Expand src/ + let srcNode = store.rootNodes.first { $0.name == "src" }! + store.expand(node: srcNode) + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(store.expandedPaths.contains("/home/user/project/src")) + + // Switch to a new provider (simulating provider recreation) + let provider2 = MockFileExplorerProvider() + provider2.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "src", path: "/home/user/project/src", isDirectory: true), + ]) + provider2.listings["/home/user/project/src"] = .success([ + FileExplorerEntry(name: "main.swift", path: "/home/user/project/src/main.swift", isDirectory: false), + FileExplorerEntry(name: "lib.swift", path: "/home/user/project/src/lib.swift", isDirectory: false), + ]) + store.setProvider(provider2) + try await Task.sleep(nanoseconds: 200_000_000) + + // Expanded paths should still be tracked + XCTAssertTrue(store.expandedPaths.contains("/home/user/project/src")) + + // The src node should have been auto-expanded with the new provider's data + let newSrcNode = store.rootNodes.first { $0.name == "src" } + XCTAssertNotNil(newSrcNode) + XCTAssertNotNil(newSrcNode?.children) + XCTAssertEqual(newSrcNode?.children?.count, 2) + } + + // MARK: - SSH hydration + + func testExpandedRemoteNodesHydrateWhenProviderBecomesAvailable() async throws { + // Start with unavailable provider + let provider = MockFileExplorerProvider(isAvailable: false) + + let store = FileExplorerStore() + store.setProvider(provider) + store.setRootPath("/home/user/project") + try await Task.sleep(nanoseconds: 100_000_000) + + // Root load fails because provider unavailable + XCTAssertTrue(store.rootNodes.isEmpty) + + // Manually track expanded state (user expanded before provider was ready) + store.expand(node: FileExplorerNode(name: "src", path: "/home/user/project/src", isDirectory: true)) + XCTAssertTrue(store.expandedPaths.contains("/home/user/project/src")) + + // Provider becomes available + provider.isAvailable = true + provider.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "src", path: "/home/user/project/src", isDirectory: true), + ]) + provider.listings["/home/user/project/src"] = .success([ + FileExplorerEntry(name: "app.swift", path: "/home/user/project/src/app.swift", isDirectory: false), + ]) + + store.hydrateExpandedNodes() + try await Task.sleep(nanoseconds: 200_000_000) + + // Root should now be loaded + XCTAssertFalse(store.rootNodes.isEmpty) + let srcNode = store.rootNodes.first { $0.name == "src" } + XCTAssertNotNil(srcNode) + // Since src was in expandedPaths, it should have been auto-expanded + XCTAssertNotNil(srcNode?.children) + XCTAssertEqual(srcNode?.children?.count, 1) + XCTAssertEqual(srcNode?.children?.first?.name, "app.swift") + } + + func testExpandedNodesSurviveStoreRecreation() async throws { + // Simulate: user expands nodes, then store/provider is recreated (e.g., workspace reconnect) + let provider = MockFileExplorerProvider() + provider.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "lib", path: "/home/user/project/lib", isDirectory: true), + ]) + provider.listings["/home/user/project/lib"] = .success([ + FileExplorerEntry(name: "utils.swift", path: "/home/user/project/lib/utils.swift", isDirectory: false), + ]) + + let store = FileExplorerStore() + store.setProvider(provider) + store.setRootPath("/home/user/project") + try await Task.sleep(nanoseconds: 100_000_000) + + let libNode = store.rootNodes.first { $0.name == "lib" }! + store.expand(node: libNode) + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(store.isExpanded(libNode)) + + // Simulate provider recreation: clear children, reload with new provider + let newProvider = MockFileExplorerProvider() + newProvider.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "lib", path: "/home/user/project/lib", isDirectory: true), + ]) + newProvider.listings["/home/user/project/lib"] = .success([ + FileExplorerEntry(name: "utils.swift", path: "/home/user/project/lib/utils.swift", isDirectory: false), + FileExplorerEntry(name: "helpers.swift", path: "/home/user/project/lib/helpers.swift", isDirectory: false), + ]) + + store.setProvider(newProvider) + try await Task.sleep(nanoseconds: 200_000_000) + + // Expanded path should survive + XCTAssertTrue(store.expandedPaths.contains("/home/user/project/lib")) + let newLibNode = store.rootNodes.first { $0.name == "lib" } + XCTAssertNotNil(newLibNode?.children) + // Should have the new provider's data + XCTAssertEqual(newLibNode?.children?.count, 2) + } + + // MARK: - Error clearing + + func testStaleErrorClearsOnRetry() async throws { + let provider = MockFileExplorerProvider() + provider.listings["/home/user/project"] = .success([ + FileExplorerEntry(name: "src", path: "/home/user/project/src", isDirectory: true), + ]) + // src listing fails + provider.listings["/home/user/project/src"] = .failure( + FileExplorerError.sshCommandFailed("connection reset") + ) + + let store = FileExplorerStore() + store.setProvider(provider) + store.setRootPath("/home/user/project") + try await Task.sleep(nanoseconds: 100_000_000) + + let srcNode = store.rootNodes.first { $0.name == "src" }! + store.expand(node: srcNode) + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNotNil(srcNode.error) + + // Fix the listing and retry + provider.listings["/home/user/project/src"] = .success([ + FileExplorerEntry(name: "main.swift", path: "/home/user/project/src/main.swift", isDirectory: false), + ]) + // Collapse then re-expand to trigger retry + store.collapse(node: srcNode) + store.expand(node: srcNode) + try await Task.sleep(nanoseconds: 100_000_000) + + // Error should be cleared + XCTAssertNil(srcNode.error) + XCTAssertNotNil(srcNode.children) + } + + // MARK: - Collapse/Expand + + func testCollapseRemovesFromExpandedPaths() { + let store = FileExplorerStore() + let node = FileExplorerNode(name: "src", path: "/project/src", isDirectory: true) + node.children = [] + store.expand(node: node) + XCTAssertTrue(store.isExpanded(node)) + + store.collapse(node: node) + XCTAssertFalse(store.isExpanded(node)) + } + + func testExpandNonDirectoryDoesNothing() { + let store = FileExplorerStore() + let node = FileExplorerNode(name: "file.txt", path: "/project/file.txt", isDirectory: false) + store.expand(node: node) + XCTAssertFalse(store.isExpanded(node)) + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index 2979ef647d..098d9fa00e 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1 +Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f