From f6e4cb58ec70f890d5d0e3f996baaffc9445cfb2 Mon Sep 17 00:00:00 2001 From: Alex Boudreaux Date: Sun, 1 Mar 2026 17:05:47 -0600 Subject: [PATCH 1/2] Add file tree sidebar with cmd-click path composition Port file tree sidebar from PR #601 (credit @Shehryar) and add cmd-click to inject file paths into the active terminal pane. - File tree sidebar toggled with Cmd+Shift+E - Toggle or split layout with workspace tabs - Cmd-click any file/folder to insert its shell-escaped path into terminal - Regular click expands/collapses dirs, selects files - Right-click context menu (copy path, reveal in Finder, open, insert path) - Lazy directory loading, hidden files toggle, manual refresh Co-Authored-By: Claude Opus 4.6 --- GhosttyTabs.xcodeproj/project.pbxproj | 24 +++- Sources/AppDelegate.swift | 30 ++++- Sources/ContentView.swift | 147 +++++++++++++++++++- Sources/FileTree/FileTreeModel.swift | 155 ++++++++++++++++++++++ Sources/FileTree/FileTreeNode.swift | 48 +++++++ Sources/FileTree/FileTreeRow.swift | 119 +++++++++++++++++ Sources/FileTree/FileTreeSidebar.swift | 102 ++++++++++++++ Sources/FileTree/SidebarContentMode.swift | 18 +++ Sources/KeyboardShortcutSettings.swift | 7 + Sources/cmuxApp.swift | 21 +++ 10 files changed, 663 insertions(+), 8 deletions(-) create mode 100644 Sources/FileTree/FileTreeModel.swift create mode 100644 Sources/FileTree/FileTreeNode.swift create mode 100644 Sources/FileTree/FileTreeRow.swift create mode 100644 Sources/FileTree/FileTreeSidebar.swift create mode 100644 Sources/FileTree/SidebarContentMode.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index aa0221e65bfc..f8e60cbb2d61 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -84,7 +84,12 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; - /* End PBXBuildFile section */ + A5003000 /* SidebarContentMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5003010 /* SidebarContentMode.swift */; }; + A5003001 /* FileTreeNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5003011 /* FileTreeNode.swift */; }; + A5003002 /* FileTreeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5003012 /* FileTreeModel.swift */; }; + A5003003 /* FileTreeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5003013 /* FileTreeRow.swift */; }; + A5003004 /* FileTreeSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5003014 /* FileTreeSidebar.swift */; }; +/* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -215,7 +220,12 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; - /* End PBXFileReference section */ + A5003010 /* SidebarContentMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/SidebarContentMode.swift; sourceTree = ""; }; + A5003011 /* FileTreeNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeNode.swift; sourceTree = ""; }; + A5003012 /* FileTreeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeModel.swift; sourceTree = ""; }; + A5003013 /* FileTreeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeRow.swift; sourceTree = ""; }; + A5003014 /* FileTreeSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeSidebar.swift; sourceTree = ""; }; +/* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ A5001030 /* Frameworks */ = { @@ -322,6 +332,11 @@ children = ( A5001011 /* cmuxApp.swift */, A5001012 /* ContentView.swift */, + A5003010 /* SidebarContentMode.swift */, + A5003011 /* FileTreeNode.swift */, + A5003012 /* FileTreeModel.swift */, + A5003013 /* FileTreeRow.swift */, + A5003014 /* FileTreeSidebar.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -561,6 +576,11 @@ files = ( A5001001 /* cmuxApp.swift in Sources */, A5001002 /* ContentView.swift in Sources */, + A5003000 /* SidebarContentMode.swift in Sources */, + A5003001 /* FileTreeNode.swift in Sources */, + A5003002 /* FileTreeModel.swift in Sources */, + A5003003 /* FileTreeRow.swift in Sources */, + A5003004 /* FileTreeSidebar.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a8718fb84d0a..a112ecc73322 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -947,6 +947,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let tabManager: TabManager let sidebarState: SidebarState let sidebarSelectionState: SidebarSelectionState + let sidebarContentModeState: SidebarContentModeState weak var window: NSWindow? init( @@ -954,12 +955,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager: TabManager, sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState, + sidebarContentModeState: SidebarContentModeState, window: NSWindow? ) { self.windowId = windowId self.tabManager = tabManager self.sidebarState = sidebarState self.sidebarSelectionState = sidebarSelectionState + self.sidebarContentModeState = sidebarContentModeState self.window = window } } @@ -990,6 +993,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? + weak var sidebarContentModeState: SidebarContentModeState? private var workspaceObserver: NSObjectProtocol? private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? @@ -2332,7 +2336,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowId: UUID, tabManager: TabManager, sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState + sidebarSelectionState: SidebarSelectionState, + sidebarContentModeState: SidebarContentModeState? = nil ) { tabManager.window = window @@ -2346,11 +2351,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent existing.window = window reindexMainWindowContextIfNeeded(existing, for: window) } else { + let resolvedContentModeState = sidebarContentModeState ?? SidebarContentModeState() mainWindowContexts[key] = MainWindowContext( windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState, + sidebarContentModeState: resolvedContentModeState, window: window ) NotificationCenter.default.addObserver( @@ -3310,6 +3317,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState + sidebarContentModeState = context.sidebarContentModeState TerminalController.shared.setActiveTabManager(context.tabManager) } #if DEBUG @@ -3757,6 +3765,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState + sidebarContentModeState = context.sidebarContentModeState TerminalController.shared.setActiveTabManager(context.tabManager) } @@ -3789,6 +3798,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let sidebarSelectionState = SidebarSelectionState( selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs ) + let sidebarContentModeState = SidebarContentModeState() let notificationStore = TerminalNotificationStore.shared let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) @@ -3796,6 +3806,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .environmentObject(notificationStore) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(sidebarContentModeState) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), @@ -3834,7 +3845,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState + sidebarSelectionState: sidebarSelectionState, + sidebarContentModeState: sidebarContentModeState ) installFileDropOverlay(on: window, tabManager: tabManager) if TerminalController.shouldSuppressSocketCommandActivation() { @@ -5205,6 +5217,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleFileTree)) { + if let modeState = sidebarContentModeState { + if modeState.mode == .fileTree { + modeState.mode = .tabs + } else { + modeState.mode = .fileTree + if sidebarState?.isVisible == false { + sidebarState?.toggle() + } + } + } + return true + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { #if DEBUG dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 49d634f02f3c..45c5683959e6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1225,6 +1225,10 @@ struct ContentView: View { @EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState + @EnvironmentObject var sidebarContentModeState: SidebarContentModeState + @StateObject private var fileTreeModel = FileTreeModel() + @AppStorage("sidebarFileTreeLayout") private var fileTreeLayout: SidebarFileTreeLayout = .toggle + @State private var splitDividerRatio: CGFloat = 0.4 @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false @@ -1773,14 +1777,149 @@ struct ContentView: View { } } - private var sidebarView: some View { + private var tabsSidebarContent: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) + } + + private var fileTreeSidebarContent: some View { + Group { + if let workspace = tabManager.selectedTab { + FileTreeSidebar( + model: fileTreeModel, + workspace: workspace, + onComposePath: { path in + sendPathToFocusedTerminal(path) + } + ) + } else { + Text("No workspace") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var sidebarModeHeader: some View { + HStack(spacing: 4) { + Button { + sidebarContentModeState.mode = .tabs + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 12)) + .foregroundColor(sidebarContentModeState.mode == .tabs ? .accentColor : .secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Show tabs") + + Button { + sidebarContentModeState.mode = .fileTree + } label: { + Image(systemName: "folder") + .font(.system(size: 12)) + .foregroundColor(sidebarContentModeState.mode == .fileTree ? .accentColor : .secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Show file tree") + + Spacer() + } + .padding(.horizontal, 8) + .padding(.top, 30) + .padding(.bottom, 4) + } + + private var sidebarView: some View { + VStack(spacing: 0) { + if fileTreeLayout == .split { + sidebarSplitContent + } else { + sidebarToggleContent + } + } .frame(width: sidebarWidth) + .background(SidebarBackdrop().ignoresSafeArea()) + } + + private var sidebarToggleContent: some View { + VStack(spacing: 0) { + sidebarModeHeader + + if sidebarContentModeState.mode == .tabs { + tabsSidebarContent + } else { + fileTreeSidebarContent + } + } + } + + private var sidebarSplitContent: some View { + GeometryReader { geo in + let totalHeight = geo.size.height - 30 // account for header + let tabsHeight = totalHeight * splitDividerRatio + let fileTreeHeight = totalHeight * (1 - splitDividerRatio) + + VStack(spacing: 0) { + sidebarModeHeader + + tabsSidebarContent + .frame(height: tabsHeight) + + // Draggable divider + Rectangle() + .fill(Color.primary.opacity(0.1)) + .frame(height: 1) + .overlay( + Rectangle() + .fill(Color.clear) + .frame(height: 8) + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + let newRatio = (tabsHeight + value.translation.height) / totalHeight + splitDividerRatio = min(max(newRatio, 0.15), 0.85) + } + ) + .onHover { hovering in + if hovering { + NSCursor.resizeUpDown.push() + } else { + NSCursor.pop() + } + } + ) + + fileTreeSidebarContent + .frame(height: fileTreeHeight) + } + } + } + + private func sendPathToFocusedTerminal(_ path: String) { + guard let workspace = tabManager.selectedTab, + let terminalPanel = workspace.focusedTerminalPanel else { return } + // Shell-escape the path so spaces and special chars don't break commands + let escaped = shellEscapePath(path) + terminalPanel.sendText(escaped) + } + + private func shellEscapePath(_ path: String) -> String { + let needsQuoting = path.contains(" ") || path.contains("'") || path.contains("\"") + || path.contains("(") || path.contains(")") || path.contains("&") + || path.contains("|") || path.contains(";") || path.contains("$") + || path.contains("`") || path.contains("!") + guard needsQuoting else { return path } + // Use single quotes with escaped inner single quotes + let escaped = path.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -2493,7 +2632,8 @@ struct ContentView: View { windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState + sidebarSelectionState: sidebarSelectionState, + sidebarContentModeState: sidebarContentModeState ) installFileDropOverlay(on: window, tabManager: tabManager) })) @@ -5665,7 +5805,6 @@ struct VerticalTabsSidebar: View { } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() - .background(SidebarBackdrop().ignoresSafeArea()) .background( WindowAccessor { window in commandKeyMonitor.setHostWindow(window) @@ -8374,7 +8513,7 @@ enum SidebarSelection { case notifications } -private struct ClearScrollBackground: ViewModifier { +struct ClearScrollBackground: ViewModifier { func body(content: Content) -> some View { if #available(macOS 13.0, *) { content diff --git a/Sources/FileTree/FileTreeModel.swift b/Sources/FileTree/FileTreeModel.swift new file mode 100644 index 000000000000..bb6b97e4d5e8 --- /dev/null +++ b/Sources/FileTree/FileTreeModel.swift @@ -0,0 +1,155 @@ +import Foundation +import SwiftUI + +@MainActor +final class FileTreeModel: ObservableObject { + @Published var rootPath: String = "" + @Published var rootNodes: [FileTreeNode] = [] + @Published var showHiddenFiles: Bool = true + + func loadDirectory(_ path: String) { + rootPath = path + Task { + let nodes = await scanDirectory(path) + self.rootNodes = nodes + } + } + + func toggleExpand(_ node: FileTreeNode) { + guard node.isDirectory else { return } + var updated = rootNodes + let _ = findAndUpdate(in: &updated, id: node.id) { n in + n.isExpanded.toggle() + if n.isExpanded && n.children == nil { + n.children = [] + let path = n.path + Task { @MainActor [weak self] in + guard let self else { return } + let children = await self.scanDirectory(path) + var current = self.rootNodes + let _ = self.findAndUpdate(in: ¤t, id: node.id) { n in + n.children = children + } + self.rootNodes = current + } + } + } + rootNodes = updated + } + + func refresh() { + guard !rootPath.isEmpty else { return } + let expandedIds = collectExpandedIds(rootNodes) + Task { + let nodes = await scanDirectory(rootPath) + var result = nodes + restoreExpandedState(in: &result, expandedIds: expandedIds) + self.rootNodes = result + } + } + + func toggleHiddenFiles() { + showHiddenFiles.toggle() + refresh() + } + + // MARK: - Private + + private func scanDirectory(_ path: String) async -> [FileTreeNode] { + let showHidden = showHiddenFiles + return await Task.detached { + let fm = FileManager.default + guard let contents = try? fm.contentsOfDirectory(atPath: path) else { + return [FileTreeNode]() + } + + var nodes: [FileTreeNode] = [] + for name in contents { + let isHidden = name.hasPrefix(".") + if isHidden && !showHidden { continue } + + let fullPath = (path as NSString).appendingPathComponent(name) + var isDir: ObjCBool = false + fm.fileExists(atPath: fullPath, isDirectory: &isDir) + + nodes.append(FileTreeNode( + id: fullPath, + name: name, + path: fullPath, + isDirectory: isDir.boolValue, + isHidden: isHidden, + children: isDir.boolValue ? nil : [] + )) + } + + nodes.sort { a, b in + if a.isDirectory != b.isDirectory { + return a.isDirectory + } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } + + return nodes + }.value + } + + @discardableResult + private func findAndUpdate( + in nodes: inout [FileTreeNode], + id: String, + transform: (inout FileTreeNode) -> Void + ) -> Bool { + for i in nodes.indices { + if nodes[i].id == id { + transform(&nodes[i]) + return true + } + if var children = nodes[i].children { + if findAndUpdate(in: &children, id: id, transform: transform) { + nodes[i].children = children + return true + } + } + } + return false + } + + private func collectExpandedIds(_ nodes: [FileTreeNode]) -> Set { + var ids = Set() + for node in nodes { + if node.isExpanded { + ids.insert(node.id) + } + if let children = node.children { + ids.formUnion(collectExpandedIds(children)) + } + } + return ids + } + + private func restoreExpandedState(in nodes: inout [FileTreeNode], expandedIds: Set) { + for i in nodes.indices { + if expandedIds.contains(nodes[i].id) && nodes[i].isDirectory { + nodes[i].isExpanded = true + if nodes[i].children == nil { + let path = nodes[i].path + let nodeId = nodes[i].id + nodes[i].children = [] + Task { @MainActor [weak self] in + guard let self else { return } + let children = await self.scanDirectory(path) + var current = self.rootNodes + let _ = self.findAndUpdate(in: ¤t, id: nodeId) { n in + n.children = children + } + self.rootNodes = current + } + } + } + if var children = nodes[i].children { + restoreExpandedState(in: &children, expandedIds: expandedIds) + nodes[i].children = children + } + } + } +} diff --git a/Sources/FileTree/FileTreeNode.swift b/Sources/FileTree/FileTreeNode.swift new file mode 100644 index 000000000000..f51f26356781 --- /dev/null +++ b/Sources/FileTree/FileTreeNode.swift @@ -0,0 +1,48 @@ +import Foundation + +struct FileTreeNode: Identifiable, Hashable { + let id: String + let name: String + let path: String + let isDirectory: Bool + let isHidden: Bool + var children: [FileTreeNode]? + var isExpanded: Bool = false + + var iconName: String { + if isDirectory { + return isExpanded ? "folder.fill" : "folder" + } + + let ext = (name as NSString).pathExtension.lowercased() + switch ext { + case "swift": + return "swift" + case "json", "yaml", "yml", "toml": + return "curlybraces" + case "md", "txt", "rst": + return "doc.text" + case "sh", "zsh", "bash": + return "terminal" + case "html", "css", "js", "ts", "tsx", "jsx": + return "globe" + case "png", "jpg", "jpeg", "gif", "svg": + return "photo" + default: + return "doc" + } + } + + static func == (lhs: FileTreeNode, rhs: FileTreeNode) -> Bool { + lhs.id == rhs.id + && lhs.name == rhs.name + && lhs.path == rhs.path + && lhs.isDirectory == rhs.isDirectory + && lhs.isHidden == rhs.isHidden + && lhs.isExpanded == rhs.isExpanded + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Sources/FileTree/FileTreeRow.swift b/Sources/FileTree/FileTreeRow.swift new file mode 100644 index 000000000000..4bf4045f01fb --- /dev/null +++ b/Sources/FileTree/FileTreeRow.swift @@ -0,0 +1,119 @@ +import Foundation +import SwiftUI +import AppKit + +struct FileTreeRow: View { + let node: FileTreeNode + let depth: Int + @ObservedObject var model: FileTreeModel + let selectedFilePath: String? + let onSelect: (String) -> Void + let onComposePath: (String) -> Void + + @State private var isHovered: Bool = false + @State private var flashOpacity: Double = 0 + + var body: some View { + VStack(spacing: 0) { + Button { + let isCmd = NSApp.currentEvent?.modifierFlags.contains(.command) == true + + if isCmd { + onComposePath(node.path) + withAnimation(.easeOut(duration: 0.15)) { + flashOpacity = 0.3 + } + withAnimation(.easeOut(duration: 0.3).delay(0.15)) { + flashOpacity = 0 + } + } else if node.isDirectory { + model.toggleExpand(node) + } else { + onSelect(node.path) + } + } label: { + HStack(spacing: 4) { + if node.isDirectory { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(node.isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.15), value: node.isExpanded) + .frame(width: 12) + } else { + Spacer() + .frame(width: 12) + } + + Image(systemName: node.iconName) + .font(.system(size: 12)) + .foregroundColor(node.isDirectory ? .accentColor : .secondary) + .frame(width: 16) + + Text(node.name) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundColor(.primary) + + Spacer() + } + .padding(.leading, CGFloat(16 * depth)) + .padding(.trailing, 8) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(backgroundColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(flashOpacity)) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + } + .contextMenu { + Button("Copy Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(node.path, forType: .string) + } + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(node.path, inFileViewerRootedAtPath: "") + } + Button("Open in Default App") { + NSWorkspace.shared.open(URL(fileURLWithPath: node.path)) + } + Divider() + Button("Insert Path to Terminal") { + onComposePath(node.path) + } + } + + if node.isDirectory && node.isExpanded, let children = node.children { + ForEach(children) { child in + FileTreeRow( + node: child, + depth: depth + 1, + model: model, + selectedFilePath: selectedFilePath, + onSelect: onSelect, + onComposePath: onComposePath + ) + } + } + } + } + + private var backgroundColor: Color { + if selectedFilePath == node.path { + return Color.accentColor.opacity(0.15) + } + if isHovered { + return Color.primary.opacity(0.05) + } + return Color.clear + } +} diff --git a/Sources/FileTree/FileTreeSidebar.swift b/Sources/FileTree/FileTreeSidebar.swift new file mode 100644 index 000000000000..6bf349549c90 --- /dev/null +++ b/Sources/FileTree/FileTreeSidebar.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftUI +import AppKit + +struct FileTreeSidebar: View { + @ObservedObject var model: FileTreeModel + @ObservedObject var workspace: Workspace + let onComposePath: (String) -> Void + + @State private var selectedFilePath: String? + + var body: some View { + VStack(spacing: 0) { + // Header with directory name and controls + HStack(spacing: 6) { + Image(systemName: "folder.fill") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Text(directoryBasename) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + + Spacer() + + Button { + model.toggleHiddenFiles() + } label: { + Image(systemName: model.showHiddenFiles ? "eye" : "eye.slash") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help(model.showHiddenFiles ? "Hide hidden files" : "Show hidden files") + + Button { + model.refresh() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Refresh") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + + Divider() + + GeometryReader { proxy in + ScrollView { + if model.rootNodes.isEmpty { + Text("Empty directory") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(model.rootNodes) { node in + FileTreeRow( + node: node, + depth: 0, + model: model, + selectedFilePath: selectedFilePath, + onSelect: { path in + selectedFilePath = path + }, + onComposePath: onComposePath + ) + } + } + .padding(.vertical, 4) + } + } + .modifier(ClearScrollBackground()) + } + } + .onChange(of: workspace.currentDirectory) { newDirectory in + let trimmed = newDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && trimmed != model.rootPath { + model.loadDirectory(trimmed) + selectedFilePath = nil + } + } + .onAppear { + let dir = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !dir.isEmpty && dir != model.rootPath { + model.loadDirectory(dir) + } + } + } + + private var directoryBasename: String { + let dir = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !dir.isEmpty else { return "No directory" } + return (dir as NSString).lastPathComponent + } +} diff --git a/Sources/FileTree/SidebarContentMode.swift b/Sources/FileTree/SidebarContentMode.swift new file mode 100644 index 000000000000..20bb17745a7d --- /dev/null +++ b/Sources/FileTree/SidebarContentMode.swift @@ -0,0 +1,18 @@ +import SwiftUI + +enum SidebarContentMode { + case tabs + case fileTree +} + +enum SidebarFileTreeLayout: String, CaseIterable, Identifiable { + case toggle + case split + + var id: String { rawValue } +} + +@MainActor +final class SidebarContentModeState: ObservableObject { + @Published var mode: SidebarContentMode = .tabs +} diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 13095d908e6e..bba93f4a6820 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -39,6 +39,7 @@ enum KeyboardShortcutSettings { case openBrowser case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole + case toggleFileTree var id: String { rawValue } @@ -72,6 +73,7 @@ enum KeyboardShortcutSettings { case .openBrowser: return "Open Browser" case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" + case .toggleFileTree: return "Toggle File Tree" } } @@ -105,6 +107,7 @@ enum KeyboardShortcutSettings { case .openBrowser: return "shortcut.openBrowser" case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" + case .toggleFileTree: return "shortcut.toggleFileTree" } } @@ -168,6 +171,8 @@ enum KeyboardShortcutSettings { case .showBrowserJavaScriptConsole: // Safari default: Show JavaScript Console. return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) + case .toggleFileTree: + return StoredShortcut(key: "e", command: true, shift: true, option: false, control: false) } } @@ -241,6 +246,8 @@ enum KeyboardShortcutSettings { static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) } static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) } static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } + + static func toggleFileTreeShortcut() -> StoredShortcut { shortcut(for: .toggleFileTree) } } /// A keyboard shortcut that can be stored in UserDefaults diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 41b8f1600525..65783a97e5f2 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -34,6 +34,7 @@ struct cmuxApp: App { @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.toggleFileTree.defaultsKey) private var toggleFileTreeShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -494,6 +495,19 @@ struct cmuxApp: App { } } + splitCommandButton(title: "Toggle File Tree", shortcut: toggleFileTreeMenuShortcut) { + if let modeState = AppDelegate.shared?.sidebarContentModeState { + if modeState.mode == .fileTree { + modeState.mode = .tabs + } else { + modeState.mode = .fileTree + if !sidebarState.isVisible { + sidebarState.toggle() + } + } + } + } + Divider() splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { @@ -753,6 +767,13 @@ struct cmuxApp: App { ) } + private var toggleFileTreeMenuShortcut: StoredShortcut { + decodeShortcut( + from: toggleFileTreeShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleFileTree.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } From 00faf68f3aef4014d3a6e4db7038d6c4870b067f Mon Sep 17 00:00:00 2001 From: Alex Boudreaux Date: Sun, 1 Mar 2026 17:22:41 -0600 Subject: [PATCH 2/2] Fix missing SidebarContentModeState environment object in primary window The SwiftUI WindowGroup body in cmuxApp.swift was not injecting the SidebarContentModeState, causing a fatal crash on launch. Co-Authored-By: Claude Opus 4.6 --- Sources/cmuxApp.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 65783a97e5f2..d49863d9522c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -9,6 +9,7 @@ struct cmuxApp: App { @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var sidebarState = SidebarState() @StateObject private var sidebarSelectionState = SidebarSelectionState() + @StateObject private var sidebarContentModeState = SidebarContentModeState() private let primaryWindowId = UUID() @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @@ -187,6 +188,7 @@ struct cmuxApp: App { .environmentObject(notificationStore) .environmentObject(sidebarState) .environmentObject(sidebarSelectionState) + .environmentObject(sidebarContentModeState) .onAppear { #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" {