From 84dc4ea8c9de29d08dd2433d5e5e35e7c4d1ea8a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 17:49:45 -0700 Subject: [PATCH 1/8] Add Finder-like file explorer sidebar with SSH support Adds a right-side file explorer panel toggled via Cmd-Shift-E or a titlebar button. Uses native NSOutlineView for Finder-like disclosure, rounded row selection, alternating backgrounds, and 13pt medium text. Local workspaces use FileManager, SSH workspaces use ssh commands via the existing connection. Root paths display with ~ for home-relative paths. Expanded nodes persist across provider changes so SSH nodes re-hydrate when the connection becomes available. Includes configurable keyboard shortcut (KeyboardShortcutSettings), localized strings (EN/JA), and unit tests for path resolution and store hydration behavior. --- GhosttyTabs.xcodeproj/project.pbxproj | 16 + Resources/Localizable.xcstrings | 85 +++ Sources/AppDelegate.swift | 9 + Sources/ContentView.swift | 70 ++- Sources/FileExplorerStore.swift | 422 ++++++++++++++ Sources/FileExplorerView.swift | 533 ++++++++++++++++++ Sources/KeyboardShortcutSettings.swift | 7 + Sources/Update/UpdateTitlebarAccessory.swift | 13 +- Sources/cmuxApp.swift | 3 + cmuxTests/FileExplorerRootResolverTests.swift | 115 ++++ cmuxTests/FileExplorerStoreTests.swift | 263 +++++++++ 11 files changed, 1532 insertions(+), 4 deletions(-) create mode 100644 Sources/FileExplorerStore.swift create mode 100644 Sources/FileExplorerView.swift create mode 100644 cmuxTests/FileExplorerRootResolverTests.swift create mode 100644 cmuxTests/FileExplorerStoreTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 76ea66de06..97abbec9ef 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 */; }; @@ -180,6 +184,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; }; @@ -469,6 +477,8 @@ A5001651 /* CmuxConfig.swift */, A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxDirectoryTrust.swift */, + FE001001 /* FileExplorerStore.swift */, + FE001002 /* FileExplorerView.swift */, ); path = Sources; sourceTree = ""; @@ -559,6 +569,8 @@ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, + FE002001 /* FileExplorerRootResolverTests.swift */, + FE002002 /* FileExplorerStoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -768,6 +780,8 @@ A5001650 /* CmuxConfig.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; }; @@ -826,6 +840,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 14de005d46..a47e115ebe 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63638,6 +63638,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 41a57562de..4983bfbb7d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2018,6 +2018,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:) @@ -5881,11 +5882,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 @@ -9447,6 +9451,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleFileExplorer)) { + fileExplorerState?.toggle() + return true + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .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 9065d59717..6e8f5aedc2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1554,6 +1554,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 @@ -1565,6 +1566,7 @@ 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 previousSelectedWorkspaceId: UUID? @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffGeneration: UInt64 = 0 @@ -2413,10 +2415,17 @@ struct ContentView: View { } private var terminalContentWithSidebarDropOverlay: some View { - terminalContent - .overlay { - SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) + HStack(spacing: 0) { + terminalContent + .overlay { + SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) + } + if fileExplorerState.isVisible { + Divider() + FileExplorerView(store: fileExplorerStore, state: fileExplorerState) + .frame(width: fileExplorerState.width) } + } } @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @@ -2569,6 +2578,59 @@ 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 } + + 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 + // Extract home from remote path context + // For SSH, try to derive home from the working directory if it starts with /home/ + 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 let existingProvider = fileExplorerStore.provider as? SSHFileExplorerProvider, + existingProvider.destination == config?.destination { + existingProvider.updateAvailability(isReady, homePath: isReady ? homePath : nil) + if isReady { + fileExplorerStore.setRootPath(dir) + 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 { @@ -2657,6 +2719,7 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) updateTitlebarText() + syncFileExplorerDirectory() // Startup recovery (#399): if session restore or a race condition leaves the // view in a broken state (empty tabs, no selection, unmounted workspaces), @@ -2726,6 +2789,7 @@ struct ContentView: View { lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue } } updateTitlebarText() + syncFileExplorerDirectory() }) view = AnyView(view.onChange(of: selectedTabIds) { _ in diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift new file mode 100644 index 0000000000..0effee2555 --- /dev/null +++ b/Sources/FileExplorerStore.swift @@ -0,0 +1,422 @@ +import AppKit +import Combine +import Foundation + +// 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) 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) async throws -> [FileExplorerEntry] { + let fm = FileManager.default + let contents = try fm.contentsOfDirectory(atPath: path) + return contents.compactMap { name in + guard !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) 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 + ) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func runSSHListCommand( + path: String, destination: String, port: Int?, + identityFile: String?, sshOptions: [String] + ) 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: "'\\''") + args += [destination, "ls -1paF '\(escapedPath)' 2>/dev/null"] + + process.arguments = args + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrData = errPipe.fileHandleForReading.readDataToEndOfFile() + let stderrStr = String(data: stderrData, encoding: .utf8) ?? "" + throw FileExplorerError.sshCommandFailed(stderrStr) + } + + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + 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 } + // Skip hidden files + let isDir = entry.hasSuffix("/") + let name = isDir ? String(entry.dropLast()) : entry + guard !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 = false + @Published var width: CGFloat = 220 + + func toggle() { + isVisible.toggle() + } +} + +// 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 + + var provider: FileExplorerProvider? + + /// 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 { return } + rootPath = path + reload() + } + + func setProvider(_ newProvider: FileExplorerProvider?) { + provider = newProvider + // Re-expand previously expanded nodes if provider becomes available + if newProvider?.isAvailable == true { + reload() + } + } + + func reload() { + 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 else { return } + // Reload root + reload() + // After root loads, re-expand previously expanded paths + // The expansion happens in loadChildren when it detects expanded paths + } + + // 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) + 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 + } +} diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift new file mode 100644 index 0000000000..fa79686cae --- /dev/null +++ b/Sources/FileExplorerView.swift @@ -0,0 +1,533 @@ +import AppKit +import Bonsplit +import Combine +import SwiftUI + +// MARK: - Container View + +struct FileExplorerView: View { + @ObservedObject var store: FileExplorerStore + @ObservedObject var state: FileExplorerState + + var body: some View { + VStack(spacing: 0) { + if store.rootPath.isEmpty { + emptyState + } else { + fileTree + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.5)) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "folder") + .font(.system(size: 28)) + .foregroundColor(.secondary) + Text(String(localized: "fileExplorer.empty", defaultValue: "No folder open")) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var fileTree: some View { + VStack(alignment: .leading, spacing: 0) { + rootPathHeader + if store.isRootLoading { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + FileExplorerOutlineView(store: store) + } + } + } + + private var rootPathHeader: some View { + HStack(spacing: 4) { + Image(systemName: "folder.fill") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Text(store.displayRootPath) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} + +// MARK: - NSOutlineView Wrapper + +struct FileExplorerOutlineView: NSViewRepresentable { + @ObservedObject var store: FileExplorerStore + + func makeCoordinator() -> Coordinator { + Coordinator(store: store) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + + let outlineView = NSOutlineView() + outlineView.headerView = nil + outlineView.usesAlternatingRowBackgroundColors = true + outlineView.style = .sourceList + outlineView.selectionHighlightStyle = .sourceList + outlineView.rowSizeStyle = .default + outlineView.indentationPerLevel = 16 + outlineView.autoresizesOutlineColumn = true + outlineView.floatsGroupRows = false + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + column.isEditable = false + column.resizingMask = .autoresizingMask + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + + scrollView.documentView = outlineView + context.coordinator.outlineView = outlineView + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.store = store + context.coordinator.reloadIfNeeded() + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + var store: FileExplorerStore + weak var outlineView: NSOutlineView? + private var lastRootNodeCount: Int = -1 + private var observationCancellable: AnyCancellable? + + init(store: FileExplorerStore) { + self.store = store + super.init() + observeStore() + } + + private func observeStore() { + observationCancellable = store.objectWillChange + .debounce(for: .milliseconds(50), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.reloadIfNeeded() + } + } + + func reloadIfNeeded() { + guard let outlineView else { return } + let newCount = store.rootNodes.count + if newCount != lastRootNodeCount { + lastRootNodeCount = newCount + let expandedPaths = store.expandedPaths + outlineView.reloadData() + restoreExpansionState(expandedPaths, in: outlineView) + } else { + refreshLoadedNodes(in: outlineView) + } + } + + private func restoreExpansionState(_ expandedPaths: Set, 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) + } + + cellView.configure(with: node) + cellView.onHover = { [weak self] isHovering in + guard let self else { return } + if isHovering { + Task { @MainActor in + self.store.prefetchChildren(for: node) + } + } else { + Task { @MainActor in + 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 } + Task { @MainActor in + 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 } + Task { @MainActor in + store.collapse(node: node) + } + return true + } + + func outlineViewItemDidExpand(_ notification: Notification) { + guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } + Task { @MainActor in + if !store.isExpanded(node) { + store.expand(node: node) + } + } + } + + func outlineViewItemDidCollapse(_ notification: Notification) { + guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } + Task { @MainActor in + 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 { + 22 + } + } +} + +// 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 func setupViews() { + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.imageScaling = .scaleProportionallyDown + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.font = .systemFont(ofSize: 13, weight: .medium) + 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) + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 16), + iconView.heightAnchor.constraint(equalToConstant: 16), + + nameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4), + 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) { + nameLabel.stringValue = node.name + + if node.isDirectory { + let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) + iconView.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(config) + iconView.contentTintColor = .systemBlue + } else { + let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) + iconView.image = NSImage(systemSymbolName: "doc", accessibilityDescription: nil)? + .withSymbolConfiguration(config) + iconView.contentTintColor = .secondaryLabelColor + } + + 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 { + 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: - Row View (Finder-like rounded inset) + +final class FileExplorerRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + let insetRect = bounds.insetBy(dx: 4, dy: 1) + let path = NSBezierPath(roundedRect: insetRect, xRadius: 4, yRadius: 4) + NSColor.controlAccentColor.withAlphaComponent(0.2).setFill() + path.fill() + } + + override var interiorBackgroundStyle: NSView.BackgroundStyle { + isSelected ? .emphasized : .normal + } +} + +// MARK: - Right Titlebar Toggle Button + +struct FileExplorerTitlebarButton: View { + let onToggle: () -> Void + let config: TitlebarControlsStyleConfig + @State private var isHovering = false + + var body: some View { + TitlebarControlButton(config: config, action: { + #if DEBUG + dlog("titlebar.toggleFileExplorer") + #endif + onToggle() + }) { + Image(systemName: "sidebar.right") + .font(.system(size: config.iconSize)) + .frame(width: config.buttonSize, height: config.buttonSize) + } + .accessibilityIdentifier("titlebarControl.toggleFileExplorer") + .accessibilityLabel(String(localized: "titlebar.fileExplorer.accessibilityLabel", defaultValue: "Toggle File Explorer")) + .safeHelp(KeyboardShortcutSettings.Action.toggleFileExplorer.tooltip( + String(localized: "titlebar.fileExplorer.tooltip", defaultValue: "Show or hide the file explorer") + )) + } +} + +// MARK: - Right Titlebar Accessory ViewController + +final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryViewController { + private let hostingView: NonDraggableHostingView + private let containerView = NSView() + private var pendingSizeUpdate = false + private var fittingSizeNeedsRefresh = true + private var cachedFittingSize: NSSize? + private var lastObservedViewSize: NSSize = .zero + private var showsWorkspaceTitlebar: Bool { !WorkspacePresentationModeSettings.isMinimal() } + + init(onToggle: @escaping () -> Void) { + let style = TitlebarControlsStyle(rawValue: UserDefaults.standard.integer(forKey: "titlebarControlsStyle")) ?? .classic + hostingView = NonDraggableHostingView( + rootView: FileExplorerTitlebarButton( + onToggle: onToggle, + config: style.config + ) + ) + + super.init(nibName: nil, bundle: nil) + + view = containerView + containerView.translatesAutoresizingMaskIntoConstraints = true + containerView.wantsLayer = true + containerView.layer?.masksToBounds = false + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.autoresizingMask = [.width, .height] + containerView.addSubview(hostingView) + + scheduleSizeUpdate(invalidateFittingSize: true) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear() { + super.viewDidAppear() + scheduleSizeUpdate(invalidateFittingSize: true) + } + + override func viewDidLayout() { + super.viewDidLayout() + let currentViewSize = view.bounds.size + guard abs(currentViewSize.width - lastObservedViewSize.width) > 0.5 + || abs(currentViewSize.height - lastObservedViewSize.height) > 0.5 else { + return + } + lastObservedViewSize = currentViewSize + scheduleSizeUpdate(invalidateFittingSize: true) + } + + private func scheduleSizeUpdate(invalidateFittingSize: Bool = false) { + if invalidateFittingSize { + fittingSizeNeedsRefresh = true + } + guard !pendingSizeUpdate else { return } + pendingSizeUpdate = true + DispatchQueue.main.async { [weak self] in + self?.pendingSizeUpdate = false + self?.updateSize() + } + } + + private func updateSize() { + guard showsWorkspaceTitlebar else { + view.isHidden = true + preferredContentSize = .zero + containerView.frame = .zero + hostingView.frame = .zero + return + } + view.isHidden = false + + let contentSize: NSSize + if fittingSizeNeedsRefresh || cachedFittingSize == nil { + hostingView.invalidateIntrinsicContentSize() + hostingView.layoutSubtreeIfNeeded() + cachedFittingSize = hostingView.fittingSize + fittingSizeNeedsRefresh = false + } + contentSize = cachedFittingSize ?? .zero + guard contentSize.width > 0, contentSize.height > 0 else { return } + + let titlebarHeight: CGFloat = { + if let window = view.window, + let closeButton = window.standardWindowButton(.closeButton), + let titlebarView = closeButton.superview, + titlebarView.frame.height > 0 { + return titlebarView.frame.height + } + return view.window.map { $0.frame.height - $0.contentLayoutRect.height } ?? contentSize.height + }() + + let containerHeight = max(contentSize.height, titlebarHeight) + let yOffset = max(0, (containerHeight - contentSize.height) / 2.0) + preferredContentSize = NSSize(width: contentSize.width + 8, height: containerHeight) + containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width + 8, height: containerHeight) + hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) + } +} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index b044c5c76f..e73fceb037 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -42,6 +42,9 @@ enum KeyboardShortcutSettings { case splitBrowserRight case splitBrowserDown + // File Explorer + case toggleFileExplorer + // Panels case openBrowser case toggleBrowserDeveloperTools @@ -80,6 +83,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 .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") @@ -117,6 +121,7 @@ enum KeyboardShortcutSettings { case .selectSurfaceByNumber: return "shortcut.selectSurfaceByNumber" case .newSurface: return "shortcut.newSurface" case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode" + case .toggleFileExplorer: return "shortcut.toggleFileExplorer" case .openBrowser: return "shortcut.openBrowser" case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" @@ -183,6 +188,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: "e", command: true, shift: true, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) case .toggleBrowserDeveloperTools: diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 344b3a2bba..61d7cd5b2f 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1201,6 +1201,7 @@ final class UpdateTitlebarAccessoryController { private var pendingAttachRetries: [ObjectIdentifier: Int] = [:] private var startupScanWorkItems: [DispatchWorkItem] = [] private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls") + private let fileExplorerIdentifier = NSUserInterfaceItemIdentifier("cmux.fileExplorerToggle") private let controlsControllers = NSHashTable.weakObjects() init(viewModel: UpdateViewModel) { @@ -1325,6 +1326,15 @@ final class UpdateTitlebarAccessoryController { controlsControllers.add(controls) } + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == fileExplorerIdentifier }) { + let toggle = FileExplorerTitlebarAccessoryViewController(onToggle: { + AppDelegate.shared?.fileExplorerState?.toggle() + }) + toggle.layoutAttribute = .trailing + toggle.view.identifier = fileExplorerIdentifier + window.addTitlebarAccessoryViewController(toggle) + } + attachedWindows.add(window) #if DEBUG @@ -1338,7 +1348,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 || id == fileExplorerIdentifier } guard !matchingIndices.isEmpty || attachedWindows.contains(window) else { return } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f0de3cf012..416f3291e8 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() private let primaryWindowId = UUID() @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @@ -338,6 +339,7 @@ struct cmuxApp: App { .environmentObject(notificationStore) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) .environmentObject(cmuxConfigStore) .onAppear { #if DEBUG @@ -348,6 +350,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() 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..18866e7ceb --- /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) 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)) + } +} From e3ac7d679d6c761f28f7b34e1c2b934c806abe78 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 19:15:24 -0700 Subject: [PATCH 2/8] Fix titlebar accessory constraint loop crash The FileExplorerTitlebarAccessoryViewController triggered an infinite Auto Layout loop by calling invalidateIntrinsicContentSize during viewDidLayout, causing NSGenericException. Replaced with a one-shot size computation in init + a single re-measure in viewDidAppear. --- Sources/FileExplorerView.swift | 82 +++++++++------------------------- 1 file changed, 22 insertions(+), 60 deletions(-) diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index fa79686cae..d91117d2dd 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -434,11 +434,7 @@ struct FileExplorerTitlebarButton: View { final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryViewController { private let hostingView: NonDraggableHostingView private let containerView = NSView() - private var pendingSizeUpdate = false - private var fittingSizeNeedsRefresh = true - private var cachedFittingSize: NSSize? - private var lastObservedViewSize: NSSize = .zero - private var showsWorkspaceTitlebar: Bool { !WorkspacePresentationModeSettings.isMinimal() } + private var didInitialLayout = false init(onToggle: @escaping () -> Void) { let style = TitlebarControlsStyle(rawValue: UserDefaults.standard.integer(forKey: "titlebarControlsStyle")) ?? .classic @@ -456,10 +452,17 @@ final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryView containerView.wantsLayer = true containerView.layer?.masksToBounds = false hostingView.translatesAutoresizingMaskIntoConstraints = true - hostingView.autoresizingMask = [.width, .height] containerView.addSubview(hostingView) - scheduleSizeUpdate(invalidateFittingSize: true) + // Compute initial size once from the hosting view's fitting size + hostingView.layoutSubtreeIfNeeded() + let fitting = hostingView.fittingSize + let width = fitting.width + 8 + let height = max(fitting.height, 28) + preferredContentSize = NSSize(width: width, height: height) + containerView.frame = NSRect(x: 0, y: 0, width: width, height: height) + let yOffset = max(0, (height - fitting.height) / 2.0) + hostingView.frame = NSRect(x: 0, y: yOffset, width: fitting.width, height: fitting.height) } required init?(coder: NSCoder) { @@ -468,52 +471,11 @@ final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryView override func viewDidAppear() { super.viewDidAppear() - scheduleSizeUpdate(invalidateFittingSize: true) - } - - override func viewDidLayout() { - super.viewDidLayout() - let currentViewSize = view.bounds.size - guard abs(currentViewSize.width - lastObservedViewSize.width) > 0.5 - || abs(currentViewSize.height - lastObservedViewSize.height) > 0.5 else { - return - } - lastObservedViewSize = currentViewSize - scheduleSizeUpdate(invalidateFittingSize: true) - } - - private func scheduleSizeUpdate(invalidateFittingSize: Bool = false) { - if invalidateFittingSize { - fittingSizeNeedsRefresh = true - } - guard !pendingSizeUpdate else { return } - pendingSizeUpdate = true - DispatchQueue.main.async { [weak self] in - self?.pendingSizeUpdate = false - self?.updateSize() - } - } - - private func updateSize() { - guard showsWorkspaceTitlebar else { - view.isHidden = true - preferredContentSize = .zero - containerView.frame = .zero - hostingView.frame = .zero - return - } - view.isHidden = false - - let contentSize: NSSize - if fittingSizeNeedsRefresh || cachedFittingSize == nil { - hostingView.invalidateIntrinsicContentSize() - hostingView.layoutSubtreeIfNeeded() - cachedFittingSize = hostingView.fittingSize - fittingSizeNeedsRefresh = false - } - contentSize = cachedFittingSize ?? .zero - guard contentSize.width > 0, contentSize.height > 0 else { return } - + guard !didInitialLayout else { return } + didInitialLayout = true + // Re-measure once after attached to window (titlebar height is now known) + let fitting = hostingView.fittingSize + guard fitting.width > 0, fitting.height > 0 else { return } let titlebarHeight: CGFloat = { if let window = view.window, let closeButton = window.standardWindowButton(.closeButton), @@ -521,13 +483,13 @@ final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryView titlebarView.frame.height > 0 { return titlebarView.frame.height } - return view.window.map { $0.frame.height - $0.contentLayoutRect.height } ?? contentSize.height + return fitting.height }() - - let containerHeight = max(contentSize.height, titlebarHeight) - let yOffset = max(0, (containerHeight - contentSize.height) / 2.0) - preferredContentSize = NSSize(width: contentSize.width + 8, height: containerHeight) - containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width + 8, height: containerHeight) - hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) + let containerHeight = max(fitting.height, titlebarHeight) + let yOffset = max(0, (containerHeight - fitting.height) / 2.0) + let width = fitting.width + 8 + preferredContentSize = NSSize(width: width, height: containerHeight) + containerView.frame = NSRect(x: 0, y: 0, width: width, height: containerHeight) + hostingView.frame = NSRect(x: 0, y: yOffset, width: fitting.width, height: fitting.height) } } From 165d31296009545622057287abf65d2821652d75 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 6 Apr 2026 21:55:21 -0700 Subject: [PATCH 3/8] Add file explorer features: git status, context menu, drag, hidden files, FS watching - Wire FileExplorerView into right panel with resize handle - Reactive CWD sync via Combine (no polling) - Git status colors (modified/added/deleted/renamed/untracked) for local and SSH - Context menu: Open in Default Editor, Reveal in Finder, Copy Path, Copy Relative Path - Drag file to terminal pastes path - Hidden files toggle (eye icon in header) - DispatchSource-based directory watching with 300ms debounce - Footer folder icon toggle button - Shortcut changed to Cmd+Option+B - Fix SSH pipe deadlock (read before waitUntilExit) - Fix SSH infinite reload loop (guard hydrateExpandedNodes on path change) - Debug logging for file explorer state changes --- Sources/ContentView.swift | 95 ++++++- Sources/FileExplorerStore.swift | 335 +++++++++++++++++++++++-- Sources/FileExplorerView.swift | 308 +++++++++++++++++++---- Sources/KeyboardShortcutSettings.swift | 2 +- cmuxTests/FileExplorerStoreTests.swift | 2 +- vendor/bonsplit | 2 +- 6 files changed, 667 insertions(+), 77 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b4f3dc82ce..15e30aeda8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2595,6 +2595,7 @@ struct ContentView: View { private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, + fileExplorerState: fileExplorerState, onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, @@ -2687,6 +2688,8 @@ struct ContentView: View { } } + @State private var fileExplorerDragStartWidth: CGFloat? + private var terminalContentWithSidebarDropOverlay: some View { HStack(spacing: 0) { terminalContent @@ -2697,10 +2700,41 @@ struct ContentView: View { Divider() FileExplorerView(store: fileExplorerStore, state: fileExplorerState) .frame(width: fileExplorerState.width) + .overlay(alignment: .leading) { + fileExplorerResizerOverlay + } } } } + private var fileExplorerResizerOverlay: some View { + Color.clear + .frame(width: 6) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + if fileExplorerDragStartWidth == nil { + fileExplorerDragStartWidth = fileExplorerState.width + } + guard let startWidth = fileExplorerDragStartWidth else { return } + // Dragging left = wider, dragging right = narrower + let newWidth = startWidth - value.translation.width + fileExplorerState.width = min(500, max(150, newWidth)) + } + .onEnded { _ in + fileExplorerDragStartWidth = nil + } + ) + } + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false @@ -2869,13 +2903,13 @@ struct ContentView: View { let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) guard !dir.isEmpty else { return } + fileExplorerStore.showHiddenFiles = fileExplorerState.showHiddenFiles + 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 - // Extract home from remote path context - // For SSH, try to derive home from the working directory if it starts with /home/ let components = dir.split(separator: "/") if components.count >= 2, components[0] == "home" { return "/home/\(components[1])" @@ -2886,12 +2920,20 @@ struct ContentView: View { 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) - fileExplorerStore.hydrateExpandedNodes() + if pathChanged { + fileExplorerStore.hydrateExpandedNodes() + } } } else if let config { let provider = SSHFileExplorerProvider( @@ -3075,13 +3117,30 @@ struct ContentView: View { lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue } } updateTitlebarText() - syncFileExplorerDirectory() }) view = AnyView(view.onChange(of: selectedTabIds) { _ in 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() { @@ -9952,6 +10011,7 @@ private final class SidebarTabItemSettingsStore: ObservableObject { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel + @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -10113,7 +10173,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") @@ -11056,13 +11116,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) @@ -11072,11 +11133,28 @@ private struct SidebarFooter: View { private struct SidebarFooterButtons: View { @ObservedObject var updateViewModel: UpdateViewModel + @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void var body: some View { HStack(spacing: 4) { SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + + Button(action: { fileExplorerState.toggle() }) { + Image(systemName: fileExplorerState.isVisible ? "folder.fill" : "folder") + .font(.system(size: 12)) + .foregroundStyle(fileExplorerState.isVisible ? Color.accentColor : Color(nsColor: .secondaryLabelColor)) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: 22, height: 22, alignment: .center) + .help(fileExplorerState.isVisible + ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") + : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) + .accessibilityLabel(fileExplorerState.isVisible + ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") + : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) + .accessibilityIdentifier("sidebarFooter.toggleFileExplorer") + UpdatePill(model: updateViewModel) } .frame(maxWidth: .infinity, alignment: .leading) @@ -12195,13 +12273,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)) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 0effee2555..e9ac924d3b 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -57,7 +57,7 @@ enum FileExplorerRootResolver { // MARK: - Provider Protocol protocol FileExplorerProvider: AnyObject { - func listDirectory(path: String) async throws -> [FileExplorerEntry] + func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] var homePath: String { get } var isAvailable: Bool { get } } @@ -68,11 +68,11 @@ final class LocalFileExplorerProvider: FileExplorerProvider { var homePath: String { NSHomeDirectory() } var isAvailable: Bool { true } - func listDirectory(path: String) async throws -> [FileExplorerEntry] { + 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 !name.hasPrefix(".") else { return nil } + 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 } @@ -114,7 +114,7 @@ final class SSHFileExplorerProvider: FileExplorerProvider { } } - func listDirectory(path: String) async throws -> [FileExplorerEntry] { + func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] { guard isAvailable else { throw FileExplorerError.providerUnavailable } @@ -128,7 +128,8 @@ final class SSHFileExplorerProvider: FileExplorerProvider { do { let result = try SSHFileExplorerProvider.runSSHListCommand( path: path, destination: dest, port: p, - identityFile: identity, sshOptions: opts + identityFile: identity, sshOptions: opts, + showHidden: showHidden ) continuation.resume(returning: result) } catch { @@ -140,7 +141,8 @@ final class SSHFileExplorerProvider: FileExplorerProvider { private static func runSSHListCommand( path: String, destination: String, port: Int?, - identityFile: String?, sshOptions: [String] + identityFile: String?, sshOptions: [String], + showHidden: Bool ) throws -> [FileExplorerEntry] { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") @@ -159,7 +161,8 @@ final class SSHFileExplorerProvider: FileExplorerProvider { args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"] // Escape single quotes in path for shell safety let escapedPath = path.replacingOccurrences(of: "'", with: "'\\''") - args += [destination, "ls -1paF '\(escapedPath)' 2>/dev/null"] + let lsFlags = showHidden ? "-1paFA" : "-1paF" + args += [destination, "ls \(lsFlags) '\(escapedPath)' 2>/dev/null"] process.arguments = args @@ -169,15 +172,15 @@ final class SSHFileExplorerProvider: FileExplorerProvider { 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 stderrData = errPipe.fileHandleForReading.readDataToEndOfFile() let stderrStr = String(data: stderrData, encoding: .utf8) ?? "" throw FileExplorerError.sshCommandFailed(stderrStr) } - - let data = outPipe.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { return [] } @@ -187,10 +190,9 @@ final class SSHFileExplorerProvider: FileExplorerProvider { let entry = String(line) // Skip . and .. entries guard entry != "./" && entry != "../" else { return nil } - // Skip hidden files let isDir = entry.hasSuffix("/") let name = isDir ? String(entry.dropLast()) : entry - guard !name.hasPrefix(".") else { return nil } + guard showHidden || !name.hasPrefix(".") else { return nil } // Strip type indicators from -F flag (*, @, =, |) for files let cleanName: String if !isDir, let last = name.last, "*@=|".contains(last) { @@ -221,8 +223,33 @@ enum FileExplorerError: LocalizedError { // MARK: - State (visibility toggle) final class FileExplorerState: ObservableObject { - @Published var isVisible: Bool = false - @Published var width: CGFloat = 220 + @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 + self.showHiddenFiles = defaults.bool(forKey: "fileExplorer.showHidden") + } func toggle() { isVisible.toggle() @@ -238,9 +265,16 @@ 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 = [] @@ -263,12 +297,69 @@ final class FileExplorerStore: ObservableObject { // MARK: - Public API func setRootPath(_ path: String) { - guard path != rootPath else { return } + 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 { @@ -277,6 +368,9 @@ final class FileExplorerStore: ObservableObject { } func reload() { + #if DEBUG + NSLog("[FileExplorer] reload() path=\(rootPath) provider=\(type(of: provider).self)") + #endif cancelAllLoads() rootNodes = [] nodesByPath = [:] @@ -339,11 +433,11 @@ final class FileExplorerStore: ObservableObject { /// Called when SSH provider becomes available after being unavailable. /// Re-hydrates expanded nodes that were waiting. func hydrateExpandedNodes() { - guard let provider, provider.isAvailable else { return } - // Reload root + guard let provider, provider.isAvailable, !expandedPaths.isEmpty else { return } + #if DEBUG + NSLog("[FileExplorer] hydrateExpandedNodes: \(expandedPaths.count) paths to hydrate") + #endif reload() - // After root loads, re-expand previously expanded paths - // The expansion happens in loadChildren when it detects expanded paths } // MARK: - Private @@ -359,7 +453,7 @@ final class FileExplorerStore: ObservableObject { } do { - let entries = try await provider.listDirectory(path: path) + 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 @@ -420,3 +514,204 @@ final class FileExplorerStore: ObservableObject { 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 index d91117d2dd..1113b22cea 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -3,6 +3,65 @@ import Bonsplit import Combine import SwiftUI +// MARK: - Right Panel Container + +/// Right-side panel that wraps the file explorer with a vertical divider and resize handle. +struct FileExplorerRightPanel: View { + @ObservedObject var store: FileExplorerStore + @ObservedObject var state: FileExplorerState + + @State private var isResizerHovered = false + @State private var isResizerDragging = false + @State private var dragStartWidth: CGFloat = 0 + + private let minWidth: CGFloat = 150 + private let maxWidth: CGFloat = 500 + private let resizerWidth: CGFloat = 6 + + var body: some View { + HStack(spacing: 0) { + // Vertical divider + resize handle + Rectangle() + .fill(isResizerDragging || isResizerHovered + ? Color.accentColor.opacity(0.5) + : Color(nsColor: .separatorColor)) + .frame(width: isResizerDragging || isResizerHovered ? 2 : 1) + .padding(.horizontal, (resizerWidth - 1) / 2) + .contentShape(Rectangle()) + .onHover { hovering in + isResizerHovered = hovering + if hovering { + NSCursor.resizeLeftRight.push() + } else if !isResizerDragging { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + if !isResizerDragging { + dragStartWidth = state.width + isResizerDragging = true + } + // Dragging left = wider panel, dragging right = narrower + let newWidth = dragStartWidth - value.translation.width + state.width = min(maxWidth, max(minWidth, newWidth)) + } + .onEnded { _ in + isResizerDragging = false + if !isResizerHovered { + NSCursor.pop() + } + } + ) + .accessibilityIdentifier("FileExplorerResizer") + + FileExplorerView(store: store, state: state) + .frame(width: state.width) + } + } +} + // MARK: - Container View struct FileExplorerView: View { @@ -18,7 +77,6 @@ struct FileExplorerView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor).opacity(0.5)) } private var emptyState: some View { @@ -57,6 +115,20 @@ struct FileExplorerView: View { .lineLimit(1) .truncationMode(.middle) Spacer() + + Button { + state.showHiddenFiles.toggle() + store.showHiddenFiles = state.showHiddenFiles + store.reload() + } label: { + Image(systemName: state.showHiddenFiles ? "eye" : "eye.slash") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help(state.showHiddenFiles + ? String(localized: "fileExplorer.hiddenFiles.hide", defaultValue: "Hide Hidden Files") + : String(localized: "fileExplorer.hiddenFiles.show", defaultValue: "Show Hidden Files")) } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -82,13 +154,14 @@ struct FileExplorerOutlineView: NSViewRepresentable { let outlineView = NSOutlineView() outlineView.headerView = nil - outlineView.usesAlternatingRowBackgroundColors = true - outlineView.style = .sourceList - outlineView.selectionHighlightStyle = .sourceList + outlineView.usesAlternatingRowBackgroundColors = false + outlineView.style = .plain + outlineView.selectionHighlightStyle = .regular outlineView.rowSizeStyle = .default outlineView.indentationPerLevel = 16 outlineView.autoresizesOutlineColumn = true outlineView.floatsGroupRows = false + outlineView.backgroundColor = .clear let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) column.isEditable = false @@ -99,6 +172,11 @@ struct FileExplorerOutlineView: NSViewRepresentable { outlineView.dataSource = context.coordinator outlineView.delegate = context.coordinator + // Context menu + let menu = NSMenu() + menu.delegate = context.coordinator + outlineView.menu = menu + scrollView.documentView = outlineView context.coordinator.outlineView = outlineView @@ -112,7 +190,7 @@ struct FileExplorerOutlineView: NSViewRepresentable { // MARK: - Coordinator - final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate, NSMenuDelegate { var store: FileExplorerStore weak var outlineView: NSOutlineView? private var lastRootNodeCount: Int = -1 @@ -215,7 +293,8 @@ struct FileExplorerOutlineView: NSViewRepresentable { cellView = FileExplorerCellView(identifier: identifier) } - cellView.configure(with: node) + 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 { @@ -273,6 +352,99 @@ struct FileExplorerOutlineView: NSViewRepresentable { func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { 22 } + + // MARK: - Drag-to-Terminal + + func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> (any NSPasteboardWriting)? { + guard let node = item as? FileExplorerNode, !node.isDirectory else { return nil } + // Only allow drag for local files + 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) + } } } @@ -331,7 +503,7 @@ final class FileExplorerCellView: NSTableCellView { ]) } - func configure(with node: FileExplorerNode) { + func configure(with node: FileExplorerNode, gitStatus: GitFileStatus? = nil) { nameLabel.stringValue = node.name if node.isDirectory { @@ -357,12 +529,25 @@ final class FileExplorerCellView: NSTableCellView { if let error = node.error { nameLabel.textColor = .systemRed nameLabel.toolTip = error + } else if let gitStatus { + nameLabel.textColor = Self.colorForGitStatus(gitStatus) + nameLabel.toolTip = node.path } else { nameLabel.textColor = .labelColor nameLabel.toolTip = node.path } } + private static func colorForGitStatus(_ status: GitFileStatus) -> NSColor { + switch status { + case .modified: return NSColor(red: 0.65, green: 0.45, blue: 0.0, alpha: 1.0) + case .added: return .systemGreen + case .deleted: return .systemRed + case .renamed: return .systemCyan + case .untracked: return NSColor(white: 0.5, alpha: 1.0) + } + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let existing = trackingArea { @@ -433,63 +618,94 @@ struct FileExplorerTitlebarButton: View { final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryViewController { private let hostingView: NonDraggableHostingView - private let containerView = NSView() - private var didInitialLayout = false init(onToggle: @escaping () -> Void) { let style = TitlebarControlsStyle(rawValue: UserDefaults.standard.integer(forKey: "titlebarControlsStyle")) ?? .classic + let config = style.config hostingView = NonDraggableHostingView( rootView: FileExplorerTitlebarButton( onToggle: onToggle, - config: style.config + config: config ) ) super.init(nibName: nil, bundle: nil) - view = containerView - containerView.translatesAutoresizingMaskIntoConstraints = true - containerView.wantsLayer = true - containerView.layer?.masksToBounds = false - hostingView.translatesAutoresizingMaskIntoConstraints = true - containerView.addSubview(hostingView) - - // Compute initial size once from the hosting view's fitting size - hostingView.layoutSubtreeIfNeeded() - let fitting = hostingView.fittingSize - let width = fitting.width + 8 - let height = max(fitting.height, 28) + // Use fixed dimensions matching the button config to avoid layout feedback loops. + let buttonSize = config.buttonSize + let width = buttonSize + 12 + let height = buttonSize + + hostingView.translatesAutoresizingMaskIntoConstraints = false + let wrapper = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) + wrapper.translatesAutoresizingMaskIntoConstraints = true + wrapper.wantsLayer = true + wrapper.layer?.masksToBounds = false + wrapper.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor), + hostingView.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor), + ]) + + view = wrapper preferredContentSize = NSSize(width: width, height: height) - containerView.frame = NSRect(x: 0, y: 0, width: width, height: height) - let yOffset = max(0, (height - fitting.height) / 2.0) - hostingView.frame = NSRect(x: 0, y: yOffset, width: fitting.width, height: fitting.height) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +// MARK: - Sidebar Explorer Divider + +/// Draggable horizontal divider between the tab list and file explorer in the sidebar. +struct SidebarExplorerDivider: View { + @Binding var position: CGFloat + let totalHeight: CGFloat + var minFraction: CGFloat = 0.1 + var maxFraction: CGFloat = 0.8 + + @State private var isDragging = false + @State private var isHovered = false + @State private var dragStartPosition: CGFloat = 0 + + private let handleHeight: CGFloat = 6 - override func viewDidAppear() { - super.viewDidAppear() - guard !didInitialLayout else { return } - didInitialLayout = true - // Re-measure once after attached to window (titlebar height is now known) - let fitting = hostingView.fittingSize - guard fitting.width > 0, fitting.height > 0 else { return } - let titlebarHeight: CGFloat = { - if let window = view.window, - let closeButton = window.standardWindowButton(.closeButton), - let titlebarView = closeButton.superview, - titlebarView.frame.height > 0 { - return titlebarView.frame.height + var body: some View { + Rectangle() + .fill(isDragging || isHovered + ? Color.accentColor.opacity(0.5) + : Color(nsColor: .separatorColor)) + .frame(height: isDragging || isHovered ? 2 : 1) + .frame(maxWidth: .infinity) + .padding(.vertical, (handleHeight - 1) / 2) + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + if hovering { + NSCursor.resizeUpDown.push() + } else if !isDragging { + NSCursor.pop() + } } - return fitting.height - }() - let containerHeight = max(fitting.height, titlebarHeight) - let yOffset = max(0, (containerHeight - fitting.height) / 2.0) - let width = fitting.width + 8 - preferredContentSize = NSSize(width: width, height: containerHeight) - containerView.frame = NSRect(x: 0, y: 0, width: width, height: containerHeight) - hostingView.frame = NSRect(x: 0, y: yOffset, width: fitting.width, height: fitting.height) + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + if !isDragging { + dragStartPosition = position + isDragging = true + } + let newPosition = dragStartPosition + (value.translation.height / totalHeight) + position = min(maxFraction, max(minFraction, newPosition)) + } + .onEnded { _ in + isDragging = false + if !isHovered { + NSCursor.pop() + } + } + ) + .accessibilityIdentifier("SidebarExplorerDivider") } } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 08de856131..af5851d480 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -227,7 +227,7 @@ enum KeyboardShortcutSettings { case .selectWorkspaceByNumber: return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .toggleFileExplorer: - return StoredShortcut(key: "e", command: true, shift: true, option: false, control: false) + 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/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index 18866e7ceb..b823a43573 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -22,7 +22,7 @@ private final class MockFileExplorerProvider: FileExplorerProvider { self.isAvailable = isAvailable } - func listDirectory(path: String) async throws -> [FileExplorerEntry] { + func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] { listCallCount += 1 listCallPaths.append(path) diff --git a/vendor/bonsplit b/vendor/bonsplit index 1610b457bc..098d9fa00e 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 1610b457bc44bb1d50dd246792f8724ce21a7c81 +Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f From 5327d39d6d538840546a6f3e8e058e5a361b5401 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 8 Apr 2026 19:01:38 -0700 Subject: [PATCH 4/8] DRY resizer, AppKit refactor, style debug window, no animations --- Sources/ContentView.swift | 113 +++--- Sources/FileExplorerStore.swift | 197 +++++++++++ Sources/FileExplorerView.swift | 598 +++++++++++++++++--------------- Sources/cmuxApp.swift | 113 ++++++ 4 files changed, 700 insertions(+), 321 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15e30aeda8..c546e1ab03 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1812,6 +1812,8 @@ struct ContentView: View { @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 @@ -2300,6 +2302,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 { @@ -2531,27 +2577,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() @@ -2688,8 +2728,6 @@ struct ContentView: View { } } - @State private var fileExplorerDragStartWidth: CGFloat? - private var terminalContentWithSidebarDropOverlay: some View { HStack(spacing: 0) { terminalContent @@ -2698,41 +2736,32 @@ struct ContentView: View { } if fileExplorerState.isVisible { Divider() - FileExplorerView(store: fileExplorerStore, state: fileExplorerState) - .frame(width: fileExplorerState.width) + FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + .frame(width: fileExplorerWidth) .overlay(alignment: .leading) { - fileExplorerResizerOverlay + fileExplorerResizerHandle } } } + .animation(nil, value: fileExplorerState.isVisible) + .animation(nil, value: fileExplorerWidth) + .onAppear { + fileExplorerWidth = fileExplorerState.width + } + .onChange(of: fileExplorerState.width) { newValue in + // Sync persisted width -> local (e.g. from debug window) + if fileExplorerDragStartWidth == nil { + fileExplorerWidth = newValue + } + } } - private var fileExplorerResizerOverlay: some View { - Color.clear - .frame(width: 6) - .contentShape(Rectangle()) - .onHover { hovering in - if hovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - if fileExplorerDragStartWidth == nil { - fileExplorerDragStartWidth = fileExplorerState.width - } - guard let startWidth = fileExplorerDragStartWidth else { return } - // Dragging left = wider, dragging right = narrower - let newWidth = startWidth - value.translation.width - fileExplorerState.width = min(500, max(150, newWidth)) - } - .onEnded { _ in - fileExplorerDragStartWidth = nil - } - ) + private var fileExplorerResizerHandle: some View { + sidebarResizerHandleOverlay( + .explorerDivider, + width: SidebarResizeInteraction.totalHitWidth, + availableWidth: observedWindow?.contentView?.bounds.width ?? 1920 + ) } @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index e9ac924d3b..1eb7afc20c 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -2,6 +2,203 @@ import AppKit import Combine import Foundation +// 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 { diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index 1113b22cea..f93ea89091 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -3,188 +3,28 @@ import Bonsplit import Combine import SwiftUI -// MARK: - Right Panel Container +// MARK: - File Explorer Panel (single NSViewRepresentable) -/// Right-side panel that wraps the file explorer with a vertical divider and resize handle. -struct FileExplorerRightPanel: View { +/// 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 - @State private var isResizerHovered = false - @State private var isResizerDragging = false - @State private var dragStartWidth: CGFloat = 0 - - private let minWidth: CGFloat = 150 - private let maxWidth: CGFloat = 500 - private let resizerWidth: CGFloat = 6 - - var body: some View { - HStack(spacing: 0) { - // Vertical divider + resize handle - Rectangle() - .fill(isResizerDragging || isResizerHovered - ? Color.accentColor.opacity(0.5) - : Color(nsColor: .separatorColor)) - .frame(width: isResizerDragging || isResizerHovered ? 2 : 1) - .padding(.horizontal, (resizerWidth - 1) / 2) - .contentShape(Rectangle()) - .onHover { hovering in - isResizerHovered = hovering - if hovering { - NSCursor.resizeLeftRight.push() - } else if !isResizerDragging { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - if !isResizerDragging { - dragStartWidth = state.width - isResizerDragging = true - } - // Dragging left = wider panel, dragging right = narrower - let newWidth = dragStartWidth - value.translation.width - state.width = min(maxWidth, max(minWidth, newWidth)) - } - .onEnded { _ in - isResizerDragging = false - if !isResizerHovered { - NSCursor.pop() - } - } - ) - .accessibilityIdentifier("FileExplorerResizer") - - FileExplorerView(store: store, state: state) - .frame(width: state.width) - } - } -} - -// MARK: - Container View - -struct FileExplorerView: View { - @ObservedObject var store: FileExplorerStore - @ObservedObject var state: FileExplorerState - - var body: some View { - VStack(spacing: 0) { - if store.rootPath.isEmpty { - emptyState - } else { - fileTree - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var emptyState: some View { - VStack(spacing: 8) { - Image(systemName: "folder") - .font(.system(size: 28)) - .foregroundColor(.secondary) - Text(String(localized: "fileExplorer.empty", defaultValue: "No folder open")) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var fileTree: some View { - VStack(alignment: .leading, spacing: 0) { - rootPathHeader - if store.isRootLoading { - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - FileExplorerOutlineView(store: store) - } - } - } - - private var rootPathHeader: some View { - HStack(spacing: 4) { - Image(systemName: "folder.fill") - .font(.system(size: 11)) - .foregroundColor(.secondary) - Text(store.displayRootPath) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - - Button { - state.showHiddenFiles.toggle() - store.showHiddenFiles = state.showHiddenFiles - store.reload() - } label: { - Image(systemName: state.showHiddenFiles ? "eye" : "eye.slash") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - .help(state.showHiddenFiles - ? String(localized: "fileExplorer.hiddenFiles.hide", defaultValue: "Hide Hidden Files") - : String(localized: "fileExplorer.hiddenFiles.show", defaultValue: "Show Hidden Files")) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - } -} - -// MARK: - NSOutlineView Wrapper - -struct FileExplorerOutlineView: NSViewRepresentable { - @ObservedObject var store: FileExplorerStore - func makeCoordinator() -> Coordinator { - Coordinator(store: store) + Coordinator(store: store, state: state) } - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - - let outlineView = NSOutlineView() - outlineView.headerView = nil - outlineView.usesAlternatingRowBackgroundColors = false - outlineView.style = .plain - outlineView.selectionHighlightStyle = .regular - outlineView.rowSizeStyle = .default - outlineView.indentationPerLevel = 16 - 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 = context.coordinator - outlineView.delegate = context.coordinator - - // Context menu - let menu = NSMenu() - menu.delegate = context.coordinator - outlineView.menu = menu - - scrollView.documentView = outlineView - context.coordinator.outlineView = outlineView - - return scrollView + func makeNSView(context: Context) -> FileExplorerContainerView { + let container = FileExplorerContainerView(coordinator: context.coordinator) + context.coordinator.containerView = container + return container } - func updateNSView(_ scrollView: NSScrollView, context: Context) { + func updateNSView(_ container: FileExplorerContainerView, context: Context) { context.coordinator.store = store + context.coordinator.state = state + container.updateHeader(store: store, state: state) context.coordinator.reloadIfNeeded() } @@ -192,14 +32,34 @@ struct FileExplorerOutlineView: NSViewRepresentable { 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) { + 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.. Bool { guard let node = item as? FileExplorerNode, node.isDirectory else { return false } - Task { @MainActor in - store.expand(node: node) - } + 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 } - Task { @MainActor in - store.collapse(node: node) - } + store.collapse(node: node) return true } func outlineViewItemDidExpand(_ notification: Notification) { guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } - Task { @MainActor in - if !store.isExpanded(node) { - store.expand(node: node) - } + if !store.isExpanded(node) { + store.expand(node: node) } } func outlineViewItemDidCollapse(_ notification: Notification) { guard let node = notification.userInfo?["NSObject"] as? FileExplorerNode else { return } - Task { @MainActor in - if store.isExpanded(node) { - store.collapse(node: node) - } + if store.isExpanded(node) { + store.collapse(node: node) } } @@ -350,14 +202,13 @@ struct FileExplorerOutlineView: NSViewRepresentable { } func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { - 22 + 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 } - // Only allow drag for local files guard store.provider is LocalFileExplorerProvider else { return nil } return NSURL(fileURLWithPath: node.path) } @@ -448,6 +299,204 @@ struct FileExplorerOutlineView: NSViewRepresentable { } } +// 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, state: FileExplorerState) { + headerView.update(displayPath: store.displayRootPath, showHidden: state.showHiddenFiles) + headerView.onToggleHidden = { + state.showHiddenFiles.toggle() + store.showHiddenFiles = state.showHiddenFiles + store.reload() + } + } + + 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: "") + private let toggleButton = NSButton() + var onToggleHidden: (() -> Void)? + + 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) + + toggleButton.translatesAutoresizingMaskIntoConstraints = false + toggleButton.bezelStyle = .inline + toggleButton.isBordered = false + toggleButton.image = NSImage(systemSymbolName: "eye.slash", accessibilityDescription: nil)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .regular)) + toggleButton.contentTintColor = .secondaryLabelColor + toggleButton.target = self + toggleButton.action = #selector(toggleHiddenFiles) + toggleButton.toolTip = String(localized: "fileExplorer.hiddenFiles.show", defaultValue: "Show Hidden Files") + + addSubview(iconView) + addSubview(pathLabel) + addSubview(toggleButton) + + 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(lessThanOrEqualTo: toggleButton.leadingAnchor, constant: -4), + + toggleButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + toggleButton.centerYAnchor.constraint(equalTo: centerYAnchor), + toggleButton.widthAnchor.constraint(equalToConstant: 20), + toggleButton.heightAnchor.constraint(equalToConstant: 20), + ]) + } + + func update(displayPath: String, showHidden: Bool) { + pathLabel.stringValue = displayPath + let symbolName = showHidden ? "eye" : "eye.slash" + toggleButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .regular)) + toggleButton.toolTip = showHidden + ? String(localized: "fileExplorer.hiddenFiles.hide", defaultValue: "Hide Hidden Files") + : String(localized: "fileExplorer.hiddenFiles.show", defaultValue: "Show Hidden Files") + } + + @objc private func toggleHiddenFiles() { + onToggleHidden?() + } +} + // MARK: - Cell View final class FileExplorerCellView: NSTableCellView { @@ -467,12 +516,15 @@ final class FileExplorerCellView: NSTableCellView { 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.font = .systemFont(ofSize: 13, weight: .medium) nameLabel.textColor = .labelColor nameLabel.lineBreakMode = .byTruncatingTail nameLabel.maximumNumberOfLines = 1 @@ -486,13 +538,17 @@ final class FileExplorerCellView: NSTableCellView { 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: 2), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), - iconView.widthAnchor.constraint(equalToConstant: 16), - iconView.heightAnchor.constraint(equalToConstant: 16), + iconWidthConstraint, + iconHeightConstraint, - nameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4), + iconToTextConstraint, nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: loadingIndicator.leadingAnchor, constant: -4), @@ -504,18 +560,38 @@ final class FileExplorerCellView: NSTableCellView { } func configure(with node: FileExplorerNode, gitStatus: GitFileStatus? = nil) { - nameLabel.stringValue = node.name + let style = FileExplorerStyle.current - if node.isDirectory { - let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) - iconView.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? - .withSymbolConfiguration(config) - iconView.contentTintColor = .systemBlue + 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 config = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) - iconView.image = NSImage(systemSymbolName: "doc", accessibilityDescription: nil)? - .withSymbolConfiguration(config) - iconView.contentTintColor = .secondaryLabelColor + 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 { @@ -530,7 +606,7 @@ final class FileExplorerCellView: NSTableCellView { nameLabel.textColor = .systemRed nameLabel.toolTip = error } else if let gitStatus { - nameLabel.textColor = Self.colorForGitStatus(gitStatus) + nameLabel.textColor = style.gitColor(for: gitStatus) nameLabel.toolTip = node.path } else { nameLabel.textColor = .labelColor @@ -538,16 +614,6 @@ final class FileExplorerCellView: NSTableCellView { } } - private static func colorForGitStatus(_ status: GitFileStatus) -> NSColor { - switch status { - case .modified: return NSColor(red: 0.65, green: 0.45, blue: 0.0, alpha: 1.0) - case .added: return .systemGreen - case .deleted: return .systemRed - case .renamed: return .systemCyan - case .untracked: return NSColor(white: 0.5, alpha: 1.0) - } - } - override func updateTrackingAreas() { super.updateTrackingAreas() if let existing = trackingArea { @@ -572,15 +638,43 @@ final class FileExplorerCellView: NSTableCellView { } } -// MARK: - Row View (Finder-like rounded inset) +// MARK: - Non-Animating Outline View + +/// NSOutlineView subclass that disables expand/collapse animations. +final class FileExplorerNSOutlineView: NSOutlineView { + 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() + } +} + +// MARK: - Row View final class FileExplorerRowView: NSTableRowView { override func drawSelection(in dirtyRect: NSRect) { guard isSelected else { return } - let insetRect = bounds.insetBy(dx: 4, dy: 1) - let path = NSBezierPath(roundedRect: insetRect, xRadius: 4, yRadius: 4) - NSColor.controlAccentColor.withAlphaComponent(0.2).setFill() - path.fill() + 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 { @@ -631,7 +725,6 @@ final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryView super.init(nibName: nil, bundle: nil) - // Use fixed dimensions matching the button config to avoid layout feedback loops. let buttonSize = config.buttonSize let width = buttonSize + 12 let height = buttonSize @@ -656,56 +749,3 @@ final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryView fatalError("init(coder:) has not been implemented") } } - -// MARK: - Sidebar Explorer Divider - -/// Draggable horizontal divider between the tab list and file explorer in the sidebar. -struct SidebarExplorerDivider: View { - @Binding var position: CGFloat - let totalHeight: CGFloat - var minFraction: CGFloat = 0.1 - var maxFraction: CGFloat = 0.8 - - @State private var isDragging = false - @State private var isHovered = false - @State private var dragStartPosition: CGFloat = 0 - - private let handleHeight: CGFloat = 6 - - var body: some View { - Rectangle() - .fill(isDragging || isHovered - ? Color.accentColor.opacity(0.5) - : Color(nsColor: .separatorColor)) - .frame(height: isDragging || isHovered ? 2 : 1) - .frame(maxWidth: .infinity) - .padding(.vertical, (handleHeight - 1) / 2) - .contentShape(Rectangle()) - .onHover { hovering in - isHovered = hovering - if hovering { - NSCursor.resizeUpDown.push() - } else if !isDragging { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - if !isDragging { - dragStartPosition = position - isDragging = true - } - let newPosition = dragStartPosition + (value.translation.height / totalHeight) - position = min(maxFraction, max(minFraction, newPosition)) - } - .onEnded { _ in - isDragging = false - if !isHovered { - NSCursor.pop() - } - } - ) - .accessibilityIdentifier("SidebarExplorerDivider") - } -} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 36c48a2833..77dd975376 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -497,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() } @@ -2638,6 +2641,116 @@ 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") +} + +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() From 725077f9da4dc10780ed19728405f5105598e829 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sat, 11 Apr 2026 21:58:27 -0700 Subject: [PATCH 5/8] File explorer: DRY titlebar hints, child NSPanel, no-animation fix - Move file explorer button into trailing NSTitlebarAccessoryVC (right-aligned) - Share modifier state via .titlebarShortcutHintsVisibilityChanged notification from TitlebarShortcutHintModifierMonitor (same timing, same delay as left hints) - Render hint pill in child NSPanel (borderless, nonactivating, ignoresMouseEvents) to float above terminal portal z-order - Extract ShortcutHintPill as shared component (sidebar tabs, left titlebar, explorer) - Fix animation: DispatchQueue.main.async in performKeyEquivalent to escape AppKit's implicit NSAnimationContext; .transition(.identity) on panel views; always-in-tree rendering with width=0 when hidden instead of conditional insertion - Show hidden files by default, remove toggle button from header - Increase disclosure triangle leading margin via frameOfOutlineCell/frameOfCell - Add .fullScreenAuxiliary + .ignoresCycle to hint panel - Style debug window in Debug > Debug Windows > File Explorer Style Debug --- Sources/AppDelegate.swift | 6 +- Sources/ContentView.swift | 73 ++++++--- Sources/FileExplorerStore.swift | 29 +++- Sources/FileExplorerView.swift | 160 +++++++++++++------ Sources/Update/UpdateTitlebarAccessory.swift | 25 +-- Sources/cmuxApp.swift | 1 + 6 files changed, 213 insertions(+), 81 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d9d368aac8..ac1797920a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11014,7 +11014,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .toggleFileExplorer) { - fileExplorerState?.toggle() + // 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 } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 80a414308c..5e27bc6eb4 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 @@ -2729,27 +2761,36 @@ struct ContentView: View { } private var terminalContentWithSidebarDropOverlay: some View { - HStack(spacing: 0) { + // 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 fileExplorerState.isVisible { + if explorerVisible { Divider() - FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) - .frame(width: fileExplorerWidth) - .overlay(alignment: .leading) { + } + FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + .frame(width: explorerVisible ? fileExplorerWidth : 0) + .clipped() + .allowsHitTesting(explorerVisible) + .accessibilityHidden(!explorerVisible) + .overlay(alignment: .leading) { + if explorerVisible { fileExplorerResizerHandle } - } + } } - .animation(nil, value: fileExplorerState.isVisible) - .animation(nil, value: fileExplorerWidth) + .transaction { $0.animation = nil } .onAppear { fileExplorerWidth = fileExplorerState.width } .onChange(of: fileExplorerState.width) { newValue in - // Sync persisted width -> local (e.g. from debug window) if fileExplorerDragStartWidth == nil { fileExplorerWidth = newValue } @@ -2932,7 +2973,7 @@ struct ContentView: View { let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) guard !dir.isEmpty else { return } - fileExplorerStore.showHiddenFiles = fileExplorerState.showHiddenFiles + fileExplorerStore.showHiddenFiles = true if tab.isRemoteWorkspace { let config = tab.remoteConfiguration @@ -12963,15 +13004,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) @@ -12979,7 +13012,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 index 1eb7afc20c..a6459f6c21 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -1,6 +1,8 @@ import AppKit import Combine import Foundation +import QuartzCore +import SwiftUI // MARK: - Explorer Visual Style @@ -445,11 +447,34 @@ final class FileExplorerState: ObservableObject { self.width = storedWidth > 0 ? CGFloat(storedWidth) : 220 let storedPosition = defaults.double(forKey: "fileExplorer.dividerPosition") self.dividerPosition = storedPosition > 0 ? CGFloat(storedPosition) : 0.6 - self.showHiddenFiles = defaults.bool(forKey: "fileExplorer.showHidden") + let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") + self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") } func toggle() { - isVisible.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 + } } } diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index f93ea89091..c5abc5b301 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -24,7 +24,7 @@ struct FileExplorerPanelView: NSViewRepresentable { func updateNSView(_ container: FileExplorerContainerView, context: Context) { context.coordinator.store = store context.coordinator.state = state - container.updateHeader(store: store, state: state) + container.updateHeader(store: store) context.coordinator.reloadIfNeeded() } @@ -395,13 +395,8 @@ final class FileExplorerContainerView: NSView { fatalError("init(coder:) has not been implemented") } - func updateHeader(store: FileExplorerStore, state: FileExplorerState) { - headerView.update(displayPath: store.displayRootPath, showHidden: state.showHiddenFiles) - headerView.onToggleHidden = { - state.showHiddenFiles.toggle() - store.showHiddenFiles = state.showHiddenFiles - store.reload() - } + func updateHeader(store: FileExplorerStore) { + headerView.update(displayPath: store.displayRootPath) } func updateVisibility(hasContent: Bool, isLoading: Bool) { @@ -423,8 +418,6 @@ final class FileExplorerContainerView: NSView { final class FileExplorerHeaderView: NSView { private let iconView = NSImageView() private let pathLabel = NSTextField(labelWithString: "") - private let toggleButton = NSButton() - var onToggleHidden: (() -> Void)? override init(frame: NSRect) { super.init(frame: frame) @@ -449,19 +442,8 @@ final class FileExplorerHeaderView: NSView { pathLabel.maximumNumberOfLines = 1 pathLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - toggleButton.translatesAutoresizingMaskIntoConstraints = false - toggleButton.bezelStyle = .inline - toggleButton.isBordered = false - toggleButton.image = NSImage(systemSymbolName: "eye.slash", accessibilityDescription: nil)? - .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .regular)) - toggleButton.contentTintColor = .secondaryLabelColor - toggleButton.target = self - toggleButton.action = #selector(toggleHiddenFiles) - toggleButton.toolTip = String(localized: "fileExplorer.hiddenFiles.show", defaultValue: "Show Hidden Files") - addSubview(iconView) addSubview(pathLabel) - addSubview(toggleButton) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: 28), @@ -473,27 +455,12 @@ final class FileExplorerHeaderView: NSView { pathLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4), pathLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - pathLabel.trailingAnchor.constraint(lessThanOrEqualTo: toggleButton.leadingAnchor, constant: -4), - - toggleButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - toggleButton.centerYAnchor.constraint(equalTo: centerYAnchor), - toggleButton.widthAnchor.constraint(equalToConstant: 20), - toggleButton.heightAnchor.constraint(equalToConstant: 20), + pathLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), ]) } - func update(displayPath: String, showHidden: Bool) { + func update(displayPath: String) { pathLabel.stringValue = displayPath - let symbolName = showHidden ? "eye" : "eye.slash" - toggleButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? - .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .regular)) - toggleButton.toolTip = showHidden - ? String(localized: "fileExplorer.hiddenFiles.hide", defaultValue: "Hide Hidden Files") - : String(localized: "fileExplorer.hiddenFiles.show", defaultValue: "Show Hidden Files") - } - - @objc private func toggleHiddenFiles() { - onToggleHidden?() } } @@ -543,7 +510,7 @@ final class FileExplorerCellView: NSTableCellView { iconToTextConstraint = nameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 4) NSLayoutConstraint.activate([ - iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), iconWidthConstraint, iconHeightConstraint, @@ -640,8 +607,11 @@ final class FileExplorerCellView: NSTableCellView { // MARK: - Non-Animating Outline View -/// NSOutlineView subclass that disables expand/collapse animations. +/// 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 @@ -655,6 +625,20 @@ final class FileExplorerNSOutlineView: NSOutlineView { 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 @@ -687,7 +671,6 @@ final class FileExplorerRowView: NSTableRowView { struct FileExplorerTitlebarButton: View { let onToggle: () -> Void let config: TitlebarControlsStyleConfig - @State private var isHovering = false var body: some View { TitlebarControlButton(config: config, action: { @@ -712,15 +695,16 @@ struct FileExplorerTitlebarButton: View { final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryViewController { private let hostingView: NonDraggableHostingView + private var hintPanel: NSPanel? + private var hintHostingView: NSHostingView? + private var hintObserver: NSObjectProtocol? + private let config: TitlebarControlsStyleConfig init(onToggle: @escaping () -> Void) { let style = TitlebarControlsStyle(rawValue: UserDefaults.standard.integer(forKey: "titlebarControlsStyle")) ?? .classic - let config = style.config + config = style.config hostingView = NonDraggableHostingView( - rootView: FileExplorerTitlebarButton( - onToggle: onToggle, - config: config - ) + rootView: FileExplorerTitlebarButton(onToggle: onToggle, config: config) ) super.init(nibName: nil, bundle: nil) @@ -743,9 +727,93 @@ final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryView view = wrapper preferredContentSize = NSSize(width: width, height: height) + + hintObserver = NotificationCenter.default.addObserver( + forName: .titlebarShortcutHintsVisibilityChanged, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + if let visible = notification.userInfo?["visible"] as? Bool { + if visible { + self.showHintPanel() + } else { + self.hideHintPanel() + } + } + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + if let hintObserver { + NotificationCenter.default.removeObserver(hintObserver) + } + hideHintPanel() + } + + private func showHintPanel() { + guard let parentWindow = view.window else { return } + + if hintPanel == nil { + let panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.ignoresMouseEvents = true + panel.isReleasedWhenClosed = false + panel.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle] + panel.animationBehavior = .none + hintPanel = panel + + let shortcut = KeyboardShortcutSettings.shortcut(for: .toggleFileExplorer) + let pill = ShortcutHintPill(shortcut: shortcut, fontSize: max(8, config.iconSize - 5)) + let hosting = NSHostingView(rootView: pill) + panel.contentView = hosting + hintHostingView = hosting + } + + guard let panel = hintPanel, let hosting = hintHostingView else { return } + + let pillSize = hosting.fittingSize + let buttonRect = hostingView.convert(hostingView.bounds, to: nil) + let buttonScreenRect = parentWindow.convertToScreen(buttonRect) + + let x = buttonScreenRect.midX - pillSize.width / 2 + let y = buttonScreenRect.minY - pillSize.height - 4 + + panel.setFrame(NSRect(x: x, y: y, width: pillSize.width, height: pillSize.height), display: true) + + if panel.parent == nil { + parentWindow.addChildWindow(panel, ordered: .above) + } + panel.alphaValue = 0 + panel.orderFront(nil) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.12 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } + + private func hideHintPanel() { + guard let panel = hintPanel else { return } + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.12 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 0 + }, completionHandler: { + panel.parent?.removeChildWindow(panel) + panel.orderOut(nil) + }) + } } + diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 0b3ee75ad8..4fca38449f 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, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 12afa4c934..321691e487 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2715,6 +2715,7 @@ private struct FileExplorerStyleDebugView: View { extension Notification.Name { static let fileExplorerStyleDidChange = Notification.Name("fileExplorerStyleDidChange") + static let titlebarShortcutHintsVisibilityChanged = Notification.Name("titlebarShortcutHintsVisibilityChanged") } private final class FileExplorerStyleDebugWindowController: NSWindowController, NSWindowDelegate { From 451e8073e2e6b37e8e46b6007fd4bf6125150970 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 12 Apr 2026 02:28:59 -0700 Subject: [PATCH 6/8] Feature flag: KVO observation for live toggle --- Sources/AppDelegate.swift | 3 +- Sources/ContentView.swift | 32 ++++++++-------- Sources/FileExplorerStore.swift | 30 +++++++++++++++ Sources/Update/UpdateTitlebarAccessory.swift | 40 ++++++++++++++++---- Sources/cmuxApp.swift | 32 ++++++++++++++++ vendor/bonsplit | 2 +- 6 files changed, 115 insertions(+), 24 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 67fbb7d2e3..26d81ad248 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11069,7 +11069,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredShortcut(event: event, action: .toggleFileExplorer) { + if FileExplorerFeatureSettings.isEnabled(), + 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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b3d08564a1..f63c3ce2d6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2764,7 +2764,7 @@ struct ContentView: View { // 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 + let explorerVisible = fileExplorerState.isVisible && fileExplorerState.isFeatureEnabled return HStack(spacing: 0) { terminalContent .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -11235,20 +11235,22 @@ private struct SidebarFooterButtons: View { HStack(spacing: 4) { SidebarHelpMenuButton(onSendFeedback: onSendFeedback) - Button(action: { fileExplorerState.toggle() }) { - Image(systemName: fileExplorerState.isVisible ? "folder.fill" : "folder") - .font(.system(size: 12)) - .foregroundStyle(fileExplorerState.isVisible ? Color.accentColor : Color(nsColor: .secondaryLabelColor)) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .frame(width: 22, height: 22, alignment: .center) - .help(fileExplorerState.isVisible - ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") - : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) - .accessibilityLabel(fileExplorerState.isVisible - ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") - : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) - .accessibilityIdentifier("sidebarFooter.toggleFileExplorer") + if fileExplorerState.isFeatureEnabled { + Button(action: { fileExplorerState.toggle() }) { + Image(systemName: fileExplorerState.isVisible ? "folder.fill" : "folder") + .font(.system(size: 12)) + .foregroundStyle(fileExplorerState.isVisible ? Color.accentColor : Color(nsColor: .secondaryLabelColor)) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: 22, height: 22, alignment: .center) + .help(fileExplorerState.isVisible + ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") + : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) + .accessibilityLabel(fileExplorerState.isVisible + ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") + : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) + .accessibilityIdentifier("sidebarFooter.toggleFileExplorer") + } UpdatePill(model: updateViewModel) } diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index a6459f6c21..b738fb55a1 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -4,6 +4,12 @@ import Foundation import QuartzCore import SwiftUI +extension UserDefaults { + @objc dynamic var fileExplorerFeatureEnabled: Bool { + bool(forKey: FileExplorerFeatureSettings.enabledKey) + } +} + // MARK: - Explorer Visual Style enum FileExplorerStyle: Int, CaseIterable { @@ -425,6 +431,9 @@ final class FileExplorerState: ObservableObject { @Published var isVisible: Bool { didSet { UserDefaults.standard.set(isVisible, forKey: "fileExplorer.isVisible") } } + @Published var isFeatureEnabled: Bool { + didSet { UserDefaults.standard.set(isFeatureEnabled, forKey: FileExplorerFeatureSettings.enabledKey) } + } @Published var width: CGFloat { didSet { UserDefaults.standard.set(Double(width), forKey: "fileExplorer.width") } } @@ -440,15 +449,36 @@ final class FileExplorerState: ObservableObject { didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } } + private var defaultsObserver: NSKeyValueObservation? + init() { let defaults = UserDefaults.standard self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") + self.isFeatureEnabled = FileExplorerFeatureSettings.isEnabled(defaults: defaults) 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") + + // KVO on the specific UserDefaults key for reliable cross-window observation + defaultsObserver = UserDefaults.standard.observe( + \.fileExplorerFeatureEnabled, + options: [.new] + ) { [weak self] _, change in + DispatchQueue.main.async { + guard let self else { return } + let enabled = FileExplorerFeatureSettings.isEnabled() + if self.isFeatureEnabled != enabled { + self.isFeatureEnabled = enabled + } + } + } + } + + deinit { + defaultsObserver?.invalidate() } func toggle() { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 4fca38449f..854f225fdb 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1210,9 +1210,19 @@ final class UpdateTitlebarAccessoryController { private let fileExplorerIdentifier = NSUserInterfaceItemIdentifier("cmux.fileExplorerToggle") private let controlsControllers = NSHashTable.weakObjects() private var lastKnownPresentationMode: WorkspacePresentationModeSettings.Mode = WorkspacePresentationModeSettings.mode() + private var lastKnownFileExplorerEnabled: Bool = FileExplorerFeatureSettings.isEnabled() + private var fileExplorerKVO: NSKeyValueObservation? init(viewModel: UpdateViewModel) { self.updateViewModel = viewModel + fileExplorerKVO = UserDefaults.standard.observe( + \.fileExplorerFeatureEnabled, + options: [.new] + ) { [weak self] _, _ in + DispatchQueue.main.async { + self?.reattachIfFileExplorerFlagChanged() + } + } } deinit { @@ -1273,7 +1283,16 @@ final class UpdateTitlebarAccessoryController { // AppKit does not provide a stable cross-SDK API for this. Startup scans handle this case. } + private func reattachIfFileExplorerFlagChanged() { + let enabled = FileExplorerFeatureSettings.isEnabled() + guard enabled != lastKnownFileExplorerEnabled else { return } + lastKnownFileExplorerEnabled = enabled + attachToExistingWindows() + } + private func reattachIfPresentationModeChanged() { + reattachIfFileExplorerFlagChanged() + let currentMode = WorkspacePresentationModeSettings.mode() guard currentMode != lastKnownPresentationMode else { return } lastKnownPresentationMode = currentMode @@ -1367,13 +1386,20 @@ final class UpdateTitlebarAccessoryController { controlsControllers.add(controls) } - if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == fileExplorerIdentifier }) { - let toggle = FileExplorerTitlebarAccessoryViewController(onToggle: { - AppDelegate.shared?.fileExplorerState?.toggle() - }) - toggle.layoutAttribute = .trailing - toggle.view.identifier = fileExplorerIdentifier - window.addTitlebarAccessoryViewController(toggle) + if FileExplorerFeatureSettings.isEnabled() { + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == fileExplorerIdentifier }) { + let toggle = FileExplorerTitlebarAccessoryViewController(onToggle: { + AppDelegate.shared?.fileExplorerState?.toggle() + }) + toggle.layoutAttribute = .trailing + toggle.view.identifier = fileExplorerIdentifier + window.addTitlebarAccessoryViewController(toggle) + } + } else { + // Remove the button if the feature was disabled + if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0.view.identifier == fileExplorerIdentifier }) { + window.removeTitlebarAccessoryViewController(at: index) + } } attachedWindows.add(window) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 321691e487..6c08d1b499 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -87,6 +87,15 @@ enum WorkspaceButtonFadeSettings { } } +enum FileExplorerFeatureSettings { + static let enabledKey = "fileExplorer.featureEnabled" + static let defaultEnabled = false + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled + } +} + enum PaneFirstClickFocusSettings { static let enabledKey = "paneFirstClickFocus.enabled" static let defaultEnabled = false @@ -4247,6 +4256,8 @@ struct SettingsView: View { private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue @AppStorage(PaneFirstClickFocusSettings.enabledKey) private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + @AppStorage(FileExplorerFeatureSettings.enabledKey) + private var fileExplorerFeatureEnabled = FileExplorerFeatureSettings.defaultEnabled @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -4346,6 +4357,22 @@ struct SettingsView: View { ) } + @ViewBuilder + private var fileExplorerSettingsRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer"), + subtitle: String(localized: "settings.app.fileExplorer.subtitle", defaultValue: "Show a file explorer panel on the right side of the terminal (Cmd+Option+B).") + ) { + Toggle("", isOn: $fileExplorerFeatureEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer") + ) + } + } + private var paneFirstClickFocusSubtitle: String { if paneFirstClickFocusEnabled { return String( @@ -4889,6 +4916,10 @@ struct SettingsView: View { SettingsCardDivider() + fileExplorerSettingsRow + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("app.preferredEditor"), String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), @@ -6333,6 +6364,7 @@ struct SettingsView: View { defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + fileExplorerFeatureEnabled = FileExplorerFeatureSettings.defaultEnabled workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage 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 From d1e6bf35efc48136c4935ccad9f92f164cfec708 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 12 Apr 2026 02:36:38 -0700 Subject: [PATCH 7/8] Feature flag: notification-based toggle sync --- Sources/FileExplorerStore.swift | 29 ++++++++++---------- Sources/Update/UpdateTitlebarAccessory.swift | 18 ++++++------ Sources/cmuxApp.swift | 23 ++++++++++++---- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index b738fb55a1..5c939c3c9d 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -449,7 +449,7 @@ final class FileExplorerState: ObservableObject { didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } } - private var defaultsObserver: NSKeyValueObservation? + private var defaultsObserver: AnyObject? init() { let defaults = UserDefaults.standard @@ -462,23 +462,24 @@ final class FileExplorerState: ObservableObject { let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") - // KVO on the specific UserDefaults key for reliable cross-window observation - defaultsObserver = UserDefaults.standard.observe( - \.fileExplorerFeatureEnabled, - options: [.new] - ) { [weak self] _, change in - DispatchQueue.main.async { - guard let self else { return } - let enabled = FileExplorerFeatureSettings.isEnabled() - if self.isFeatureEnabled != enabled { - self.isFeatureEnabled = enabled - } + // Listen for feature flag changes from settings UI + defaultsObserver = NotificationCenter.default.addObserver( + forName: .fileExplorerFeatureToggled, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + let enabled = (notification.userInfo?["enabled"] as? Bool) ?? FileExplorerFeatureSettings.isEnabled() + if self.isFeatureEnabled != enabled { + self.isFeatureEnabled = enabled } - } + } as AnyObject } deinit { - defaultsObserver?.invalidate() + if let defaultsObserver { + NotificationCenter.default.removeObserver(defaultsObserver) + } } func toggle() { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 854f225fdb..aaa4d1165d 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1211,17 +1211,16 @@ final class UpdateTitlebarAccessoryController { private let controlsControllers = NSHashTable.weakObjects() private var lastKnownPresentationMode: WorkspacePresentationModeSettings.Mode = WorkspacePresentationModeSettings.mode() private var lastKnownFileExplorerEnabled: Bool = FileExplorerFeatureSettings.isEnabled() - private var fileExplorerKVO: NSKeyValueObservation? + private var fileExplorerObserver: NSObjectProtocol? init(viewModel: UpdateViewModel) { self.updateViewModel = viewModel - fileExplorerKVO = UserDefaults.standard.observe( - \.fileExplorerFeatureEnabled, - options: [.new] - ) { [weak self] _, _ in - DispatchQueue.main.async { - self?.reattachIfFileExplorerFlagChanged() - } + fileExplorerObserver = NotificationCenter.default.addObserver( + forName: .fileExplorerFeatureToggled, + object: nil, + queue: .main + ) { [weak self] _ in + self?.reattachIfFileExplorerFlagChanged() } } @@ -1229,6 +1228,9 @@ final class UpdateTitlebarAccessoryController { for observer in observers { NotificationCenter.default.removeObserver(observer) } + if let fileExplorerObserver { + NotificationCenter.default.removeObserver(fileExplorerObserver) + } } func start() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6c08d1b499..e802397a6a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2724,6 +2724,7 @@ private struct FileExplorerStyleDebugView: View { extension Notification.Name { static let fileExplorerStyleDidChange = Notification.Name("fileExplorerStyleDidChange") + static let fileExplorerFeatureToggled = Notification.Name("fileExplorerFeatureToggled") static let titlebarShortcutHintsVisibilityChanged = Notification.Name("titlebarShortcutHintsVisibilityChanged") } @@ -4364,12 +4365,22 @@ struct SettingsView: View { String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer"), subtitle: String(localized: "settings.app.fileExplorer.subtitle", defaultValue: "Show a file explorer panel on the right side of the terminal (Cmd+Option+B).") ) { - Toggle("", isOn: $fileExplorerFeatureEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer") - ) + Toggle("", isOn: Binding( + get: { fileExplorerFeatureEnabled }, + set: { newValue in + fileExplorerFeatureEnabled = newValue + NotificationCenter.default.post( + name: .fileExplorerFeatureToggled, + object: nil, + userInfo: ["enabled": newValue] + ) + } + )) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer") + ) } } From ebf2df838440dfb59105912e4abc919c2d5731d3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 13 Apr 2026 00:00:19 -0700 Subject: [PATCH 8/8] Remove feature flag and titlebar button, Cmd+Option+B always enabled --- Sources/AppDelegate.swift | 3 +- Sources/ContentView.swift | 20 +-- Sources/FileExplorerStore.swift | 31 ---- Sources/FileExplorerView.swift | 149 ------------------- Sources/Update/UpdateTitlebarAccessory.swift | 44 +----- Sources/cmuxApp.swift | 43 ------ 6 files changed, 4 insertions(+), 286 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 26d81ad248..67fbb7d2e3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11069,8 +11069,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if FileExplorerFeatureSettings.isEnabled(), - matchConfiguredShortcut(event: event, action: .toggleFileExplorer) { + 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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f63c3ce2d6..7254c9345b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2764,7 +2764,7 @@ struct ContentView: View { // 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 && fileExplorerState.isFeatureEnabled + let explorerVisible = fileExplorerState.isVisible return HStack(spacing: 0) { terminalContent .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -11234,24 +11234,6 @@ private struct SidebarFooterButtons: View { var body: some View { HStack(spacing: 4) { SidebarHelpMenuButton(onSendFeedback: onSendFeedback) - - if fileExplorerState.isFeatureEnabled { - Button(action: { fileExplorerState.toggle() }) { - Image(systemName: fileExplorerState.isVisible ? "folder.fill" : "folder") - .font(.system(size: 12)) - .foregroundStyle(fileExplorerState.isVisible ? Color.accentColor : Color(nsColor: .secondaryLabelColor)) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .frame(width: 22, height: 22, alignment: .center) - .help(fileExplorerState.isVisible - ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") - : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) - .accessibilityLabel(fileExplorerState.isVisible - ? String(localized: "sidebar.fileExplorer.hide", defaultValue: "Hide File Explorer") - : String(localized: "sidebar.fileExplorer.show", defaultValue: "Show File Explorer")) - .accessibilityIdentifier("sidebarFooter.toggleFileExplorer") - } - UpdatePill(model: updateViewModel) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 5c939c3c9d..a6459f6c21 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -4,12 +4,6 @@ import Foundation import QuartzCore import SwiftUI -extension UserDefaults { - @objc dynamic var fileExplorerFeatureEnabled: Bool { - bool(forKey: FileExplorerFeatureSettings.enabledKey) - } -} - // MARK: - Explorer Visual Style enum FileExplorerStyle: Int, CaseIterable { @@ -431,9 +425,6 @@ final class FileExplorerState: ObservableObject { @Published var isVisible: Bool { didSet { UserDefaults.standard.set(isVisible, forKey: "fileExplorer.isVisible") } } - @Published var isFeatureEnabled: Bool { - didSet { UserDefaults.standard.set(isFeatureEnabled, forKey: FileExplorerFeatureSettings.enabledKey) } - } @Published var width: CGFloat { didSet { UserDefaults.standard.set(Double(width), forKey: "fileExplorer.width") } } @@ -449,37 +440,15 @@ final class FileExplorerState: ObservableObject { didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } } - private var defaultsObserver: AnyObject? - init() { let defaults = UserDefaults.standard self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") - self.isFeatureEnabled = FileExplorerFeatureSettings.isEnabled(defaults: defaults) 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") - - // Listen for feature flag changes from settings UI - defaultsObserver = NotificationCenter.default.addObserver( - forName: .fileExplorerFeatureToggled, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self else { return } - let enabled = (notification.userInfo?["enabled"] as? Bool) ?? FileExplorerFeatureSettings.isEnabled() - if self.isFeatureEnabled != enabled { - self.isFeatureEnabled = enabled - } - } as AnyObject - } - - deinit { - if let defaultsObserver { - NotificationCenter.default.removeObserver(defaultsObserver) - } } func toggle() { diff --git a/Sources/FileExplorerView.swift b/Sources/FileExplorerView.swift index c5abc5b301..fe52d5dbfb 100644 --- a/Sources/FileExplorerView.swift +++ b/Sources/FileExplorerView.swift @@ -666,154 +666,5 @@ final class FileExplorerRowView: NSTableRowView { } } -// MARK: - Right Titlebar Toggle Button - -struct FileExplorerTitlebarButton: View { - let onToggle: () -> Void - let config: TitlebarControlsStyleConfig - - var body: some View { - TitlebarControlButton(config: config, action: { - #if DEBUG - dlog("titlebar.toggleFileExplorer") - #endif - onToggle() - }) { - Image(systemName: "sidebar.right") - .font(.system(size: config.iconSize)) - .frame(width: config.buttonSize, height: config.buttonSize) - } - .accessibilityIdentifier("titlebarControl.toggleFileExplorer") - .accessibilityLabel(String(localized: "titlebar.fileExplorer.accessibilityLabel", defaultValue: "Toggle File Explorer")) - .safeHelp(KeyboardShortcutSettings.Action.toggleFileExplorer.tooltip( - String(localized: "titlebar.fileExplorer.tooltip", defaultValue: "Show or hide the file explorer") - )) - } -} - -// MARK: - Right Titlebar Accessory ViewController - -final class FileExplorerTitlebarAccessoryViewController: NSTitlebarAccessoryViewController { - private let hostingView: NonDraggableHostingView - private var hintPanel: NSPanel? - private var hintHostingView: NSHostingView? - private var hintObserver: NSObjectProtocol? - private let config: TitlebarControlsStyleConfig - - init(onToggle: @escaping () -> Void) { - let style = TitlebarControlsStyle(rawValue: UserDefaults.standard.integer(forKey: "titlebarControlsStyle")) ?? .classic - config = style.config - hostingView = NonDraggableHostingView( - rootView: FileExplorerTitlebarButton(onToggle: onToggle, config: config) - ) - - super.init(nibName: nil, bundle: nil) - - let buttonSize = config.buttonSize - let width = buttonSize + 12 - let height = buttonSize - - hostingView.translatesAutoresizingMaskIntoConstraints = false - let wrapper = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) - wrapper.translatesAutoresizingMaskIntoConstraints = true - wrapper.wantsLayer = true - wrapper.layer?.masksToBounds = false - wrapper.addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor), - hostingView.centerYAnchor.constraint(equalTo: wrapper.centerYAnchor), - ]) - - view = wrapper - preferredContentSize = NSSize(width: width, height: height) - - hintObserver = NotificationCenter.default.addObserver( - forName: .titlebarShortcutHintsVisibilityChanged, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self else { return } - if let visible = notification.userInfo?["visible"] as? Bool { - if visible { - self.showHintPanel() - } else { - self.hideHintPanel() - } - } - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let hintObserver { - NotificationCenter.default.removeObserver(hintObserver) - } - hideHintPanel() - } - - private func showHintPanel() { - guard let parentWindow = view.window else { return } - - if hintPanel == nil { - let panel = NSPanel( - contentRect: .zero, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: true - ) - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.ignoresMouseEvents = true - panel.isReleasedWhenClosed = false - panel.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle] - panel.animationBehavior = .none - hintPanel = panel - - let shortcut = KeyboardShortcutSettings.shortcut(for: .toggleFileExplorer) - let pill = ShortcutHintPill(shortcut: shortcut, fontSize: max(8, config.iconSize - 5)) - let hosting = NSHostingView(rootView: pill) - panel.contentView = hosting - hintHostingView = hosting - } - guard let panel = hintPanel, let hosting = hintHostingView else { return } - - let pillSize = hosting.fittingSize - let buttonRect = hostingView.convert(hostingView.bounds, to: nil) - let buttonScreenRect = parentWindow.convertToScreen(buttonRect) - - let x = buttonScreenRect.midX - pillSize.width / 2 - let y = buttonScreenRect.minY - pillSize.height - 4 - - panel.setFrame(NSRect(x: x, y: y, width: pillSize.width, height: pillSize.height), display: true) - - if panel.parent == nil { - parentWindow.addChildWindow(panel, ordered: .above) - } - panel.alphaValue = 0 - panel.orderFront(nil) - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = 0.12 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 1 - } - } - - private func hideHintPanel() { - guard let panel = hintPanel else { return } - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = 0.12 - ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 0 - }, completionHandler: { - panel.parent?.removeChildWindow(panel) - panel.orderOut(nil) - }) - } -} diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index aaa4d1165d..cb0a73194e 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1207,30 +1207,17 @@ final class UpdateTitlebarAccessoryController { private var pendingAttachRetries: [ObjectIdentifier: Int] = [:] private var startupScanWorkItems: [DispatchWorkItem] = [] private let controlsIdentifier = NSUserInterfaceItemIdentifier("cmux.titlebarControls") - private let fileExplorerIdentifier = NSUserInterfaceItemIdentifier("cmux.fileExplorerToggle") private let controlsControllers = NSHashTable.weakObjects() private var lastKnownPresentationMode: WorkspacePresentationModeSettings.Mode = WorkspacePresentationModeSettings.mode() - private var lastKnownFileExplorerEnabled: Bool = FileExplorerFeatureSettings.isEnabled() - private var fileExplorerObserver: NSObjectProtocol? init(viewModel: UpdateViewModel) { self.updateViewModel = viewModel - fileExplorerObserver = NotificationCenter.default.addObserver( - forName: .fileExplorerFeatureToggled, - object: nil, - queue: .main - ) { [weak self] _ in - self?.reattachIfFileExplorerFlagChanged() - } } deinit { for observer in observers { NotificationCenter.default.removeObserver(observer) } - if let fileExplorerObserver { - NotificationCenter.default.removeObserver(fileExplorerObserver) - } } func start() { @@ -1285,15 +1272,7 @@ final class UpdateTitlebarAccessoryController { // AppKit does not provide a stable cross-SDK API for this. Startup scans handle this case. } - private func reattachIfFileExplorerFlagChanged() { - let enabled = FileExplorerFeatureSettings.isEnabled() - guard enabled != lastKnownFileExplorerEnabled else { return } - lastKnownFileExplorerEnabled = enabled - attachToExistingWindows() - } - private func reattachIfPresentationModeChanged() { - reattachIfFileExplorerFlagChanged() let currentMode = WorkspacePresentationModeSettings.mode() guard currentMode != lastKnownPresentationMode else { return } @@ -1372,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 }) { @@ -1388,22 +1364,6 @@ final class UpdateTitlebarAccessoryController { controlsControllers.add(controls) } - if FileExplorerFeatureSettings.isEnabled() { - if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == fileExplorerIdentifier }) { - let toggle = FileExplorerTitlebarAccessoryViewController(onToggle: { - AppDelegate.shared?.fileExplorerState?.toggle() - }) - toggle.layoutAttribute = .trailing - toggle.view.identifier = fileExplorerIdentifier - window.addTitlebarAccessoryViewController(toggle) - } - } else { - // Remove the button if the feature was disabled - if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0.view.identifier == fileExplorerIdentifier }) { - window.removeTitlebarAccessoryViewController(at: index) - } - } - attachedWindows.add(window) #if DEBUG @@ -1418,7 +1378,7 @@ final class UpdateTitlebarAccessoryController { private func removeAccessoryIfPresent(from window: NSWindow) { let matchingIndices = window.titlebarAccessoryViewControllers.indices.reversed().filter { index in let id = window.titlebarAccessoryViewControllers[index].view.identifier - return id == controlsIdentifier || id == fileExplorerIdentifier + return id == controlsIdentifier } guard !matchingIndices.isEmpty || attachedWindows.contains(window) else { return } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e802397a6a..321691e487 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -87,15 +87,6 @@ enum WorkspaceButtonFadeSettings { } } -enum FileExplorerFeatureSettings { - static let enabledKey = "fileExplorer.featureEnabled" - static let defaultEnabled = false - - static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled - } -} - enum PaneFirstClickFocusSettings { static let enabledKey = "paneFirstClickFocus.enabled" static let defaultEnabled = false @@ -2724,7 +2715,6 @@ private struct FileExplorerStyleDebugView: View { extension Notification.Name { static let fileExplorerStyleDidChange = Notification.Name("fileExplorerStyleDidChange") - static let fileExplorerFeatureToggled = Notification.Name("fileExplorerFeatureToggled") static let titlebarShortcutHintsVisibilityChanged = Notification.Name("titlebarShortcutHintsVisibilityChanged") } @@ -4257,8 +4247,6 @@ struct SettingsView: View { private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue @AppStorage(PaneFirstClickFocusSettings.enabledKey) private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled - @AppStorage(FileExplorerFeatureSettings.enabledKey) - private var fileExplorerFeatureEnabled = FileExplorerFeatureSettings.defaultEnabled @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -4358,32 +4346,6 @@ struct SettingsView: View { ) } - @ViewBuilder - private var fileExplorerSettingsRow: some View { - SettingsCardRow( - configurationReview: .settingsOnly, - String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer"), - subtitle: String(localized: "settings.app.fileExplorer.subtitle", defaultValue: "Show a file explorer panel on the right side of the terminal (Cmd+Option+B).") - ) { - Toggle("", isOn: Binding( - get: { fileExplorerFeatureEnabled }, - set: { newValue in - fileExplorerFeatureEnabled = newValue - NotificationCenter.default.post( - name: .fileExplorerFeatureToggled, - object: nil, - userInfo: ["enabled": newValue] - ) - } - )) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.fileExplorer", defaultValue: "File Explorer") - ) - } - } - private var paneFirstClickFocusSubtitle: String { if paneFirstClickFocusEnabled { return String( @@ -4927,10 +4889,6 @@ struct SettingsView: View { SettingsCardDivider() - fileExplorerSettingsRow - - SettingsCardDivider() - SettingsCardRow( configurationReview: .json("app.preferredEditor"), String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), @@ -6375,7 +6333,6 @@ struct SettingsView: View { defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled - fileExplorerFeatureEnabled = FileExplorerFeatureSettings.defaultEnabled workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage